Skip to main content

dinamika_core/shape/
text.rs

1//! Text shape — drawing lines of text with CSS-like properties.
2//!
3//! This module holds the text state ([`TextData`]) and its layout on top of the
4//! raster renderer [`dinamika_cpu`]: the font is loaded from TrueType/OpenType
5//! bytes, lines are laid out horizontally (with `\n` breaks), and the result — a
6//! single fillable [`Path`] — is cached.
7//!
8//! # Properties as in CSS
9//!
10//! The text shape exposes the characteristics familiar from CSS:
11//!
12//! - **`font`** — the family (the bytes of the `.ttf`/`.otf` font file are passed);
13//! - **`font_size`** — font size in pixels (animatable);
14//! - **`color`** — glyph fill color (animatable);
15//! - **`text_align`** — line alignment ([`TextAlign`]);
16//! - **`line_height`** — line spacing as a multiplier of the font's natural line
17//!   height (animatable, `1.0` by default);
18//! - **`letter_spacing`** — tracking (extra gap between characters, px,
19//!   animatable).
20//!
21//! On top of that, text inherits the box properties of an ordinary shape:
22//! background and rounding (the text background is transparent by default, as in
23//! CSS), inner padding ([`padding`](crate::Shape::padding)), opacity, rotation,
24//! scale and explicit box sizes.
25//!
26//! # Range highlighting
27//!
28//! Text and code can emphasize arbitrary regions, dimming everything else — like
29//! `.selection` in Motion Canvas, but this is a **timeline animation**, not a
30//! builder property, and without the limit of a single range.
31//! [`highlight`](crate::Shape::highlight) marks the highlighted range
32//! `[from, to)` (the bounds are the same [`TextPos`]: a character, the start of a
33//! line [`line`], the end of the text [`infinite`]) and returns a
34//! [`HighlightEdit`](crate::HighlightEdit) handle; its
35//! [`over`](crate::HighlightEdit::over) turns the edit into a smooth transition —
36//! "highlight over 0.5 s". Several ranges are highlighted with several
37//! `highlight(..).over(..)` in one [`parallel`](crate::parallel) (they merge into
38//! one consistent transition), and the highlighting is removed with
39//! [`clear_highlight`](crate::Shape::clear_highlight). Glyphs inside the active
40//! ranges are drawn at full strength, the rest dim down to [`DEFAULT_DIM`].
41//!
42//! The highlight frame is described by [`HighlightStage`] — a cell separate from
43//! the text [`TextStage`], overwritten by its own leaf [`HighlightTween`]: the
44//! fill reads it per-character and interpolates each glyph's opacity from the old
45//! set of ranges to the new one, so the highlighting smoothly appears, is removed
46//! and moves from place to place. The committed set of ranges (the base for
47//! [`HighlightStage::Base`]) is stored next to the committed text. Highlighting
48//! acts on settled text (static, spawn, typing) and is not combined with the
49//! text smoothing morph ([`TextStage::Crossfade`]), where the set of characters
50//! changes and ranges are ambiguous.
51//!
52//! # Content editing
53//!
54//! The content is changed with a CSS-like chain of the shape's editor methods
55//! ([`content`](crate::Shape::content), [`append`](crate::Shape::append),
56//! [`prepend`](crate::Shape::prepend), [`insert`](crate::Shape::insert),
57//! [`rewrite`](crate::Shape::rewrite)). The edit is applied immediately (like
58//! ordinary setters), and the returned [`TextEdit`](crate::TextEdit) handle lets
59//! you turn it into an animation on the timeline.
60//!
61//! # Text appearance and change animations
62//!
63//! The [`TextEdit`](crate::TextEdit) handle offers three transitions:
64//!
65//! - **instant spawn** ([`spawn`](crate::TextEdit::spawn)) — an instant
66//!   replacement (no duration);
67//! - **typing** ([`typing`](crate::TextEdit::typing)) — per-character typing over
68//!   a given time, the block expands as it is typed;
69//! - **smoothing** ([`smooth`](crate::TextEdit::smooth)) — a crossfade of the old
70//!   and new text with a block-width morph, as in Motion Canvas.
71//!
72//! The current animation frame is described by [`TextStage`]: the layout passes
73//! ([`TextData::natural_size`]) and fill passes ([`TextData::layout_path`],
74//! [`TextData::alpha`]) read it, and the animation itself — [`TextTween`] —
75//! overwrites it on each frame via the timeline.
76//!
77//! # Layout caching
78//!
79//! Extracting glyph outlines and assembling the path is the expensive part,
80//! which during animation is easy to repeat every frame for nothing. So the
81//! finished path and metrics are memoized by a key of geometrically significant
82//! properties (text, font size, letter spacing, line height, alignment, block
83//! width). Animating color, opacity and position does not change the key — the
84//! text is not rebuilt, only re-filled.
85//!
86//! # Limitations
87//!
88//! The layout is inherited from [`dinamika_cpu`] and is deliberately minimal: no
89//! kerning, shaping, bidi or word wrap — only tracking, alignment and explicit
90//! `\n` breaks. Several edits of one text in a single
91//! [`parallel`](crate::parallel) are supported: they merge into one consistent
92//! morph (the common base is the committed text before the whole group), so that,
93//! for example, `prepend(..).smooth()` and `append(..).smooth()` in one parallel
94//! don't "leak" their appended edges before the start. Arbitrarily
95//! time-overlapping transitions of one text (in different timeline blocks) are
96//! still not supported.
97
98use std::cell::{Cell, RefCell};
99use std::rc::Rc;
100
101use dinamika_cpu::{Color, Font, Path, PathBuilder, PathSegment};
102
103use crate::easing::Easing;
104use crate::signal::Signal;
105use crate::timeline::{Action, TweenObj};
106
107/// Horizontal alignment of lines within a text block (analogous to
108/// CSS `text-align`).
109#[derive(Copy, Clone, Debug, PartialEq, Eq)]
110pub enum TextAlign {
111    /// Left-aligned (the default value).
112    Left,
113    /// Centered.
114    Center,
115    /// Right-aligned.
116    Right,
117}
118
119/// A position in the text for the bounds of a [`rewrite`](crate::Shape::rewrite)
120/// range.
121///
122/// Resolves to a character index (0-based) for the half-open range `[from, to)`.
123/// A bare `usize` is treated as a character index (via [`From<usize>`]); [`line`]
124/// sets the start of a line, [`infinite`] — the end of the text.
125#[derive(Copy, Clone, Debug, PartialEq, Eq)]
126pub enum TextPos {
127    /// A character index (0-based, Unicode scalar). Clamped to `[0, length]`.
128    Char(usize),
129    /// The start of line `n` (0-based) — the boundary before its first
130    /// character. Lines are separated by `\n`. An index past the last line is the
131    /// end of the text.
132    Line(usize),
133    /// The end of the text. Valid primarily as `to` (for `from` it equals the
134    /// length, i.e. an empty range at the end). Constructed via [`infinite`].
135    End,
136}
137
138impl From<usize> for TextPos {
139    fn from(i: usize) -> Self {
140        TextPos::Char(i)
141    }
142}
143
144/// The position of the start of line `n` (0-based) for
145/// [`rewrite`](crate::Shape::rewrite).
146///
147/// ```
148/// # use dinamika_core::*;
149/// // Replace the whole first line (together with its `\n`):
150/// let t = Shape::text("foo\nbar").rewrite(0, line(1), "X");
151/// ```
152pub fn line(n: usize) -> TextPos {
153    TextPos::Line(n)
154}
155
156/// The position of the end of the text for the `to` bound in
157/// [`rewrite`](crate::Shape::rewrite).
158///
159/// ```
160/// # use dinamika_core::*;
161/// // Replace the "tail" starting from the second line:
162/// let t = Shape::text("foo\nbar\nbaz").rewrite(line(1), infinite(), "X");
163/// ```
164pub fn infinite() -> TextPos {
165    TextPos::End
166}
167
168impl TextPos {
169    /// A character index (0-based), clamped to `[0, character count]`.
170    fn resolve(self, text: &str) -> usize {
171        let count = text.chars().count();
172        match self {
173            TextPos::Char(i) => i.min(count),
174            TextPos::Line(n) => line_start_char(text, n).min(count),
175            TextPos::End => count,
176        }
177    }
178}
179
180/// A snapshot of the text animation state at a specific frame — what the layout
181/// passes ([`TextData::natural_size`]) and fill passes
182/// ([`TextData::layout_path`], [`TextData::alpha`]) read.
183///
184/// By default [`Base`](TextStage::Base): no active animation, the whole committed
185/// text is drawn. Text animations overwrite this state on each frame via their
186/// own [`TweenObj`] ([`TextTween`]).
187#[derive(Clone, Debug)]
188pub(crate) enum TextStage {
189    /// No active animation: the whole committed text, alpha 1.
190    Base,
191    /// Show exactly this whole string (instant spawn — before and after the
192    /// moment).
193    Shown(String),
194    /// Typing: the first `visible` characters of the string `text` (fractional —
195    /// for a smooth front, floored). The block width is by the visible substring.
196    Typing { text: String, visible: f32 },
197    /// Crossfade `from`→`to` with progress `p` (`0..=1`, already eased): the first
198    /// half shows `from` with alpha `1→0`, the second — `to` with alpha `0→1`; the
199    /// block width morphs `from`→`to` for the whole duration.
200    Crossfade { from: String, to: String, p: f32 },
201}
202
203/// A snapshot of the highlight state at a specific frame — what the fill reads
204/// when computing each glyph's opacity ([`TextData::highlight_alphas`]).
205///
206/// By default [`Base`](HighlightStage::Base): no active animation, the committed
207/// set of ranges is used. The highlight animation ([`HighlightTween`])
208/// overwrites this on each frame.
209#[derive(Clone, Debug)]
210pub(crate) enum HighlightStage {
211    /// No active animation: the committed set of ranges
212    /// ([`TextData::highlights`]) is used.
213    Base,
214    /// An opacity transition from the set of ranges `from` to `to` with progress
215    /// `p` (`0..=1`, already eased). Each glyph's opacity is interpolated between
216    /// its value at `from` and at `to`, so the highlighting smoothly appears
217    /// (`from` empty), is removed (`to` empty) and moves between regions.
218    Morph {
219        from: Vec<(TextPos, TextPos)>,
220        to: Vec<(TextPos, TextPos)>,
221        p: f32,
222    },
223}
224
225/// The kind of text animation, set by the [`TextEdit`](super::TextEdit) methods.
226///
227/// The transition endpoints (`old`→`new`) themselves are held by [`TextTween`]
228/// (mutably — they are reset by the merging of overlapping edits in a parallel);
229/// here is only how to interpret them on a frame.
230#[derive(Copy, Clone)]
231pub(super) enum TextMotion {
232    /// Instant spawn: an instant replacement `old`→`new` at the start moment.
233    Spawn,
234    /// Typing: the visible part grows from the prefix shared with `old` to the
235    /// end of `new`.
236    Typing,
237    /// Smoothing: a crossfade `old`→`new` with a block-width morph.
238    Smooth,
239}
240
241/// A text animation leaf: on each frame it overwrites the shape's [`TextStage`].
242///
243/// Implements [`TweenObj`] directly (rather than through a numeric
244/// [`Signal`](crate::Signal)), because the text state is structural and
245/// self-contained: `apply` computes the stage from the local progress without
246/// "picking up" the previous value.
247pub(crate) struct TextTween {
248    /// The stage cell shared with its [`TextData`] — what the animation
249    /// overwrites and what layout and fill read.
250    stage: Rc<RefCell<TextStage>>,
251    motion: TextMotion,
252    /// The committed text before the edit — the transition's "from". Inside a
253    /// [`RefCell`], because [`parallel`](crate::parallel) resets the transition
254    /// endpoints to the common base/final of a group of overlapping edits of one
255    /// text (see `rebase`).
256    old: RefCell<String>,
257    /// The committed text after the edit — the transition's "to" (also resettable).
258    new: RefCell<String>,
259    start: Cell<f64>,
260    duration: f64,
261    easing: Easing,
262}
263
264impl TextTween {
265    /// Builds an [`Action`] over the stage `stage` for the transition `old`→`new`
266    /// of kind `motion`.
267    pub(super) fn action(
268        stage: Rc<RefCell<TextStage>>,
269        motion: TextMotion,
270        old: String,
271        new: String,
272        duration: f64,
273        easing: Easing,
274    ) -> Action {
275        let leaf = TextTween {
276            stage,
277            motion,
278            old: RefCell::new(old),
279            new: RefCell::new(new),
280            start: Cell::new(0.0),
281            duration: duration.max(0.0),
282            easing,
283        };
284        Action::from_tween(Rc::new(leaf))
285    }
286
287    /// The frame stage for local progress `local` (`0..=1`, not yet eased;
288    /// `reset` calls with `0.0`).
289    fn stage_at(&self, local: f32) -> TextStage {
290        let old = self.old.borrow();
291        let new = self.new.borrow();
292        match self.motion {
293            // Instant replacement: before the start — old, from the start moment — new.
294            TextMotion::Spawn => {
295                TextStage::Shown(if local <= 0.0 { old.clone() } else { new.clone() })
296            }
297            TextMotion::Typing => {
298                let from = common_prefix_chars(&old, &new) as f32;
299                let to = new.chars().count() as f32;
300                let eased = self.easing.apply(local);
301                TextStage::Typing { text: new.clone(), visible: from + (to - from) * eased }
302            }
303            TextMotion::Smooth => {
304                let p = self.easing.apply(local);
305                TextStage::Crossfade { from: old.clone(), to: new.clone(), p }
306            }
307        }
308    }
309}
310
311impl TweenObj for TextTween {
312    fn duration(&self) -> f64 {
313        self.duration
314    }
315
316    fn start(&self) -> f64 {
317        self.start.get()
318    }
319
320    fn set_start(&self, start: f64) {
321        self.start.set(start);
322    }
323
324    fn reset(&self) {
325        // The "before the start" state of the animation. For smoothing this is a
326        // static `old` ([`TextStage::Shown`]), NOT `Crossfade{p=0}`: otherwise
327        // resetting a smooth edit that hasn't started yet would leave an "active"
328        // morph in the shared cell, and the colored fill would go into the morph
329        // path, suppressing the highlight of another block that is active at that
330        // moment (see `later_smooth_edit_does_not_suppress_earlier_highlight`).
331        // Visually Crossfade{p=0} and Shown(old) coincide. At the very start of
332        // the morph (apply with local==0) the stage is again Crossfade{p=0} — the
333        // transition does not suffer. Spawn and typing in the "before the start"
334        // state are not a morph anyway.
335        *self.stage.borrow_mut() = match self.motion {
336            TextMotion::Smooth => TextStage::Shown(self.old.borrow().clone()),
337            _ => self.stage_at(0.0),
338        };
339    }
340
341    fn capture_from(&self) {
342        // Text animations are self-contained: "picking up" the signal's current
343        // value, as numeric tweens do, is not needed here.
344    }
345
346    fn apply(&self, t: f64) {
347        let local = if self.duration <= 0.0 {
348            1.0
349        } else {
350            (((t - self.start.get()) / self.duration).clamp(0.0, 1.0)) as f32
351        };
352        *self.stage.borrow_mut() = self.stage_at(local);
353    }
354
355    fn morph_group(&self) -> Option<*const ()> {
356        // The shared stage cell's identity = the text shape's identity.
357        Some(Rc::as_ptr(&self.stage) as *const ())
358    }
359
360    fn morph_from(&self) -> Option<String> {
361        Some(self.old.borrow().clone())
362    }
363
364    fn morph_new(&self) -> Option<String> {
365        Some(self.new.borrow().clone())
366    }
367
368    fn rebase(&self, old: &str, new: &str) {
369        *self.old.borrow_mut() = old.to_owned();
370        *self.new.borrow_mut() = new.to_owned();
371    }
372}
373
374/// A highlight animation leaf: on each frame it overwrites the shape's
375/// [`HighlightStage`], morphing the opacity from the set of ranges `from` to
376/// `to`.
377///
378/// Implements [`TweenObj`] directly (like [`TextTween`]): the highlight state is
379/// structural and self-contained, the transition endpoints are fixed at build
380/// time.
381pub(crate) struct HighlightTween {
382    /// The highlight-stage cell shared with its [`TextData`].
383    stage: Rc<RefCell<HighlightStage>>,
384    /// The "from" set of ranges (committed before the edit). Inside a
385    /// [`RefCell`], because [`parallel`](crate::parallel) resets the transition
386    /// endpoints to the common base/final of a group of overlapping edits of one
387    /// shape (see `rebase`).
388    from: RefCell<Vec<(TextPos, TextPos)>>,
389    /// The "to" set of ranges (committed after the edit; also resettable).
390    to: RefCell<Vec<(TextPos, TextPos)>>,
391    start: Cell<f64>,
392    duration: f64,
393    easing: Easing,
394}
395
396impl HighlightTween {
397    /// Builds an [`Action`] over the stage `stage` for the transition of the set
398    /// of ranges `from`→`to`.
399    pub(super) fn action(
400        stage: Rc<RefCell<HighlightStage>>,
401        from: Vec<(TextPos, TextPos)>,
402        to: Vec<(TextPos, TextPos)>,
403        duration: f64,
404        easing: Easing,
405    ) -> Action {
406        let leaf = HighlightTween {
407            stage,
408            from: RefCell::new(from),
409            to: RefCell::new(to),
410            start: Cell::new(0.0),
411            duration: duration.max(0.0),
412            easing,
413        };
414        Action::from_tween(Rc::new(leaf))
415    }
416
417    /// The frame stage for local progress `local` (`0..=1`, not yet eased).
418    fn stage_at(&self, local: f32) -> HighlightStage {
419        HighlightStage::Morph {
420            from: self.from.borrow().clone(),
421            to: self.to.borrow().clone(),
422            p: self.easing.apply(local),
423        }
424    }
425}
426
427impl TweenObj for HighlightTween {
428    fn duration(&self) -> f64 {
429        self.duration
430    }
431
432    fn start(&self) -> f64 {
433        self.start.get()
434    }
435
436    fn set_start(&self, start: f64) {
437        self.start.set(start);
438    }
439
440    fn reset(&self) {
441        // The "before the start" state: a morph at zero — the `from` set is drawn.
442        *self.stage.borrow_mut() = self.stage_at(0.0);
443    }
444
445    fn capture_from(&self) {
446        // The transition endpoints are fixed at build time — "picking up" is not needed.
447    }
448
449    fn apply(&self, t: f64) {
450        let local = if self.duration <= 0.0 {
451            1.0
452        } else {
453            (((t - self.start.get()) / self.duration).clamp(0.0, 1.0)) as f32
454        };
455        *self.stage.borrow_mut() = self.stage_at(local);
456    }
457
458    fn highlight_group(&self) -> Option<*const ()> {
459        // The shared highlight-stage cell's identity = the shape's identity.
460        Some(Rc::as_ptr(&self.stage) as *const ())
461    }
462
463    fn highlight_from(&self) -> Option<Vec<(TextPos, TextPos)>> {
464        Some(self.from.borrow().clone())
465    }
466
467    fn highlight_to(&self) -> Option<Vec<(TextPos, TextPos)>> {
468        Some(self.to.borrow().clone())
469    }
470
471    fn highlight_rebase(&self, from: Vec<(TextPos, TextPos)>, to: Vec<(TextPos, TextPos)>) {
472        *self.from.borrow_mut() = from;
473        *self.to.borrow_mut() = to;
474    }
475}
476
477/// The opacity of non-highlighted glyphs while a
478/// [`highlight`](crate::Shape::highlight) is active.
479const DEFAULT_DIM: f32 = 0.3;
480
481/// The text shape's state: content, font, style, animation stage and layout
482/// cache.
483///
484/// Lives inside [`ShapeData`](super::ShapeData) for shapes of kind
485/// [`ShapeKind::Text`](super::ShapeKind::Text). All fields use interior
486/// mutability, like the other shape properties.
487pub(crate) struct TextData {
488    /// The font file bytes (`.ttf`/`.otf`). `None` until a font is set — then the
489    /// text is not drawn and measures as zero.
490    font: RefCell<Option<Rc<Vec<u8>>>>,
491    /// The face index in the collection (`.ttc`); `0` for a regular file.
492    face_index: Cell<u32>,
493    /// The committed content (lines separated by `\n`) — what the editor methods
494    /// set. Drawn in full when the stage is [`TextStage::Base`].
495    text: RefCell<String>,
496    /// The current frame's animation stage. Shared with its [`TextTween`]s
497    /// (shared `Rc`): the animation overwrites it on the timeline, layout and
498    /// fill read it.
499    stage: Rc<RefCell<TextStage>>,
500    /// Font size in pixels.
501    pub size: Signal<f32>,
502    /// Glyph fill color.
503    pub color: Signal<Color>,
504    /// Tracking — extra gap between characters in pixels.
505    pub letter_spacing: Signal<f32>,
506    /// Line spacing as a multiplier of the font's natural line height.
507    pub line_height: Signal<f32>,
508    /// Line alignment.
509    pub align: Cell<TextAlign>,
510    /// The committed set of highlighted character ranges (half-open
511    /// `[from, to)`) — the base for [`HighlightStage::Base`]. Empty — no
512    /// highlighting, the whole text is bright; otherwise glyphs outside all
513    /// ranges dim down to [`DEFAULT_DIM`]. Changed by the highlight editor methods
514    /// ([`add_highlight`](TextData::add_highlight),
515    /// [`clear_highlights`](TextData::clear_highlights)).
516    highlights: RefCell<Vec<(TextPos, TextPos)>>,
517    /// The current frame's highlight stage. A cell separate from the text
518    /// `stage`, shared with its [`HighlightTween`]s: the animation overwrites it
519    /// on the timeline, the fill reads it.
520    highlight_stage: Rc<RefCell<HighlightStage>>,
521    /// A memo of the metrics (natural size) of the committed text, keyed by the
522    /// geometric properties. Animation stages are measured bypassing the memo
523    /// (they change every frame).
524    metrics_memo: RefCell<Option<(MetricsKey, (f32, f32))>>,
525    /// A memo of the finished path, keyed by the geometric properties and the
526    /// block width.
527    path_memo: RefCell<Option<(PathKey, Option<Rc<Path>>)>>,
528}
529
530/// The metrics-cache key: everything that affects the text's natural size.
531#[derive(Clone, PartialEq)]
532struct MetricsKey {
533    text: String,
534    size: f32,
535    letter_spacing: f32,
536    line_height: f32,
537}
538
539/// The path-cache key: the string being laid out plus the alignment and block
540/// width, on which the horizontal offset of lines depends.
541#[derive(Clone, PartialEq)]
542struct PathKey {
543    text: String,
544    size: f32,
545    letter_spacing: f32,
546    line_height: f32,
547    align: TextAlign,
548    width: f32,
549}
550
551impl TextData {
552    /// Creates text with default settings: no font, font size 32px, black color,
553    /// no tracking, line height `1.0`, left alignment, no animation.
554    pub(crate) fn new(text: String) -> Self {
555        TextData {
556            font: RefCell::new(None),
557            face_index: Cell::new(0),
558            text: RefCell::new(text),
559            stage: Rc::new(RefCell::new(TextStage::Base)),
560            size: Signal::new(32.0),
561            color: Signal::new(Color::BLACK),
562            letter_spacing: Signal::new(0.0),
563            line_height: Signal::new(1.0),
564            align: Cell::new(TextAlign::Left),
565            highlights: RefCell::new(Vec::new()),
566            highlight_stage: Rc::new(RefCell::new(HighlightStage::Base)),
567            metrics_memo: RefCell::new(None),
568            path_memo: RefCell::new(None),
569        }
570    }
571
572    /// Sets the font from the file bytes and clears the layout cache (the font's
573    /// identity is not part of the cache keys).
574    pub(crate) fn set_font(&self, bytes: Rc<Vec<u8>>, index: u32) {
575        *self.font.borrow_mut() = Some(bytes);
576        self.face_index.set(index);
577        self.invalidate();
578    }
579
580    /// Changes the committed content. The cache is keyed by the text and will
581    /// miss on its own.
582    pub(crate) fn set_text(&self, text: String) {
583        *self.text.borrow_mut() = text;
584    }
585
586    /// The committed content (a clone) — what the editor methods read as the
587    /// "previous" text.
588    pub(crate) fn get_text(&self) -> String {
589        self.text.borrow().clone()
590    }
591
592    /// The shared animation-stage cell — the [`TextTween`] receives it at build
593    /// time.
594    pub(crate) fn stage_handle(&self) -> Rc<RefCell<TextStage>> {
595        Rc::clone(&self.stage)
596    }
597
598    /// Clears both memos.
599    fn invalidate(&self) {
600        *self.metrics_memo.borrow_mut() = None;
601        *self.path_memo.borrow_mut() = None;
602    }
603
604    /// The current fill color.
605    pub(crate) fn color(&self) -> Color {
606        self.color.get()
607    }
608
609    /// Adds a highlighted character range `[from, to)` to the committed set.
610    /// Ranges accumulate — there can be several highlights.
611    pub(crate) fn add_highlight(&self, from: TextPos, to: TextPos) {
612        self.highlights.borrow_mut().push((from, to));
613    }
614
615    /// Clears the committed set of highlighted ranges — the whole text is bright
616    /// again.
617    pub(crate) fn clear_highlights(&self) {
618        self.highlights.borrow_mut().clear();
619    }
620
621    /// The committed set of highlighted ranges (a clone) — the "from"/"to" for
622    /// the highlight handle.
623    pub(crate) fn get_highlights(&self) -> Vec<(TextPos, TextPos)> {
624        self.highlights.borrow().clone()
625    }
626
627    /// The shared highlight-stage cell — the [`HighlightTween`] receives it at
628    /// build time.
629    pub(crate) fn highlight_stage_handle(&self) -> Rc<RefCell<HighlightStage>> {
630        Rc::clone(&self.highlight_stage)
631    }
632
633    /// The current frame's natural size `(width, height)` in pixels, accounting
634    /// for the animation stage: for typing — by the visible substring (the block
635    /// expands as it is typed), for smoothing — a `from`→`to` width/height morph.
636    /// Without a font — `(0, 0)`. For static text ([`TextStage::Base`]) the
637    /// result is memoized.
638    pub(crate) fn natural_size(&self) -> (f32, f32) {
639        match &*self.stage.borrow() {
640            TextStage::Base => self.measure_committed(),
641            TextStage::Shown(s) => self.measure_text(s),
642            TextStage::Typing { text, visible } => self.measure_text(&prefix_chars(text, *visible)),
643            TextStage::Crossfade { from, to, p } => {
644                let (fw, fh) = self.measure_text(from);
645                let (tw, th) = self.measure_text(to);
646                (lerp_f32(fw, tw, *p), lerp_f32(fh, th, *p))
647            }
648        }
649    }
650
651    /// The current frame's finished glyph path in the content-area coordinate
652    /// system (top-left corner at `(0, 0)`), with the alignment for width
653    /// `content_w` already applied. `None` if there is nothing to draw (no font,
654    /// empty or whitespace text). The result is memoized by the string being laid
655    /// out.
656    pub(crate) fn layout_path(&self, content_w: f32) -> Option<Rc<Path>> {
657        let key = PathKey {
658            text: self.layout_text(),
659            size: self.size.get(),
660            letter_spacing: self.letter_spacing.get(),
661            line_height: self.line_height.get(),
662            align: self.align.get(),
663            width: content_w,
664        };
665        if let Some((k, v)) = self.path_memo.borrow().as_ref() {
666            if *k == key {
667                return v.clone();
668            }
669        }
670        let built = self
671            .with_font(|font| {
672                build_block_path(
673                    font,
674                    &key.text,
675                    key.size,
676                    key.letter_spacing,
677                    key.line_height,
678                    key.align,
679                    content_w,
680                )
681            })
682            .flatten()
683            .map(Rc::new);
684        *self.path_memo.borrow_mut() = Some((key, built.clone()));
685        built
686    }
687
688    /// The current frame's fill layers: the glyph path and an opacity multiplier
689    /// for each. For static, spawn and typing — a single layer with alpha `1`.
690    ///
691    /// For smoothing ([`TextStage::Crossfade`]) the frame is laid out by a
692    /// per-character diff `from`→`to` (see [`morph_layers`](Self::morph_layers)):
693    /// the common characters form an opaque layer and smoothly travel from their
694    /// positions in `from` to the positions in `to`, while the changed regions
695    /// crossfade (the old fades, the new appears). So the lines not touched by the
696    /// edit do not flicker, and the appended/removed fragments appear and
697    /// disappear in place — including in multi-line text.
698    pub(crate) fn draw_layers(&self, content_w: f32) -> Vec<(Rc<Path>, f32)> {
699        let stage = self.stage.borrow().clone();
700        if let TextStage::Crossfade { from, to, p } = &stage {
701            return self.morph_layers(from, to, *p, content_w);
702        }
703        // Highlighting inactive — a single memoized path (the static hot path).
704        if self.highlight_idle() {
705            return self
706                .layout_path(content_w)
707                .map(|path| vec![(path, 1.0)])
708                .unwrap_or_default();
709        }
710        let text = self.layout_text();
711        match self.highlight_alphas(&text) {
712            None => self
713                .layout_path(content_w)
714                .map(|path| vec![(path, 1.0)])
715                .unwrap_or_default(),
716            // With highlighting — glyphs are grouped by their opacity (the path is
717            // not memoized, as for a code shape).
718            Some(alphas) => self.alpha_layers(&text, content_w, &alphas),
719        }
720    }
721
722    /// The highlighted frame's layers: the glyphs of `text` are grouped by their
723    /// opacity `alphas` (aligned to `chars()`). Fully transparent glyphs are
724    /// dropped.
725    fn alpha_layers(&self, text: &str, content_w: f32, alphas: &[f32]) -> Vec<(Rc<Path>, f32)> {
726        let size = self.size.get();
727        let ls = self.letter_spacing.get();
728        let lh = self.line_height.get();
729        let align = self.align.get();
730        self.with_font(|font| {
731            let positions = char_positions(font, text, size, ls, lh, align, content_w);
732            let mut groups: Vec<(f32, PathBuilder)> = Vec::new();
733            for (i, ch) in text.chars().enumerate() {
734                if let Some((x, y)) = positions[i] {
735                    let alpha = alphas.get(i).copied().unwrap_or(1.0);
736                    if alpha <= 0.0 {
737                        continue;
738                    }
739                    place_glyph(alpha_group(&mut groups, alpha), font, ch, size, x, y);
740                }
741            }
742            groups
743                .into_iter()
744                .filter_map(|(alpha, b)| b.finish().map(|path| (Rc::new(path), alpha)))
745                .collect()
746        })
747        .unwrap_or_default()
748    }
749
750    /// Smoothing layout by a per-character diff `from`→`to` with multi-line text
751    /// support.
752    ///
753    /// The diff (longest common substring, recursively — see [`diff_runs`]) splits
754    /// both texts into common and changed regions. Common characters are placed
755    /// into an opaque layer at position `lerp(position in from, position in to,
756    /// p)`: the unchanged part of the text does not flicker, but merely travels to
757    /// its new place as the layout morphs. Each changed region yields two
758    /// semi-transparent layers — the removed characters of `from` fade out (at
759    /// their positions in `from`), the added characters of `to` appear (at their
760    /// positions in `to`) — with the same alphas as [`crossfade_mid_alpha`].
761    fn morph_layers(&self, from: &str, to: &str, p: f32, content_w: f32) -> Vec<(Rc<Path>, f32)> {
762        let size = self.size.get();
763        let ls = self.letter_spacing.get();
764        let lh = self.line_height.get();
765        let align = self.align.get();
766        self.with_font(|font| {
767            let from_chars: Vec<char> = from.chars().collect();
768            let to_chars: Vec<char> = to.chars().collect();
769            let from_pos = char_positions(font, from, size, ls, lh, align, content_w);
770            let to_pos = char_positions(font, to, size, ls, lh, align, content_w);
771
772            let mut ops = Vec::new();
773            diff_runs(&from_chars, &to_chars, 0, 0, &mut ops);
774            let (old_a, new_a) = crossfade_mid_alpha(p);
775
776            let mut common = PathBuilder::new();
777            let mut deleted = PathBuilder::new();
778            let mut inserted = PathBuilder::new();
779            for op in ops {
780                match op {
781                    DiffOp::Common { a, b, len } => {
782                        for k in 0..len {
783                            if let (Some((fx, fy)), Some((tx, ty))) = (from_pos[a + k], to_pos[b + k]) {
784                                let x = lerp_f32(fx, tx, p);
785                                let y = lerp_f32(fy, ty, p);
786                                place_glyph(&mut common, font, to_chars[b + k], size, x, y);
787                            }
788                        }
789                    }
790                    DiffOp::Replace { a, alen, b, blen } => {
791                        if old_a > 0.0 {
792                            for k in 0..alen {
793                                if let Some((fx, fy)) = from_pos[a + k] {
794                                    place_glyph(&mut deleted, font, from_chars[a + k], size, fx, fy);
795                                }
796                            }
797                        }
798                        if new_a > 0.0 {
799                            for k in 0..blen {
800                                if let Some((tx, ty)) = to_pos[b + k] {
801                                    place_glyph(&mut inserted, font, to_chars[b + k], size, tx, ty);
802                                }
803                            }
804                        }
805                    }
806                }
807            }
808
809            let mut layers: Vec<(Rc<Path>, f32)> = Vec::new();
810            if let Some(path) = common.finish() {
811                layers.push((Rc::new(path), 1.0));
812            }
813            if old_a > 0.0 {
814                if let Some(path) = deleted.finish() {
815                    layers.push((Rc::new(path), old_a));
816                }
817            }
818            if new_a > 0.0 {
819                if let Some(path) = inserted.finish() {
820                    layers.push((Rc::new(path), new_a));
821                }
822            }
823            layers
824        })
825        .unwrap_or_default()
826    }
827
828    /// The current frame's colored layers for a code shape: one path per color and
829    /// an opacity multiplier. Unlike [`draw_layers`](Self::draw_layers) (single
830    /// color, memoized path), here glyphs are grouped by the color that `colorize`
831    /// returns for each character of the string being laid out (aligned to
832    /// `chars()`); characters without their own color get `default`.
833    ///
834    /// The static, spawn and typing stages produce opaque (`alpha == 1`) groups
835    /// over the visible string. Smoothing ([`TextStage::Crossfade`]) is laid out
836    /// by the same per-character diff as [`morph_layers`](Self::morph_layers): the
837    /// common glyphs travel `from`→`to` (the color is taken from `to`), and the
838    /// changed ones crossfade (the removed are colored by `from`, the added — by
839    /// `to`).
840    pub(crate) fn draw_layers_colored(
841        &self,
842        content_w: f32,
843        default: Color,
844        colorize: &dyn Fn(&str) -> Rc<Vec<Color>>,
845    ) -> Vec<(Rc<Path>, Color, f32)> {
846        let stage = self.stage.borrow().clone();
847        if let TextStage::Crossfade { from, to, p } = &stage {
848            return self.morph_layers_colored(from, to, *p, content_w, default, colorize);
849        }
850        self.colored_block_layers(&self.layout_text(), content_w, default, colorize)
851    }
852
853    /// Path groups by color and opacity for a single laid-out string `text`.
854    /// Without highlighting — all groups are opaque (alpha `1`); with an active
855    /// highlight glyphs are also grouped by their opacity
856    /// ([`highlight_alphas`](Self::highlight_alphas)) while keeping the color, so
857    /// the non-highlighted regions dim.
858    fn colored_block_layers(
859        &self,
860        text: &str,
861        content_w: f32,
862        default: Color,
863        colorize: &dyn Fn(&str) -> Rc<Vec<Color>>,
864    ) -> Vec<(Rc<Path>, Color, f32)> {
865        let size = self.size.get();
866        let ls = self.letter_spacing.get();
867        let lh = self.line_height.get();
868        let align = self.align.get();
869        let alphas = self.highlight_alphas(text);
870        self.with_font(|font| {
871            let positions = char_positions(font, text, size, ls, lh, align, content_w);
872            let colors = colorize(text);
873            let mut groups: Vec<(Color, f32, PathBuilder)> = Vec::new();
874            for (i, ch) in text.chars().enumerate() {
875                if let Some((x, y)) = positions[i] {
876                    let color = colors.get(i).copied().unwrap_or(default);
877                    let alpha = alphas.as_ref().and_then(|a| a.get(i).copied()).unwrap_or(1.0);
878                    if alpha <= 0.0 {
879                        continue;
880                    }
881                    place_glyph(color_alpha_group(&mut groups, color, alpha), font, ch, size, x, y);
882                }
883            }
884            groups
885                .into_iter()
886                .filter_map(|(color, alpha, b)| b.finish().map(|path| (Rc::new(path), color, alpha)))
887                .collect()
888        })
889        .unwrap_or_default()
890    }
891
892    /// Highlighting inactive: no committed ranges and no animation — a cheap check
893    /// for the static hot path.
894    fn highlight_idle(&self) -> bool {
895        matches!(&*self.highlight_stage.borrow(), HighlightStage::Base)
896            && self.highlights.borrow().is_empty()
897    }
898
899    /// The opacity of each character of `text` (aligned to `chars()`) for the
900    /// current highlight stage, or `None` if highlighting is inactive and the
901    /// whole text is bright (the hot path). Ranges are resolved against the
902    /// frame's laid-out string itself, so `line`/`infinite` are taken relative to
903    /// it.
904    fn highlight_alphas(&self, text: &str) -> Option<Vec<f32>> {
905        let count = text.chars().count();
906        match &*self.highlight_stage.borrow() {
907            HighlightStage::Base => {
908                let committed = self.highlights.borrow();
909                if committed.is_empty() {
910                    return None;
911                }
912                let ranges = resolve_ranges(&committed, text);
913                Some((0..count).map(|i| alpha_for(i, &ranges)).collect())
914            }
915            HighlightStage::Morph { from, to, p } => {
916                let rf = resolve_ranges(from, text);
917                let rt = resolve_ranges(to, text);
918                Some((0..count).map(|i| lerp_f32(alpha_for(i, &rf), alpha_for(i, &rt), *p)).collect())
919            }
920        }
921    }
922
923    /// The colored counterpart of [`morph_layers`](Self::morph_layers): the same
924    /// common, removed and added regions, but each laid out into path groups by
925    /// color (removed — by the `from` palette, common and added — by `to`).
926    fn morph_layers_colored(
927        &self,
928        from: &str,
929        to: &str,
930        p: f32,
931        content_w: f32,
932        default: Color,
933        colorize: &dyn Fn(&str) -> Rc<Vec<Color>>,
934    ) -> Vec<(Rc<Path>, Color, f32)> {
935        let size = self.size.get();
936        let ls = self.letter_spacing.get();
937        let lh = self.line_height.get();
938        let align = self.align.get();
939        self.with_font(|font| {
940            let from_chars: Vec<char> = from.chars().collect();
941            let to_chars: Vec<char> = to.chars().collect();
942            let from_pos = char_positions(font, from, size, ls, lh, align, content_w);
943            let to_pos = char_positions(font, to, size, ls, lh, align, content_w);
944            let from_col = colorize(from);
945            let to_col = colorize(to);
946
947            let mut ops = Vec::new();
948            diff_runs(&from_chars, &to_chars, 0, 0, &mut ops);
949            let (old_a, new_a) = crossfade_mid_alpha(p);
950
951            let mut common: Vec<(Color, PathBuilder)> = Vec::new();
952            let mut deleted: Vec<(Color, PathBuilder)> = Vec::new();
953            let mut inserted: Vec<(Color, PathBuilder)> = Vec::new();
954            for op in ops {
955                match op {
956                    DiffOp::Common { a, b, len } => {
957                        for k in 0..len {
958                            if let (Some((fx, fy)), Some((tx, ty))) = (from_pos[a + k], to_pos[b + k]) {
959                                let x = lerp_f32(fx, tx, p);
960                                let y = lerp_f32(fy, ty, p);
961                                let color = to_col.get(b + k).copied().unwrap_or(default);
962                                place_glyph(group_for(&mut common, color), font, to_chars[b + k], size, x, y);
963                            }
964                        }
965                    }
966                    DiffOp::Replace { a, alen, b, blen } => {
967                        if old_a > 0.0 {
968                            for k in 0..alen {
969                                if let Some((fx, fy)) = from_pos[a + k] {
970                                    let color = from_col.get(a + k).copied().unwrap_or(default);
971                                    place_glyph(group_for(&mut deleted, color), font, from_chars[a + k], size, fx, fy);
972                                }
973                            }
974                        }
975                        if new_a > 0.0 {
976                            for k in 0..blen {
977                                if let Some((tx, ty)) = to_pos[b + k] {
978                                    let color = to_col.get(b + k).copied().unwrap_or(default);
979                                    place_glyph(group_for(&mut inserted, color), font, to_chars[b + k], size, tx, ty);
980                                }
981                            }
982                        }
983                    }
984                }
985            }
986
987            let mut layers = finish_groups(common, 1.0);
988            if old_a > 0.0 {
989                layers.extend(finish_groups(deleted, old_a));
990            }
991            if new_a > 0.0 {
992                layers.extend(finish_groups(inserted, new_a));
993            }
994            layers
995        })
996        .unwrap_or_default()
997    }
998
999    /// The string to lay out on the current frame, based on the stage.
1000    fn layout_text(&self) -> String {
1001        match &*self.stage.borrow() {
1002            TextStage::Base => self.text.borrow().clone(),
1003            TextStage::Shown(s) => s.clone(),
1004            TextStage::Typing { text, visible } => prefix_chars(text, *visible),
1005            TextStage::Crossfade { from, to, p } => {
1006                if *p < 0.5 {
1007                    from.clone()
1008                } else {
1009                    to.clone()
1010                }
1011            }
1012        }
1013    }
1014
1015    /// The natural size of the committed text with memoization (the static hot path).
1016    fn measure_committed(&self) -> (f32, f32) {
1017        let key = self.metrics_key();
1018        if let Some((k, v)) = self.metrics_memo.borrow().as_ref() {
1019            if *k == key {
1020                return *v;
1021            }
1022        }
1023        let result = self.measure_text(&key.text);
1024        *self.metrics_memo.borrow_mut() = Some((key, result));
1025        result
1026    }
1027
1028    /// The natural size of an arbitrary string in the current style (without memo).
1029    fn measure_text(&self, text: &str) -> (f32, f32) {
1030        self.with_font(|font| {
1031            measure_block(font, text, self.size.get(), self.letter_spacing.get(), self.line_height.get())
1032        })
1033        .unwrap_or((0.0, 0.0))
1034    }
1035
1036    /// A snapshot of the properties affecting the committed text's metrics.
1037    fn metrics_key(&self) -> MetricsKey {
1038        MetricsKey {
1039            text: self.text.borrow().clone(),
1040            size: self.size.get(),
1041            letter_spacing: self.letter_spacing.get(),
1042            line_height: self.line_height.get(),
1043        }
1044    }
1045
1046    /// Parses the font from the bytes and runs `f` over it. `None` if the font is
1047    /// not set or the bytes do not parse as a font.
1048    fn with_font<R>(&self, f: impl FnOnce(&Font) -> R) -> Option<R> {
1049        let bytes = self.font.borrow().clone()?;
1050        let font = Font::from_collection(bytes.as_slice(), self.face_index.get()).ok()?;
1051        Some(f(&font))
1052    }
1053}
1054
1055/// The length of the common prefix of two strings in characters (Unicode scalar).
1056///
1057/// Typing types only the "tail" after the start already shared with the previous
1058/// text.
1059pub(super) fn common_prefix_chars(a: &str, b: &str) -> usize {
1060    a.chars().zip(b.chars()).take_while(|(x, y)| x == y).count()
1061}
1062
1063/// The alphas of the old and new middle of the crossfade by progress `p`: in the
1064/// first half the old fades (`1→0`), in the second the new appears (`0→1`); at the
1065/// seam both are `0`.
1066fn crossfade_mid_alpha(p: f32) -> (f32, f32) {
1067    if p < 0.5 {
1068        (1.0 - p * 2.0, 0.0)
1069    } else {
1070        (0.0, (p - 0.5) * 2.0)
1071    }
1072}
1073
1074/// The pen positions of each character of `text` in content-area coordinates:
1075/// `(x, baseline)` for drawable characters and `None` for `\n` (a line break has
1076/// no glyph). The index in the result matches the character index in `text`, so
1077/// the array can be addressed by indices from [`diff_runs`]. The layout is
1078/// multi-line: the first baseline is at `ascent`, each next one lower by the line
1079/// step; within a line characters are aligned by `align` relative to the width
1080/// `content_w`.
1081fn char_positions(
1082    font: &Font,
1083    text: &str,
1084    size: f32,
1085    letter_spacing: f32,
1086    line_height: f32,
1087    align: TextAlign,
1088    content_w: f32,
1089) -> Vec<Option<(f32, f32)>> {
1090    let ascent = font.ascent(size);
1091    let step = font.line_height(size) * line_height;
1092    let lines: Vec<&str> = text.split('\n').collect();
1093    let mut out = Vec::new();
1094    for (i, line) in lines.iter().enumerate() {
1095        let baseline = ascent + i as f32 * step;
1096        let lw = line_width(font, line, size, letter_spacing);
1097        let mut pen = match align {
1098            TextAlign::Left => 0.0,
1099            TextAlign::Center => (content_w - lw) * 0.5,
1100            TextAlign::Right => content_w - lw,
1101        };
1102        for ch in line.chars() {
1103            out.push(Some((pen, baseline)));
1104            pen += font.advance_width(ch, size) + letter_spacing;
1105        }
1106        // A placeholder position for the separating `\n` between lines.
1107        if i + 1 < lines.len() {
1108            out.push(None);
1109        }
1110    }
1111    out
1112}
1113
1114/// Appends the outline of character `ch` to the builder: pen at `x`, baseline
1115/// `baseline`. Whitespace characters (with no outline) add nothing.
1116fn place_glyph(builder: &mut PathBuilder, font: &Font, ch: char, size: f32, x: f32, baseline: f32) {
1117    if let Some(glyph) = font.glyph_path(ch, size, x, baseline) {
1118        append_segments(builder, glyph.segments());
1119    }
1120}
1121
1122/// Returns the group builder for color `color`, creating a new one on its first
1123/// appearance. There are few distinct colors in the layout (the palette size), so
1124/// a linear search is cheaper than a hash map — especially since [`Color`] is not
1125/// hashable.
1126fn group_for(groups: &mut Vec<(Color, PathBuilder)>, color: Color) -> &mut PathBuilder {
1127    match groups.iter().position(|(c, _)| *c == color) {
1128        Some(i) => &mut groups[i].1,
1129        None => {
1130            groups.push((color, PathBuilder::new()));
1131            &mut groups.last_mut().expect("the just-added group").1
1132        }
1133    }
1134}
1135
1136/// Whether the character at index `i` falls into at least one half-open range `[a, b)`.
1137fn in_ranges(i: usize, ranges: &[(usize, usize)]) -> bool {
1138    ranges.iter().any(|&(a, b)| i >= a && i < b)
1139}
1140
1141/// Resolves the highlight ranges into half-open `[a, b)` character indices
1142/// relative to `text` (the bounds of each are ordered).
1143fn resolve_ranges(ranges: &[(TextPos, TextPos)], text: &str) -> Vec<(usize, usize)> {
1144    ranges
1145        .iter()
1146        .map(|(from, to)| {
1147            let mut a = from.resolve(text);
1148            let mut b = to.resolve(text);
1149            if a > b {
1150                std::mem::swap(&mut a, &mut b);
1151            }
1152            (a, b)
1153        })
1154        .collect()
1155}
1156
1157/// The opacity of character `i`: full (`1`) for highlighted ones — and also when
1158/// there are no ranges at all — otherwise [`DEFAULT_DIM`].
1159fn alpha_for(i: usize, ranges: &[(usize, usize)]) -> f32 {
1160    if ranges.is_empty() || in_ranges(i, ranges) {
1161        1.0
1162    } else {
1163        DEFAULT_DIM
1164    }
1165}
1166
1167/// The group builder for opacity `alpha` (a linear search with tolerance — only a
1168/// few distinct values per frame).
1169fn alpha_group(groups: &mut Vec<(f32, PathBuilder)>, alpha: f32) -> &mut PathBuilder {
1170    match groups.iter().position(|(a, _)| (a - alpha).abs() < 1e-4) {
1171        Some(i) => &mut groups[i].1,
1172        None => {
1173            groups.push((alpha, PathBuilder::new()));
1174            &mut groups.last_mut().expect("the just-added group").1
1175        }
1176    }
1177}
1178
1179/// The group builder for a (color, opacity) pair — the colored counterpart of
1180/// [`alpha_group`] for a code shape.
1181fn color_alpha_group(
1182    groups: &mut Vec<(Color, f32, PathBuilder)>,
1183    color: Color,
1184    alpha: f32,
1185) -> &mut PathBuilder {
1186    match groups.iter().position(|(c, a, _)| *c == color && (a - alpha).abs() < 1e-4) {
1187        Some(i) => &mut groups[i].2,
1188        None => {
1189            groups.push((color, alpha, PathBuilder::new()));
1190            &mut groups.last_mut().expect("the just-added group").2
1191        }
1192    }
1193}
1194
1195/// Finalizes the groups into `(path, color, alpha)` layers, dropping empty
1196/// (whitespace) groups with no outlines.
1197fn finish_groups(groups: Vec<(Color, PathBuilder)>, alpha: f32) -> Vec<(Rc<Path>, Color, f32)> {
1198    groups
1199        .into_iter()
1200        .filter_map(|(color, builder)| builder.finish().map(|path| (Rc::new(path), color, alpha)))
1201        .collect()
1202}
1203
1204/// A step of the per-character diff `from`→`to` for the smoothing morph
1205/// ([`diff_runs`]).
1206enum DiffOp {
1207    /// A common region: `len` characters matching in both texts, starting at
1208    /// index `a` in `from` and `b` in `to`.
1209    Common { a: usize, b: usize, len: usize },
1210    /// A changed region: `alen` characters of `from` (from index `a`) are replaced
1211    /// by `blen` characters of `to` (from index `b`). Either length may be zero —
1212    /// a pure insertion or deletion.
1213    Replace { a: usize, alen: usize, b: usize, blen: usize },
1214}
1215
1216/// The minimum length of a common substring that the morph treats as a meaningful
1217/// anchor rather than a coincidental match of characters.
1218///
1219/// An anchor ([`DiffOp::Common`]) travels from its place in `from` to its place
1220/// in `to` — this is needed for the unchanged part of the text (wrapping in
1221/// braces, an appended tail, etc.). But on a real text change the longest common
1222/// substring degenerates into one or two coincidentally matching letters: for
1223/// example, "Привет мир!" → "На дворе {age} год" shares "р" and "е" — and those
1224/// would "fly" from the old positions to the new ones. That must not happen: the
1225/// disappearing text should not move glyphs. So a common region shorter than this
1226/// threshold is treated as changed and fades in place (becomes part of
1227/// [`DiffOp::Replace`]).
1228const MIN_COMMON_RUN: usize = 3;
1229
1230/// Lays out the difference `from`→`to` into a sequence of [`DiffOp`] by a
1231/// divide-and-conquer over the longest common substring: a sufficiently long
1232/// common chunk becomes an anchor ([`DiffOp::Common`]), and the regions to its
1233/// left and right are diffed recursively. If there is no common substring or it
1234/// is shorter than [`MIN_COMMON_RUN`] (a coincidental letter match on a text
1235/// change rather than a real unchanged fragment), the whole remainder is one
1236/// replacement ([`DiffOp::Replace`]) — the disappearing text fades in place,
1237/// moving nothing. The exception is fully-matching slices (including equal
1238/// `from`/`to`): they stay an anchor at any length so unchanged text does not
1239/// flicker. `ai`/`bi` are the absolute offsets of slices `a`/`b` in the original
1240/// texts (for the indices in the output operations).
1241fn diff_runs(a: &[char], b: &[char], ai: usize, bi: usize, out: &mut Vec<DiffOp>) {
1242    if a.is_empty() && b.is_empty() {
1243        return;
1244    }
1245    let (la, lb, len) = longest_common_substring(a, b);
1246    let whole = len == a.len() && len == b.len();
1247    if len == 0 || (len < MIN_COMMON_RUN && !whole) {
1248        out.push(DiffOp::Replace { a: ai, alen: a.len(), b: bi, blen: b.len() });
1249        return;
1250    }
1251    diff_runs(&a[..la], &b[..lb], ai, bi, out);
1252    out.push(DiffOp::Common { a: ai + la, b: bi + lb, len });
1253    diff_runs(&a[la + len..], &b[lb + len..], ai + la + len, bi + lb + len, out);
1254}
1255
1256/// The longest common substring of `a` and `b`: returns `(start in a, start in b,
1257/// length)`; length `0` if there are no common characters. A classic DP over two
1258/// strings in `O(|a|·|b|)` — smoothing strings are short, so this is enough.
1259fn longest_common_substring(a: &[char], b: &[char]) -> (usize, usize, usize) {
1260    if a.is_empty() || b.is_empty() {
1261        return (0, 0, 0);
1262    }
1263    let (mut best_a, mut best_b, mut best) = (0, 0, 0);
1264    let mut prev = vec![0usize; b.len() + 1];
1265    let mut curr = vec![0usize; b.len() + 1];
1266    for i in 1..=a.len() {
1267        for j in 1..=b.len() {
1268            curr[j] = if a[i - 1] == b[j - 1] { prev[j - 1] + 1 } else { 0 };
1269            if curr[j] > best {
1270                best = curr[j];
1271                best_a = i - curr[j];
1272                best_b = j - curr[j];
1273            }
1274        }
1275        std::mem::swap(&mut prev, &mut curr);
1276    }
1277    (best_a, best_b, best)
1278}
1279
1280/// Inserts `ins` into `text` before the character at index `char_index` (0-based,
1281/// clamped to `[0, length]`). Returns a new string.
1282pub(super) fn insert_at(text: &str, char_index: usize, ins: &str) -> String {
1283    let count = text.chars().count();
1284    let at = char_to_byte(text, char_index.min(count));
1285    let mut out = String::with_capacity(text.len() + ins.len());
1286    out.push_str(&text[..at]);
1287    out.push_str(ins);
1288    out.push_str(&text[at..]);
1289    out
1290}
1291
1292/// Replaces the half-open character range `[from, to)` with `ins`. The bounds are
1293/// resolved via [`TextPos::resolve`] and swapped if needed, so `from > to` does
1294/// not panic. Returns a new string.
1295pub(super) fn rewrite_range(text: &str, from: TextPos, to: TextPos, ins: &str) -> String {
1296    let mut a = from.resolve(text);
1297    let mut b = to.resolve(text);
1298    if a > b {
1299        std::mem::swap(&mut a, &mut b);
1300    }
1301    let ba = char_to_byte(text, a);
1302    let bb = char_to_byte(text, b);
1303    let mut out = String::with_capacity(text.len() + ins.len());
1304    out.push_str(&text[..ba]);
1305    out.push_str(ins);
1306    out.push_str(&text[bb..]);
1307    out
1308}
1309
1310/// The byte offset of the start of the character at index `char_index`. An index
1311/// past the end gives the string length.
1312fn char_to_byte(text: &str, char_index: usize) -> usize {
1313    text.char_indices().nth(char_index).map(|(b, _)| b).unwrap_or(text.len())
1314}
1315
1316/// The (0-based) character index of the first character of line `line` (0-based).
1317/// Lines are separated by `\n`. An index past the last line is the end of the
1318/// text.
1319fn line_start_char(text: &str, line: usize) -> usize {
1320    if line == 0 {
1321        return 0;
1322    }
1323    let mut seen = 0;
1324    for (i, ch) in text.chars().enumerate() {
1325        if ch == '\n' {
1326            seen += 1;
1327            if seen == line {
1328                return i + 1;
1329            }
1330        }
1331    }
1332    text.chars().count()
1333}
1334
1335/// The first `floor(visible)` characters of the string (the typing visible substring).
1336fn prefix_chars(text: &str, visible: f32) -> String {
1337    let n = visible.max(0.0).floor() as usize;
1338    text.chars().take(n).collect()
1339}
1340
1341/// Linear interpolation `a`→`b` by `t`.
1342fn lerp_f32(a: f32, b: f32, t: f32) -> f32 {
1343    a + (b - a) * t
1344}
1345
1346/// The width of a single line in pixels: the sum of horizontal advances plus
1347/// tracking between characters (no kerning).
1348fn line_width(font: &Font, line: &str, size: f32, letter_spacing: f32) -> f32 {
1349    let mut width = 0.0;
1350    let mut count = 0u32;
1351    for ch in line.chars() {
1352        width += font.advance_width(ch, size);
1353        count += 1;
1354    }
1355    if count > 1 {
1356        width += letter_spacing * (count - 1) as f32;
1357    }
1358    width
1359}
1360
1361/// The natural size of a text block: width — the widest line, height —
1362/// `ascent + descent` plus the line gaps between lines.
1363fn measure_block(font: &Font, text: &str, size: f32, letter_spacing: f32, line_height: f32) -> (f32, f32) {
1364    let mut widest = 0.0f32;
1365    let mut lines = 0u32;
1366    for line in text.split('\n') {
1367        lines += 1;
1368        widest = widest.max(line_width(font, line, size, letter_spacing));
1369    }
1370    let lines = lines.max(1);
1371    let step = font.line_height(size) * line_height;
1372    let height = font.ascent(size) + font.descent(size) + (lines - 1) as f32 * step;
1373    (widest, height)
1374}
1375
1376/// Assembles a single path of all the block's glyphs in content-area coordinates
1377/// (origin at `(0, 0)`). The first baseline is at `ascent`, each next one lower by
1378/// the line step. Within a line characters are aligned by `align` relative to the
1379/// width `content_w`.
1380fn build_block_path(
1381    font: &Font,
1382    text: &str,
1383    size: f32,
1384    letter_spacing: f32,
1385    line_height: f32,
1386    align: TextAlign,
1387    content_w: f32,
1388) -> Option<Path> {
1389    let ascent = font.ascent(size);
1390    let step = font.line_height(size) * line_height;
1391    let mut builder = PathBuilder::new();
1392
1393    for (i, line) in text.split('\n').enumerate() {
1394        let baseline = ascent + i as f32 * step;
1395        let lw = line_width(font, line, size, letter_spacing);
1396        let mut pen = match align {
1397            TextAlign::Left => 0.0,
1398            TextAlign::Center => (content_w - lw) * 0.5,
1399            TextAlign::Right => content_w - lw,
1400        };
1401        for ch in line.chars() {
1402            if let Some(glyph) = font.glyph_path(ch, size, pen, baseline) {
1403                append_segments(&mut builder, glyph.segments());
1404            }
1405            pen += font.advance_width(ch, size) + letter_spacing;
1406        }
1407    }
1408
1409    builder.finish()
1410}
1411
1412/// Appends the outlines of a single glyph to the shared builder.
1413fn append_segments(builder: &mut PathBuilder, segments: &[PathSegment]) {
1414    for seg in segments {
1415        match *seg {
1416            PathSegment::MoveTo(p) => {
1417                builder.move_to(p.x, p.y);
1418            }
1419            PathSegment::LineTo(p) => {
1420                builder.line_to(p.x, p.y);
1421            }
1422            PathSegment::QuadTo(c, p) => {
1423                builder.quad_to(c.x, c.y, p.x, p.y);
1424            }
1425            PathSegment::CubicTo(c1, c2, p) => {
1426                builder.cubic_to(c1.x, c1.y, c2.x, c2.y, p.x, p.y);
1427            }
1428            PathSegment::Close => {
1429                builder.close();
1430            }
1431        }
1432    }
1433}
1434
1435#[cfg(test)]
1436mod tests {
1437    use super::*;
1438
1439    #[test]
1440    fn line_start_char_indexes_lines() {
1441        let t = "abc\nDEF\nxyz";
1442        assert_eq!(line_start_char(t, 0), 0);
1443        assert_eq!(line_start_char(t, 1), 4); // after the first '\n'
1444        assert_eq!(line_start_char(t, 2), 8);
1445        // Past the last line — the end of the text.
1446        assert_eq!(line_start_char(t, 3), t.chars().count());
1447        assert_eq!(line_start_char(t, 99), t.chars().count());
1448    }
1449
1450    #[test]
1451    fn rewrite_range_replaces_half_open() {
1452        let t = "abc\nDEF\nxyz";
1453        // [0, line(1)) — the whole first line together with its '\n'.
1454        assert_eq!(rewrite_range(t, TextPos::Char(0), TextPos::Line(1), "X"), "XDEF\nxyz");
1455        // [line(1), infinite) — the tail from the second line.
1456        assert_eq!(rewrite_range(t, TextPos::Line(1), TextPos::End, "Y"), "abc\nY");
1457        // [1, 2) — a single character in the middle.
1458        assert_eq!(rewrite_range(t, TextPos::Char(1), TextPos::Char(2), "_"), "a_c\nDEF\nxyz");
1459    }
1460
1461    #[test]
1462    fn rewrite_range_swaps_reversed_bounds() {
1463        let t = "abcdef";
1464        // from > to does not panic, the bounds are swapped.
1465        assert_eq!(rewrite_range(t, TextPos::Char(4), TextPos::Char(2), "_"), "ab_ef");
1466    }
1467
1468    #[test]
1469    fn rewrite_range_clamps_out_of_range() {
1470        let t = "abc";
1471        assert_eq!(rewrite_range(t, TextPos::Char(10), TextPos::Char(20), "Z"), "abcZ");
1472    }
1473
1474    #[test]
1475    fn insert_at_inserts_before_index() {
1476        assert_eq!(insert_at("héllo", 0, ">"), ">héllo");
1477        // Correct per character (é is multi-byte).
1478        assert_eq!(insert_at("héllo", 2, "-"), "hé-llo");
1479        assert_eq!(insert_at("abc", 99, "!"), "abc!");
1480    }
1481
1482    #[test]
1483    fn common_prefix_counts_shared_chars() {
1484        assert_eq!(common_prefix_chars("Hello", "Hello world"), 5);
1485        assert_eq!(common_prefix_chars("foo", "bar"), 0);
1486        assert_eq!(common_prefix_chars("", "abc"), 0);
1487        assert_eq!(common_prefix_chars("abc", "abc"), 3);
1488    }
1489
1490    /// A convenient folding of [`diff_runs`] into strings for assertions:
1491    /// `=common`, `-removed`, `+added` (empty replacements are dropped).
1492    fn diff_script(from: &str, to: &str) -> Vec<String> {
1493        let a: Vec<char> = from.chars().collect();
1494        let b: Vec<char> = to.chars().collect();
1495        let mut ops = Vec::new();
1496        diff_runs(&a, &b, 0, 0, &mut ops);
1497        let mut out = Vec::new();
1498        for op in ops {
1499            match op {
1500                DiffOp::Common { a: ai, len, .. } => {
1501                    out.push(format!("={}", a[ai..ai + len].iter().collect::<String>()));
1502                }
1503                DiffOp::Replace { a: ai, alen, b: bi, blen } => {
1504                    if alen > 0 {
1505                        out.push(format!("-{}", a[ai..ai + alen].iter().collect::<String>()));
1506                    }
1507                    if blen > 0 {
1508                        out.push(format!("+{}", b[bi..bi + blen].iter().collect::<String>()));
1509                    }
1510                }
1511            }
1512        }
1513        out
1514    }
1515
1516    #[test]
1517    fn diff_runs_keeps_unchanged_middle_when_wrapping() {
1518        // Wrapping in braces: the middle is common, only the edges are appended —
1519        // during smoothing it must not flicker.
1520        assert_eq!(
1521            diff_script("println!();", "{\n    println!();\n}"),
1522            vec!["+{\n    ", "=println!();", "+\n}"]
1523        );
1524    }
1525
1526    #[test]
1527    fn diff_runs_handles_prepend_append_and_replace() {
1528        // Pure prefix.
1529        assert_eq!(diff_script("bar", "foobar"), vec!["+foo", "=bar"]);
1530        // Pure suffix (including multi-line): the common part stays, the tail is added.
1531        assert_eq!(diff_script("a\nb", "a\nb\nc"), vec!["=a\nb", "+\nc"]);
1532        // Replacement of the middle between common edges.
1533        assert_eq!(diff_script("main.rs", "test.rs"), vec!["-main", "+test", "=.rs"]);
1534        // A full match — one common region, no changes.
1535        assert_eq!(diff_script("same", "same"), vec!["=same"]);
1536        // No common characters — one whole replacement.
1537        assert_eq!(diff_script("abc", "xyz"), vec!["-abc", "+xyz"]);
1538    }
1539
1540    #[test]
1541    fn diff_runs_ignores_coincidental_short_runs() {
1542        // The case from the bug: on a text change the letters "р" and "е"
1543        // coincidentally occur in the new text, but must not become anchors and
1544        // fly — the disappearing text must fade in place as a single replacement.
1545        assert_eq!(
1546            diff_script("Привет мир!", "На дворе {age} год"),
1547            vec!["-Привет мир!", "+На дворе {age} год"]
1548        );
1549        // A coincidentally matching pair of characters (shorter than the threshold) is not an anchor either.
1550        assert_eq!(diff_script("ab xy", "cd ab"), vec!["-ab xy", "+cd ab"]);
1551        // But a sufficiently long common fragment stays an anchor even amid a
1552        // replacement — a real unchanged chunk must not flicker.
1553        assert_eq!(diff_script("xxxcommonyyy", "zzcommonww"), vec!["-xxx", "+zz", "=common", "-yyy", "+ww"]);
1554    }
1555
1556    #[test]
1557    fn diff_runs_keeps_short_identical_text_as_anchor() {
1558        // Fully equal strings shorter than the threshold stay common (do not
1559        // flicker), despite the minimum anchor length.
1560        assert_eq!(diff_script("ok", "ok"), vec!["=ok"]);
1561        assert_eq!(diff_script(" ", " "), vec!["= "]);
1562    }
1563
1564    #[test]
1565    fn crossfade_mid_alpha_swaps_through_zero() {
1566        assert_eq!(crossfade_mid_alpha(0.0), (1.0, 0.0));
1567        assert_eq!(crossfade_mid_alpha(0.5), (0.0, 0.0));
1568        assert_eq!(crossfade_mid_alpha(1.0), (0.0, 1.0));
1569    }
1570
1571    #[test]
1572    fn prefix_chars_takes_floor_visible() {
1573        assert_eq!(prefix_chars("abcd", 0.0), "");
1574        assert_eq!(prefix_chars("abcd", 2.9), "ab");
1575        assert_eq!(prefix_chars("abcd", 4.0), "abcd");
1576        assert_eq!(prefix_chars("abcd", 99.0), "abcd");
1577    }
1578
1579    #[test]
1580    fn resolve_ranges_orders_and_resolves_bounds() {
1581        let text = "foo\nbar\nbaz";
1582        // A character and (start of line, end of text).
1583        assert_eq!(
1584            resolve_ranges(&[(TextPos::Char(0), TextPos::Char(3)), (TextPos::Line(1), TextPos::End)], text),
1585            vec![(0, 3), (4, 11)]
1586        );
1587        // Reversed bounds are ordered.
1588        assert_eq!(resolve_ranges(&[(TextPos::Char(5), TextPos::Char(2))], text), vec![(2, 5)]);
1589    }
1590
1591    #[test]
1592    fn in_ranges_checks_half_open_membership() {
1593        let r = [(0, 3), (5, 7)];
1594        assert!(in_ranges(0, &r));
1595        assert!(in_ranges(2, &r));
1596        assert!(!in_ranges(3, &r)); // the upper bound is excluded
1597        assert!(!in_ranges(4, &r)); // the gap between ranges
1598        assert!(in_ranges(6, &r));
1599        assert!(!in_ranges(7, &r));
1600    }
1601
1602    #[test]
1603    fn alpha_for_dims_outside_ranges() {
1604        // No ranges — everything is bright.
1605        assert_eq!(alpha_for(0, &[]), 1.0);
1606        let r = [(2usize, 5usize)];
1607        assert_eq!(alpha_for(2, &r), 1.0);
1608        assert_eq!(alpha_for(4, &r), 1.0);
1609        assert_eq!(alpha_for(5, &r), DEFAULT_DIM); // the upper bound is outside the range
1610        assert_eq!(alpha_for(0, &r), DEFAULT_DIM);
1611    }
1612
1613    #[test]
1614    fn committed_highlight_dims_and_clears() {
1615        let t = TextData::new("abcdef".to_string());
1616        // Without highlighting — None (the hot path), idle.
1617        assert!(t.highlight_idle());
1618        assert!(t.highlight_alphas("abcdef").is_none());
1619
1620        // committed range [1, 3) → bright inside, DEFAULT_DIM outside.
1621        t.add_highlight(TextPos::Char(1), TextPos::Char(3));
1622        assert_eq!(t.get_highlights().len(), 1);
1623        assert!(!t.highlight_idle());
1624        assert_eq!(
1625            t.highlight_alphas("abcdef").unwrap(),
1626            vec![DEFAULT_DIM, 1.0, 1.0, DEFAULT_DIM, DEFAULT_DIM, DEFAULT_DIM]
1627        );
1628
1629        // Clearing returns to "everything bright".
1630        t.clear_highlights();
1631        assert!(t.highlight_alphas("abcdef").is_none());
1632    }
1633
1634    #[test]
1635    fn morph_stage_interpolates_alpha() {
1636        let t = TextData::new("abc".to_string());
1637        // Appearance of highlighting for character 0: from empty, to = [0, 1), progress 0.5.
1638        *t.highlight_stage.borrow_mut() = HighlightStage::Morph {
1639            from: vec![],
1640            to: vec![(TextPos::Char(0), TextPos::Char(1))],
1641            p: 0.5,
1642        };
1643        let a = t.highlight_alphas("abc").unwrap();
1644        // Character 0 is highlighted in `to` → stays bright; 1 and 2 dim halfway.
1645        assert!((a[0] - 1.0).abs() < 1e-6);
1646        let mid = 1.0 + (DEFAULT_DIM - 1.0) * 0.5;
1647        assert!((a[1] - mid).abs() < 1e-6, "a[1]={}", a[1]);
1648        assert!((a[2] - mid).abs() < 1e-6);
1649    }
1650}