Keep each widget's original local frame and replay the same composition order on reuse. Remove inverse region remapping, including its fixed-frame fallback that forced otherwise valid subtrees to draw again. Require exact pixel-region equality in the shared generated oracle. Check primitive and mask geometry as well as draw reuse when fixed frames resize. Publish text's retained line-break range, with no upper bound when there are no soft breaks, and cover widening, explicit newlines, and empty text. Compared withefb416b, the depth-8 diagnostic rig performs 7-9% fewer widget evaluations in the affected phases. Uninstrumented release runs use 3.5% fewer instructions for size changes and 5.0% fewer for resize. Repaint and scroll use 0.7% and 0.6% more instructions. Container updates remain substantially more expensive than thee44dea3baseline; this is still an experimental continuation, not a production replacement.
441 lines
14 KiB
Rust
441 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
|
|
}
|
|
|
|
/// Widths covered by the current line breaks, including a wider shaping
|
|
/// retained when a later draw requested a narrower box.
|
|
pub fn width_holds(&self) -> crate::Holds {
|
|
let Some(width) = self.wrap_width() else {
|
|
return crate::Holds::ANY;
|
|
};
|
|
let width = Px::from_f32(width);
|
|
let soft_wrapped = self.layout.lines().any(|line| {
|
|
matches!(
|
|
line.break_reason(),
|
|
parley::layout::BreakReason::Regular | parley::layout::BreakReason::Emergency
|
|
)
|
|
});
|
|
let upper = if soft_wrapped { width } else { Px::MAX };
|
|
crate::Holds::from(Px::ceil_from_f32(self.layout.width()).min(width)..=upper)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|