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) 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 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
230pub(crate) struct DiffGlyph {
232 pub path: BezPath,
233 pub color: Color,
234}
235
236pub(crate) struct DiffFlash {
238 pub rect: kurbo::Rect,
239 pub color: Color,
240}
241
242pub(crate) struct DiffFrame {
244 pub glyphs: Vec<DiffGlyph>,
245 pub flashes: Vec<DiffFlash>,
246 pub size: (f64, f64),
247}
248
249#[derive(Debug, Clone)]
252#[cfg_attr(not(test), allow(dead_code))]
253pub(crate) struct DiffSlot {
254 pub text: String,
256 pub x: f64,
258 pub y: f64,
260 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
283pub(crate) struct DiffView<'a> {
285 pub theme: &'a CodeTheme,
287 pub style: &'a TextStyle,
289 pub focus: Vec2,
291 pub dim: f64,
293 pub t: f64,
295 pub flash: bool,
297}
298
299pub(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 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
448pub(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 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 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}