Skip to main content

kui_core/
theme.rs

1//! The named colours a view paints with, derived from what the OS said —
2//! `docs/adr/0019-a-theme-derived-from-appearance-and-accent.md`.
3//!
4//! [`Appearance`] and the OS accent have been in [`crate::env::SystemEnv`]
5//! since they were plumbed, and `env`'s own doc is firm that the core acts
6//! on neither: "a dark appearance does not repaint anything: the view
7//! decides, because only it knows which of its colours is the background".
8//! That is still true. What was missing is the other half — a view that
9//! *wants* to decide had nothing to decide *with*, so every one of them
10//! wrote the same two dozen hex literals again, and the stock widgets in
11//! this crate wrote them a third time. A [`Theme`] is that missing half:
12//! plain data, derived from the two facts, and read rather than obeyed.
13//!
14//! Three ways to have one, which is [`ThemeSource`]:
15//!
16//! - **Derived** (the default): the OS's appearance and the OS's accent.
17//!   A host that reports neither gets exactly what kui painted before this
18//!   module existed, which is what makes it safe to be the default.
19//! - **Derived with an accent**: the OS's light/dark, the app's brand
20//!   colour. What most apps with a colour of their own actually want.
21//! - **Pinned**: a [`Theme`] the app built, followed by nothing.
22//!
23//! The roles are the ones the codebase had already voted for: three
24//! examples arrived independently at `bg` / `panel` / `border` / `fg` /
25//! `dim` / `faint` / `accent`, with the same values. This is that set,
26//! spelled once.
27
28use crate::color::Color;
29use crate::env::{Appearance, SystemEnv};
30
31/// Every colour the stock widgets and the core's own chrome paint with,
32/// as roles rather than values. Plain data and [`Copy`]: a view reads it
33/// off `ui.theme()` and may keep, mutate or replace its own copy.
34///
35/// Roles, not a ramp. `surface` is not "grey 800" — it is *the colour a
36/// card is*, and under a light theme it is nearly white. A view that
37/// wants "one step lighter than this" has [`Color::mix`] and
38/// [`Theme::raise`] for that, and nothing here promises an ordering
39/// beyond the one the names carry.
40#[derive(Clone, Copy, Debug, PartialEq)]
41pub struct Theme {
42    /// Which base this was built from. `Unknown` means the host never
43    /// said, and the dark base stands — see [`Theme::derive`].
44    pub appearance: Appearance,
45
46    // -- surfaces, back to front --------------------------------------
47    /// The window behind everything.
48    pub bg: Color,
49    /// A card, panel or list sitting on `bg`.
50    pub surface: Color,
51    /// A surface that floats *above* content: a menu, a tooltip, a
52    /// popover. Separate from `surface` because it has to read as
53    /// nearer, and under a light theme "nearer" is not "lighter" — a
54    /// float on a white page separates by its border.
55    pub raised: Color,
56    /// A well cut *into* a surface: a text field, a code block, a track.
57    pub sunken: Color,
58
59    // -- lines ---------------------------------------------------------
60    /// The hairline between two surfaces.
61    pub border: Color,
62    /// A border that has to be seen — a float's edge, a focused field.
63    pub border_strong: Color,
64
65    // -- text ----------------------------------------------------------
66    /// Body text. What a `TextStyle` with no colour of its own resolves
67    /// to, which is what makes `<text>hello</text>` legible on both bases.
68    pub fg: Color,
69    /// Secondary text: captions, hints, an accelerator beside a label.
70    pub muted: Color,
71    /// Text that is barely there: a placeholder, a gutter number.
72    pub faint: Color,
73
74    // -- accent --------------------------------------------------------
75    /// The one saturated colour: the OS accent when the host reports one,
76    /// the app's when it pinned one, and kui's blue otherwise.
77    pub accent: Color,
78    /// `accent` under a pointer, and under a press.
79    pub accent_hover: Color,
80    pub accent_pressed: Color,
81    /// Black or white — whichever a reader can see on `accent`.
82    pub on_accent: Color,
83    /// The accent as a *wash* rather than a fill: what a selected menu
84    /// row, a chosen tab or a highlighted list item is painted with.
85    ///
86    /// Translucent on purpose. A row filled with the solid accent needs
87    /// its label to flip to `on_accent` in the same frame the fill lands,
88    /// and `hover_bg` is resolved by the core after the view has already
89    /// chosen that label — so on a light theme the row would spend a
90    /// frame as dark-on-blue. A wash keeps `fg` readable over both bases
91    /// and stays declarative.
92    pub accent_soft: Color,
93    /// What a text selection is painted under. Translucent: the glyphs
94    /// under it keep their own colour.
95    pub selection: Color,
96    /// The default keyboard focus ring (ADR 0002).
97    pub focus_ring: Color,
98
99    // -- neutral interaction -------------------------------------------
100    /// A translucent wash over a neutral control that is hovered, and one
101    /// over a pressed one. Overlays, not fills: they go *on* whatever
102    /// surface the control sits on, so one pair works for every surface.
103    /// `pressed` is the firmer of the two on both bases.
104    pub hover: Color,
105    pub pressed: Color,
106    /// What a disabled control's opacity is multiplied by.
107    pub disabled_opacity: f32,
108
109    // -- status --------------------------------------------------------
110    pub success: Color,
111    pub warning: Color,
112    pub danger: Color,
113
114    // -- chrome --------------------------------------------------------
115    /// The scrollbar thumb at rest, and while hovered or dragged.
116    pub scrollbar: Color,
117    pub scrollbar_active: Color,
118}
119
120impl Default for Theme {
121    /// The dark base with kui's own accent: what this crate painted
122    /// before themes existed.
123    fn default() -> Self {
124        Self::dark()
125    }
126}
127
128impl Theme {
129    /// The text colour kui painted before it could ask the OS anything,
130    /// and the dark base's `fg`. What an unresolved style falls back to.
131    pub const DEFAULT_FG: Color = Color {
132        r: 0xe8 as f32 / 255.0,
133        g: 0xe8 as f32 / 255.0,
134        b: 0xea as f32 / 255.0,
135        a: 1.0,
136    };
137
138    /// kui's own accent, and the stock button's background since there
139    /// was a stock button. Stands in wherever no accent is known.
140    pub const ACCENT: Color = Color {
141        r: 0x3b as f32 / 255.0,
142        g: 0x5b as f32 / 255.0,
143        b: 0xd4 as f32 / 255.0,
144        a: 1.0,
145    };
146
147    /// The theme for what the OS said: `system.appearance` picks the base,
148    /// `system.accent` recolours it.
149    ///
150    /// An `Unknown` appearance takes the **dark** base. Not a guess about
151    /// the user — the honest answer to "what did kui paint before it could
152    /// ask" — and the reason a host that reports nothing sees no change at
153    /// all. A view that would rather guess light has [`Theme::light`].
154    pub fn derive(appearance: Appearance, accent: Option<Color>) -> Self {
155        let base = match appearance {
156            Appearance::Light => Self::light(),
157            Appearance::Dark | Appearance::Unknown => Self::dark(),
158        };
159        let base = Theme { appearance, ..base };
160        match accent {
161            Some(c) => base.with_accent(c),
162            None => base,
163        }
164    }
165
166    /// [`derive`](Theme::derive) from a whole [`SystemEnv`], which is how
167    /// the core does it every frame.
168    pub fn from_system(sys: &SystemEnv) -> Self {
169        Self::derive(sys.appearance, sys.accent)
170    }
171
172    /// The dark base. Every value here is one the crate already painted:
173    /// the muted grey 16 files were writing out, the field background the
174    /// stock input had, the ring ADR 0002 nailed down.
175    ///
176    /// The accent family is hand-picked rather than run through
177    /// [`with_accent`](Theme::with_accent), for the reason
178    /// [`crate::widgets::button_spec`] gives for its own trio: so that a
179    /// host which reports no accent paints exactly what it always did,
180    /// down to the byte. Hand an accent in and the arithmetic takes over.
181    pub fn dark() -> Self {
182        Self {
183            appearance: Appearance::Dark,
184            bg: Color::rgb8(0x14, 0x16, 0x1e),
185            surface: Color::rgb8(0x1a, 0x1d, 0x27),
186            raised: Color::rgb8(0x24, 0x27, 0x33),
187            sunken: Color::rgb8(0x0e, 0x10, 0x16),
188            border: Color::rgb8(0x2a, 0x2d, 0x3a),
189            border_strong: Color::rgb8(0x3a, 0x3e, 0x4e),
190            fg: Self::DEFAULT_FG,
191            muted: Color::rgb8(0x8a, 0x8f, 0xa3),
192            faint: Color::rgb8(0x6e, 0x75, 0x8a),
193            accent: Self::ACCENT,
194            accent_hover: Color::rgb8(0x47, 0x6c, 0xe0),
195            accent_pressed: Color::rgb8(0x2f, 0x54, 0xc4),
196            on_accent: Color::WHITE,
197            accent_soft: Self::ACCENT.with_alpha(0.30),
198            selection: Color::rgba8(0x3b, 0x5b, 0xd4, 0x66),
199            focus_ring: Color::rgb8(0x7f, 0x9c, 0xf5),
200            hover: Color::rgba(1.0, 1.0, 1.0, 0.08),
201            pressed: Color::rgba(1.0, 1.0, 1.0, 0.14),
202            disabled_opacity: 0.5,
203            success: Color::rgb8(0x73, 0xd9, 0x8c),
204            warning: Color::rgb8(0xd9, 0xa1, 0x4d),
205            danger: Color::rgb8(0xe8, 0x5d, 0x5d),
206            scrollbar: Color::rgba(1.0, 1.0, 1.0, 0.18),
207            scrollbar_active: Color::rgba(1.0, 1.0, 1.0, 0.40),
208        }
209    }
210
211    /// The light base: the same roles, mirrored rather than inverted.
212    ///
213    /// Mirrored, because inverting is wrong twice. A float above content
214    /// is *lighter* than the page on a dark base and no lighter than
215    /// white on a light one, so it separates by border instead; and the
216    /// accent does not flip at all — a blue button is a blue button, and
217    /// only its ring and its selection tint have to move, because the
218    /// pale ring that reads on `#14161e` is invisible on `#f7f8fa`.
219    pub fn light() -> Self {
220        let accent = Self::ACCENT;
221        Self {
222            appearance: Appearance::Light,
223            bg: Color::rgb8(0xf6, 0xf7, 0xf9),
224            surface: Color::rgb8(0xff, 0xff, 0xff),
225            raised: Color::rgb8(0xff, 0xff, 0xff),
226            sunken: Color::rgb8(0xec, 0xee, 0xf2),
227            border: Color::rgb8(0xdd, 0xe1, 0xe8),
228            border_strong: Color::rgb8(0xb4, 0xbb, 0xc8),
229            fg: Color::rgb8(0x1b, 0x1e, 0x27),
230            muted: Color::rgb8(0x5b, 0x61, 0x71),
231            faint: Color::rgb8(0x76, 0x7d, 0x8d),
232            accent,
233            accent_hover: accent.mix(Color::WHITE, 0.09),
234            accent_pressed: accent.mix(Color::BLACK, 0.10),
235            on_accent: Color::WHITE,
236            accent_soft: accent.with_alpha(0.16),
237            selection: accent.with_alpha(0.28),
238            focus_ring: accent,
239            hover: Color::rgba(0.0, 0.0, 0.0, 0.06),
240            pressed: Color::rgba(0.0, 0.0, 0.0, 0.12),
241            disabled_opacity: 0.5,
242            success: Color::rgb8(0x1a, 0x7a, 0x3e),
243            warning: Color::rgb8(0x8a, 0x5c, 0x08),
244            danger: Color::rgb8(0xc0, 0x2b, 0x2b),
245            scrollbar: Color::rgba(0.0, 0.0, 0.0, 0.22),
246            scrollbar_active: Color::rgba(0.0, 0.0, 0.0, 0.42),
247        }
248    }
249
250    /// This theme with `accent` in place of its own, and everything that
251    /// comes *off* the accent recomputed with it: the two button shades,
252    /// the label that goes on top, the selection tint and the ring.
253    ///
254    /// The shades are [`crate::widgets::button_palette`]'s arithmetic, so
255    /// an accent-painted button reads as the same control in a different
256    /// colour rather than as a different control. The ring keeps each
257    /// base's habit and is then held to [`Theme::ring_for`]'s promise.
258    pub fn with_accent(self, accent: Color) -> Self {
259        let ring = self.ring_for(accent);
260        Self {
261            accent,
262            accent_hover: accent.mix(Color::WHITE, 0.09),
263            accent_pressed: accent.mix(Color::BLACK, 0.10),
264            on_accent: crate::widgets::readable_on(accent),
265            accent_soft: accent.with_alpha(match self.appearance {
266                Appearance::Light => 0.16,
267                _ => 0.30,
268            }),
269            selection: accent.with_alpha(match self.appearance {
270                Appearance::Light => 0.28,
271                _ => 0.40,
272            }),
273            focus_ring: ring,
274            ..self
275        }
276    }
277
278    /// A focus ring in `accent` that can actually be *seen* on this
279    /// theme's `bg`: the accent moved toward the front of the base —
280    /// white on a dark one, black on a light one — until it clears the
281    /// 3:1 ADR 0002 asks of a focus indicator.
282    ///
283    /// Each base's habit is where it starts: the dark one lifts a
284    /// saturated ring that would otherwise sink into the page, and the
285    /// light one takes the accent as it is, because most accents are
286    /// already dark enough on a near-white page. The loop is what turns
287    /// that from a hope into a promise — a *light* accent on the light
288    /// base is the case it exists for. macOS's yellow taken verbatim is
289    /// 1.49:1 on `#f6f7f9`, which is not a ring, it is a rumour.
290    pub fn ring_for(self, accent: Color) -> Color {
291        let from = if self.is_dark() { 0.35 } else { 0.0 };
292        accent.toward_contrast(self.front(), self.bg, 3.0, from)
293    }
294
295    /// `accent` as ink on `surface` — strokes, borders, short labels —
296    /// held to 3:1, the UI-edge grade, and painted verbatim when it
297    /// already reads. The devtools panel's accent (F50); the same
298    /// promise as [`ring_for`](Theme::ring_for) with a different start,
299    /// since a fill that reads has no reason to move.
300    pub fn ink_for(self, accent: Color) -> Color {
301        accent.toward_contrast(self.front(), self.surface, 3.0, 0.0)
302    }
303
304    /// Whether this is a dark theme — the question a view asks when it has
305    /// a decision of its own to make (which of two images, how heavy a
306    /// shadow). `Unknown` answers the way [`derive`](Theme::derive) does.
307    pub fn is_dark(self) -> bool {
308        self.appearance != Appearance::Light
309    }
310
311    /// The *front* of this theme's base: white on a dark one, black on a
312    /// light one — what [`raise`](Theme::raise) moves toward and what
313    /// clears any contrast on the base by itself. The one fact about
314    /// contrast that is the theme's rather than the colour's.
315    pub fn front(self) -> Color {
316        if self.is_dark() {
317            Color::WHITE
318        } else {
319            Color::BLACK
320        }
321    }
322
323    /// `c` moved `t` of the way toward the *front* of this theme: lighter
324    /// on a dark one, darker on a light one. The arithmetic behind
325    /// "one step up from this surface", written once so a view does not
326    /// have to branch on the appearance to get it right.
327    pub fn raise(self, c: Color, t: f32) -> Color {
328        c.mix(self.front(), t)
329    }
330
331    /// Black or white, whichever a reader can see on `bg`
332    /// ([`crate::widgets::readable_on`]).
333    pub fn on(self, bg: Color) -> Color {
334        crate::widgets::readable_on(bg)
335    }
336}
337
338/// Where a [`Core`](crate::runtime::Core)'s theme comes from, re-read at
339/// the start of every frame. `Derived` is the default.
340///
341/// `Pinned` makes this as big as a whole [`Theme`], which is the point:
342/// there is one of these per window, read once a frame, and boxing it to
343/// save three hundred bytes would cost the `Copy` that lets a view hold
344/// the answer without borrowing the core.
345#[allow(clippy::large_enum_variant)]
346#[derive(Clone, Copy, Debug, Default, PartialEq)]
347pub enum ThemeSource {
348    /// [`Theme::from_system`] on whatever `env.system` currently says, so
349    /// the app follows the OS without writing a line about it.
350    #[default]
351    Derived,
352    /// The OS's appearance, this accent. What an app with a brand colour
353    /// wants: it should still go light when the user does.
354    DerivedWithAccent(Color),
355    /// Exactly this, following nothing.
356    Pinned(Theme),
357}
358
359impl ThemeSource {
360    /// The theme this source resolves to under `sys`.
361    pub fn resolve(&self, sys: &SystemEnv) -> Theme {
362        match self {
363            Self::Derived => Theme::from_system(sys),
364            Self::DerivedWithAccent(c) => Theme::derive(sys.appearance, Some(*c)),
365            Self::Pinned(t) => *t,
366        }
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    /// The reason this is safe to turn on for everyone: a host that
375    /// cannot ask the OS anything gets the palette kui always painted.
376    #[test]
377    fn an_unknown_appearance_is_what_kui_always_painted() {
378        let t = Theme::derive(Appearance::Unknown, None);
379        assert_eq!(t.fg, Color::rgb8(0xe8, 0xe8, 0xea), "the old text colour");
380        assert_eq!(t.accent, Theme::ACCENT, "the old button blue");
381        assert_eq!(
382            t.focus_ring,
383            Color::rgb8(0x7f, 0x9c, 0xf5),
384            "ADR 0002's ring"
385        );
386        assert_eq!(t.selection, Color::rgba8(0x3b, 0x5b, 0xd4, 0x66));
387        assert_eq!(t.muted, Color::rgb8(0x8a, 0x8f, 0xa3));
388        // And it is the dark base, without claiming the user chose it.
389        assert_eq!(t.bg, Theme::dark().bg);
390        assert_eq!(t.appearance, Appearance::Unknown);
391    }
392
393    /// Both bases have to be readable, which is the one thing a palette
394    /// can be checked for rather than argued about. WCAG AA is 4.5:1 for
395    /// body text and 3:1 for large text and UI edges.
396    #[test]
397    fn every_text_role_is_readable_on_every_surface() {
398        for t in [Theme::dark(), Theme::light()] {
399            let name = if t.is_dark() { "dark" } else { "light" };
400            for (sn, surface) in [
401                ("bg", t.bg),
402                ("surface", t.surface),
403                ("sunken", t.sunken),
404                ("raised", t.raised),
405            ] {
406                let fg = t.fg.contrast(surface);
407                assert!(fg >= 4.5, "{name}: fg on {sn} is {fg:.2}:1");
408                let muted = t.muted.contrast(surface);
409                assert!(muted >= 4.5, "{name}: muted on {sn} is {muted:.2}:1");
410                // Faint is the placeholder tier: large-text/UI grade.
411                let faint = t.faint.contrast(surface);
412                assert!(faint >= 3.0, "{name}: faint on {sn} is {faint:.2}:1");
413            }
414            let label = t.on_accent.contrast(t.accent);
415            assert!(label >= 4.5, "{name}: the button label is {label:.2}:1");
416            // A ring nobody can see is not a focus indicator (ADR 0002).
417            let ring = t.focus_ring.contrast(t.bg);
418            assert!(ring >= 3.0, "{name}: the focus ring is {ring:.2}:1");
419            for (sn, status) in [
420                ("success", t.success),
421                ("warning", t.warning),
422                ("danger", t.danger),
423            ] {
424                let c = status.contrast(t.surface);
425                assert!(c >= 4.5, "{name}: {sn} on a surface is {c:.2}:1");
426            }
427        }
428    }
429
430    /// An accent recolours everything that comes off it, and a light
431    /// accent flips the label the way the stock button always did.
432    #[test]
433    fn an_accent_carries_the_whole_family_with_it() {
434        let yellow = Color::hex(0xffc409ff);
435        let t = Theme::derive(Appearance::Dark, Some(yellow));
436        assert_eq!(t.accent, yellow);
437        assert_eq!(t.on_accent, Color::BLACK, "white on yellow is not a button");
438        assert_ne!(t.accent_hover, t.accent);
439        assert_ne!(t.accent_pressed, t.accent);
440        assert_eq!(t.selection.a, 0.40, "still a tint, not a fill");
441        // And the surfaces are untouched: an accent is not a repaint.
442        assert_eq!(t.bg, Theme::dark().bg);
443        assert_eq!(t.fg, Theme::dark().fg);
444    }
445
446    /// The middle source is the one an app with a brand colour wants.
447    #[test]
448    fn a_source_can_follow_the_os_light_dark_and_not_its_accent() {
449        let brand = Color::hex(0xd2691eff);
450        let src = ThemeSource::DerivedWithAccent(brand);
451        let mut sys = SystemEnv {
452            accent: Some(Color::hex(0x007affff)),
453            appearance: Appearance::Light,
454            ..SystemEnv::default()
455        };
456        let t = src.resolve(&sys);
457        assert_eq!(t.accent, brand, "the app's colour, not the OS's");
458        assert_eq!(t.bg, Theme::light().bg, "the OS's light, not the app's");
459        sys.appearance = Appearance::Dark;
460        assert_eq!(src.resolve(&sys).bg, Theme::dark().bg);
461        // Pinned follows nothing at all.
462        let pinned = ThemeSource::Pinned(Theme::light());
463        assert_eq!(pinned.resolve(&sys).bg, Theme::light().bg);
464    }
465
466    /// The two stock bases are checked above, but the ring is the one
467    /// role that is *derived* from a colour kui does not choose — so it
468    /// has to hold for whatever the OS reports, not only for kui's blue.
469    /// The light base is where a verbatim accent fails: macOS's yellow
470    /// is 1.49:1 on `#f6f7f9`, which no one would find.
471    #[test]
472    fn a_derived_focus_ring_is_visible_whatever_the_accent_is() {
473        for hex in [
474            0x3b5bd4ff, // kui's own
475            0x007affff, // macOS blue
476            0xffc409ff, // macOS yellow — the light one
477            0xf74f9eff, // macOS pink
478            0x2f7d4fff, // a dark brand green
479            0xffffffff, // and the two ends
480            0x000000ff,
481        ] {
482            for appearance in [Appearance::Light, Appearance::Dark, Appearance::Unknown] {
483                let t = Theme::derive(appearance, Some(Color::hex(hex)));
484                let c = t.focus_ring.contrast(t.bg);
485                assert!(c >= 3.0, "{appearance:?} + {hex:08x}: the ring is {c:.2}:1");
486            }
487        }
488        // And the case that used to ship: the ring is no longer the
489        // accent itself here, because the accent itself was invisible.
490        let yellow = Color::hex(0xffc409ff);
491        let light = Theme::derive(Appearance::Light, Some(yellow));
492        assert_ne!(light.focus_ring, yellow);
493        assert!(yellow.contrast(light.bg) < 1.6, "which is why");
494    }
495
496    /// `raise` is the branch a view would otherwise write by hand.
497    #[test]
498    fn raise_goes_toward_the_front_of_whichever_base() {
499        let dark = Theme::dark();
500        assert!(dark.raise(dark.surface, 0.1).luminance() > dark.surface.luminance());
501        let light = Theme::light();
502        assert!(light.raise(light.surface, 0.1).luminance() < light.surface.luminance());
503    }
504}