1use 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#[derive(Debug, Clone, PartialEq, Default)]
14pub struct RichSpan {
15 pub text: String,
17 pub color: Option<Color>,
19 pub weight: Option<f64>,
21 pub italic: Option<bool>,
23 pub size: Option<f64>,
25 pub family: Option<String>,
27}
28
29impl RichSpan {
30 pub fn new(text: impl Into<String>) -> Self {
32 Self {
33 text: text.into(),
34 ..Default::default()
35 }
36 }
37 pub fn color(mut self, c: impl IntoPaint) -> Self {
39 self.color = Some(c.into_paint());
40 self
41 }
42 pub fn weight(mut self, w: f64) -> Self {
44 self.weight = Some(w);
45 self
46 }
47 pub fn bold(self) -> Self {
49 self.weight(700.0)
50 }
51 pub fn italic(mut self) -> Self {
53 self.italic = Some(true);
54 self
55 }
56 pub fn size(mut self, s: f64) -> Self {
58 self.size = Some(s);
59 self
60 }
61 pub fn family(mut self, f: impl Into<String>) -> Self {
63 self.family = Some(f.into());
64 self
65 }
66}
67
68pub fn span(text: impl Into<String>) -> RichSpan {
70 RichSpan::new(text)
71}
72
73impl 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#[derive(Debug, Clone, PartialEq)]
83pub struct CodeTheme {
84 pub plain: Color,
86 pub keyword: Color,
88 pub types: Color,
90 pub function: Color,
92 pub macros: Color,
94 pub string: Color,
96 pub number: Color,
98 pub comment: Color,
100 pub attribute: Color,
102 pub punct: Color,
104}
105
106impl CodeTheme {
107 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 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 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
164pub(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
188pub(crate) struct CodeFocus {
190 pub lines: Vec2,
192 pub span: Vec2,
194}
195
196pub(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
209fn 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 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 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 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
276pub(crate) struct DiffGlyph {
278 pub path: BezPath,
279 pub color: Color,
280}
281
282pub(crate) struct DiffFlash {
284 pub rect: kurbo::Rect,
285 pub color: Color,
286}
287
288pub(crate) struct DiffFrame {
290 pub glyphs: Vec<DiffGlyph>,
291 pub flashes: Vec<DiffFlash>,
292 pub size: (f64, f64),
293}
294
295#[derive(Debug, Clone)]
298#[cfg_attr(not(test), allow(dead_code))]
299pub(crate) struct DiffSlot {
300 pub text: String,
302 pub x: f64,
304 pub y: f64,
306 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
329pub(crate) struct DiffView<'a> {
331 pub theme: &'a CodeTheme,
333 pub style: &'a TextStyle,
335 pub focus: Vec2,
337 pub dim: f64,
339 pub t: f64,
341 pub flash: bool,
343}
344
345pub(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 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
494pub(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 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 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}