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}