Skip to main content

kui_core/
theme.rs

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