A wrapping text reported the width it used rounded to the nearest step, which is under the line it measured half the time. A parent that sizes itself from that report then hands the text back a box its own longest line does not fit in, and breaking there is a different break -- one line more. Two tolerances were hiding it and both go. `TextBuffer::shape` answered a width up to 0.05 px under the longest line from the break in hand, which is a structural decision taken on a hair's breadth: it kept a warm tree self-consistent while a cold tree at the same width broke differently, and 0.05 px is fifty steps of the grid. The `Holds` range the text declares started at the nearest step to its longest line for the same reason, so it admitted boxes the line does not fit in. Both are the line itself now, exactly, because the report no longer lands under it. Found by seeds 1121 and 1839 at depth 4, which fail on `ea6dbae` and every commit before it: a defect older than anything on this branch, reached by running 2000 seeds at a depth the long runs do not use. Shrunk to the eight widgets `a_text_is_given_back_a_box_the_line_it_measured_fits_in` builds. 2000 seeds at depth 4 over all fifteen cases are clean now, as are the three long runs. `text` is the one reference render that moves: its lower paragraph shifts a pixel, the box being a step wider and its left edge crossing a snap boundary. Same words, same lines, same breaks; `tabs`, `view`, `minimal` and `random` are byte-identical.
424 lines
14 KiB
Rust
424 lines
14 KiB
Rust
#[cfg(feature = "layout-diagnostics")]
|
|
use crate::layout_diagnostics::{self as diag, Counter, TimerKind};
|
|
use crate::{
|
|
Align, GlyphAtlas, GlyphEntry, GlyphKey, PlacedGlyph, Px, PxVec2, RegionAlign, UiColor,
|
|
util::Vec2,
|
|
};
|
|
use parley::{
|
|
Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, GenericFamily, Layout,
|
|
LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty,
|
|
};
|
|
use std::{
|
|
collections::VecDeque,
|
|
hash::{DefaultHasher, Hash, Hasher},
|
|
};
|
|
use swash::{
|
|
FontRef,
|
|
scale::{Render, ScaleContext, Source, StrikeWith},
|
|
zeno::{Format, Vector},
|
|
};
|
|
|
|
pub struct TextData {
|
|
pub font_ctx: FontContext,
|
|
pub layout_ctx: LayoutContext<UiColor>,
|
|
scale_ctx: ScaleContext,
|
|
pub atlas: GlyphAtlas,
|
|
spare: VecDeque<Placed>,
|
|
}
|
|
|
|
/// The glyphs of one text at one width. A buffer holds the ones it is drawn
|
|
/// as; these are the ones it had before, kept because a container measures a
|
|
/// child by drawing it in a box it may not keep, and so comes back to widths
|
|
/// it has already asked for.
|
|
struct Placed {
|
|
/// Where the glyphs land is a function of these three and nothing else,
|
|
/// so no widget or buffer identity is involved and two texts of the same
|
|
/// words share an answer.
|
|
text: String,
|
|
key: LayoutKey,
|
|
glyphs: RenderedText,
|
|
}
|
|
|
|
/// How many to keep. Bounding the whole store rather than each buffer is what
|
|
/// makes this a fixed cost instead of one a tree of ten thousand texts pays
|
|
/// ten thousand times; the re-asks come from laying out one subtree, so they
|
|
/// are close together and few are needed. Instructions over 500 resize frames
|
|
/// of `tests/revision_cost.rs`, both the repeating widths and the sweep that
|
|
/// cannot hit across frames: 13.7B at 32, 12.1B at 64, 10.4B and 12.1B at 128,
|
|
/// and nothing past that -- so 128, which is no worse in the case that never
|
|
/// repeats and better in the one that does.
|
|
const SPARE_PLACED: usize = 128;
|
|
|
|
impl Default for TextData {
|
|
fn default() -> Self {
|
|
Self {
|
|
font_ctx: FontContext::new(),
|
|
layout_ctx: LayoutContext::new(),
|
|
scale_ctx: ScaleContext::new(),
|
|
atlas: GlyphAtlas::default(),
|
|
spare: VecDeque::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, PartialEq)]
|
|
pub enum Family {
|
|
SansSerif,
|
|
Serif,
|
|
Monospace,
|
|
Named(String),
|
|
}
|
|
|
|
impl Family {
|
|
fn family(&self) -> FontFamily<'_> {
|
|
let name = match self {
|
|
Self::SansSerif => FontFamilyName::Generic(GenericFamily::SansSerif),
|
|
Self::Serif => FontFamilyName::Generic(GenericFamily::Serif),
|
|
Self::Monospace => FontFamilyName::Generic(GenericFamily::Monospace),
|
|
Self::Named(name) => FontFamilyName::Named(name.as_str().into()),
|
|
};
|
|
FontFamily::Single(name)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, PartialEq)]
|
|
pub struct TextAttrs {
|
|
pub color: UiColor,
|
|
pub font_size: f32,
|
|
pub line_height: f32,
|
|
pub family: Family,
|
|
pub wrap: bool,
|
|
pub align: RegionAlign,
|
|
}
|
|
|
|
pub const LINE_HEIGHT_MULT: f32 = 1.1;
|
|
|
|
impl Default for TextAttrs {
|
|
fn default() -> Self {
|
|
let size = 16.0;
|
|
Self {
|
|
color: UiColor::WHITE,
|
|
font_size: size,
|
|
line_height: size * LINE_HEIGHT_MULT,
|
|
family: Family::SansSerif,
|
|
wrap: false,
|
|
align: Align::CENTER_LEFT,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Keeps text and its corresponding layout from getting out of sync.
|
|
pub struct TextBuffer {
|
|
text: String,
|
|
layout: Layout<UiColor>,
|
|
layout_key: Option<LayoutKey>,
|
|
/// The glyphs placed from `layout`, so drawing this text again at the
|
|
/// width it already has places them once.
|
|
placed: Option<RenderedText>,
|
|
}
|
|
|
|
#[derive(PartialEq)]
|
|
struct LayoutKey {
|
|
attrs: TextAttrs,
|
|
max_width: Option<f32>,
|
|
}
|
|
|
|
impl TextBuffer {
|
|
pub fn new(text: impl Into<String>) -> Self {
|
|
Self {
|
|
text: text.into(),
|
|
layout: Layout::new(),
|
|
layout_key: None,
|
|
placed: None,
|
|
}
|
|
}
|
|
|
|
pub fn new_empty() -> Self {
|
|
Self::new("")
|
|
}
|
|
|
|
pub fn text(&self) -> &str {
|
|
&self.text
|
|
}
|
|
|
|
pub fn layout(&self) -> &Layout<UiColor> {
|
|
&self.layout
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.text.is_empty()
|
|
}
|
|
|
|
pub fn set_text(&mut self, text: impl Into<String>) {
|
|
let text = text.into();
|
|
if text != self.text {
|
|
self.text = text;
|
|
self.layout_key = None;
|
|
self.placed = None;
|
|
}
|
|
}
|
|
|
|
/// Invalidates the layout and returns the underlying string for editing.
|
|
pub fn edit(&mut self) -> &mut String {
|
|
self.layout_key = None;
|
|
self.placed = None;
|
|
&mut self.text
|
|
}
|
|
|
|
/// The glyphs of the shaping it is drawn as, once they are placed.
|
|
pub fn rendered(&self) -> Option<&RenderedText> {
|
|
self.placed.as_ref()
|
|
}
|
|
|
|
/// The width its shaping wraps at, and `None` where it does not wrap or
|
|
/// has not been shaped.
|
|
pub fn wrap_width(&self) -> Option<f32> {
|
|
self.layout_key.as_ref()?.max_width
|
|
}
|
|
|
|
pub fn size(&self) -> Vec2 {
|
|
Vec2::new(self.layout.width(), self.layout.height())
|
|
}
|
|
|
|
pub fn shape(&mut self, data: &mut TextData, attrs: &TextAttrs, width: Option<f32>) {
|
|
let layout_key = LayoutKey {
|
|
attrs: attrs.clone(),
|
|
max_width: width,
|
|
};
|
|
if self.layout_key.as_ref() == Some(&layout_key) {
|
|
#[cfg(feature = "layout-diagnostics")]
|
|
diag::bump(Counter::TextShapeHits);
|
|
return;
|
|
}
|
|
// A greedy break at one width is the same break at every width down
|
|
// to the longest line it produced: each line still fits, and none can
|
|
// take a word that would not fit in the wider box. So the layout in
|
|
// hand already answers, and re-breaking would only be work.
|
|
//
|
|
// At the longest line exactly, with no margin below it. A narrower
|
|
// width really does break differently, so answering one from the
|
|
// break in hand is how a warm tree keeps lines a cold tree would
|
|
// never produce. The margin was here because a text reports the
|
|
// width it used and a parent hands that back; the report is the step
|
|
// at or above its longest line now, so what comes back fits.
|
|
if let Some(key) = &self.layout_key
|
|
&& key.attrs == *attrs
|
|
&& let (Some(broke_at), Some(want)) = (key.max_width, width)
|
|
&& want <= broke_at
|
|
&& want >= self.layout.width()
|
|
{
|
|
#[cfg(feature = "layout-diagnostics")]
|
|
diag::bump(Counter::TextShapeHits);
|
|
return;
|
|
}
|
|
let same_shaping = self
|
|
.layout_key
|
|
.as_ref()
|
|
.is_some_and(|key| key.attrs == *attrs);
|
|
let old_key = self.layout_key.replace(layout_key);
|
|
// The glyphs it holds are of the width it held, which the layout may
|
|
// well come back to.
|
|
if let Some(key) = old_key
|
|
&& let Some(glyphs) = self.placed.take()
|
|
{
|
|
data.keep_placed(Placed {
|
|
text: self.text.clone(),
|
|
key,
|
|
glyphs,
|
|
});
|
|
}
|
|
// Only the line breaking depends on the width: the shaped runs under
|
|
// it are a function of the text and the attrs, and parley re-breaks
|
|
// them in place. So a new width is a break, not a shaping.
|
|
if same_shaping {
|
|
#[cfg(feature = "layout-diagnostics")]
|
|
diag::bump(Counter::TextBreaks);
|
|
#[cfg(feature = "layout-diagnostics")]
|
|
let _break = diag::timer(TimerKind::TextBreak);
|
|
self.break_lines(width);
|
|
return;
|
|
}
|
|
#[cfg(feature = "layout-diagnostics")]
|
|
diag::bump(Counter::TextShapes);
|
|
#[cfg(feature = "layout-diagnostics")]
|
|
let _shape = diag::timer(TimerKind::TextShape);
|
|
let mut builder = data
|
|
.layout_ctx
|
|
.ranged_builder(&mut data.font_ctx, &self.text, 1.0, true);
|
|
builder.push_default(StyleProperty::FontFamily(attrs.family.family()));
|
|
builder.push_default(StyleProperty::FontSize(attrs.font_size));
|
|
builder.push_default(StyleProperty::LineHeight(LineHeight::Absolute(
|
|
attrs.line_height,
|
|
)));
|
|
builder.push_default(StyleProperty::Brush(attrs.color));
|
|
builder.build_into(&mut self.layout, &self.text);
|
|
self.break_lines(width);
|
|
}
|
|
|
|
fn break_lines(&mut self, width: Option<f32>) {
|
|
self.layout.break_all_lines(width);
|
|
self.layout
|
|
.align(Alignment::Start, AlignmentOptions::default());
|
|
}
|
|
}
|
|
|
|
impl TextData {
|
|
pub fn place(&mut self, buffer: &TextBuffer) -> Vec<PlacedGlyph> {
|
|
let mut placed = Vec::new();
|
|
for line in buffer.layout.lines() {
|
|
for item in line.items() {
|
|
let PositionedLayoutItem::GlyphRun(run) = item else {
|
|
continue;
|
|
};
|
|
let font = run.run().font();
|
|
let font_size = run.run().font_size();
|
|
let coords = run.run().normalized_coords();
|
|
let Some(font_ref) = FontRef::from_index(font.data.as_ref(), font.index as usize)
|
|
else {
|
|
continue;
|
|
};
|
|
let coords_hash = hash_coords(coords);
|
|
let font_id = font.data.id();
|
|
|
|
for glyph in run.positioned_glyphs() {
|
|
let subpixel = ((glyph.x.fract() * 4.0).round() as i32).rem_euclid(4) as u8;
|
|
let key = GlyphKey {
|
|
font: font_id,
|
|
glyph: glyph.id,
|
|
size: glyph_size_key(font_size),
|
|
subpixel,
|
|
coords: coords_hash,
|
|
};
|
|
let Some(entry) = self.glyph_entry(GlyphRaster {
|
|
key,
|
|
font: font_ref,
|
|
font_size,
|
|
coords,
|
|
subpixel,
|
|
glyph_id: glyph.id,
|
|
}) else {
|
|
continue;
|
|
};
|
|
placed.push(PlacedGlyph {
|
|
entry,
|
|
offset: PxVec2::new(
|
|
Px::from_int(glyph.x.floor() as i32 + entry.left),
|
|
Px::from_int(glyph.y.floor() as i32 - entry.top),
|
|
),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
placed
|
|
}
|
|
|
|
fn glyph_entry(&mut self, glyph: GlyphRaster<'_>) -> Option<GlyphEntry> {
|
|
if let Some(entry) = self.atlas.get(&glyph.key) {
|
|
return entry;
|
|
}
|
|
|
|
let mut scaler = self
|
|
.scale_ctx
|
|
.builder(glyph.font)
|
|
.size(glyph.font_size)
|
|
.hint(true)
|
|
.normalized_coords(glyph.coords)
|
|
.build();
|
|
let image = Render::new(&[
|
|
Source::ColorOutline(0),
|
|
Source::ColorBitmap(StrikeWith::BestFit),
|
|
Source::Outline,
|
|
])
|
|
.format(Format::Alpha)
|
|
.offset(Vector::new(glyph.subpixel as f32 / 4.0, 0.0))
|
|
.render(&mut scaler, glyph.glyph_id as u16);
|
|
|
|
if let Some(image) = image {
|
|
self.atlas.insert(glyph.key, &image)
|
|
} else {
|
|
self.atlas.insert_empty(glyph.key);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
struct GlyphRaster<'a> {
|
|
key: GlyphKey,
|
|
font: FontRef<'a>,
|
|
font_size: f32,
|
|
coords: &'a [i16],
|
|
subpixel: u8,
|
|
glyph_id: u32,
|
|
}
|
|
|
|
fn hash_coords(coords: &[i16]) -> u64 {
|
|
let mut hasher = DefaultHasher::new();
|
|
coords.hash(&mut hasher);
|
|
hasher.finish()
|
|
}
|
|
|
|
const GLYPH_SIZE_STEPS_PER_PIXEL: f32 = 16.0;
|
|
|
|
fn glyph_size_key(font_size: f32) -> u32 {
|
|
(font_size * GLYPH_SIZE_STEPS_PER_PIXEL).round() as u32
|
|
}
|
|
|
|
pub struct RenderedText {
|
|
pub glyphs: Vec<PlacedGlyph>,
|
|
pub size: Vec2,
|
|
pub color: UiColor,
|
|
}
|
|
|
|
impl TextData {
|
|
/// The glyphs of this text at this width, taken out of what is kept.
|
|
fn take_placed(&mut self, text: &str, key: &LayoutKey) -> Option<RenderedText> {
|
|
// From the newest, since a re-ask is usually of something recent.
|
|
let at = self
|
|
.spare
|
|
.iter()
|
|
.rposition(|spare| spare.key == *key && spare.text == text)?;
|
|
self.spare.remove(at).map(|spare| spare.glyphs)
|
|
}
|
|
|
|
fn keep_placed(&mut self, placed: Placed) {
|
|
if self.spare.len() >= SPARE_PLACED {
|
|
self.spare.pop_front();
|
|
}
|
|
self.spare.push_back(placed);
|
|
}
|
|
|
|
pub fn render<'b>(
|
|
&mut self,
|
|
buffer: &'b mut TextBuffer,
|
|
attrs: &TextAttrs,
|
|
width: Option<f32>,
|
|
) -> &'b RenderedText {
|
|
#[cfg(feature = "layout-diagnostics")]
|
|
diag::bump(Counter::TextRenders);
|
|
#[cfg(feature = "layout-diagnostics")]
|
|
let _render = diag::timer(TimerKind::TextRender);
|
|
buffer.shape(self, attrs, width);
|
|
// Only asked for when the buffer no longer holds them: taking one out
|
|
// of the store to then drop it would throw an answer away.
|
|
let placed = buffer.placed.take().or_else(|| {
|
|
let key = buffer.layout_key.as_ref()?;
|
|
self.take_placed(&buffer.text, key)
|
|
});
|
|
let placed = match placed {
|
|
Some(placed) => placed,
|
|
None => {
|
|
#[cfg(feature = "layout-diagnostics")]
|
|
diag::bump(Counter::GlyphPlacements);
|
|
#[cfg(feature = "layout-diagnostics")]
|
|
let _place = diag::timer(TimerKind::GlyphPlacement);
|
|
RenderedText {
|
|
glyphs: self.place(buffer),
|
|
size: buffer.size(),
|
|
color: attrs.color,
|
|
}
|
|
}
|
|
};
|
|
buffer.placed.insert(placed)
|
|
}
|
|
}
|