caixa_theme/blackmatter.rs
1//! Blackmatter overlays — the chosen Nord→Semantic mapping.
2//!
3//! The default overlay is `blackmatter_dark`, which matches blackmatter-nvim
4//! and blackmatter-shell. Light and high-contrast overlays are provided so a
5//! caller can pick at runtime.
6
7use crate::palette::{Nord, Rgb};
8use crate::style::Semantic;
9
10#[derive(Debug, Clone, Copy)]
11pub struct Theme {
12 pub name: &'static str,
13 resolver: fn(Semantic) -> Rgb,
14}
15
16impl Theme {
17 #[must_use]
18 pub fn blackmatter_dark() -> Self {
19 Self {
20 name: "blackmatter-dark",
21 resolver: blackmatter_dark_color,
22 }
23 }
24
25 #[must_use]
26 pub fn blackmatter_light() -> Self {
27 Self {
28 name: "blackmatter-light",
29 resolver: blackmatter_light_color,
30 }
31 }
32
33 #[must_use]
34 pub fn color(&self, s: Semantic) -> Rgb {
35 (self.resolver)(s)
36 }
37
38 #[must_use]
39 pub fn ansi(&self, s: Semantic) -> String {
40 self.color(s).fg_ansi()
41 }
42
43 #[must_use]
44 pub fn paint(&self, s: Semantic, text: &str) -> String {
45 format!("{}{}{}", self.ansi(s), text, crate::palette::ANSI_RESET)
46 }
47}
48
49impl Default for Theme {
50 fn default() -> Self {
51 Self::blackmatter_dark()
52 }
53}
54
55// Arms are merged by their target Nord color so each equivalence class
56// (Error ≡ Removed on alert-red, String ≡ Added on growth-green, …) reads
57// as a single design decision rather than a duplicated-arm coincidence a
58// future overlay edit could silently decouple. Arm order follows the
59// declaration order of each group's first `Semantic` variant in
60// `Semantic::ALL`, so the resolver still tracks the enum surface.
61fn blackmatter_dark_color(s: Semantic) -> Rgb {
62 match s {
63 Semantic::Keyword => Nord::NORD9,
64 Semantic::Symbol | Semantic::Unchanged => Nord::NORD4,
65 Semantic::KeywordArg | Semantic::Accent | Semantic::Info => Nord::NORD8,
66 Semantic::String | Semantic::Added => Nord::NORD14,
67 Semantic::Number => Nord::NORD15,
68 Semantic::Literal | Semantic::Hint => Nord::NORD13,
69 Semantic::Comment | Semantic::Muted => Nord::NORD3,
70 Semantic::Error | Semantic::Removed => Nord::NORD11,
71 Semantic::Warning => Nord::NORD12,
72 }
73}
74
75// Invert background-assuming choices for readability on light terminals;
76// arm order and merging discipline match `blackmatter_dark_color`.
77fn blackmatter_light_color(s: Semantic) -> Rgb {
78 match s {
79 Semantic::Keyword | Semantic::KeywordArg | Semantic::Accent | Semantic::Info => {
80 Nord::NORD10
81 }
82 Semantic::Symbol | Semantic::Unchanged => Nord::NORD0,
83 Semantic::String | Semantic::Added => Nord::NORD14,
84 Semantic::Number => Nord::NORD15,
85 Semantic::Literal | Semantic::Warning => Nord::NORD12,
86 Semantic::Comment | Semantic::Muted => Nord::NORD2,
87 Semantic::Error | Semantic::Removed => Nord::NORD11,
88 Semantic::Hint => Nord::NORD13,
89 }
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95
96 #[test]
97 fn default_is_dark() {
98 let t = Theme::default();
99 assert_eq!(t.name, "blackmatter-dark");
100 }
101
102 #[test]
103 fn error_maps_to_red() {
104 let t = Theme::blackmatter_dark();
105 assert_eq!(t.color(Semantic::Error), Nord::NORD11);
106 }
107
108 #[test]
109 fn paint_wraps_with_reset() {
110 let t = Theme::blackmatter_dark();
111 let out = t.paint(Semantic::Error, "boom");
112 assert!(out.starts_with("\x1b["));
113 assert!(out.ends_with(crate::palette::ANSI_RESET));
114 assert!(out.contains("boom"));
115 }
116
117 #[test]
118 fn every_semantic_maps_to_a_nord_palette_color_across_both_themes() {
119 // Fail-before-pass-after pin on [`Semantic::ALL`] as the
120 // canonical iteration axis every theme overlay must resolve
121 // in full: for each of the 15 variants and each of the two
122 // shipped [`Theme`] overlays (`blackmatter_dark`,
123 // `blackmatter_light`), the resolver must return one of the
124 // 16 Nord palette colors. Pre-lift the two resolver functions
125 // were paired 15-arm exhaustive matches with no cross-consumer
126 // link back to the closed [`Semantic`] partition, so a future
127 // wildcard arm on either resolver (an `_ => Rgb::from_hex(0)`
128 // for a hypothetical "unstyled" fallback that would render
129 // invisibly on a dark terminal, an `_ => Nord::NORD0` for a
130 // hypothetical "default background" fallback the light
131 // overlay's `Symbol` arm already hand-authored) would silently
132 // slip past both the compile-time exhaustiveness check (each
133 // wildcard *is* exhaustive) and any per-arm smoke test.
134 // Iterating [`Semantic::ALL`] and asserting each resolver's
135 // output falls inside the closed 16-color Nord palette makes
136 // such a slip a caixa-theme build-time failure. Compounding
137 // peer of the peer [`caixa_core::CaixaKind`] /
138 // [`caixa_lint::Severity`] `ALL`-based iteration disciplines.
139 use crate::palette::Nord;
140 let nord_palette: [Rgb; 16] = [
141 Nord::NORD0,
142 Nord::NORD1,
143 Nord::NORD2,
144 Nord::NORD3,
145 Nord::NORD4,
146 Nord::NORD5,
147 Nord::NORD6,
148 Nord::NORD7,
149 Nord::NORD8,
150 Nord::NORD9,
151 Nord::NORD10,
152 Nord::NORD11,
153 Nord::NORD12,
154 Nord::NORD13,
155 Nord::NORD14,
156 Nord::NORD15,
157 ];
158 for theme in [Theme::blackmatter_dark(), Theme::blackmatter_light()] {
159 for &sem in Semantic::ALL {
160 let rgb = theme.color(sem);
161 assert!(
162 nord_palette.contains(&rgb),
163 "Theme::{} maps Semantic::{sem:?} to {rgb:?} — a \
164 color outside the closed Nord palette. Every \
165 theme-overlay resolver must reach for one of the \
166 16 Nord entries; a wildcard arm returning a \
167 color outside the palette (an invisible fallback, \
168 a paint-bleed on a hypothetical extension arm) \
169 is a defect.",
170 theme.name,
171 );
172 }
173 }
174 }
175
176 #[test]
177 fn cross_tier_equivalence_classes_hold_across_both_themes() {
178 // Fail-before-pass-after pin on the two cross-tier
179 // `Semantic` equivalence classes the two shipped Blackmatter
180 // overlays already observe, made structurally load-bearing by
181 // the resolver's merged-`|`-arm form:
182 //
183 // * `Error` ≡ `Removed` — both alert-red (`Nord::NORD11`) in
184 // every overlay. A diff view's removed line reads with the
185 // same visual weight as a diagnostic error; a lint report's
186 // error reads with the same visual weight as a deletion.
187 // * `String` ≡ `Added` — both growth-green (`Nord::NORD14`)
188 // in every overlay. A literal string in source reads with
189 // the same visual weight as an added diff line.
190 //
191 // Peer of the sibling
192 // `diagnostic_severity_arms_paint_red_and_orange_across_both_themes`
193 // pin which locks `Error` + `Warning` at their specific Nord
194 // slots but leaves the cross-tier `Removed` / `Added`
195 // equivalences under-pinned. Pre-merge the two resolvers spelled
196 // the same color at four distinct per-arm sites (dark: `Error`
197 // and `Removed` both `Nord::NORD11`; light: same), so a future
198 // overlay decoupling one arm from the other (a "removed lines
199 // are amber" author-experience tweak that reroutes
200 // `Semantic::Removed => Nord::NORD12` on one theme but forgets
201 // the other) would slip past the compile-time exhaustiveness
202 // check and past the existing single-arm pins. Iterating both
203 // shipped overlays and asserting the two equivalences —
204 // separately from their specific Nord slot — pins the design
205 // intent even under a future palette rework that shifts which
206 // Nord entry the equivalence class lands on.
207 use crate::palette::Nord;
208 for theme in [Theme::blackmatter_dark(), Theme::blackmatter_light()] {
209 assert_eq!(
210 theme.color(Semantic::Removed),
211 theme.color(Semantic::Error),
212 "Theme::{} must paint Semantic::Removed and \
213 Semantic::Error with the same color — the diff-tier \
214 removed-line arm shares its alert weight with the \
215 diagnostic-tier error arm.",
216 theme.name,
217 );
218 assert_eq!(
219 theme.color(Semantic::Removed),
220 Nord::NORD11,
221 "Theme::{} must paint the Removed ≡ Error equivalence \
222 class as NORD11 (alert-red).",
223 theme.name,
224 );
225 assert_eq!(
226 theme.color(Semantic::Added),
227 theme.color(Semantic::String),
228 "Theme::{} must paint Semantic::Added and \
229 Semantic::String with the same color — the diff-tier \
230 added-line arm shares its growth weight with the \
231 literal-tier string arm.",
232 theme.name,
233 );
234 assert_eq!(
235 theme.color(Semantic::Added),
236 Nord::NORD14,
237 "Theme::{} must paint the Added ≡ String equivalence \
238 class as NORD14 (growth-green).",
239 theme.name,
240 );
241 }
242 }
243
244 #[test]
245 fn diagnostic_severity_arms_paint_red_and_orange_across_both_themes() {
246 // Fail-before-pass-after pin on the cross-theme-invariant
247 // paint of the two structurally-most-load-bearing diagnostic
248 // arms — [`Semantic::Error`] (red, `Nord::NORD11`) and
249 // [`Semantic::Warning`] (orange, `Nord::NORD12`). These two
250 // are the Aurora hues every terminal reader keys off
251 // ("something is wrong here"); a future theme overlay that
252 // rerouted either through a Frost blue (silently reading as
253 // an informational tag) would remove the paint's semantic
254 // signal without any compile-time failure. Iterating both
255 // shipped overlays via [`Semantic::ALL`]'s canonical arm-list
256 // and asserting the two paint invariants on the exact Nord
257 // slot pins the semantic-color-contract that
258 // [`crate::palette::Nord::NORD11`]'s `// red — errors` /
259 // [`crate::palette::Nord::NORD12`]'s `// orange — warnings`
260 // documentation comments already assert at the palette
261 // definition site, but that no cross-overlay test currently
262 // guards.
263 use crate::palette::Nord;
264 for theme in [Theme::blackmatter_dark(), Theme::blackmatter_light()] {
265 assert_eq!(
266 theme.color(Semantic::Error),
267 Nord::NORD11,
268 "Theme::{} must paint Semantic::Error as NORD11 (red)",
269 theme.name,
270 );
271 assert_eq!(
272 theme.color(Semantic::Warning),
273 Nord::NORD12,
274 "Theme::{} must paint Semantic::Warning as NORD12 (orange)",
275 theme.name,
276 );
277 }
278 }
279}