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}