Skip to main content

oxframe_2d/
text.rs

1//! Styled text for `Txt` and `Code`: rich spans, syntax themes, focus
2//! dimming and glyph-level morphing between two strings.
3
4use std::rc::Rc;
5
6use kurbo::{Affine, BezPath, Point, Vec2};
7use oxframe_core::{Color, Lerp};
8
9use crate::theme::IntoPaint;
10use oxframe_text::{Lang, TextLayout, TextSpan, TextStyle, Token};
11
12/// A run of text with its own look; unset fields use the node's style.
13#[derive(Debug, Clone, PartialEq, Default)]
14pub struct RichSpan {
15    /// The text of this run.
16    pub text: String,
17    /// Color; default: the node's fill.
18    pub color: Option<Color>,
19    /// Font weight 100..=900; default: the node's.
20    pub weight: Option<f64>,
21    /// Italic; default: the node's.
22    pub italic: Option<bool>,
23    /// Font size; default: the node's.
24    pub size: Option<f64>,
25    /// Font family; default: the node's.
26    pub family: Option<String>,
27}
28
29impl RichSpan {
30    /// A run with the node's style; same as [`span`].
31    pub fn new(text: impl Into<String>) -> Self {
32        Self {
33            text: text.into(),
34            ..Default::default()
35        }
36    }
37    /// Text color: a [`Color`] or a theme role such as `Warning`.
38    pub fn color(mut self, c: impl IntoPaint) -> Self {
39        self.color = Some(c.into_paint());
40        self
41    }
42    /// Font weight, 100..=900.
43    pub fn weight(mut self, w: f64) -> Self {
44        self.weight = Some(w);
45        self
46    }
47    /// Weight 700.
48    pub fn bold(self) -> Self {
49        self.weight(700.0)
50    }
51    /// Italic style.
52    pub fn italic(mut self) -> Self {
53        self.italic = Some(true);
54        self
55    }
56    /// Font size in pixels.
57    pub fn size(mut self, s: f64) -> Self {
58        self.size = Some(s);
59        self
60    }
61    /// Font family, e.g. [`MONO`](crate::MONO).
62    pub fn family(mut self, f: impl Into<String>) -> Self {
63        self.family = Some(f.into());
64        self
65    }
66}
67
68/// Shorthand for [`RichSpan::new`].
69pub fn span(text: impl Into<String>) -> RichSpan {
70    RichSpan::new(text)
71}
72
73/// Strings tween as a typewriter over the concatenated text, keeping the
74/// styles of the target spans.
75impl Lerp for RichSpan {
76    fn lerp(&self, to: &Self, t: f64) -> Self {
77        if t < 0.5 { self.clone() } else { to.clone() }
78    }
79}
80
81/// Colors of a code theme.
82#[derive(Debug, Clone, PartialEq)]
83pub struct CodeTheme {
84    /// Text that is not a token below.
85    pub plain: Color,
86    /// `fn`, `let`, `if`…
87    pub keyword: Color,
88    /// Type names.
89    pub types: Color,
90    /// Function names.
91    pub function: Color,
92    /// Macro names.
93    pub macros: Color,
94    /// String literals.
95    pub string: Color,
96    /// Number literals.
97    pub number: Color,
98    /// Comments.
99    pub comment: Color,
100    /// Attributes and decorators.
101    pub attribute: Color,
102    /// Punctuation and operators.
103    pub punct: Color,
104}
105
106impl CodeTheme {
107    /// One Dark.
108    pub fn dark() -> Self {
109        let h = |s| Color::from_hex(s).expect("valid color");
110        Self {
111            plain: h("#abb2bf"),
112            keyword: h("#c678dd"),
113            types: h("#e5c07b"),
114            function: h("#61afef"),
115            macros: h("#56b6c2"),
116            string: h("#98c379"),
117            number: h("#d19a66"),
118            comment: h("#7f848e"),
119            attribute: h("#e06c75"),
120            punct: h("#abb2bf"),
121        }
122    }
123
124    /// One Light.
125    pub fn light() -> Self {
126        let h = |s| Color::from_hex(s).expect("valid color");
127        Self {
128            plain: h("#383a42"),
129            keyword: h("#a626a4"),
130            types: h("#c18401"),
131            function: h("#4078f2"),
132            macros: h("#0184bc"),
133            string: h("#50a14f"),
134            number: h("#986801"),
135            comment: h("#6b6f78"),
136            attribute: h("#e45649"),
137            punct: h("#383a42"),
138        }
139    }
140
141    /// Color for a token kind.
142    pub fn color(&self, t: Token) -> Color {
143        match t {
144            Token::Plain => self.plain,
145            Token::Keyword => self.keyword,
146            Token::Type => self.types,
147            Token::Function => self.function,
148            Token::Macro => self.macros,
149            Token::String => self.string,
150            Token::Number => self.number,
151            Token::Comment => self.comment,
152            Token::Attribute => self.attribute,
153            Token::Punct => self.punct,
154        }
155    }
156}
157
158impl Default for CodeTheme {
159    fn default() -> Self {
160        Self::dark()
161    }
162}
163
164/// Text ready to draw: layout plus the color of each span (`None` = node
165/// fill).
166pub(crate) struct Styled {
167    pub layout: Rc<TextLayout>,
168    pub colors: Vec<Option<Color>>,
169}
170
171pub(crate) fn rich(spans: &[RichSpan], style: &TextStyle) -> Styled {
172    let ts: Vec<TextSpan> = spans
173        .iter()
174        .map(|s| TextSpan {
175            text: s.text.clone(),
176            family: s.family.clone(),
177            size: s.size,
178            weight: s.weight,
179            italic: s.italic,
180        })
181        .collect();
182    Styled {
183        layout: oxframe_text::layout_rich(&ts, style),
184        colors: spans.iter().map(|s| s.color).collect(),
185    }
186}
187
188/// Highlighted code; lines outside `focus` (1-based, inclusive; (0, 0) =
189/// all) get their alpha multiplied by `dim`.
190pub(crate) fn code(
191    src: &str,
192    lang: Lang,
193    theme: &CodeTheme,
194    style: &TextStyle,
195    focus: Vec2,
196    dim: f64,
197) -> Styled {
198    let (a, b) = (focus.x.round(), focus.y.round());
199    let focused = |line: usize| a <= 0.0 && b <= 0.0 || (line as f64 >= a && line as f64 <= b);
200    let mut spans = Vec::new();
201    for (range, tok) in oxframe_text::highlight(src, lang) {
202        // Split tokens at newlines so each piece knows its line.
203        let mut start = range.start;
204        for (i, ch) in src[range.clone()].char_indices() {
205            if ch == '\n' {
206                spans.push((start..range.start + i + 1, tok));
207                start = range.start + i + 1;
208            }
209        }
210        if start < range.end {
211            spans.push((start..range.end, tok));
212        }
213    }
214    let mut out = Vec::with_capacity(spans.len());
215    for (r, tok) in spans {
216        let line = src[..r.start].matches('\n').count() + 1;
217        let mut c = theme.color(tok);
218        if !focused(line) {
219            c = c.with_alpha((f64::from(c.a) * dim.clamp(0.0, 1.0)) as f32);
220        }
221        out.push(RichSpan {
222            text: src[r].to_owned(),
223            color: Some(c),
224            ..Default::default()
225        });
226    }
227    rich(&out, style)
228}
229
230/// One painted glyph of a code diff, in the layout box (origin top-left).
231pub(crate) struct DiffGlyph {
232    pub path: BezPath,
233    pub color: Color,
234}
235
236/// A flash behind an inserted fragment, in the same coordinates.
237pub(crate) struct DiffFlash {
238    pub rect: kurbo::Rect,
239    pub color: Color,
240}
241
242/// A frame of a code diff: glyphs, optional flashes, and the box size.
243pub(crate) struct DiffFrame {
244    pub glyphs: Vec<DiffGlyph>,
245    pub flashes: Vec<DiffFlash>,
246    pub size: (f64, f64),
247}
248
249/// Where one piece sits before it is drawn. Tests check that unchanged
250/// tokens move continuously and keep their color; the renderer uses the glyphs.
251#[derive(Debug, Clone)]
252#[cfg_attr(not(test), allow(dead_code))]
253pub(crate) struct DiffSlot {
254    /// Piece text.
255    pub text: String,
256    /// Left of the piece's slot.
257    pub x: f64,
258    /// Top of the line.
259    pub y: f64,
260    /// sRGB color, alpha included.
261    pub color: Color,
262}
263
264struct Metrics {
265    char_width: f64,
266    line_height: f64,
267}
268
269fn metrics(style: &TextStyle) -> Metrics {
270    let cell = oxframe_text::layout("M", style);
271    let rows = oxframe_text::layout("M\nM", style);
272    let char_width = cell.width.max(0.01);
273    let line_height = match rows.info.as_slice() {
274        [a, b, ..] => (b.origin.y - a.origin.y).max(style.size),
275        _ => style.size * style.line_height,
276    };
277    Metrics {
278        char_width,
279        line_height,
280    }
281}
282
283/// How a code diff is drawn for one frame.
284pub(crate) struct DiffView<'a> {
285    /// Syntax colors.
286    pub theme: &'a CodeTheme,
287    /// Font.
288    pub style: &'a TextStyle,
289    /// Focused lines, 1-based; (0, 0) is everything.
290    pub focus: Vec2,
291    /// Alpha of unfocused lines.
292    pub dim: f64,
293    /// 0 is the old source, 1 is the new one.
294    pub t: f64,
295    /// Background behind inserted text.
296    pub flash: bool,
297}
298
299/// Lays out the diff of `from` into `to`.
300pub(crate) fn layout_diff(
301    from: &str,
302    to: &str,
303    lang: Lang,
304    view: DiffView<'_>,
305) -> (DiffFrame, Vec<DiffSlot>) {
306    let DiffView {
307        theme,
308        style,
309        focus,
310        dim,
311        t,
312        flash,
313    } = view;
314    let t = t.clamp(0.0, 1.0);
315    let diff = oxframe_text::diff_code(from, to, lang);
316    let m = metrics(style);
317    let dim = dim.clamp(0.0, 1.0);
318    let (fa, fb) = (focus.x.round(), focus.y.round());
319    let all_focused = fa <= 0.0 && fb <= 0.0;
320    let mapped = if all_focused {
321        None
322    } else {
323        oxframe_text::map_focus(&diff, fa as usize, fb as usize)
324    };
325    let old_focused = |line: usize| all_focused || (line as f64 >= fa && line as f64 <= fb);
326    let new_focused = |line: usize| match mapped {
327        None if all_focused => true,
328        None => false,
329        Some((a, b)) => line >= a && line <= b,
330    };
331
332    let mut glyphs = Vec::new();
333    let mut flashes = Vec::new();
334    let mut slots = Vec::new();
335    let mut y = 0.0;
336    let mut width = 0.0_f64;
337    for line in &diff.lines {
338        let (pieces, old, new, line_factor) = match line {
339            oxframe_text::LineEdit::Equal { old, new, line } => (
340                pieces_of(line, lang, PieceKind::Equal),
341                Some(*old),
342                Some(*new),
343                1.0,
344            ),
345            oxframe_text::LineEdit::Delete { old, line } => (
346                pieces_of(line, lang, PieceKind::Delete),
347                Some(*old),
348                None,
349                1.0 - t,
350            ),
351            oxframe_text::LineEdit::Insert { new, line } => (
352                pieces_of(line, lang, PieceKind::Insert),
353                None,
354                Some(*new),
355                t,
356            ),
357            oxframe_text::LineEdit::Change { old, new, pieces } => {
358                (pieces.clone(), Some(*old), Some(*new), 1.0)
359            }
360        };
361        let mut x = 0.0;
362        for piece in pieces {
363            let (text, token, kind) = match &piece {
364                oxframe_text::PieceEdit::Equal(p) => (&p.text, p.token, PieceKind::Equal),
365                oxframe_text::PieceEdit::Delete(p) => (&p.text, p.token, PieceKind::Delete),
366                oxframe_text::PieceEdit::Insert(p) => (&p.text, p.token, PieceKind::Insert),
367            };
368            let natural = text.chars().count() as f64 * m.char_width;
369            // A deleted or inserted token collapses sideways. A whole line
370            // that appears or disappears also collapses its height.
371            let x_scale = match kind {
372                PieceKind::Equal => 1.0,
373                PieceKind::Delete => 1.0 - t,
374                PieceKind::Insert => t,
375            };
376            let focused = match kind {
377                PieceKind::Insert => new.is_some_and(|n| new_focused(n + 1)),
378                _ => old.is_some_and(|n| old_focused(n + 1)),
379            };
380            let fade = x_scale;
381            let alpha = fade * if focused { 1.0 } else { dim };
382            let mut color = theme.color(token);
383            color = color.with_alpha((f64::from(color.a) * alpha) as f32);
384            if flash && matches!(kind, PieceKind::Insert) && natural > 0.0 && x_scale > 0.0 {
385                let a = (t * std::f64::consts::PI).sin() * 0.45;
386                if a > 0.01 {
387                    flashes.push(DiffFlash {
388                        rect: kurbo::Rect::new(
389                            x,
390                            y,
391                            x + natural * x_scale,
392                            y + m.line_height * line_factor,
393                        ),
394                        color: theme.attribute.with_alpha(a as f32),
395                    });
396                }
397            }
398            if alpha > 0.0 && !text.trim().is_empty() {
399                let laid = oxframe_text::layout(text, style);
400                let tf = Affine::translate((x, y))
401                    * Affine::scale_non_uniform(x_scale.max(0.0), line_factor.max(0.0));
402                for g in &laid.glyphs {
403                    let path = tf * g.clone();
404                    if !path.elements().is_empty() {
405                        glyphs.push(DiffGlyph { path, color });
406                    }
407                }
408            }
409            slots.push(DiffSlot {
410                text: text.clone(),
411                x,
412                y,
413                color,
414            });
415            x += natural * x_scale;
416        }
417        width = width.max(x);
418        y += m.line_height * line_factor;
419    }
420    (
421        DiffFrame {
422            glyphs,
423            flashes,
424            size: (width, y),
425        },
426        slots,
427    )
428}
429
430#[derive(Clone, Copy)]
431enum PieceKind {
432    Equal,
433    Delete,
434    Insert,
435}
436
437fn pieces_of(line: &str, lang: Lang, kind: PieceKind) -> Vec<oxframe_text::PieceEdit> {
438    oxframe_text::line_pieces(line, lang)
439        .into_iter()
440        .map(|piece| match kind {
441            PieceKind::Equal => oxframe_text::PieceEdit::Equal(piece),
442            PieceKind::Delete => oxframe_text::PieceEdit::Delete(piece),
443            PieceKind::Insert => oxframe_text::PieceEdit::Insert(piece),
444        })
445        .collect()
446}
447
448/// Glyphs of a morph frame: (path, alpha) in the text box centered at 0.
449pub(crate) fn morph(
450    from: &TextLayout,
451    to: &TextLayout,
452    t: f64,
453) -> (Vec<(BezPath, f64)>, (f64, f64)) {
454    let a: Vec<char> = from.info.iter().map(|g| g.ch).collect();
455    let b: Vec<char> = to.info.iter().map(|g| g.ch).collect();
456    // Longest common subsequence of characters: matched glyphs travel.
457    let (n, m) = (a.len(), b.len());
458    let mut dp = vec![vec![0u16; m + 1]; n + 1];
459    for i in (0..n).rev() {
460        for j in (0..m).rev() {
461            dp[i][j] = if a[i] == b[j] {
462                dp[i + 1][j + 1] + 1
463            } else {
464                dp[i + 1][j].max(dp[i][j + 1])
465            };
466        }
467    }
468    let mut pairs = Vec::new();
469    let (mut i, mut j) = (0, 0);
470    while i < n && j < m {
471        if a[i] == b[j] {
472            pairs.push((i, j));
473            i += 1;
474            j += 1;
475        } else if dp[i + 1][j] >= dp[i][j + 1] {
476            i += 1;
477        } else {
478            j += 1;
479        }
480    }
481    let off_from = Vec2::new(-from.width / 2.0, -from.height / 2.0);
482    let off_to = Vec2::new(-to.width / 2.0, -to.height / 2.0);
483    let mut out = Vec::new();
484    let mut matched_a = vec![false; n];
485    let mut matched_b = vec![false; m];
486    for &(i, j) in &pairs {
487        matched_a[i] = true;
488        matched_b[j] = true;
489        let pa = from.info[i].origin + off_from;
490        let pb = to.info[j].origin + off_to;
491        let shift = Lerp::lerp(&Point::ZERO, &(pb - pa).to_point(), t).to_vec2() + off_from;
492        out.push((Affine::translate(shift) * from.glyphs[i].clone(), 1.0));
493    }
494    for (i, g) in from.glyphs.iter().enumerate() {
495        if !matched_a[i] {
496            out.push((Affine::translate(off_from) * g.clone(), 1.0 - t));
497        }
498    }
499    for (j, g) in to.glyphs.iter().enumerate() {
500        if !matched_b[j] {
501            out.push((Affine::translate(off_to) * g.clone(), t));
502        }
503    }
504    let size = (
505        from.width.lerp(&to.width, t),
506        from.height.lerp(&to.height, t),
507    );
508    (out, size)
509}
510
511#[cfg(test)]
512mod diff_tests {
513    use super::{CodeTheme, DiffSlot, DiffView, layout_diff};
514    use kurbo::Vec2;
515    use oxframe_text::{Lang, MONO, TextStyle};
516
517    fn style() -> TextStyle {
518        TextStyle {
519            family: MONO.to_owned(),
520            size: 32.0,
521            line_height: 1.45,
522            ..TextStyle::default()
523        }
524    }
525
526    fn piece<'a>(slots: &'a [DiffSlot], text: &str) -> &'a DiffSlot {
527        slots
528            .iter()
529            .find(|s| s.text == text)
530            .unwrap_or_else(|| panic!("no piece {text:?} in {slots:?}"))
531    }
532
533    #[test]
534    fn an_unchanged_line_slides_and_keeps_its_color() {
535        let theme = CodeTheme::dark();
536        let style = style();
537        let mut ys = Vec::new();
538        let mut colors = Vec::new();
539        for t in [0.0, 0.5, 1.0] {
540            let (_, slots) = layout_diff(
541                "a\nkeep\n",
542                "NEW\na\nkeep\n",
543                Lang::Rust,
544                DiffView {
545                    theme: &theme,
546                    style: &style,
547                    focus: Vec2::new(2.0, 2.0),
548                    dim: 0.3,
549                    t,
550                    flash: false,
551                },
552            );
553            let keep = piece(&slots, "keep");
554            ys.push(keep.y);
555            colors.push((keep.color.r, keep.color.g, keep.color.b, keep.color.a));
556            // Line 1 stays dim; the focused line rides down at full strength.
557            assert!((piece(&slots, "a").color.a - 0.3).abs() < 0.02, "{t}");
558        }
559        assert!(ys[0] < ys[1] && ys[1] < ys[2], "{ys:?}");
560        assert_eq!(colors[0], colors[1]);
561        assert_eq!(colors[1], colors[2]);
562        assert!((colors[0].3 - 1.0).abs() < 0.02, "{colors:?}");
563    }
564
565    #[test]
566    fn a_shorter_token_pulls_its_neighbor_across() {
567        let theme = CodeTheme::dark();
568        let style = style();
569        let at = |t| {
570            layout_diff(
571                "let value = 1;",
572                "let v = 1;",
573                Lang::Rust,
574                DiffView {
575                    theme: &theme,
576                    style: &style,
577                    focus: Vec2::ZERO,
578                    dim: 0.3,
579                    t,
580                    flash: false,
581                },
582            )
583            .1
584        };
585        let (a, b, c) = (at(0.0), at(0.5), at(1.0));
586        let xs = [piece(&a, "1").x, piece(&b, "1").x, piece(&c, "1").x];
587        assert!(xs[0] > xs[1] && xs[1] > xs[2], "{xs:?}");
588        assert_eq!(piece(&a, "let").x, piece(&c, "let").x);
589        assert_eq!(piece(&a, "let").color.r, piece(&b, "let").color.r);
590        assert_eq!(piece(&a, "let").color.g, piece(&c, "let").color.g);
591        assert!(piece(&a, "value").color.a > 0.9);
592        assert!(piece(&c, "value").color.a < 0.01);
593        assert!(piece(&a, "v").color.a < 0.01);
594        assert!(piece(&c, "v").color.a > 0.9);
595    }
596}