Skip to main content

escriba_ui/
syntax.rs

1//! Syntax colours, resolved through ishou for every theme.
2//!
3//! ## Why this exists
4//!
5//! hikari ships exactly one theme — `NordTheme`, a table of twenty hardcoded
6//! hex literals — and escriba's GPU face held one by value. So picking Vellum
7//! gave you Vellum chrome with **Nord code** inside it: the frame changed
8//! colour and the text did not. That is not a theme; it is a border.
9//!
10//! ## Why roles reproduce Nord rather than replacing it
11//!
12//! The obvious worry with routing syntax through a role vocabulary is that it
13//! flattens a rich palette onto too few colours and makes the default look
14//! worse. That worry does not survive contact with the numbers: ishou's
15//! `SemanticRoles` carries 27 roles, and on `pleme_dark` they resolve to the
16//! Nord palette that `NordTheme` was hand-transcribed from. Nineteen of the
17//! twenty `HlClass` variants land on the **identical hex**:
18//!
19//! | class | role | Nord |
20//! |---|---|---|
21//! | `Function` | `primary` | `#88C0D0` |
22//! | `Type` / `Namespace` | `info` | `#8FBCBB` |
23//! | `Keyword` / `Operator` | `link` | `#81A1C1` |
24//! | `Str` / `Added` | `success` | `#A3BE8C` |
25//! | `Numeric` / `Attribute` | `agent` | `#B48EAD` |
26//! | `Boolean` / `Constant` | `warning` | `#D08770` |
27//! | `Escape` / `Special` | `search` | `#EBCB8B` |
28//! | `Error` / `Removed` | `error` | `#BF616A` |
29//! | `Punctuation` | `text_bright` | `#ECEFF4` |
30//! | `Hyperlink` / `Hint` | `structural` | `#5E81AC` |
31//! | `Plain` / `Variable` | `text_muted` | `#D8DEE9` |
32//!
33//! The single divergence is `Comment`: `NordTheme` uses `#616E88`, the
34//! `text_dim` role resolves to `#4C566A`. Both are Nord Polar Night, and
35//! `text_dim` is the colour the rest of the fleet already dims with — so
36//! escriba's comments now match escriba's gutter instead of matching a
37//! constant in someone else's crate. That is stated rather than hidden
38//! because it IS a visible change on the default theme.
39//!
40//! `themes_reproduce_nord_on_the_fleet_default` below pins all of this
41//! against the real `NordTheme`, so a future role rebinding that silently
42//! moved the default look fails the build.
43
44use hikari_core::{HlClass, Rgb as HlRgb, Theme};
45
46use crate::chrome::ChromePalette;
47
48/// A `hikari_core::Theme` that answers from a `ChromePalette`.
49///
50/// Carries the palette by value — it is 18 `Rgb`s, `Copy`, and the alternative
51/// is a lifetime on a trait object the renderer holds across frames.
52#[derive(Debug, Clone, Copy)]
53pub struct ChromeSyntax {
54    chrome: ChromePalette,
55}
56
57impl ChromeSyntax {
58    #[must_use]
59    pub const fn new(chrome: ChromePalette) -> Self {
60        Self { chrome }
61    }
62
63    /// The syntax colours for `theme`.
64    #[must_use]
65    pub fn for_theme(theme: crate::chrome::FleetTheme) -> Self {
66        Self::new(ChromePalette::for_theme(theme))
67    }
68
69    /// The palette this resolves against.
70    #[must_use]
71    pub const fn chrome(&self) -> &ChromePalette {
72        &self.chrome
73    }
74}
75
76impl Theme for ChromeSyntax {
77    fn color(&self, class: HlClass) -> HlRgb {
78        let c = &self.chrome;
79        // Total over `HlClass` — no wildcard arm. A variant added upstream
80        // fails THIS match to compile rather than falling into a catch-all
81        // and rendering as plain text, which is the failure mode that hides:
82        // new syntax silently loses its colour and nobody files a bug.
83        let rgb = match class {
84            HlClass::Comment { .. } => c.text_dim,
85            HlClass::Keyword | HlClass::Operator | HlClass::Info => c.link,
86            HlClass::KeywordArg | HlClass::Attribute | HlClass::Numeric { .. } => c.agent,
87            HlClass::Type | HlClass::Namespace => c.info,
88            HlClass::Function => c.primary,
89            HlClass::Str | HlClass::Added => c.success,
90            HlClass::Escape | HlClass::Special | HlClass::Warning => c.search,
91            HlClass::Boolean | HlClass::Constant => c.warning,
92            HlClass::Punctuation => c.text_bright,
93            HlClass::Hyperlink | HlClass::Hint => c.structural,
94            HlClass::Error | HlClass::Removed => c.error,
95            HlClass::Variable | HlClass::Whitespace | HlClass::Unchanged | HlClass::Plain => {
96                c.text_muted
97            }
98        };
99        HlRgb::new(rgb.r, rgb.g, rgb.b)
100    }
101}
102
103/// Every `HlClass` variant, for tests and for any consumer that needs to walk
104/// the vocabulary (a theme previewer, a contrast audit).
105///
106/// Hand-listed because `HlClass` is upstream and has no enumeration; the
107/// exhaustive `match` above is what actually guarantees nothing is missed, and
108/// `every_class_is_in_the_roster` keeps this list honest against it.
109pub const ALL_CLASSES: &[HlClass] = &[
110    HlClass::Comment { multiline: false },
111    HlClass::Comment { multiline: true },
112    HlClass::Keyword,
113    HlClass::KeywordArg,
114    HlClass::Type,
115    HlClass::Function,
116    HlClass::Namespace,
117    HlClass::Variable,
118    HlClass::Constant,
119    HlClass::Str,
120    HlClass::Escape,
121    HlClass::Numeric { float: false },
122    HlClass::Numeric { float: true },
123    HlClass::Boolean,
124    HlClass::Punctuation,
125    HlClass::Operator,
126    HlClass::Attribute,
127    HlClass::Special,
128    HlClass::Hyperlink,
129    HlClass::Whitespace,
130    HlClass::Error,
131    HlClass::Warning,
132    HlClass::Info,
133    HlClass::Hint,
134    HlClass::Added,
135    HlClass::Removed,
136    HlClass::Unchanged,
137    HlClass::Plain,
138];
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::chrome::FleetTheme;
144
145    fn hex(c: HlRgb) -> String {
146        let mut s = String::with_capacity(7);
147        s.push('#');
148        for b in [c.r, c.g, c.b] {
149            s.push(char::from_digit(u32::from(b >> 4), 16).unwrap_or('0'));
150            s.push(char::from_digit(u32::from(b & 0xF), 16).unwrap_or('0'));
151        }
152        s.to_uppercase()
153    }
154
155    /// THE load-bearing test: on the fleet default, routing syntax through
156    /// ishou must not change how code looks.
157    ///
158    /// Without this, "support every theme" would be free to quietly degrade
159    /// the one theme almost everyone actually uses.
160    #[test]
161    fn themes_reproduce_nord_on_the_fleet_default() {
162        let ours = ChromeSyntax::for_theme(FleetTheme::prescribed_default());
163        let nord = hikari_core::NordTheme;
164        let mut diffs = Vec::new();
165        for class in ALL_CLASSES {
166            let a = hex(ours.color(*class));
167            let b = hex(nord.color(*class));
168            if a != b {
169                diffs.push((format!("{class:?}"), a, b));
170            }
171        }
172        // Exactly one accepted divergence, named in the module docs: Comment
173        // moves from hikari's `#616E88` to the `text_dim` role escriba dims
174        // everything else with. Anything else is a regression.
175        for (class, ours, nord) in &diffs {
176            assert!(
177                class.starts_with("Comment"),
178                "{class} drifted off Nord: {ours} (ours) != {nord} (NordTheme)",
179            );
180        }
181        assert_eq!(
182            diffs.len(),
183            2,
184            "expected exactly the two Comment variants to differ, got {diffs:?}",
185        );
186    }
187
188    /// A theme change must actually reach the code, which is the whole point.
189    #[test]
190    fn a_different_theme_paints_code_differently() {
191        let nordish = ChromeSyntax::for_theme(FleetTheme::PlemeDark);
192        let vellum = ChromeSyntax::for_theme(FleetTheme::Vellum);
193        let differing = ALL_CLASSES
194            .iter()
195            .filter(|c| hex(nordish.color(**c)) != hex(vellum.color(**c)))
196            .count();
197        assert!(
198            differing > ALL_CLASSES.len() / 2,
199            "picking Vellum must recolour the CODE, not just the frame: only \
200             {differing}/{} classes moved",
201            ALL_CLASSES.len(),
202        );
203    }
204
205    /// Nothing may render invisible.
206    #[test]
207    fn no_class_collapses_onto_the_background_in_any_theme() {
208        for theme in [
209            FleetTheme::PlemeDark,
210            FleetTheme::Vellum,
211            FleetTheme::PolarVeil,
212            FleetTheme::Bare,
213        ] {
214            let syn = ChromeSyntax::for_theme(theme);
215            let bg = syn.chrome().background.hex();
216            for class in ALL_CLASSES {
217                assert_ne!(
218                    hex(syn.color(*class)),
219                    bg,
220                    "{theme:?}: {class:?} is the same colour as the ground",
221                );
222            }
223        }
224    }
225
226    /// Code must stay readable as code: the classes a reader scans for have
227    /// to be told apart.
228    #[test]
229    fn the_load_bearing_classes_stay_distinguishable_in_every_theme() {
230        // Not ALL classes — some deliberately share a role (`Keyword` and
231        // `Operator` are one colour in Nord too). These five are the ones a
232        // reader separates at a glance.
233        let key = [
234            HlClass::Comment { multiline: false },
235            HlClass::Keyword,
236            HlClass::Str,
237            HlClass::Function,
238            HlClass::Numeric { float: false },
239        ];
240        for theme in [
241            FleetTheme::PlemeDark,
242            FleetTheme::Vellum,
243            FleetTheme::PolarVeil,
244            FleetTheme::Bare,
245        ] {
246            let syn = ChromeSyntax::for_theme(theme);
247            let mut seen = std::collections::BTreeSet::new();
248            for class in key {
249                assert!(
250                    seen.insert(hex(syn.color(class))),
251                    "{theme:?}: {class:?} duplicates another load-bearing class",
252                );
253            }
254        }
255    }
256
257    /// The roster must not fall behind the `match`.
258    #[test]
259    fn every_class_is_in_the_roster() {
260        // `HlClass` is upstream and not enumerable, so the roster is hand-
261        // listed and this is the honest floor: a count check plus no
262        // duplicates. The exhaustive `match` in `color` is what actually
263        // guarantees completeness — this only catches a roster that was not
264        // updated alongside it.
265        let mut seen = std::collections::BTreeSet::new();
266        for c in ALL_CLASSES {
267            assert!(seen.insert(format!("{c:?}")), "{c:?} listed twice");
268        }
269        assert_eq!(seen.len(), ALL_CLASSES.len());
270    }
271}