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/// What a [`Code`](crate::Code) node shows at full strength.
189pub(crate) struct CodeFocus {
190    /// Lines, 1-based and inclusive; (0, 0) = all.
191    pub lines: Vec2,
192    /// Characters, 0-based, end excluded; (0, 0) = all.
193    pub span: Vec2,
194}
195
196/// Highlighted code; text outside the focus gets its alpha multiplied by
197/// `dim`.
198pub(crate) fn code(
199    src: &str,
200    lang: Lang,
201    theme: &CodeTheme,
202    style: &TextStyle,
203    focus: CodeFocus,
204    dim: f64,
205) -> Styled {
206    rich(&code_spans(src, lang, theme, focus, dim), style)
207}
208
209/// The colored pieces of [`code`].
210fn code_spans(
211    src: &str,
212    lang: Lang,
213    theme: &CodeTheme,
214    focus: CodeFocus,
215    dim: f64,
216) -> Vec<RichSpan> {
217    let (a, b) = (focus.lines.x.round(), focus.lines.y.round());
218    let focused = |line: usize| a <= 0.0 && b <= 0.0 || (line as f64 >= a && line as f64 <= b);
219    // The character focus as byte offsets; None focuses everything.
220    let (cs, ce) = (focus.span.x.round().max(0.0), focus.span.y.round().max(0.0));
221    let byte = |chars: f64| {
222        src.char_indices()
223            .nth(chars as usize)
224            .map_or(src.len(), |(i, _)| i)
225    };
226    let span = (cs > 0.0 || ce > 0.0).then(|| (byte(cs), byte(ce.max(cs))));
227    let mut spans = Vec::new();
228    for (range, tok) in oxframe_text::highlight(src, lang) {
229        // Split tokens at newlines so each piece knows its line.
230        let mut start = range.start;
231        for (i, ch) in src[range.clone()].char_indices() {
232            if ch == '\n' {
233                spans.push((start..range.start + i + 1, tok));
234                start = range.start + i + 1;
235            }
236        }
237        if start < range.end {
238            spans.push((start..range.end, tok));
239        }
240    }
241    // Split at the edges of the character focus, so each piece is in or out.
242    if let Some((s, e)) = span {
243        spans = spans
244            .into_iter()
245            .flat_map(|(r, tok)| {
246                let cuts = [
247                    r.start,
248                    s.clamp(r.start, r.end),
249                    e.clamp(r.start, r.end),
250                    r.end,
251                ];
252                cuts.windows(2)
253                    .filter(|w| w[0] < w[1])
254                    .map(|w| (w[0]..w[1], tok))
255                    .collect::<Vec<_>>()
256            })
257            .collect();
258    }
259    let mut out = Vec::with_capacity(spans.len());
260    for (r, tok) in spans {
261        let line = src[..r.start].matches('\n').count() + 1;
262        let in_span = span.is_none_or(|(s, e)| r.start >= s && r.end <= e);
263        let mut c = theme.color(tok);
264        if !focused(line) || !in_span {
265            c = c.with_alpha((f64::from(c.a) * dim.clamp(0.0, 1.0)) as f32);
266        }
267        out.push(RichSpan {
268            text: src[r].to_owned(),
269            color: Some(c),
270            ..Default::default()
271        });
272    }
273    out
274}
275
276/// One painted glyph of a code diff, in the layout box (origin top-left).
277pub(crate) struct DiffGlyph {
278    pub path: BezPath,
279    pub color: Color,
280}
281
282/// A flash behind an inserted fragment, in the same coordinates.
283pub(crate) struct DiffFlash {
284    pub rect: kurbo::Rect,
285    pub color: Color,
286}
287
288/// A frame of a code diff: glyphs, optional flashes, and the box size.
289pub(crate) struct DiffFrame {
290    pub glyphs: Vec<DiffGlyph>,
291    pub flashes: Vec<DiffFlash>,
292    pub size: (f64, f64),
293}
294
295/// Where one piece sits before it is drawn. Tests check that unchanged
296/// tokens move continuously and keep their color; the renderer uses the glyphs.
297#[derive(Debug, Clone)]
298#[cfg_attr(not(test), allow(dead_code))]
299pub(crate) struct DiffSlot {
300    /// Piece text.
301    pub text: String,
302    /// Left of the piece's slot.
303    pub x: f64,
304    /// Top of the line.
305    pub y: f64,
306    /// sRGB color, alpha included.
307    pub color: Color,
308}
309
310struct Metrics {
311    char_width: f64,
312    line_height: f64,
313}
314
315fn metrics(style: &TextStyle) -> Metrics {
316    let cell = oxframe_text::layout("M", style);
317    let rows = oxframe_text::layout("M\nM", style);
318    let char_width = cell.width.max(0.01);
319    let line_height = match rows.info.as_slice() {
320        [a, b, ..] => (b.origin.y - a.origin.y).max(style.size),
321        _ => style.size * style.line_height,
322    };
323    Metrics {
324        char_width,
325        line_height,
326    }
327}
328
329/// How a code diff is drawn for one frame.
330pub(crate) struct DiffView<'a> {
331    /// Syntax colors.
332    pub theme: &'a CodeTheme,
333    /// Font.
334    pub style: &'a TextStyle,
335    /// Focused lines, 1-based; (0, 0) is everything.
336    pub focus: Vec2,
337    /// Alpha of unfocused lines.
338    pub dim: f64,
339    /// 0 is the old source, 1 is the new one.
340    pub t: f64,
341    /// Background behind inserted text.
342    pub flash: bool,
343}
344
345/// Lays out the diff of `from` into `to`.
346pub(crate) fn layout_diff(
347    from: &str,
348    to: &str,
349    lang: Lang,
350    view: DiffView<'_>,
351) -> (DiffFrame, Vec<DiffSlot>) {
352    let DiffView {
353        theme,
354        style,
355        focus,
356        dim,
357        t,
358        flash,
359    } = view;
360    let t = t.clamp(0.0, 1.0);
361    let diff = oxframe_text::diff_code(from, to, lang);
362    let m = metrics(style);
363    let dim = dim.clamp(0.0, 1.0);
364    let (fa, fb) = (focus.x.round(), focus.y.round());
365    let all_focused = fa <= 0.0 && fb <= 0.0;
366    let mapped = if all_focused {
367        None
368    } else {
369        oxframe_text::map_focus(&diff, fa as usize, fb as usize)
370    };
371    let old_focused = |line: usize| all_focused || (line as f64 >= fa && line as f64 <= fb);
372    let new_focused = |line: usize| match mapped {
373        None if all_focused => true,
374        None => false,
375        Some((a, b)) => line >= a && line <= b,
376    };
377
378    let mut glyphs = Vec::new();
379    let mut flashes = Vec::new();
380    let mut slots = Vec::new();
381    let mut y = 0.0;
382    let mut width = 0.0_f64;
383    for line in &diff.lines {
384        let (pieces, old, new, line_factor) = match line {
385            oxframe_text::LineEdit::Equal { old, new, line } => (
386                pieces_of(line, lang, PieceKind::Equal),
387                Some(*old),
388                Some(*new),
389                1.0,
390            ),
391            oxframe_text::LineEdit::Delete { old, line } => (
392                pieces_of(line, lang, PieceKind::Delete),
393                Some(*old),
394                None,
395                1.0 - t,
396            ),
397            oxframe_text::LineEdit::Insert { new, line } => (
398                pieces_of(line, lang, PieceKind::Insert),
399                None,
400                Some(*new),
401                t,
402            ),
403            oxframe_text::LineEdit::Change { old, new, pieces } => {
404                (pieces.clone(), Some(*old), Some(*new), 1.0)
405            }
406        };
407        let mut x = 0.0;
408        for piece in pieces {
409            let (text, token, kind) = match &piece {
410                oxframe_text::PieceEdit::Equal(p) => (&p.text, p.token, PieceKind::Equal),
411                oxframe_text::PieceEdit::Delete(p) => (&p.text, p.token, PieceKind::Delete),
412                oxframe_text::PieceEdit::Insert(p) => (&p.text, p.token, PieceKind::Insert),
413            };
414            let natural = text.chars().count() as f64 * m.char_width;
415            // A deleted or inserted token collapses sideways. A whole line
416            // that appears or disappears also collapses its height.
417            let x_scale = match kind {
418                PieceKind::Equal => 1.0,
419                PieceKind::Delete => 1.0 - t,
420                PieceKind::Insert => t,
421            };
422            let focused = match kind {
423                PieceKind::Insert => new.is_some_and(|n| new_focused(n + 1)),
424                _ => old.is_some_and(|n| old_focused(n + 1)),
425            };
426            let fade = x_scale;
427            let alpha = fade * if focused { 1.0 } else { dim };
428            let mut color = theme.color(token);
429            color = color.with_alpha((f64::from(color.a) * alpha) as f32);
430            if flash && matches!(kind, PieceKind::Insert) && natural > 0.0 && x_scale > 0.0 {
431                let a = (t * std::f64::consts::PI).sin() * 0.45;
432                if a > 0.01 {
433                    flashes.push(DiffFlash {
434                        rect: kurbo::Rect::new(
435                            x,
436                            y,
437                            x + natural * x_scale,
438                            y + m.line_height * line_factor,
439                        ),
440                        color: theme.attribute.with_alpha(a as f32),
441                    });
442                }
443            }
444            if alpha > 0.0 && !text.trim().is_empty() {
445                let laid = oxframe_text::layout(text, style);
446                let tf = Affine::translate((x, y))
447                    * Affine::scale_non_uniform(x_scale.max(0.0), line_factor.max(0.0));
448                for g in &laid.glyphs {
449                    let path = tf * g.clone();
450                    if !path.elements().is_empty() {
451                        glyphs.push(DiffGlyph { path, color });
452                    }
453                }
454            }
455            slots.push(DiffSlot {
456                text: text.clone(),
457                x,
458                y,
459                color,
460            });
461            x += natural * x_scale;
462        }
463        width = width.max(x);
464        y += m.line_height * line_factor;
465    }
466    (
467        DiffFrame {
468            glyphs,
469            flashes,
470            size: (width, y),
471        },
472        slots,
473    )
474}
475
476#[derive(Clone, Copy)]
477enum PieceKind {
478    Equal,
479    Delete,
480    Insert,
481}
482
483fn pieces_of(line: &str, lang: Lang, kind: PieceKind) -> Vec<oxframe_text::PieceEdit> {
484    oxframe_text::line_pieces(line, lang)
485        .into_iter()
486        .map(|piece| match kind {
487            PieceKind::Equal => oxframe_text::PieceEdit::Equal(piece),
488            PieceKind::Delete => oxframe_text::PieceEdit::Delete(piece),
489            PieceKind::Insert => oxframe_text::PieceEdit::Insert(piece),
490        })
491        .collect()
492}
493
494/// Glyphs of a morph frame: (path, alpha) in the text box centered at 0.
495pub(crate) fn morph(
496    from: &TextLayout,
497    to: &TextLayout,
498    t: f64,
499) -> (Vec<(BezPath, f64)>, (f64, f64)) {
500    let a: Vec<char> = from.info.iter().map(|g| g.ch).collect();
501    let b: Vec<char> = to.info.iter().map(|g| g.ch).collect();
502    // Longest common subsequence of characters: matched glyphs travel.
503    let (n, m) = (a.len(), b.len());
504    let mut dp = vec![vec![0u16; m + 1]; n + 1];
505    for i in (0..n).rev() {
506        for j in (0..m).rev() {
507            dp[i][j] = if a[i] == b[j] {
508                dp[i + 1][j + 1] + 1
509            } else {
510                dp[i + 1][j].max(dp[i][j + 1])
511            };
512        }
513    }
514    let mut pairs = Vec::new();
515    let (mut i, mut j) = (0, 0);
516    while i < n && j < m {
517        if a[i] == b[j] {
518            pairs.push((i, j));
519            i += 1;
520            j += 1;
521        } else if dp[i + 1][j] >= dp[i][j + 1] {
522            i += 1;
523        } else {
524            j += 1;
525        }
526    }
527    let off_from = Vec2::new(-from.width / 2.0, -from.height / 2.0);
528    let off_to = Vec2::new(-to.width / 2.0, -to.height / 2.0);
529    let mut out = Vec::new();
530    let mut matched_a = vec![false; n];
531    let mut matched_b = vec![false; m];
532    for &(i, j) in &pairs {
533        matched_a[i] = true;
534        matched_b[j] = true;
535        let pa = from.info[i].origin + off_from;
536        let pb = to.info[j].origin + off_to;
537        let shift = Lerp::lerp(&Point::ZERO, &(pb - pa).to_point(), t).to_vec2() + off_from;
538        out.push((Affine::translate(shift) * from.glyphs[i].clone(), 1.0));
539    }
540    for (i, g) in from.glyphs.iter().enumerate() {
541        if !matched_a[i] {
542            out.push((Affine::translate(off_from) * g.clone(), 1.0 - t));
543        }
544    }
545    for (j, g) in to.glyphs.iter().enumerate() {
546        if !matched_b[j] {
547            out.push((Affine::translate(off_to) * g.clone(), t));
548        }
549    }
550    let size = (
551        from.width.lerp(&to.width, t),
552        from.height.lerp(&to.height, t),
553    );
554    (out, size)
555}
556
557#[cfg(test)]
558mod diff_tests {
559
560    #[test]
561    fn focus_text_dims_everything_but_the_piece() {
562        let src = "let mid = lo + (hi - lo) / 2;\nmid";
563        let start = src.find("(hi").unwrap() as f64;
564        let spans = super::code_spans(
565            src,
566            Lang::Rust,
567            &CodeTheme::dark(),
568            super::CodeFocus {
569                lines: Vec2::ZERO,
570                span: Vec2::new(start, start + 9.0),
571            },
572            0.3,
573        );
574        let lit: String = spans
575            .iter()
576            .filter(|s| s.color.is_some_and(|c| c.a > 0.9))
577            .map(|s| s.text.as_str())
578            .collect();
579        assert_eq!(lit, "(hi - lo)");
580        let all: String = spans.iter().map(|s| s.text.as_str()).collect();
581        assert_eq!(all, src);
582    }
583
584    use super::{CodeTheme, DiffSlot, DiffView, layout_diff};
585    use kurbo::Vec2;
586    use oxframe_text::{Lang, MONO, TextStyle};
587
588    fn style() -> TextStyle {
589        TextStyle {
590            family: MONO.to_owned(),
591            size: 32.0,
592            line_height: 1.45,
593            ..TextStyle::default()
594        }
595    }
596
597    fn piece<'a>(slots: &'a [DiffSlot], text: &str) -> &'a DiffSlot {
598        slots
599            .iter()
600            .find(|s| s.text == text)
601            .unwrap_or_else(|| panic!("no piece {text:?} in {slots:?}"))
602    }
603
604    #[test]
605    fn an_unchanged_line_slides_and_keeps_its_color() {
606        let theme = CodeTheme::dark();
607        let style = style();
608        let mut ys = Vec::new();
609        let mut colors = Vec::new();
610        for t in [0.0, 0.5, 1.0] {
611            let (_, slots) = layout_diff(
612                "a\nkeep\n",
613                "NEW\na\nkeep\n",
614                Lang::Rust,
615                DiffView {
616                    theme: &theme,
617                    style: &style,
618                    focus: Vec2::new(2.0, 2.0),
619                    dim: 0.3,
620                    t,
621                    flash: false,
622                },
623            );
624            let keep = piece(&slots, "keep");
625            ys.push(keep.y);
626            colors.push((keep.color.r, keep.color.g, keep.color.b, keep.color.a));
627            // Line 1 stays dim; the focused line rides down at full strength.
628            assert!((piece(&slots, "a").color.a - 0.3).abs() < 0.02, "{t}");
629        }
630        assert!(ys[0] < ys[1] && ys[1] < ys[2], "{ys:?}");
631        assert_eq!(colors[0], colors[1]);
632        assert_eq!(colors[1], colors[2]);
633        assert!((colors[0].3 - 1.0).abs() < 0.02, "{colors:?}");
634    }
635
636    #[test]
637    fn a_shorter_token_pulls_its_neighbor_across() {
638        let theme = CodeTheme::dark();
639        let style = style();
640        let at = |t| {
641            layout_diff(
642                "let value = 1;",
643                "let v = 1;",
644                Lang::Rust,
645                DiffView {
646                    theme: &theme,
647                    style: &style,
648                    focus: Vec2::ZERO,
649                    dim: 0.3,
650                    t,
651                    flash: false,
652                },
653            )
654            .1
655        };
656        let (a, b, c) = (at(0.0), at(0.5), at(1.0));
657        let xs = [piece(&a, "1").x, piece(&b, "1").x, piece(&c, "1").x];
658        assert!(xs[0] > xs[1] && xs[1] > xs[2], "{xs:?}");
659        assert_eq!(piece(&a, "let").x, piece(&c, "let").x);
660        assert_eq!(piece(&a, "let").color.r, piece(&b, "let").color.r);
661        assert_eq!(piece(&a, "let").color.g, piece(&c, "let").color.g);
662        assert!(piece(&a, "value").color.a > 0.9);
663        assert!(piece(&c, "value").color.a < 0.01);
664        assert!(piece(&a, "v").color.a < 0.01);
665        assert!(piece(&c, "v").color.a > 0.9);
666    }
667}