Skip to main content

ashlar_ui/
code_view.rs

1//! A read-only code view: virtualized lines, a line-number gutter, optional wrapping, mouse
2//! selection with copy, search marks, and syntax colours from [`ashlar_syntax`].
3//!
4//! A frame costs O(visible lines). The line index and the lexer state at the start of each line
5//! are kept per `(id, revision)`; line states are computed lazily up to the lowest line shown.
6//! Each line's galley is cached by `(id, line)` and rebuilt only when the revision, its start
7//! state, the width, the font or the theme changes.
8//!
9//! Unwrapped (`wrap(false)`), lines wider than the view scroll sideways: the horizontal wheel
10//! (Shift+wheel) over the view, or a drag on the thin bar along its bottom.
11//!
12//! ```
13//! # use ashlar_ui::code_view::CodeView;
14//! # fn ui(ui: &mut egui::Ui, source: &str, revision: u64) {
15//! let out = CodeView::new("editor.preview", source).lang_for_path("src/main.rs").revision(revision).wrap(false).show(ui);
16//! if out.copied {
17//!     // the selection is on the clipboard
18//! }
19//! # }
20//! ```
21//!
22//! Automation: the view registers as `<id>` (a document) and each visible line as `<id>.line.<n>`
23//! (1-based, labelled with its text). AccessKit: the view is a read-only document, each visible
24//! line a paragraph labelled with its text.
25
26use std::ops::Range;
27use std::sync::Arc;
28
29use ashlar_syntax::{Builtin, Highlighter, Kind, State};
30use egui::text::{ByteIndex, CCursor, LayoutJob, LayoutSection, TextWrapping};
31use egui::text_selection::CCursorRange;
32use egui::{Color32, Event, FontId, Galley, Key, Pos2, Rect, TextFormat, Ui, Visuals, pos2, vec2};
33
34use crate::automation::{self, Details, Role};
35use crate::cache::{self, hash_of};
36use crate::hscroll::HScroll;
37use crate::tokens::{self, Tokens};
38use crate::virtual_list::{self, MAX_ROWS, RowHeights, VirtualList};
39
40/// Colours for each [`Kind`] of token.
41#[derive(Clone, Debug, PartialEq)]
42pub struct SyntaxTheme {
43    colors: [Color32; Kind::ALL.len()],
44}
45
46fn kind_index(kind: Kind) -> usize {
47    Kind::ALL.iter().position(|k| *k == kind).unwrap_or(0)
48}
49
50impl SyntaxTheme {
51    /// A theme derived from the design tokens (accent, status and text colours), so it follows
52    /// light and dark mode and any brand accent.
53    pub fn from_tokens(t: &Tokens) -> SyntaxTheme {
54        let text = t.text.primary;
55        let weak = t.text.secondary;
56        let mix = |a: Color32, b: Color32, f: f32| a.lerp_to_gamma(b, f);
57        let mut theme = SyntaxTheme { colors: [text; Kind::ALL.len()] };
58        let pairs = [
59            (Kind::Plain, text),
60            (Kind::Keyword, t.accent),
61            (Kind::Type, mix(t.status.info, t.status.success, 0.5)),
62            (Kind::Function, t.status.info),
63            (Kind::String, t.status.success),
64            (Kind::Comment, mix(weak, t.text.disabled, 0.3)),
65            (Kind::Number, t.status.warning),
66            (Kind::Constant, mix(t.status.warning, t.status.danger, 0.4)),
67            (Kind::Punctuation, mix(text, weak, 0.6)),
68            (Kind::Operator, mix(text, t.accent, 0.35)),
69            (Kind::Attribute, mix(t.status.warning, t.accent, 0.4)),
70            (Kind::Property, mix(t.status.info, text, 0.3)),
71            (Kind::Variable, mix(t.status.danger, t.accent, 0.3)),
72            (Kind::Tag, t.status.danger),
73            (Kind::Heading, t.accent),
74            (Kind::Emphasis, mix(text, t.accent, 0.2)),
75            (Kind::Link, t.status.info),
76            (Kind::Inserted, t.status.success),
77            (Kind::Deleted, t.status.danger),
78            (Kind::Meta, weak),
79        ];
80        for (kind, color) in pairs {
81            theme.set(kind, color);
82        }
83        theme
84    }
85
86    /// The colour of `kind`.
87    pub fn color(&self, kind: Kind) -> Color32 {
88        self.colors.get(kind_index(kind)).copied().unwrap_or(Color32::PLACEHOLDER)
89    }
90
91    /// Overrides the colour of `kind`.
92    pub fn set(&mut self, kind: Kind, color: Color32) {
93        if let Some(c) = self.colors.get_mut(kind_index(kind)) {
94            *c = color;
95        }
96    }
97
98    /// A hash of the colours, for galley cache revisions.
99    pub fn revision(&self) -> u64 {
100        hash_of(&self.colors.map(|c| c.to_array()))
101    }
102}
103
104/// A highlighted layout job for one block of code (used by Markdown code fences): each span
105/// becomes a section in `font` coloured by `theme`. Unknown languages give one plain section.
106pub fn highlight_job(lang: Option<&str>, text: &str, font: FontId, theme: &SyntaxTheme) -> LayoutJob {
107    let spans = ashlar_syntax::highlight(lang, text);
108    job_from_spans(text, &spans, font, theme)
109}
110
111fn job_from_spans(text: &str, spans: &[ashlar_syntax::Span], font: FontId, theme: &SyntaxTheme) -> LayoutJob {
112    let sections = spans
113        .iter()
114        .map(|(range, kind)| LayoutSection {
115            leading_space: 0.0,
116            byte_range: ByteIndex(range.start)..ByteIndex(range.end),
117            format: TextFormat { font_id: font.clone(), color: theme.color(*kind), ..Default::default() },
118        })
119        .collect();
120    LayoutJob { text: text.to_owned(), sections, break_on_newline: true, ..Default::default() }
121}
122
123/// What the view reports this frame.
124pub struct CodeViewResponse {
125    /// The view's viewport.
126    pub response: egui::Response,
127    /// The selected byte range of the text (never empty).
128    pub selection: Option<Range<usize>>,
129    /// The selection was copied to the clipboard this frame (Cmd/Ctrl+C).
130    pub copied: bool,
131    /// Line (0-based) pressed with the pointer this frame.
132    pub clicked_line: Option<usize>,
133    /// Lines (0-based) laid out this frame.
134    pub visible: Range<usize>,
135}
136
137/// A read-only, virtualized code view. See the [module docs](self).
138pub struct CodeView<'a> {
139    id: &'a str,
140    text: &'a str,
141    lang: Option<&'a str>,
142    revision: u64,
143    wrap: bool,
144    line_numbers: bool,
145    theme: Option<&'a SyntaxTheme>,
146    highlighter: &'a dyn Highlighter,
147    marks: &'a [Range<usize>],
148    scroll_to_line: Option<usize>,
149    max_height: Option<f32>,
150}
151
152impl<'a> CodeView<'a> {
153    pub fn new(id: &'a str, text: &'a str) -> Self {
154        CodeView {
155            id,
156            text,
157            lang: None,
158            revision: 0,
159            wrap: false,
160            line_numbers: true,
161            theme: None,
162            highlighter: &Builtin,
163            marks: &[],
164            scroll_to_line: None,
165            max_height: None,
166        }
167    }
168
169    /// The language (a name, alias or extension; see [`ashlar_syntax::Lang::from_name`]). Unknown
170    /// or `None` shows plain text.
171    pub fn lang(mut self, lang: Option<&'a str>) -> Self {
172        self.lang = lang;
173        self
174    }
175
176    /// The language for a file path (`"src/main.rs"`).
177    pub fn lang_for_path(mut self, path: &str) -> Self {
178        self.lang = ashlar_syntax::lang_for_path(path);
179        self
180    }
181
182    /// Bump when the text changes. (A change of the text's address or length is noticed anyway;
183    /// an edit that keeps both needs a new revision.)
184    pub fn revision(mut self, revision: u64) -> Self {
185        self.revision = revision;
186        self
187    }
188
189    /// Wrap long lines at the view's width (off: long lines are clipped).
190    pub fn wrap(mut self, wrap: bool) -> Self {
191        self.wrap = wrap;
192        self
193    }
194
195    /// Show the line-number gutter (on by default).
196    pub fn line_numbers(mut self, yes: bool) -> Self {
197        self.line_numbers = yes;
198        self
199    }
200
201    /// Token colours (default: [`SyntaxTheme::from_tokens`] of the current tokens).
202    pub fn theme(mut self, theme: &'a SyntaxTheme) -> Self {
203        self.theme = Some(theme);
204        self
205    }
206
207    /// The highlighting engine (default: [`Builtin`]).
208    pub fn highlighter(mut self, h: &'a dyn Highlighter) -> Self {
209        self.highlighter = h;
210        self
211    }
212
213    /// Byte ranges to mark (search hits), sorted by start and not overlapping.
214    pub fn marks(mut self, marks: &'a [Range<usize>]) -> Self {
215        self.marks = marks;
216        self
217    }
218
219    /// Scroll so this line (0-based) is visible.
220    pub fn scroll_to_line(mut self, line: Option<usize>) -> Self {
221        self.scroll_to_line = line;
222        self
223    }
224
225    /// Cap the view's height (default: the available height).
226    pub fn max_height(mut self, h: f32) -> Self {
227        self.max_height = Some(h);
228        self
229    }
230
231    pub fn show(self, ui: &mut Ui) -> CodeViewResponse {
232        show(self, ui)
233    }
234}
235
236/// The line index and lexer states of one text revision.
237#[derive(Clone, Default)]
238struct Doc {
239    key: u64,
240    /// Byte offset of each line start, then `text.len()`.
241    starts: Vec<usize>,
242    /// The lexer state at the start of lines `0..states.len()`.
243    states: Vec<State>,
244    /// The line with the most bytes (its width starts the horizontal scroll range).
245    longest: usize,
246}
247
248impl Doc {
249    fn build(key: u64, text: &str) -> Doc {
250        let mut starts = Vec::with_capacity(text.len() / 32 + 2);
251        starts.push(0);
252        for (i, _) in text.match_indices('\n') {
253            if starts.len() > MAX_ROWS {
254                break;
255            }
256            starts.push(i + 1);
257        }
258        starts.push(text.len());
259        let longest = starts.windows(2).enumerate().max_by_key(|(_, w)| w.get(1).zip(w.first()).map_or(0, |(b, a)| b - a)).map_or(0, |(i, _)| i);
260        Doc { key, starts, states: vec![State::START], longest }
261    }
262}
263
264fn line_count(starts: &[usize]) -> usize {
265    starts.len().saturating_sub(1).max(1)
266}
267
268/// The byte range of line `i` without its line ending.
269fn line_range(starts: &[usize], text: &str, i: usize) -> Range<usize> {
270    let s = starts.get(i).copied().unwrap_or(text.len()).min(text.len());
271    let e = starts.get(i + 1).copied().unwrap_or(text.len()).clamp(s, text.len());
272    let line = text.get(s..e).unwrap_or("");
273    let body = line.strip_suffix('\n').unwrap_or(line);
274    let body = body.strip_suffix('\r').unwrap_or(body);
275    s..s + body.len()
276}
277
278/// Line starts, the states known so far, and what lexes the rest.
279struct Lines<'d> {
280    starts: &'d [usize],
281    states: &'d mut Vec<State>,
282    text: &'d str,
283    lang: &'d str,
284    hl: &'d dyn Highlighter,
285}
286
287impl Lines<'_> {
288    /// The state line `i` starts in, lexing forward from the last known line.
289    fn state(&mut self, i: usize, scratch: &mut Vec<ashlar_syntax::Span>) -> State {
290        let target = i.min(line_count(self.starts));
291        if self.states.is_empty() {
292            self.states.push(State::START);
293        }
294        while self.states.len() <= target {
295            let k = self.states.len() - 1;
296            let prev = self.states.last().copied().unwrap_or_default();
297            let r = line_range(self.starts, self.text, k);
298            scratch.clear();
299            let next = self.hl.highlight_line(self.lang, self.text.get(r).unwrap_or(""), prev, scratch);
300            self.states.push(next);
301        }
302        self.states.get(target).copied().unwrap_or_default()
303    }
304}
305
306/// Mouse selection, as byte offsets into the text.
307#[derive(Clone, Copy, Default)]
308struct Sel {
309    anchor: usize,
310    head: usize,
311    dragging: bool,
312}
313
314impl Sel {
315    fn range(&self) -> Option<Range<usize>> {
316        let (a, b) = (self.anchor.min(self.head), self.anchor.max(self.head));
317        (b > a).then_some(a..b)
318    }
319}
320
321/// A line's text for labels: at most 256 bytes (a minified file can have megabyte lines).
322fn short(line: &str) -> &str {
323    let mut end = line.len().min(256);
324    while end > 0 && !line.is_char_boundary(end) {
325        end -= 1;
326    }
327    line.get(..end).unwrap_or("")
328}
329
330/// The char index of byte offset `byte` in `line` (clamped).
331fn char_index(line: &str, byte: usize) -> usize {
332    let mut b = byte.min(line.len());
333    while b > 0 && !line.is_char_boundary(b) {
334        b -= 1;
335    }
336    line.get(..b).map_or(0, |s| s.chars().count())
337}
338
339/// The byte offset of char index `ci` in `line` (clamped).
340fn byte_index(line: &str, ci: usize) -> usize {
341    line.char_indices().nth(ci).map_or(line.len(), |(b, _)| b)
342}
343
344fn show(view: CodeView<'_>, ui: &mut Ui) -> CodeViewResponse {
345    let t = tokens::current(ui.ctx());
346    let base = ui.make_persistent_id(view.id);
347    let text = view.text;
348    let lang = view.lang.unwrap_or("");
349    let font = t.mono();
350    let (line_h, char_w) = ui.fonts_mut(|f| (f.row_height(&font), f.glyph_width(&font, '0')));
351    let (line_h, char_w) = (line_h.max(1.0), char_w.max(1.0));
352    let own_theme;
353    let theme = match view.theme {
354        Some(th) => th,
355        None => {
356            own_theme = SyntaxTheme::from_tokens(&t);
357            &own_theme
358        }
359    };
360
361    let doc_id = base.with("doc");
362    let key = hash_of(&(view.revision, text.as_ptr() as usize, text.len(), lang));
363    let mut doc = ui.data_mut(|d| d.remove_temp::<Doc>(doc_id)).filter(|d| d.key == key).unwrap_or_else(|| Doc::build(key, text));
364    let mut states = std::mem::take(&mut doc.states);
365    let lines = line_count(&doc.starts);
366
367    // geometry
368    let pad = t.space.sm;
369    let digits = lines.to_string().len().max(2) as f32;
370    let gutter_w = if view.line_numbers { digits * char_w + 2.0 * pad } else { 0.0 };
371    let text_w = (ui.available_width() - gutter_w - 3.0 * pad - ui.spacing().scroll.bar_width).max(char_w);
372    let cols = ((text_w / char_w).floor() as usize).max(1);
373    let wrap_w = if view.wrap { cols as f32 * char_w + 0.5 } else { f32::INFINITY };
374    let style_rev = hash_of(&(theme.revision(), font.size.to_bits(), wrap_w.to_bits(), t.dark));
375
376    // input, read once
377    let sel_id = base.with("sel");
378    let mut sel: Sel = ui.data(|d| d.get_temp(sel_id)).unwrap_or_default();
379    let (pressed, down, pointer, shift) =
380        ui.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_down(), i.pointer.interact_pos(), i.modifiers.shift));
381    let mut clicked_line = None;
382
383    let height_fn = |i: usize| {
384        let r = line_range(&doc.starts, text, i);
385        let chars = text.get(r).map_or(0, |s| s.chars().count());
386        chars.div_ceil(cols).max(1) as f32 * line_h
387    };
388    let heights = if view.wrap {
389        RowHeights::Variable { revision: hash_of(&(key, cols, line_h.to_bits())), height: &height_fn }
390    } else {
391        RowHeights::Uniform(line_h)
392    };
393    // unwrapped: long lines scroll sideways; the range starts at the longest line
394    let longest = text.get(line_range(&doc.starts, text, doc.longest)).map_or(0, |l| l.chars().count());
395    let area = ui.available_rect_before_wrap();
396    let mut hs = (!view.wrap).then(|| HScroll::begin(ui, base.with("hx"), area, longest as f32 * char_w + pad, text_w));
397    let hx = hs.as_ref().map_or(0.0, |h| h.offset);
398    let mut list = VirtualList::new(view.id, lines, heights).scroll_to(view.scroll_to_line);
399    if let Some(h) = view.max_height {
400        list = list.max_height(h);
401    }
402
403    let sel_range = sel.range();
404    let mut sel_visuals: Option<Visuals> = None;
405    let mut mark_visuals: Option<Visuals> = None;
406    let mut scratch = Vec::new();
407    let mut known = Lines { starts: &doc.starts, states: &mut states, text, lang, hl: view.highlighter };
408    let kind =
409        virtual_list::Kind { role: Role::Document, rows: false, node: egui::accesskit::Role::Document, item: egui::accesskit::Role::Paragraph };
410    let out = virtual_list::show_rows(ui, base, &mut list, kind, None, |ui, row| {
411        let i = row.index;
412        let range = line_range(known.starts, text, i);
413        let line = text.get(range.clone()).unwrap_or("");
414        let state = known.state(i, &mut scratch);
415        let rev = hash_of(&(key, style_rev, state.to_bits()));
416        let mut galley: Arc<Galley> = cache::galley(ui, (base, "line", i), rev, || {
417            scratch.clear();
418            let _ = view.highlighter.highlight_line(lang, line, state, &mut scratch);
419            let mut job = job_from_spans(line, &scratch, font.clone(), theme);
420            job.wrap = TextWrapping { max_width: wrap_w, break_anywhere: true, ..Default::default() };
421            job
422        });
423        let rect = row.rect;
424        let origin = pos2(rect.left() + pad + gutter_w - hx, rect.top());
425        if let Some(h) = hs.as_mut() {
426            h.see(galley.size().x + 2.0 * pad);
427        }
428
429        // pointer: press starts a selection, a drag extends it
430        if let Some(p) = pointer {
431            let offset_at = |p: Pos2| {
432                let local = (p - origin).max(vec2(0.0, 0.0));
433                range.start + byte_index(line, galley.cursor_from_pos(local).index.0)
434            };
435            if pressed && rect.contains(p) {
436                let off = offset_at(p);
437                if !shift {
438                    sel.anchor = off;
439                }
440                sel.head = off;
441                sel.dragging = true;
442                clicked_line = Some(i);
443                ui.memory_mut(|m| m.request_focus(base));
444            } else if sel.dragging && down && p.y >= rect.top() && p.y < rect.bottom() {
445                sel.head = offset_at(p);
446            }
447        }
448
449        // marks, then the selection, painted into the galley's background
450        let first = view.marks.partition_point(|m| m.end <= range.start);
451        for m in view.marks.iter().skip(first).take_while(|m| m.start < range.end.max(range.start + 1)).take(256) {
452            let vis = mark_visuals.get_or_insert_with(|| {
453                let mut v = ui.visuals().clone();
454                v.selection.bg_fill = t.status.warning.gamma_multiply(0.35);
455                v
456            });
457            paint_range(&mut galley, vis, line, &range, m);
458        }
459        if let Some(s) = &sel_range {
460            let vis = sel_visuals.get_or_insert_with(|| {
461                let mut v = ui.visuals().clone();
462                v.selection.bg_fill = t.selection;
463                v
464            });
465            paint_range(&mut galley, vis, line, &range, s);
466        }
467
468        let painter = ui.painter();
469        if view.line_numbers {
470            let gutter = Rect::from_min_max(pos2(rect.left(), rect.top()), pos2(rect.left() + gutter_w, rect.bottom()));
471            painter.rect_filled(gutter, 0, t.surface.sunken);
472            let n = i + 1;
473            let num = cache::galley(ui, (base, "ln", n), hash_of(&(font.size.to_bits(), t.dark)), || {
474                LayoutJob::simple_singleline(n.to_string(), font.clone(), t.text.disabled)
475            });
476            painter.galley(pos2(gutter.right() - pad - num.size().x, rect.top()), num, t.text.disabled);
477        }
478        let text_clip = Rect::from_min_max(pos2(rect.left() + gutter_w, rect.top()), rect.max).intersect(ui.clip_rect());
479        painter.with_clip_rect(text_clip).galley(origin, galley, theme.color(Kind::Plain));
480
481        let label = short(line);
482        let row_id = base.with(("row", i));
483        ui.ctx().accesskit_node_builder(row_id, |node| {
484            node.set_role(egui::accesskit::Role::Paragraph);
485            node.set_label(label);
486        });
487        automation::register_fmt(ui, rect, format_args!("{}.line.{}", view.id, i + 1), label, Role::Label, true, Details::default());
488    });
489    doc.states = states;
490    if let Some(h) = hs {
491        let r = out.response.rect;
492        h.end(ui, Rect::from_min_max(pos2(r.left() + gutter_w, r.top()), pos2(r.right() - ui.spacing().scroll.bar_width, r.bottom())), &t);
493    }
494
495    // keyboard: select all and copy, while the view has the focus
496    let focused = out.response.has_focus();
497    let mut copied = false;
498    if focused {
499        let (all, copy) = ui.input(|i| {
500            let cmd = i.modifiers.command;
501            (cmd && i.key_pressed(Key::A), i.events.iter().any(|e| matches!(e, Event::Copy)) || (cmd && i.key_pressed(Key::C)))
502        });
503        if all {
504            sel = Sel { anchor: 0, head: text.len(), dragging: false };
505        }
506        if copy && let Some(s) = sel.range().and_then(|r| text.get(r)) {
507            ui.ctx().copy_text(s.to_owned());
508            copied = true;
509        }
510    }
511    if !down {
512        sel.dragging = false;
513    }
514    let selection = sel.range();
515    ui.data_mut(|d| {
516        d.insert_temp(sel_id, sel);
517        d.insert_temp(doc_id, doc);
518    });
519    ui.ctx().accesskit_node_builder(out.response.id, |node| {
520        node.set_role(egui::accesskit::Role::Document);
521        // the automation id is not a name a screen reader should read out
522        node.set_read_only();
523    });
524    CodeViewResponse { response: out.response, selection, copied, clicked_line, visible: out.visible }
525}
526
527/// Paints the part of byte range `r` (of the whole text) that falls on this line.
528fn paint_range(galley: &mut Arc<Galley>, vis: &Visuals, line: &str, line_range: &Range<usize>, r: &Range<usize>) {
529    let from = r.start.max(line_range.start).saturating_sub(line_range.start);
530    let to = r.end.min(line_range.end).saturating_sub(line_range.start);
531    if to <= from {
532        return;
533    }
534    let range = CCursorRange::two(CCursor::new(char_index(line, from)), CCursor::new(char_index(line, to)));
535    egui::text_selection::visuals::paint_text_selection(galley, vis, &range, None);
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541
542    #[test]
543    fn line_index_and_ranges() {
544        let text = "a\r\nbc\n\nlast";
545        let d = Doc::build(1, text);
546        assert_eq!(line_count(&d.starts), 4);
547        assert_eq!(line_range(&d.starts, text, 0), 0..1);
548        assert_eq!(line_range(&d.starts, text, 1), 3..5);
549        assert_eq!(line_range(&d.starts, text, 2), 6..6);
550        assert_eq!(line_range(&d.starts, text, 3), 7..11);
551        assert_eq!(line_range(&d.starts, text, 99), 11..11);
552        assert_eq!(line_count(&Doc::build(1, "x\n").starts), 2);
553        assert_eq!(line_count(&Doc::build(1, "").starts), 1);
554    }
555
556    #[test]
557    fn char_and_byte_indices_round_trip() {
558        let s = "aé😀b";
559        assert_eq!(char_index(s, 3), 2);
560        assert_eq!(char_index(s, 4), 2); // inside the emoji: clamped back
561        assert_eq!(byte_index(s, 3), 7);
562        assert_eq!(byte_index(s, 99), s.len());
563    }
564
565    #[test]
566    fn states_are_lazy_and_carry_comments() {
567        let text = "/* a\nb */\nfn x";
568        let mut d = Doc::build(1, text);
569        let mut scratch = Vec::new();
570        assert_eq!(d.states.len(), 1);
571        let mut lines = Lines { starts: &d.starts, states: &mut d.states, text, lang: "rust", hl: &Builtin };
572        let s1 = lines.state(1, &mut scratch);
573        assert_ne!(s1, State::START);
574        assert_eq!(lines.state(2, &mut scratch), State::START);
575        assert_eq!(d.states.len(), 3);
576    }
577
578    #[test]
579    fn theme_from_tokens_differs_by_kind() {
580        let th = SyntaxTheme::from_tokens(&Tokens::dark());
581        assert_ne!(th.color(Kind::Keyword), th.color(Kind::Plain));
582        assert_ne!(th.color(Kind::String), th.color(Kind::Comment));
583        let mut th2 = th.clone();
584        th2.set(Kind::Keyword, Color32::RED);
585        assert_eq!(th2.color(Kind::Keyword), Color32::RED);
586        assert_ne!(th.revision(), th2.revision());
587    }
588}