Skip to main content

max / makeover-tui

28.3 KB · 685 lines History Blame Raw
1 //! A loaded makeover theme, resolved into the colours ratatui draws with.
2 //!
3 //! Behind the `theme` feature, because it is the one thing here that needs
4 //! `makeover` itself. The rest of this crate takes [`Color`]s it is handed and
5 //! never asks where they came from, which keeps a consumer that only wants
6 //! [`frame`](crate::frame) off the theme loader and its embedded theme files.
7 //!
8 //! # Why this lives here rather than in each consumer
9 //!
10 //! Reading makeover's intents into ratatui `Color`s is the same work every
11 //! terminal consumer does, and doing it twice is how two of them end up
12 //! disagreeing about which intent a surface reads from. The mapping is
13 //! mechanical, the failure mode is silent, and there is exactly one right
14 //! answer, so it belongs with the renderer.
15 //!
16 //! # What is deliberately absent
17 //!
18 //! Tokens a consumer derives for itself. `alloy_tui` mixes a `border-subtle`
19 //! and its own `border-strong` out of the authored border, holding the latter
20 //! to WCAG AA-UI against the page because Alloy spends it as its entire **focus
21 //! ring**. makeover emits a `border-strong` too, and it is a flat 5% darkening:
22 //! a firmer divider, and not a focus ring at all. Those are different tokens
23 //! wearing one name, and on Akari Dawn they land at 1.63:1 and 3.27:1. This
24 //! struct carries makeover's, and a consumer that needs its own keeps deriving
25 //! it. Adopting one for the other would take a focus ring to half its floor.
26 //!
27 //! **Reach** and **focus** are absent for a different reason: they are not
28 //! colours and not this crate's at all. A terminal consumer derives reach from
29 //! draw order and tracks focus itself; a description states neither. The three
30 //! terms are defined once in `makeover_layout`'s crate header, "Reach, focus
31 //! and the focus ring".
32
33 use makeover::{Rgb, ThemeColors};
34 use ratatui::style::Color;
35
36 /// A theme's polarity, as its author declared it.
37 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
38 pub enum Mode {
39 Light,
40 Dark,
41 HighContrast,
42 }
43
44 /// A makeover theme's intents, resolved to ratatui colours.
45 ///
46 /// `#[non_exhaustive]`: this gains a field whenever makeover gains an intent,
47 /// and without the attribute every one of those would be a major here. Nothing
48 /// should be building one field-by-field anyway, since [`Theme::from_theme`] is
49 /// the only way to get one and a partial theme is an error rather than a
50 /// default.
51 #[derive(Debug, Clone, Copy)]
52 #[non_exhaustive]
53 pub struct Theme {
54 pub mode: Mode,
55
56 pub surface_page: Color,
57 pub surface_raised: Color,
58 pub surface_sunken: Color,
59 pub surface_overlay: Color,
60
61 /// makeover's inset content surface: the surface inside a raised container,
62 /// so a list reads as content in a container rather than as bands on a
63 /// panel.
64 ///
65 /// Not [`surface_sunken`](Theme::surface_sunken). A theme is free to author
66 /// sunken *darker* than raised while a well always inverts away from the
67 /// text, so substituting one for the other lands a well on the wrong side of
68 /// its face on exactly the themes where it matters.
69 ///
70 /// `None` where makeover derived nothing, which is a theme authoring no
71 /// raised surface or no content colour. Left missing rather than guessed,
72 /// the same way [`Palette::fill`](crate::Palette::fill) answers a missing
73 /// well with structure instead of a substitute colour.
74 pub surface_well: Option<Color>,
75
76 pub content_primary: Color,
77 pub content_secondary: Color,
78 pub content_muted: Color,
79
80 pub action_primary: Color,
81
82 pub status_danger: Color,
83 pub status_success: Color,
84 pub status_warning: Color,
85 pub status_info: Color,
86
87 /// The authored border colour.
88 pub line_border: Color,
89 /// makeover's derived firmer divider: the authored border, 5% darker.
90 ///
91 /// A divider, not a focus ring. See the module header before spending it as
92 /// one.
93 pub border_strong: Color,
94
95 /// The foreground for text sitting on [`action_primary`](Theme::action_primary).
96 ///
97 /// DERIVED, not authored. Every consumer measured did selection with
98 /// `REVERSED`, because there was no on-accent foreground to pair with an
99 /// accent background, and four ports were each about to invent one.
100 ///
101 /// Chosen between [`surface_page`](Theme::surface_page) and
102 /// [`content_primary`](Theme::content_primary) by contrast against the
103 /// accent, rather than mixed: both are colours the theme authored, so a
104 /// selected row stays inside the theme's own palette instead of landing on
105 /// a colour that appears nowhere in the file. That is
106 /// [`Quantize::against`]'s reasoning applied a step earlier — a colour on a
107 /// colour, answered by measuring rather than by taste.
108 ///
109 /// A candidate for promotion to an authored theme key if a theme ever needs
110 /// to tune it. That direction works and the reverse does not: an authored
111 /// key can fall back to this derivation and break no theme on disk, while a
112 /// key this crate started requiring would break every theme that has one.
113 pub selection_on: Color,
114 /// The colour of a focus ring.
115 ///
116 /// DERIVED, not authored, and deliberately not
117 /// [`border_strong`](Theme::border_strong). That is a divider at 5%
118 /// darkening — see the module header — and spending it here takes a focus
119 /// ring to half its floor. The cue comes off
120 /// [`action_primary`](Theme::action_primary) instead, held to AA-UI against
121 /// the page, which is what `alloy_tui` already derives for itself rather
122 /// than adopting makeover's border for a job it does not do.
123 ///
124 /// Promotable to an authored key on the same terms as
125 /// [`selection_on`](Theme::selection_on).
126 pub focus_ring: Color,
127
128 /// The lit and shadowed edges of a raised surface.
129 ///
130 /// A control is lit from the top left, so its top and left edges take
131 /// `bevel_light` and its bottom and right edges `bevel_dark`; swapping the
132 /// two recesses it, which is what a pressed state and a text well are. The
133 /// light source does not flip with polarity, or the rule stops transferring
134 /// between widgets, which is the whole reason to have one.
135 pub bevel_light: Color,
136 pub bevel_dark: Color,
137
138 pub category: [Color; 6],
139 }
140
141 /// Why a theme could not be resolved.
142 ///
143 /// Both variants name the key, because "the theme is bad" is not something a
144 /// user can act on and "the theme is missing `content.muted`" is.
145 #[derive(Debug, Clone)]
146 pub enum ThemeError {
147 MissingKey(&'static str),
148 InvalidHex { key: &'static str, value: String },
149 }
150
151 impl std::fmt::Display for ThemeError {
152 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153 match self {
154 Self::MissingKey(k) => write!(f, "theme missing required key `{k}`"),
155 Self::InvalidHex { key, value } => {
156 write!(f, "theme key `{key}` has invalid hex value `{value}`")
157 }
158 }
159 }
160 }
161
162 impl std::error::Error for ThemeError {}
163
164 impl Theme {
165 /// Resolve a loaded [`ThemeColors`] into the colours ratatui draws with.
166 ///
167 /// Every intent this struct names is required, apart from
168 /// [`surface_well`](Theme::surface_well), which makeover derives only when
169 /// the theme gave it enough to derive from. A malformed or partial theme is
170 /// rejected rather than papered over with defaults: rendering in colours
171 /// that appear nowhere in the theme file is worse than refusing to render.
172 pub fn from_theme(theme: &ThemeColors) -> Result<Self, ThemeError> {
173 let authored = |key: &'static str| -> Result<Rgb, ThemeError> {
174 let hex = theme.colors.get(key).ok_or(ThemeError::MissingKey(key))?;
175 Rgb::from_hex(hex).ok_or_else(|| ThemeError::InvalidHex {
176 key,
177 value: hex.clone(),
178 })
179 };
180
181 // The bevel pair, the well and the firm border are makeover's derived
182 // intents, so a console, a webview and an egui app light a raised
183 // surface the same way. Read through `resolve` rather than recomputed
184 // here, which is the point of them living in that crate.
185 let resolved = makeover::resolve(theme);
186 let derived = |key: &'static str| -> Result<Rgb, ThemeError> {
187 let hex = resolved.hex(key).ok_or(ThemeError::MissingKey(key))?;
188 Rgb::from_hex(hex).ok_or_else(|| ThemeError::InvalidHex {
189 key,
190 value: hex.to_string(),
191 })
192 };
193
194 // The two derived cues. Both are computed here rather than in
195 // `makeover::resolve`, because neither is a colour a webview or an egui
196 // app needs: CSS has `:focus-visible` with its own accent handling, and
197 // a browser paints a selection itself. This is the terminal's problem,
198 // so it is answered where the terminal is drawn.
199 let accent = authored("action.primary")?;
200 let page = authored("surface.page")?;
201 let selection_on = on_accent(accent, page, authored("content.primary")?);
202
203 let mode = match theme.meta.variant.as_str() {
204 "dark" => Mode::Dark,
205 "high-contrast" => Mode::HighContrast,
206 _ => Mode::Light,
207 };
208
209 Ok(Self {
210 mode,
211
212 surface_page: rgb(authored("surface.page")?),
213 surface_raised: rgb(authored("surface.raised")?),
214 surface_sunken: rgb(authored("surface.sunken")?),
215 surface_overlay: rgb(authored("surface.overlay")?),
216 surface_well: resolved
217 .hex("surface-well")
218 .and_then(Rgb::from_hex)
219 .map(rgb),
220
221 content_primary: rgb(authored("content.primary")?),
222 content_secondary: rgb(authored("content.secondary")?),
223 content_muted: rgb(authored("content.muted")?),
224
225 action_primary: rgb(accent),
226 selection_on: rgb(selection_on),
227 focus_ring: rgb(focus_ring(accent, page)),
228
229 status_danger: rgb(authored("status.danger")?),
230 status_success: rgb(authored("status.success")?),
231 status_warning: rgb(authored("status.warning")?),
232 status_info: rgb(authored("status.info")?),
233
234 line_border: rgb(authored("line.border")?),
235 border_strong: rgb(derived("border-strong")?),
236
237 bevel_light: rgb(derived("bevel-light")?),
238 bevel_dark: rgb(derived("bevel-dark")?),
239
240 category: [
241 rgb(authored("category.one")?),
242 rgb(authored("category.two")?),
243 rgb(authored("category.three")?),
244 rgb(authored("category.four")?),
245 rgb(authored("category.five")?),
246 rgb(authored("category.six")?),
247 ],
248 })
249 }
250
251 /// This theme as the terminal can actually draw it.
252 ///
253 /// At [`TrueColor`](crate::Fidelity::TrueColor) the theme is returned
254 /// untouched. Otherwise every colour becomes a palette index, which is the
255 /// point: left as 24-bit, the terminal approximates them itself, and its
256 /// approximation collapses tones the theme keeps apart. Alloy's console lost
257 /// its frame that way, drawing a border in a colour the Linux console could
258 /// not tell from the page behind it.
259 ///
260 /// Anything that has to be seen against the page is quantised against it
261 /// rather than on its own, so a border stays a border and text stays
262 /// readable. The surfaces themselves are quantised plainly: they are what
263 /// the others are measured against.
264 ///
265 /// The bevel edges are quantised plainly too, for a different reason. They
266 /// are measured against the raised surface they surround rather than against
267 /// the page, and running them through [`Quantize::against`] would push both
268 /// onto the same entry and invert the bevel on one side. At
269 /// [`Ansi16`](crate::Fidelity::Ansi16) the palette cannot hold the pair at
270 /// all and one edge lands back on its face, which is a property of sixteen
271 /// colours rather than something this can fix. A caller drawing there does
272 /// not have to handle it: [`Theme::palette`] carries the fidelity through,
273 /// and [`frame`](crate::frame) answers it with glyphs instead of tones.
274 ///
275 /// A consumer holding tokens of its own quantises them alongside this, with
276 /// the same [`Quantize`], rather than after the fact.
277 #[must_use]
278 pub fn for_terminal(self, fidelity: crate::Fidelity) -> Self {
279 let Some(q) = Quantize::for_fidelity(fidelity) else {
280 return self;
281 };
282
283 let plain = |c: Color| q.plain(c);
284 let on_page = |c: Color| q.against(c, self.surface_page);
285
286 Self {
287 mode: self.mode,
288
289 surface_page: plain(self.surface_page),
290 surface_raised: plain(self.surface_raised),
291 surface_sunken: plain(self.surface_sunken),
292 surface_overlay: plain(self.surface_overlay),
293 // Plainly, like the other surfaces and for the same reason as the
294 // bevel pair: a well is measured against the raised face it is cut
295 // into, not against the page, so quantising it against the page
296 // would push it toward contrast it is not supposed to have.
297 surface_well: self.surface_well.map(plain),
298
299 content_primary: on_page(self.content_primary),
300 content_secondary: on_page(self.content_secondary),
301 content_muted: on_page(self.content_muted),
302
303 action_primary: on_page(self.action_primary),
304 // Against the accent, not against the page: this is the one colour
305 // here whose whole job is to be legible on the accent behind it.
306 // Quantising it against the page would answer the wrong question
307 // and hand a selected row unreadable text at Ansi16.
308 selection_on: q.against(self.selection_on, self.action_primary),
309 focus_ring: on_page(self.focus_ring),
310
311 status_danger: on_page(self.status_danger),
312 status_success: on_page(self.status_success),
313 status_warning: on_page(self.status_warning),
314 status_info: on_page(self.status_info),
315
316 line_border: on_page(self.line_border),
317 border_strong: on_page(self.border_strong),
318
319 bevel_light: plain(self.bevel_light),
320 bevel_dark: plain(self.bevel_dark),
321
322 category: self.category.map(on_page),
323 }
324 }
325
326 /// The depth-painting palette this theme implies, at `fidelity`.
327 ///
328 /// The bridge between the two halves of this crate: [`Theme`] is what a
329 /// theme file says, [`Palette`](crate::Palette) is the subset
330 /// [`frame`](crate::frame) and [`paint_bevel`](crate::paint_bevel) need. A
331 /// consumer holding a `Theme` should not be assembling that by hand and
332 /// picking the wrong surface for the well.
333 #[must_use]
334 pub const fn palette(&self, fidelity: crate::Fidelity) -> crate::Palette {
335 crate::Palette {
336 page: self.surface_page,
337 raised: self.surface_raised,
338 overlay: self.surface_overlay,
339 well: self.surface_well,
340 bevel_light: self.bevel_light,
341 bevel_dark: self.bevel_dark,
342 fidelity,
343 }
344 }
345 }
346
347 fn rgb(c: Rgb) -> Color {
348 Color::Rgb(c.r, c.g, c.b)
349 }
350
351 /// The AA floor for text against what it sits on.
352 const TEXT_FLOOR: f32 = 4.5;
353
354 /// The AA-UI floor a non-text cue has to clear against what it sits on.
355 ///
356 /// 3:1 rather than 4.5:1 because a ring is a graphical object and not text.
357 /// The same number `alloy_tui` holds its own derived ring to.
358 const UI_FLOOR: f32 = 3.0;
359
360 /// The better of the theme's two candidates for text on the accent, or black or
361 /// white where neither is legible.
362 ///
363 /// Preferring an authored colour is the point: a selected row should stay inside
364 /// the palette the theme wrote rather than land on a colour appearing nowhere in
365 /// the file. But an unreadable selection is worse than an off-palette one, and
366 /// the two candidates can both fail — ayu-light's page and content both land
367 /// under 2.5:1 on its accent. So the walk is: page or content by contrast,
368 /// and `readable_on` only when the winner misses the AA text floor.
369 ///
370 /// Measured across every bundled theme when this was written: 29 of 31 clear
371 /// the floor from the theme's own colours, one clears it at 3.99 and 3.36 (the
372 /// app themes), and ayu-light is the one that needs the backstop.
373 fn on_accent(accent: Rgb, page: Rgb, content: Rgb) -> Rgb {
374 let best = if makeover::wcag_contrast(page, accent) >= makeover::wcag_contrast(content, accent)
375 {
376 page
377 } else {
378 content
379 };
380 if makeover::wcag_contrast(best, accent) >= TEXT_FLOOR {
381 best
382 } else {
383 makeover::readable_on(accent)
384 }
385 }
386
387 /// A ring colour off the accent, pushed away from the page until it is visible.
388 ///
389 /// The accent itself is the answer on most themes, and the walk only runs where
390 /// it is not: a theme whose accent sits close to its page has a real focus
391 /// problem, and returning the accent unchanged there would be a ring nobody can
392 /// see. Which way to push is decided by the page rather than by the theme's
393 /// declared variant, because a light theme may carry a dark panel and the
394 /// question is always "away from *this* surface".
395 ///
396 /// Steps in 5% and stops at the floor rather than going as far as it can, so
397 /// the ring stays recognisably the accent on the themes that need the help.
398 fn focus_ring(accent: Rgb, page: Rgb) -> Rgb {
399 if makeover::wcag_contrast(accent, page) >= UI_FLOOR {
400 return accent;
401 }
402
403 // Whether the page is dark, by the same relative-luminance rule the
404 // contrast ratio is built on: white against it beats black against it.
405 let page_is_dark = makeover::wcag_contrast(
406 Rgb {
407 r: 255,
408 g: 255,
409 b: 255,
410 },
411 page,
412 ) > makeover::wcag_contrast(Rgb { r: 0, g: 0, b: 0 }, page);
413
414 let mut candidate = accent;
415 for _ in 0..20 {
416 candidate = if page_is_dark {
417 makeover::lighten(candidate, 0.05)
418 } else {
419 makeover::darken(candidate, 0.05)
420 };
421 if makeover::wcag_contrast(candidate, page) >= UI_FLOOR {
422 return candidate;
423 }
424 }
425 candidate
426 }
427
428 /// The palette a [`Fidelity`](crate::Fidelity) quantises into, and the rules for
429 /// landing a colour in it.
430 ///
431 /// Public because a consumer carrying tokens of its own has to quantise them the
432 /// same way this crate quantises the ones it knows about. `alloy_tui` derives a
433 /// decorative divider and a focus ring from the authored border; those are its
434 /// tokens, but "a colour that must stay legible against the page is quantised
435 /// against the page" is not its rule to reinvent.
436 #[derive(Debug, Clone, Copy)]
437 pub struct Quantize {
438 palette: &'static [Rgb],
439 offset: usize,
440 }
441
442 impl Quantize {
443 /// The quantiser for `fidelity`, or `None` at
444 /// [`TrueColor`](crate::Fidelity::TrueColor), where nothing is quantised.
445 ///
446 /// 256 resolves to makeover's fixed region rather than the whole table: the
447 /// low sixteen are repaintable in every emulator, so a match landing there
448 /// is a match against a colour the user may have moved out from under it.
449 #[must_use]
450 pub const fn for_fidelity(fidelity: crate::Fidelity) -> Option<Self> {
451 match fidelity {
452 crate::Fidelity::TrueColor => None,
453 crate::Fidelity::Ansi256 => Some(Self {
454 palette: makeover::ANSI_240,
455 offset: makeover::ANSI_240_OFFSET,
456 }),
457 crate::Fidelity::Ansi16 => Some(Self {
458 palette: &makeover::ANSI_16,
459 offset: 0,
460 }),
461 }
462 }
463
464 /// The palette entry for `c`, as an index the terminal will not reinterpret.
465 ///
466 /// For a colour measured against the surface it sits on rather than against
467 /// the page: the surfaces themselves, and the bevel pair.
468 #[must_use]
469 pub fn plain(&self, c: Color) -> Color {
470 match c {
471 Color::Rgb(r, g, b) => Color::Indexed(
472 (makeover::quantize(Rgb { r, g, b }, self.palette) + self.offset) as u8,
473 ),
474 other => other,
475 }
476 }
477
478 /// As [`plain`](Self::plain), but guaranteed to stay legible against `on`.
479 ///
480 /// Only for a colour whose job is to be told apart from a known background.
481 /// It answers "nearest entry that still contrasts with `on`" and has no
482 /// notion of which side of `on` the answer should fall, so a pair of colours
483 /// that must also stay apart from *each other* is the one thing it must not
484 /// be used for: both get pushed onto the same contrasting entry. That is why
485 /// the bevel edges go through [`plain`](Self::plain).
486 #[must_use]
487 pub fn against(&self, c: Color, on: Color) -> Color {
488 match (c, on) {
489 (Color::Rgb(r, g, b), Color::Rgb(br, bg, bb)) => Color::Indexed(
490 (makeover::quantize_against(
491 Rgb { r, g, b },
492 Rgb {
493 r: br,
494 g: bg,
495 b: bb,
496 },
497 self.palette,
498 ) + self.offset) as u8,
499 ),
500 _ => self.plain(c),
501 }
502 }
503 }
504
505 #[cfg(test)]
506 mod tests {
507 use super::*;
508
509 fn bundled(id: &str) -> ThemeColors {
510 let dir = makeover::bundled_themes_dir().expect("makeover ships themes");
511 makeover::load_theme(&[(dir, false)], id).expect("bundled theme loads")
512 }
513
514 #[test]
515 fn every_bundled_theme_resolves() {
516 // The point of rejecting a partial theme is that it never happens to a
517 // theme we ship. If one of these stops resolving, that is a real gap in
518 // the theme file, not a reason to soften the error.
519 let dir = makeover::bundled_themes_dir().expect("makeover ships themes");
520 let metas = makeover::list_themes_from_dirs(&[(dir, false)]);
521 assert!(
522 !metas.is_empty(),
523 "makeover shipped no themes to test against"
524 );
525 for meta in &metas {
526 let colors = bundled(&meta.id);
527 assert!(
528 Theme::from_theme(&colors).is_ok(),
529 "bundled theme `{}` failed to resolve",
530 meta.id
531 );
532 }
533 }
534
535 fn to_rgb(c: Color) -> Rgb {
536 match c {
537 Color::Rgb(r, g, b) => Rgb { r, g, b },
538 other => panic!("expected a resolved colour, got {other:?}"),
539 }
540 }
541
542 #[test]
543 fn the_two_derived_cues_are_legible_in_every_bundled_theme() {
544 // The test that would have caught `border_strong` being spent as a
545 // focus ring: it lands at 1.63:1 on Akari Dawn, and nothing asked.
546 // Every theme rather than one, because a derivation that works on the
547 // theme it was written against says nothing about the other thirty.
548 let dir = makeover::bundled_themes_dir().expect("makeover ships themes");
549 for meta in makeover::list_themes_from_dirs(&[(dir, false)]) {
550 let theme = Theme::from_theme(&bundled(&meta.id)).expect("resolves");
551
552 // Text on the accent, so the text floor.
553 let selection =
554 makeover::wcag_contrast(to_rgb(theme.selection_on), to_rgb(theme.action_primary));
555 assert!(
556 selection >= TEXT_FLOOR,
557 "{}: selection_on is {selection:.2} on the accent",
558 meta.id
559 );
560
561 // A ring is a graphical object, so the AA-UI floor.
562 let ring =
563 makeover::wcag_contrast(to_rgb(theme.focus_ring), to_rgb(theme.surface_page));
564 assert!(
565 ring >= UI_FLOOR,
566 "{}: focus_ring is {ring:.2} on the page",
567 meta.id
568 );
569
570 // And the ring is not the divider wearing the ring's name, which
571 // is the whole reason it is derived rather than adopted.
572 assert_ne!(theme.focus_ring, theme.border_strong, "{}", meta.id);
573 }
574 }
575
576 #[test]
577 fn the_derived_cues_are_quantised_for_the_surface_each_sits_on() {
578 // A colour that has to be told apart from a known background is
579 // quantised against that background, and the two cues do not share one.
580 // Left plain, both collapse toward whatever the palette has nearest.
581 let theme = Theme::from_theme(&bundled("goingson")).expect("resolves");
582 let ansi16 = theme.for_terminal(crate::Fidelity::Ansi16);
583 let q = Quantize::for_fidelity(crate::Fidelity::Ansi16).expect("Ansi16 quantises");
584
585 assert_eq!(
586 ansi16.selection_on,
587 q.against(theme.selection_on, theme.action_primary)
588 );
589 assert_eq!(
590 ansi16.focus_ring,
591 q.against(theme.focus_ring, theme.surface_page)
592 );
593 }
594
595 #[test]
596 fn a_missing_intent_names_the_key_it_wanted() {
597 let mut colors = bundled("goingson");
598 colors.colors.remove("content.muted");
599 match Theme::from_theme(&colors) {
600 Err(ThemeError::MissingKey(k)) => assert_eq!(k, "content.muted"),
601 other => panic!("expected MissingKey(content.muted), got {other:?}"),
602 }
603 }
604
605 #[test]
606 fn an_unparseable_hex_names_the_key_and_the_value() {
607 let mut colors = bundled("goingson");
608 colors
609 .colors
610 .insert("content.muted".into(), "not-a-colour".into());
611 match Theme::from_theme(&colors) {
612 Err(ThemeError::InvalidHex { key, value }) => {
613 assert_eq!(key, "content.muted");
614 assert_eq!(value, "not-a-colour");
615 }
616 other => panic!("expected InvalidHex, got {other:?}"),
617 }
618 }
619
620 #[test]
621 fn a_capable_terminal_gets_the_theme_as_authored() {
622 let theme = Theme::from_theme(&bundled("goingson")).expect("resolves");
623 let same = theme.for_terminal(crate::Fidelity::TrueColor);
624 assert_eq!(same.surface_page, theme.surface_page);
625 assert_eq!(same.content_primary, theme.content_primary);
626 assert!(matches!(same.surface_page, Color::Rgb(..)));
627 }
628
629 #[test]
630 fn a_limited_terminal_gets_indices_rather_than_rgb() {
631 let theme = Theme::from_theme(&bundled("goingson")).expect("resolves");
632 for fidelity in [crate::Fidelity::Ansi16, crate::Fidelity::Ansi256] {
633 let q = theme.for_terminal(fidelity);
634 assert!(
635 matches!(q.surface_page, Color::Indexed(_)),
636 "{fidelity:?} left a surface as rgb"
637 );
638 assert!(
639 matches!(q.content_primary, Color::Indexed(_)),
640 "{fidelity:?} left content as rgb"
641 );
642 }
643 }
644
645 #[test]
646 fn the_256_indices_land_outside_the_repaintable_low_sixteen() {
647 // The reason Quantize::for_fidelity resolves 256 to makeover's fixed
648 // region: an index below 16 is one the user's emulator may have moved.
649 let theme = Theme::from_theme(&bundled("goingson")).expect("resolves");
650 let q = theme.for_terminal(crate::Fidelity::Ansi256);
651 for (name, c) in [
652 ("surface_page", q.surface_page),
653 ("content_primary", q.content_primary),
654 ("bevel_light", q.bevel_light),
655 ("bevel_dark", q.bevel_dark),
656 ] {
657 match c {
658 Color::Indexed(i) => assert!(i >= 16, "{name} landed on repaintable index {i}"),
659 other => panic!("{name} was not quantised: {other:?}"),
660 }
661 }
662 }
663
664 #[test]
665 fn the_bevel_pair_stays_two_tones_at_256() {
666 // Quantised plainly rather than against the page, precisely so they do
667 // not collapse onto one entry and invert the bevel on one side.
668 let theme = Theme::from_theme(&bundled("goingson")).expect("resolves");
669 let q = theme.for_terminal(crate::Fidelity::Ansi256);
670 assert_ne!(q.bevel_light, q.bevel_dark);
671 }
672
673 #[test]
674 fn the_palette_takes_the_well_and_not_the_sunken_surface() {
675 // The substitution this crate deleted from the description, asserted
676 // absent here too: a theme authoring sunken darker than raised would
677 // land the well on the wrong side of its face.
678 let colors = bundled("goingson");
679 let theme = Theme::from_theme(&colors).expect("resolves");
680 let palette = theme.palette(crate::Fidelity::TrueColor);
681 assert_eq!(palette.well, theme.surface_well);
682 assert_ne!(palette.well, Some(theme.surface_sunken));
683 }
684 }
685