//! A loaded makeover theme, resolved into the colours ratatui draws with. //! //! Behind the `theme` feature, because it is the one thing here that needs //! `makeover` itself. The rest of this crate takes [`Color`]s it is handed and //! never asks where they came from, which keeps a consumer that only wants //! [`frame`](crate::frame) off the theme loader and its embedded theme files. //! //! # Why this lives here rather than in each consumer //! //! Reading makeover's intents into ratatui `Color`s is the same work every //! terminal consumer does, and doing it twice is how two of them end up //! disagreeing about which intent a surface reads from. The mapping is //! mechanical, the failure mode is silent, and there is exactly one right //! answer, so it belongs with the renderer. //! //! # What is deliberately absent //! //! Tokens a consumer derives for itself. `alloy_tui` mixes a `border-subtle` //! and its own focus-ring `border-strong` out of the authored border, holding //! the latter to WCAG AA-UI against the page because Alloy spends it as the //! entire focus cue. makeover emits a `border-strong` too, and it is a flat 5% //! darkening: a firmer divider, not a focus ring. Those are different tokens //! wearing one name, and on Akari Dawn they land at 1.63:1 and 3.27:1. This //! struct carries makeover's, and a consumer that needs its own keeps deriving //! it. Adopting one for the other would take a focus ring to half its floor. use makeover::{Rgb, ThemeColors}; use ratatui::style::Color; /// A theme's polarity, as its author declared it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Mode { Light, Dark, HighContrast, } /// A makeover theme's intents, resolved to ratatui colours. /// /// `#[non_exhaustive]`: this gains a field whenever makeover gains an intent, /// and without the attribute every one of those would be a major here. Nothing /// should be building one field-by-field anyway, since [`Theme::from_theme`] is /// the only way to get one and a partial theme is an error rather than a /// default. #[derive(Debug, Clone, Copy)] #[non_exhaustive] pub struct Theme { pub mode: Mode, pub surface_page: Color, pub surface_raised: Color, pub surface_sunken: Color, pub surface_overlay: Color, /// makeover's inset content surface: the surface inside a raised container, /// so a list reads as content in a container rather than as bands on a /// panel. /// /// Not [`surface_sunken`](Theme::surface_sunken). A theme is free to author /// sunken *darker* than raised while a well always inverts away from the /// text, so substituting one for the other lands a well on the wrong side of /// its face on exactly the themes where it matters. /// /// `None` where makeover derived nothing, which is a theme authoring no /// raised surface or no content colour. Left missing rather than guessed, /// the same way [`Palette::fill`](crate::Palette::fill) answers a missing /// well with structure instead of a substitute colour. pub surface_well: Option, pub content_primary: Color, pub content_secondary: Color, pub content_muted: Color, pub action_primary: Color, pub status_danger: Color, pub status_success: Color, pub status_warning: Color, pub status_info: Color, /// The authored border colour. pub line_border: Color, /// makeover's derived firmer divider: the authored border, 5% darker. /// /// A divider, not a focus ring. See the module header before spending it as /// one. pub border_strong: Color, /// The foreground for text sitting on [`action_primary`](Theme::action_primary). /// /// DERIVED, not authored. Every consumer measured did selection with /// `REVERSED`, because there was no on-accent foreground to pair with an /// accent background, and four ports were each about to invent one. /// /// Chosen between [`surface_page`](Theme::surface_page) and /// [`content_primary`](Theme::content_primary) by contrast against the /// accent, rather than mixed: both are colours the theme authored, so a /// selected row stays inside the theme's own palette instead of landing on /// a colour that appears nowhere in the file. That is /// [`Quantize::against`]'s reasoning applied a step earlier — a colour on a /// colour, answered by measuring rather than by taste. /// /// A candidate for promotion to an authored theme key if a theme ever needs /// to tune it. That direction works and the reverse does not: an authored /// key can fall back to this derivation and break no theme on disk, while a /// key this crate started requiring would break every theme that has one. pub selection_on: Color, /// The colour of a focus ring. /// /// DERIVED, not authored, and deliberately not /// [`border_strong`](Theme::border_strong). That is a divider at 5% /// darkening — see the module header — and spending it here takes a focus /// ring to half its floor. The cue comes off /// [`action_primary`](Theme::action_primary) instead, held to AA-UI against /// the page, which is what `alloy_tui` already derives for itself rather /// than adopting makeover's border for a job it does not do. /// /// Promotable to an authored key on the same terms as /// [`selection_on`](Theme::selection_on). pub focus_ring: Color, /// The lit and shadowed edges of a raised surface. /// /// A control is lit from the top left, so its top and left edges take /// `bevel_light` and its bottom and right edges `bevel_dark`; swapping the /// two recesses it, which is what a pressed state and a text well are. The /// light source does not flip with polarity, or the rule stops transferring /// between widgets, which is the whole reason to have one. pub bevel_light: Color, pub bevel_dark: Color, pub category: [Color; 6], } /// Why a theme could not be resolved. /// /// Both variants name the key, because "the theme is bad" is not something a /// user can act on and "the theme is missing `content.muted`" is. #[derive(Debug, Clone)] pub enum ThemeError { MissingKey(&'static str), InvalidHex { key: &'static str, value: String }, } impl std::fmt::Display for ThemeError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::MissingKey(k) => write!(f, "theme missing required key `{k}`"), Self::InvalidHex { key, value } => { write!(f, "theme key `{key}` has invalid hex value `{value}`") } } } } impl std::error::Error for ThemeError {} impl Theme { /// Resolve a loaded [`ThemeColors`] into the colours ratatui draws with. /// /// Every intent this struct names is required, apart from /// [`surface_well`](Theme::surface_well), which makeover derives only when /// the theme gave it enough to derive from. A malformed or partial theme is /// rejected rather than papered over with defaults: rendering in colours /// that appear nowhere in the theme file is worse than refusing to render. pub fn from_theme(theme: &ThemeColors) -> Result { let authored = |key: &'static str| -> Result { let hex = theme.colors.get(key).ok_or(ThemeError::MissingKey(key))?; Rgb::from_hex(hex).ok_or_else(|| ThemeError::InvalidHex { key, value: hex.clone(), }) }; // The bevel pair, the well and the firm border are makeover's derived // intents, so a console, a webview and an egui app light a raised // surface the same way. Read through `resolve` rather than recomputed // here, which is the point of them living in that crate. let resolved = makeover::resolve(theme); let derived = |key: &'static str| -> Result { let hex = resolved.hex(key).ok_or(ThemeError::MissingKey(key))?; Rgb::from_hex(hex).ok_or_else(|| ThemeError::InvalidHex { key, value: hex.to_string(), }) }; // The two derived cues. Both are computed here rather than in // `makeover::resolve`, because neither is a colour a webview or an egui // app needs: CSS has `:focus-visible` with its own accent handling, and // a browser paints a selection itself. This is the terminal's problem, // so it is answered where the terminal is drawn. let accent = authored("action.primary")?; let page = authored("surface.page")?; let selection_on = on_accent(accent, page, authored("content.primary")?); let mode = match theme.meta.variant.as_str() { "dark" => Mode::Dark, "high-contrast" => Mode::HighContrast, _ => Mode::Light, }; Ok(Self { mode, surface_page: rgb(authored("surface.page")?), surface_raised: rgb(authored("surface.raised")?), surface_sunken: rgb(authored("surface.sunken")?), surface_overlay: rgb(authored("surface.overlay")?), surface_well: resolved .hex("surface-well") .and_then(Rgb::from_hex) .map(rgb), content_primary: rgb(authored("content.primary")?), content_secondary: rgb(authored("content.secondary")?), content_muted: rgb(authored("content.muted")?), action_primary: rgb(accent), selection_on: rgb(selection_on), focus_ring: rgb(focus_ring(accent, page)), status_danger: rgb(authored("status.danger")?), status_success: rgb(authored("status.success")?), status_warning: rgb(authored("status.warning")?), status_info: rgb(authored("status.info")?), line_border: rgb(authored("line.border")?), border_strong: rgb(derived("border-strong")?), bevel_light: rgb(derived("bevel-light")?), bevel_dark: rgb(derived("bevel-dark")?), category: [ rgb(authored("category.one")?), rgb(authored("category.two")?), rgb(authored("category.three")?), rgb(authored("category.four")?), rgb(authored("category.five")?), rgb(authored("category.six")?), ], }) } /// This theme as the terminal can actually draw it. /// /// At [`TrueColor`](crate::Fidelity::TrueColor) the theme is returned /// untouched. Otherwise every colour becomes a palette index, which is the /// point: left as 24-bit, the terminal approximates them itself, and its /// approximation collapses tones the theme keeps apart. Alloy's console lost /// its frame that way, drawing a border in a colour the Linux console could /// not tell from the page behind it. /// /// Anything that has to be seen against the page is quantised against it /// rather than on its own, so a border stays a border and text stays /// readable. The surfaces themselves are quantised plainly: they are what /// the others are measured against. /// /// The bevel edges are quantised plainly too, for a different reason. They /// are measured against the raised surface they surround rather than against /// the page, and running them through [`Quantize::against`] would push both /// onto the same entry and invert the bevel on one side. At /// [`Ansi16`](crate::Fidelity::Ansi16) the palette cannot hold the pair at /// all and one edge lands back on its face, which is a property of sixteen /// colours rather than something this can fix. A caller drawing there does /// not have to handle it: [`Theme::palette`] carries the fidelity through, /// and [`frame`](crate::frame) answers it with glyphs instead of tones. /// /// A consumer holding tokens of its own quantises them alongside this, with /// the same [`Quantize`], rather than after the fact. #[must_use] pub fn for_terminal(self, fidelity: crate::Fidelity) -> Self { let Some(q) = Quantize::for_fidelity(fidelity) else { return self; }; let plain = |c: Color| q.plain(c); let on_page = |c: Color| q.against(c, self.surface_page); Self { mode: self.mode, surface_page: plain(self.surface_page), surface_raised: plain(self.surface_raised), surface_sunken: plain(self.surface_sunken), surface_overlay: plain(self.surface_overlay), // Plainly, like the other surfaces and for the same reason as the // bevel pair: a well is measured against the raised face it is cut // into, not against the page, so quantising it against the page // would push it toward contrast it is not supposed to have. surface_well: self.surface_well.map(plain), content_primary: on_page(self.content_primary), content_secondary: on_page(self.content_secondary), content_muted: on_page(self.content_muted), action_primary: on_page(self.action_primary), // Against the accent, not against the page: this is the one colour // here whose whole job is to be legible on the accent behind it. // Quantising it against the page would answer the wrong question // and hand a selected row unreadable text at Ansi16. selection_on: q.against(self.selection_on, self.action_primary), focus_ring: on_page(self.focus_ring), status_danger: on_page(self.status_danger), status_success: on_page(self.status_success), status_warning: on_page(self.status_warning), status_info: on_page(self.status_info), line_border: on_page(self.line_border), border_strong: on_page(self.border_strong), bevel_light: plain(self.bevel_light), bevel_dark: plain(self.bevel_dark), category: self.category.map(on_page), } } /// The depth-painting palette this theme implies, at `fidelity`. /// /// The bridge between the two halves of this crate: [`Theme`] is what a /// theme file says, [`Palette`](crate::Palette) is the subset /// [`frame`](crate::frame) and [`paint_bevel`](crate::paint_bevel) need. A /// consumer holding a `Theme` should not be assembling that by hand and /// picking the wrong surface for the well. #[must_use] pub const fn palette(&self, fidelity: crate::Fidelity) -> crate::Palette { crate::Palette { page: self.surface_page, raised: self.surface_raised, overlay: self.surface_overlay, well: self.surface_well, bevel_light: self.bevel_light, bevel_dark: self.bevel_dark, fidelity, } } } fn rgb(c: Rgb) -> Color { Color::Rgb(c.r, c.g, c.b) } /// The AA floor for text against what it sits on. const TEXT_FLOOR: f32 = 4.5; /// The AA-UI floor a non-text cue has to clear against what it sits on. /// /// 3:1 rather than 4.5:1 because a ring is a graphical object and not text. /// The same number `alloy_tui` holds its own derived ring to. const UI_FLOOR: f32 = 3.0; /// The better of the theme's two candidates for text on the accent, or black or /// white where neither is legible. /// /// Preferring an authored colour is the point: a selected row should stay inside /// the palette the theme wrote rather than land on a colour appearing nowhere in /// the file. But an unreadable selection is worse than an off-palette one, and /// the two candidates can both fail — ayu-light's page and content both land /// under 2.5:1 on its accent. So the walk is: page or content by contrast, /// and `readable_on` only when the winner misses the AA text floor. /// /// Measured across every bundled theme when this was written: 29 of 31 clear /// the floor from the theme's own colours, one clears it at 3.99 and 3.36 (the /// app themes), and ayu-light is the one that needs the backstop. fn on_accent(accent: Rgb, page: Rgb, content: Rgb) -> Rgb { let best = if makeover::wcag_contrast(page, accent) >= makeover::wcag_contrast(content, accent) { page } else { content }; if makeover::wcag_contrast(best, accent) >= TEXT_FLOOR { best } else { makeover::readable_on(accent) } } /// A ring colour off the accent, pushed away from the page until it is visible. /// /// The accent itself is the answer on most themes, and the walk only runs where /// it is not: a theme whose accent sits close to its page has a real focus /// problem, and returning the accent unchanged there would be a ring nobody can /// see. Which way to push is decided by the page rather than by the theme's /// declared variant, because a light theme may carry a dark panel and the /// question is always "away from *this* surface". /// /// Steps in 5% and stops at the floor rather than going as far as it can, so /// the ring stays recognisably the accent on the themes that need the help. fn focus_ring(accent: Rgb, page: Rgb) -> Rgb { if makeover::wcag_contrast(accent, page) >= UI_FLOOR { return accent; } // Whether the page is dark, by the same relative-luminance rule the // contrast ratio is built on: white against it beats black against it. let page_is_dark = makeover::wcag_contrast( Rgb { r: 255, g: 255, b: 255, }, page, ) > makeover::wcag_contrast(Rgb { r: 0, g: 0, b: 0 }, page); let mut candidate = accent; for _ in 0..20 { candidate = if page_is_dark { makeover::lighten(candidate, 0.05) } else { makeover::darken(candidate, 0.05) }; if makeover::wcag_contrast(candidate, page) >= UI_FLOOR { return candidate; } } candidate } /// The palette a [`Fidelity`](crate::Fidelity) quantises into, and the rules for /// landing a colour in it. /// /// Public because a consumer carrying tokens of its own has to quantise them the /// same way this crate quantises the ones it knows about. `alloy_tui` derives a /// decorative divider and a focus ring from the authored border; those are its /// tokens, but "a colour that must stay legible against the page is quantised /// against the page" is not its rule to reinvent. #[derive(Debug, Clone, Copy)] pub struct Quantize { palette: &'static [Rgb], offset: usize, } impl Quantize { /// The quantiser for `fidelity`, or `None` at /// [`TrueColor`](crate::Fidelity::TrueColor), where nothing is quantised. /// /// 256 resolves to makeover's fixed region rather than the whole table: the /// low sixteen are repaintable in every emulator, so a match landing there /// is a match against a colour the user may have moved out from under it. #[must_use] pub const fn for_fidelity(fidelity: crate::Fidelity) -> Option { match fidelity { crate::Fidelity::TrueColor => None, crate::Fidelity::Ansi256 => Some(Self { palette: makeover::ANSI_240, offset: makeover::ANSI_240_OFFSET, }), crate::Fidelity::Ansi16 => Some(Self { palette: &makeover::ANSI_16, offset: 0, }), } } /// The palette entry for `c`, as an index the terminal will not reinterpret. /// /// For a colour measured against the surface it sits on rather than against /// the page: the surfaces themselves, and the bevel pair. #[must_use] pub fn plain(&self, c: Color) -> Color { match c { Color::Rgb(r, g, b) => Color::Indexed( (makeover::quantize(Rgb { r, g, b }, self.palette) + self.offset) as u8, ), other => other, } } /// As [`plain`](Self::plain), but guaranteed to stay legible against `on`. /// /// Only for a colour whose job is to be told apart from a known background. /// It answers "nearest entry that still contrasts with `on`" and has no /// notion of which side of `on` the answer should fall, so a pair of colours /// that must also stay apart from *each other* is the one thing it must not /// be used for: both get pushed onto the same contrasting entry. That is why /// the bevel edges go through [`plain`](Self::plain). #[must_use] pub fn against(&self, c: Color, on: Color) -> Color { match (c, on) { (Color::Rgb(r, g, b), Color::Rgb(br, bg, bb)) => Color::Indexed( (makeover::quantize_against( Rgb { r, g, b }, Rgb { r: br, g: bg, b: bb, }, self.palette, ) + self.offset) as u8, ), _ => self.plain(c), } } } #[cfg(test)] mod tests { use super::*; fn bundled(id: &str) -> ThemeColors { let dir = makeover::bundled_themes_dir().expect("makeover ships themes"); makeover::load_theme(&[(dir, false)], id).expect("bundled theme loads") } #[test] fn every_bundled_theme_resolves() { // The point of rejecting a partial theme is that it never happens to a // theme we ship. If one of these stops resolving, that is a real gap in // the theme file, not a reason to soften the error. let dir = makeover::bundled_themes_dir().expect("makeover ships themes"); let metas = makeover::list_themes_from_dirs(&[(dir, false)]); assert!( !metas.is_empty(), "makeover shipped no themes to test against" ); for meta in &metas { let colors = bundled(&meta.id); assert!( Theme::from_theme(&colors).is_ok(), "bundled theme `{}` failed to resolve", meta.id ); } } fn to_rgb(c: Color) -> Rgb { match c { Color::Rgb(r, g, b) => Rgb { r, g, b }, other => panic!("expected a resolved colour, got {other:?}"), } } #[test] fn the_two_derived_cues_are_legible_in_every_bundled_theme() { // The test that would have caught `border_strong` being spent as a // focus ring: it lands at 1.63:1 on Akari Dawn, and nothing asked. // Every theme rather than one, because a derivation that works on the // theme it was written against says nothing about the other thirty. let dir = makeover::bundled_themes_dir().expect("makeover ships themes"); for meta in makeover::list_themes_from_dirs(&[(dir, false)]) { let theme = Theme::from_theme(&bundled(&meta.id)).expect("resolves"); // Text on the accent, so the text floor. let selection = makeover::wcag_contrast(to_rgb(theme.selection_on), to_rgb(theme.action_primary)); assert!( selection >= TEXT_FLOOR, "{}: selection_on is {selection:.2} on the accent", meta.id ); // A ring is a graphical object, so the AA-UI floor. let ring = makeover::wcag_contrast(to_rgb(theme.focus_ring), to_rgb(theme.surface_page)); assert!( ring >= UI_FLOOR, "{}: focus_ring is {ring:.2} on the page", meta.id ); // And the ring is not the divider wearing the ring's name, which // is the whole reason it is derived rather than adopted. assert_ne!(theme.focus_ring, theme.border_strong, "{}", meta.id); } } #[test] fn the_derived_cues_are_quantised_for_the_surface_each_sits_on() { // A colour that has to be told apart from a known background is // quantised against that background, and the two cues do not share one. // Left plain, both collapse toward whatever the palette has nearest. let theme = Theme::from_theme(&bundled("goingson")).expect("resolves"); let ansi16 = theme.for_terminal(crate::Fidelity::Ansi16); let q = Quantize::for_fidelity(crate::Fidelity::Ansi16).expect("Ansi16 quantises"); assert_eq!( ansi16.selection_on, q.against(theme.selection_on, theme.action_primary) ); assert_eq!( ansi16.focus_ring, q.against(theme.focus_ring, theme.surface_page) ); } #[test] fn a_missing_intent_names_the_key_it_wanted() { let mut colors = bundled("goingson"); colors.colors.remove("content.muted"); match Theme::from_theme(&colors) { Err(ThemeError::MissingKey(k)) => assert_eq!(k, "content.muted"), other => panic!("expected MissingKey(content.muted), got {other:?}"), } } #[test] fn an_unparseable_hex_names_the_key_and_the_value() { let mut colors = bundled("goingson"); colors .colors .insert("content.muted".into(), "not-a-colour".into()); match Theme::from_theme(&colors) { Err(ThemeError::InvalidHex { key, value }) => { assert_eq!(key, "content.muted"); assert_eq!(value, "not-a-colour"); } other => panic!("expected InvalidHex, got {other:?}"), } } #[test] fn a_capable_terminal_gets_the_theme_as_authored() { let theme = Theme::from_theme(&bundled("goingson")).expect("resolves"); let same = theme.for_terminal(crate::Fidelity::TrueColor); assert_eq!(same.surface_page, theme.surface_page); assert_eq!(same.content_primary, theme.content_primary); assert!(matches!(same.surface_page, Color::Rgb(..))); } #[test] fn a_limited_terminal_gets_indices_rather_than_rgb() { let theme = Theme::from_theme(&bundled("goingson")).expect("resolves"); for fidelity in [crate::Fidelity::Ansi16, crate::Fidelity::Ansi256] { let q = theme.for_terminal(fidelity); assert!( matches!(q.surface_page, Color::Indexed(_)), "{fidelity:?} left a surface as rgb" ); assert!( matches!(q.content_primary, Color::Indexed(_)), "{fidelity:?} left content as rgb" ); } } #[test] fn the_256_indices_land_outside_the_repaintable_low_sixteen() { // The reason Quantize::for_fidelity resolves 256 to makeover's fixed // region: an index below 16 is one the user's emulator may have moved. let theme = Theme::from_theme(&bundled("goingson")).expect("resolves"); let q = theme.for_terminal(crate::Fidelity::Ansi256); for (name, c) in [ ("surface_page", q.surface_page), ("content_primary", q.content_primary), ("bevel_light", q.bevel_light), ("bevel_dark", q.bevel_dark), ] { match c { Color::Indexed(i) => assert!(i >= 16, "{name} landed on repaintable index {i}"), other => panic!("{name} was not quantised: {other:?}"), } } } #[test] fn the_bevel_pair_stays_two_tones_at_256() { // Quantised plainly rather than against the page, precisely so they do // not collapse onto one entry and invert the bevel on one side. let theme = Theme::from_theme(&bundled("goingson")).expect("resolves"); let q = theme.for_terminal(crate::Fidelity::Ansi256); assert_ne!(q.bevel_light, q.bevel_dark); } #[test] fn the_palette_takes_the_well_and_not_the_sunken_surface() { // The substitution this crate deleted from the description, asserted // absent here too: a theme authoring sunken darker than raised would // land the well on the wrong side of its face. let colors = bundled("goingson"); let theme = Theme::from_theme(&colors).expect("resolves"); let palette = theme.palette(crate::Fidelity::TrueColor); assert_eq!(palette.well, theme.surface_well); assert_ne!(palette.well, Some(theme.surface_sunken)); } }