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}