Skip to main content

dinamika_core/shape/
code.rs

1//! Code shape — source highlighting with a manually configured palette.
2//!
3//! The code shape ([`Shape::code`](crate::Shape::code)) is the same text shape
4//! ([`Shape::text`](crate::Shape::text)) in everything concerning font, font
5//! size, layout, content edits and animations (spawn, typing, smoothing). There
6//! is exactly one difference: it has **no single color**
7//! ([`color`](crate::Shape::color) panics on it) — instead glyphs are colored
8//! per-character by the result of syntax parsing.
9//!
10//! # A palette instead of a color
11//!
12//! Colors are set by a [`Palette`] — a "token category → color" table, assembled
13//! manually with a fluent builder. The base color ([`Palette::new`]) is given to
14//! all characters for which the palette has no rule; named categories
15//! ([`keyword`](Palette::keyword), [`string`](Palette::string),
16//! [`comment`](Palette::comment), etc.) override it for their tokens. An empty
17//! palette (without a single category) colors all the code with the base color —
18//! like plain text, until highlighting is configured.
19//!
20//! # Choosing the language
21//!
22//! The grammar is set by [`Language`] (for example [`Rust`](Language::Rust) or
23//! [`JavaScript`](Language::JavaScript)). The default is
24//! [`PlainText`](Language::PlainText): no parsing, all the code in the base
25//! color. Parsing relies on the built-in Sublime Text grammars from `syntect`.
26//!
27//! ```no_run
28//! use dinamika_core::*;
29//!
30//! let bytes = std::fs::read("Consolas.ttf").unwrap();
31//! let palette = Palette::new(Color::from_rgba8(212, 212, 212, 255))
32//!     .keyword(Color::from_rgba8(197, 134, 192, 255))
33//!     .string(Color::from_rgba8(206, 145, 120, 255))
34//!     .comment(Color::from_rgba8(106, 153, 85, 255))
35//!     .number(Color::from_rgba8(181, 206, 168, 255))
36//!     .function(Color::from_rgba8(220, 220, 170, 255));
37//!
38//! let snippet = Shape::code("fn main() {\n    println!(\"hi\");\n}")
39//!     .font(bytes)
40//!     .font_size(28.0)
41//!     .language(Language::Rust)
42//!     .palette(palette);
43//! ```
44//!
45//! # Parsing and cache
46//!
47//! The palette does not depend on `syntect`: parsing only splits the source into
48//! scopes (`keyword.control`, `string.quoted`, `comment.line`, …), and mapping a
49//! scope to a color is done by the [`Palette`] (see [`classify`]). The result —
50//! a color per character of the string — is cached by a key (text, language,
51//! palette), so static code is not re-parsed every frame. The glyph layout itself
52//! and the path groups by color are assembled in [`text`](super::text) — here are
53//! only the colors.
54
55use std::cell::{Cell, RefCell};
56use std::rc::Rc;
57use std::sync::OnceLock;
58
59use dinamika_cpu::Color;
60use syntect::parsing::{ParseState, Scope, ScopeStack, SyntaxSet};
61
62/// The code shape's highlight language. Resolves to a Sublime Text grammar from
63/// the built-in `syntect` set; an unknown grammar (or
64/// [`PlainText`](Language::PlainText)) means "no parsing" — all the code is
65/// colored with the palette's base color.
66#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
67pub enum Language {
68    /// No highlighting (the default value): the code is colored with the base color.
69    #[default]
70    PlainText,
71    /// Rust.
72    Rust,
73    /// JavaScript.
74    JavaScript,
75    /// Python.
76    Python,
77    /// C.
78    C,
79    /// C++.
80    Cpp,
81    /// Go.
82    Go,
83    /// JSON.
84    Json,
85    /// HTML.
86    Html,
87    /// CSS.
88    Css,
89    /// Java.
90    Java,
91    /// Bash / shell.
92    Bash,
93}
94
95impl Language {
96    /// The `syntect` grammar token (name/extension). `None` —
97    /// [`PlainText`](Language::PlainText), no parsing.
98    fn token(self) -> Option<&'static str> {
99        Some(match self {
100            Language::PlainText => return None,
101            Language::Rust => "rust",
102            Language::JavaScript => "js",
103            Language::Python => "python",
104            Language::C => "c",
105            Language::Cpp => "c++",
106            Language::Go => "go",
107            Language::Json => "json",
108            Language::Html => "html",
109            Language::Css => "css",
110            Language::Java => "java",
111            Language::Bash => "sh",
112        })
113    }
114}
115
116/// A highlight palette: "token category → color", assembled manually.
117///
118/// The base color ([`new`](Palette::new)) is the default color for all
119/// characters; named categories override it for their tokens. An unset category
120/// (`None`) falls back to the base color, so it is enough to set only the colors
121/// you need. The palette is cheap and comparable by value — it can be cloned and
122/// passed to [`Shape::palette`](crate::Shape::palette).
123#[derive(Clone, Debug, PartialEq)]
124pub struct Palette {
125    /// The base color (for tokens without a rule in the palette).
126    foreground: Color,
127    /// Keywords (`if`, `fn`, `let`, `class`, modifiers…).
128    keyword: Option<Color>,
129    /// String literals.
130    string: Option<Color>,
131    /// Comments.
132    comment: Option<Color>,
133    /// Numeric literals.
134    number: Option<Color>,
135    /// Function names (declarations and calls).
136    function: Option<Color>,
137    /// Names of types, classes, structs.
138    type_: Option<Color>,
139    /// Language and named constants (`true`, `null`, `NaN`…).
140    constant: Option<Color>,
141    /// Operators (`=`, `+`, `&&`…).
142    operator: Option<Color>,
143    /// Variable names.
144    variable: Option<Color>,
145    /// Punctuation (brackets, commas, semicolons).
146    punctuation: Option<Color>,
147}
148
149impl Default for Palette {
150    /// An empty palette: base color [`Color::BLACK`], without a single category —
151    /// the code is colored entirely with the base color until highlighting is
152    /// configured.
153    fn default() -> Self {
154        Palette::new(Color::BLACK)
155    }
156}
157
158impl Palette {
159    /// A palette with base color `foreground` and no highlight rules. Categories
160    /// are added with the fluent methods ([`keyword`](Palette::keyword), etc.).
161    pub fn new(foreground: Color) -> Self {
162        Palette {
163            foreground,
164            keyword: None,
165            string: None,
166            comment: None,
167            number: None,
168            function: None,
169            type_: None,
170            constant: None,
171            operator: None,
172            variable: None,
173            punctuation: None,
174        }
175    }
176
177    /// Keyword color.
178    pub fn keyword(mut self, c: Color) -> Self {
179        self.keyword = Some(c);
180        self
181    }
182    /// String-literal color.
183    pub fn string(mut self, c: Color) -> Self {
184        self.string = Some(c);
185        self
186    }
187    /// Comment color.
188    pub fn comment(mut self, c: Color) -> Self {
189        self.comment = Some(c);
190        self
191    }
192    /// Numeric-literal color.
193    pub fn number(mut self, c: Color) -> Self {
194        self.number = Some(c);
195        self
196    }
197    /// Function-name color.
198    pub fn function(mut self, c: Color) -> Self {
199        self.function = Some(c);
200        self
201    }
202    /// Type/class-name color.
203    pub fn type_(mut self, c: Color) -> Self {
204        self.type_ = Some(c);
205        self
206    }
207    /// Constant color.
208    pub fn constant(mut self, c: Color) -> Self {
209        self.constant = Some(c);
210        self
211    }
212    /// Operator color.
213    pub fn operator(mut self, c: Color) -> Self {
214        self.operator = Some(c);
215        self
216    }
217    /// Variable-name color.
218    pub fn variable(mut self, c: Color) -> Self {
219        self.variable = Some(c);
220        self
221    }
222    /// Punctuation color.
223    pub fn punctuation(mut self, c: Color) -> Self {
224        self.punctuation = Some(c);
225        self
226    }
227
228    /// A palette without a single highlight rule — all the code will go in the
229    /// base color, so syntax parsing can be skipped.
230    fn is_plain(&self) -> bool {
231        self.keyword.is_none()
232            && self.string.is_none()
233            && self.comment.is_none()
234            && self.number.is_none()
235            && self.function.is_none()
236            && self.type_.is_none()
237            && self.constant.is_none()
238            && self.operator.is_none()
239            && self.variable.is_none()
240            && self.punctuation.is_none()
241    }
242
243    /// The token's color by its scope stack (outer to inner, as `syntect`
244    /// returns it).
245    ///
246    /// Comments and strings color their whole region (any `comment*`/`string*`
247    /// scope in the stack decides the outcome), otherwise the color is taken from
248    /// the most specific (innermost) scope, falling back outward: the first scope
249    /// whose category is set in the palette gives the color. If nothing matched —
250    /// the base color.
251    fn color_for(&self, scopes: &[Scope]) -> Color {
252        for scope in scopes {
253            let s = scope.build_string();
254            if s.starts_with("comment") {
255                return self.comment.unwrap_or(self.foreground);
256            }
257            if s.starts_with("string") {
258                return self.string.unwrap_or(self.foreground);
259            }
260        }
261        for scope in scopes.iter().rev() {
262            if let Some(color) = classify(&scope.build_string()).and_then(|c| self.category_color(c)) {
263                return color;
264            }
265        }
266        self.foreground
267    }
268
269    /// The color of a specific category (if set in the palette).
270    fn category_color(&self, category: Category) -> Option<Color> {
271        match category {
272            Category::Keyword => self.keyword,
273            Category::Number => self.number,
274            Category::Function => self.function,
275            Category::Type => self.type_,
276            Category::Constant => self.constant,
277            Category::Operator => self.operator,
278            Category::Variable => self.variable,
279            Category::Punctuation => self.punctuation,
280        }
281    }
282}
283
284/// The token category a `syntect` scope maps to. Comments and strings are handled
285/// separately ([`Palette::color_for`]) and do not get here.
286#[derive(Copy, Clone)]
287enum Category {
288    Keyword,
289    Number,
290    Function,
291    Type,
292    Constant,
293    Operator,
294    Variable,
295    Punctuation,
296}
297
298/// Maps a Sublime Text scope string (`keyword.control.rust`,
299/// `entity.name.function`, …) to a palette category. `None` — a scope without a
300/// category (its color is looked up in a more outer scope or taken as the base).
301fn classify(scope: &str) -> Option<Category> {
302    if scope.starts_with("keyword.operator") {
303        Some(Category::Operator)
304    } else if scope.starts_with("keyword") || scope.starts_with("storage") {
305        // storage.type / storage.modifier are `fn`, `let`, `const`, `pub`… —
306        // keywords in meaning.
307        Some(Category::Keyword)
308    } else if scope.starts_with("constant.numeric") {
309        Some(Category::Number)
310    } else if scope.starts_with("constant") {
311        Some(Category::Constant)
312    } else if scope.starts_with("entity.name.function")
313        || scope.starts_with("support.function")
314        || scope.starts_with("variable.function")
315        || scope.starts_with("meta.function-call")
316    {
317        Some(Category::Function)
318    } else if scope.starts_with("entity.name.type")
319        || scope.starts_with("entity.name.class")
320        || scope.starts_with("entity.name.struct")
321        || scope.starts_with("entity.name.enum")
322        || scope.starts_with("entity.name.trait")
323        || scope.starts_with("entity.name.namespace")
324        || scope.starts_with("support.type")
325        || scope.starts_with("support.class")
326        || scope.starts_with("entity.other.inherited-class")
327    {
328        Some(Category::Type)
329    } else if scope.starts_with("variable") {
330        Some(Category::Variable)
331    } else if scope.starts_with("punctuation") {
332        Some(Category::Punctuation)
333    } else {
334        None
335    }
336}
337
338/// The code shape's highlight state: palette, language and a cache of computed
339/// colors.
340///
341/// Lives next to [`TextData`](super::text::TextData) inside
342/// [`ShapeData`](super::ShapeData) for shapes of kind
343/// [`ShapeKind::Code`](super::ShapeKind::Code). The text part (content, font,
344/// style, animations) is stored in `TextData`; here is only what replaces the
345/// color.
346pub(crate) struct CodeData {
347    palette: RefCell<Palette>,
348    language: Cell<Language>,
349    /// A small "color per character" cache keyed by (text, language, palette).
350    /// The size is enough for static code and for both ends (`from`/`to`) of the
351    /// smoothing morph, which are computed every frame.
352    cache: RefCell<Vec<(CodeKey, Rc<Vec<Color>>)>>,
353}
354
355/// The color-cache key: everything the parsing and coloring result depends on.
356#[derive(Clone, PartialEq)]
357struct CodeKey {
358    text: String,
359    language: Language,
360    palette: Palette,
361}
362
363/// How many recent parses to keep in the cache.
364const CACHE_CAP: usize = 4;
365
366impl CodeData {
367    /// Default code: an empty palette (base black) and [`Language::PlainText`] —
368    /// no highlighting until it is configured.
369    pub(crate) fn new() -> Self {
370        CodeData {
371            palette: RefCell::new(Palette::default()),
372            language: Cell::new(Language::default()),
373            cache: RefCell::new(Vec::new()),
374        }
375    }
376
377    /// Sets the palette and clears the color cache.
378    pub(crate) fn set_palette(&self, palette: Palette) {
379        *self.palette.borrow_mut() = palette;
380        self.cache.borrow_mut().clear();
381    }
382
383    /// Sets the highlight language and clears the color cache.
384    pub(crate) fn set_language(&self, language: Language) {
385        self.language.set(language);
386        self.cache.borrow_mut().clear();
387    }
388
389    /// The palette's base color — the color of characters without a highlight
390    /// rule.
391    pub(crate) fn foreground(&self) -> Color {
392        self.palette.borrow().foreground
393    }
394
395    /// The color of each character of the string `text` (aligned to
396    /// `text.chars()`: one color per Unicode scalar, including `\n`). The result
397    /// is cached by (text, language, palette).
398    pub(crate) fn char_colors(&self, text: &str) -> Rc<Vec<Color>> {
399        let palette = self.palette.borrow().clone();
400        let language = self.language.get();
401        if let Some(hit) = self.cache.borrow().iter().find_map(|(k, v)| {
402            (k.text == text && k.language == language && k.palette == palette).then(|| Rc::clone(v))
403        }) {
404            return hit;
405        }
406        let colors = Rc::new(compute_colors(text, language, &palette));
407        let mut cache = self.cache.borrow_mut();
408        cache.insert(0, (CodeKey { text: text.to_owned(), language, palette }, Rc::clone(&colors)));
409        cache.truncate(CACHE_CAP);
410        colors
411    }
412}
413
414/// The built-in `syntect` grammar set (loaded once per process).
415fn syntax_set() -> &'static SyntaxSet {
416    static SET: OnceLock<SyntaxSet> = OnceLock::new();
417    SET.get_or_init(SyntaxSet::load_defaults_newlines)
418}
419
420/// Computes the color of each character of `text`: parses the source into scopes
421/// with the grammar `language` and colors each character via
422/// [`Palette::color_for`]. Returns one color per character (aligned to
423/// `text.chars()`).
424///
425/// Without a language, with an empty palette or for a grammar that isn't found,
426/// parsing is skipped — all characters get the base color.
427fn compute_colors(text: &str, language: Language, palette: &Palette) -> Vec<Color> {
428    let count = text.chars().count();
429    let foreground = palette.foreground;
430    let token = match language.token() {
431        Some(token) if !palette.is_plain() => token,
432        _ => return vec![foreground; count],
433    };
434    let syntax_set = syntax_set();
435    let syntax = match syntax_set.find_syntax_by_token(token) {
436        Some(syntax) => syntax,
437        None => return vec![foreground; count],
438    };
439
440    let mut parse = ParseState::new(syntax);
441    let mut stack = ScopeStack::new();
442    let mut colors = Vec::with_capacity(count);
443    // Lines with `\n` kept: the newlines-set grammars and the parser state
444    // between lines are designed exactly for this (block comments, etc.).
445    for line in text.split_inclusive('\n') {
446        let ops = parse.parse_line(line, syntax_set).unwrap_or_default();
447        let mut ops = ops.into_iter().peekable();
448        for (byte_offset, _ch) in line.char_indices() {
449            // Apply all scope changes starting no later than this character.
450            while let Some(&(offset, _)) = ops.peek() {
451                if offset <= byte_offset {
452                    let (_, op) = ops.next().unwrap();
453                    let _ = stack.apply(&op);
454                } else {
455                    break;
456                }
457            }
458            colors.push(palette.color_for(stack.as_slice()));
459        }
460        // Trailing scope changes after the last character of the line.
461        for (_, op) in ops {
462            let _ = stack.apply(&op);
463        }
464    }
465    colors
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    /// The index of the first character of the substring `needle` in `text` (in
473    /// scalar indices).
474    fn char_index_of(text: &str, needle: &str) -> usize {
475        let byte = text.find(needle).expect("the substring is present in the text");
476        text[..byte].chars().count()
477    }
478
479    #[test]
480    fn plain_language_paints_everything_with_foreground() {
481        // Without a highlight language all the code is in the base color, even with a palette set.
482        let fg = Color::from_rgba8(10, 20, 30, 255);
483        let palette = Palette::new(fg).keyword(Color::from_rgba8(200, 0, 0, 255));
484        let code = CodeData::new();
485        code.set_palette(palette);
486        let text = "fn main() {}";
487        let colors = code.char_colors(text);
488        assert_eq!(colors.len(), text.chars().count());
489        assert!(colors.iter().all(|c| *c == fg), "only the base color was expected");
490    }
491
492    #[test]
493    fn empty_palette_skips_highlighting() {
494        // A language is set, but the palette is empty — no highlighting, all in the base color.
495        let fg = Color::from_rgba8(1, 2, 3, 255);
496        let code = CodeData::new();
497        code.set_palette(Palette::new(fg));
498        code.set_language(Language::Rust);
499        let colors = code.char_colors("fn main() {}");
500        assert!(colors.iter().all(|c| *c == fg));
501    }
502
503    #[test]
504    fn rust_keyword_string_and_comment_take_palette_colors() {
505        let fg = Color::from_rgba8(212, 212, 212, 255);
506        let kw = Color::from_rgba8(197, 134, 192, 255);
507        let st = Color::from_rgba8(206, 145, 120, 255);
508        let cm = Color::from_rgba8(106, 153, 85, 255);
509        let palette = Palette::new(fg).keyword(kw).string(st).comment(cm);
510        let code = CodeData::new();
511        code.set_palette(palette);
512        code.set_language(Language::Rust);
513
514        let text = "let x = \"hi\"; // note";
515        let colors = code.char_colors(text);
516        assert_eq!(colors.len(), text.chars().count());
517
518        // `let` is a keyword.
519        assert_eq!(colors[char_index_of(text, "let")], kw);
520        // A character inside a string literal — the string color.
521        assert_eq!(colors[char_index_of(text, "hi")], st);
522        // Inside a comment — the comment color (including `//`).
523        assert_eq!(colors[char_index_of(text, "// note")], cm);
524        assert_eq!(colors[char_index_of(text, "note")], cm);
525    }
526
527    #[test]
528    fn unset_category_falls_back_to_foreground() {
529        // A category without a rule (here — number) falls back to the base color.
530        let fg = Color::from_rgba8(50, 50, 50, 255);
531        let kw = Color::from_rgba8(0, 100, 200, 255);
532        let palette = Palette::new(fg).keyword(kw); // number is not set
533        let code = CodeData::new();
534        code.set_palette(palette);
535        code.set_language(Language::Rust);
536
537        let text = "let n = 42;";
538        let colors = code.char_colors(text);
539        assert_eq!(colors[char_index_of(text, "let")], kw);
540        assert_eq!(colors[char_index_of(text, "42")], fg, "a number without a rule — in the base color");
541    }
542
543    #[test]
544    fn colors_align_with_multiline_chars() {
545        // The result length matches the number of scalars, including `\n`.
546        let code = CodeData::new();
547        code.set_language(Language::Rust);
548        code.set_palette(Palette::new(Color::BLACK).keyword(Color::WHITE));
549        let text = "fn a() {}\nfn b() {}\n";
550        let colors = code.char_colors(text);
551        assert_eq!(colors.len(), text.chars().count());
552    }
553
554    #[test]
555    fn cache_returns_same_allocation_for_repeated_calls() {
556        let code = CodeData::new();
557        code.set_language(Language::Rust);
558        code.set_palette(Palette::new(Color::BLACK).keyword(Color::WHITE));
559        let a = code.char_colors("fn main() {}");
560        let b = code.char_colors("fn main() {}");
561        assert!(Rc::ptr_eq(&a, &b), "re-parsing the same text is taken from the cache");
562        // Changing the palette clears the cache.
563        code.set_palette(Palette::new(Color::BLACK).keyword(Color::from_rgba8(1, 1, 1, 255)));
564        let c = code.char_colors("fn main() {}");
565        assert!(!Rc::ptr_eq(&a, &c), "after changing the palette — recompute");
566    }
567}