Skip to main content

makeover_immediate/
host.rs

1//! The design system, handed to egui as a `Style`.
2//!
3//! <!-- wiki: makeover-immediate -->
4//!
5//! Every other module here draws *with* the palette. This one hands the
6//! palette to egui itself, so that the widgets egui draws on its own account
7//! — a button's fill, a text field's well, a scrollbar, a checkbox, the frame
8//! around a window — wear the same theme as the ones this crate paints.
9//!
10//! # Why this is not the app's job, though it has been
11//!
12//! [`Palette`] is twenty-five colours and nothing else, so before this module
13//! an app got the colours for *described* content and inherited egui's own
14//! defaults for everything else. That is not a small residue. audiofiles
15//! discovered it three separate times in one afternoon, each time as a
16//! defect found by measurement rather than by reading:
17//!
18//! - every control was 18 points high, egui's `interact_size`, against a
19//!   24-point floor;
20//! - every table column heading was 9 points, egui's `TextStyle::Small`,
21//!   under the 11 points at which text stops being readable;
22//! - every window offered a collapse triangle, egui's default, on screens
23//!   with no collapsed state to show.
24//!
25//! None of those were choices. They were what egui ships with, surviving
26//! because nothing had said otherwise, and the app that found them then
27//! wrote about a hundred and seventy lines of mapping by hand. The next
28//! consumer would have written them again, differently, and a fourth defect
29//! would have waited for a fourth measurement.
30//!
31//! # What is here and what is deliberately not
32//!
33//! [`visuals`] is the colour half: every fill, stroke, shadow and corner egui
34//! draws from, derived from a [`Palette`]. [`spacing`] and [`text_styles`]
35//! are the metric half, derived from `makeover-geometry`'s scale, and
36//! [`style`] composes all three.
37//!
38//! What is *not* here is anything an app should still decide. The density it
39//! draws at, the corner radius its buttons take, which theme is loaded: those
40//! are arguments. This module maps a resolved design system onto egui and
41//! holds no opinion of its own.
42
43use egui::{Color32, CornerRadius, Shadow, Stroke, Visuals};
44use makeover_geometry::{Density, Gap, Step, Surface, Text};
45
46use crate::Palette;
47
48/// The focus ring's width, in points.
49///
50/// egui's default is a one-point contrast hairline that is very hard to see
51/// against a themed surface. A ring is the only thing telling a keyboard user
52/// where they are, so it is drawn at a width that survives being looked at.
53const FOCUS_STROKE: f32 = 1.5;
54
55/// Whether this palette's page is a light surface.
56///
57/// Decides which of egui's two presets the visuals start from, and that is
58/// not cosmetic even though every widget colour below is overridden:
59/// `RichText::strong()` resolves through `dark_mode`, so starting from the
60/// wrong one paints emphasised text white on a light page.
61///
62/// Perceptual luminance rather than a plain average, so a saturated blue page
63/// is not mistaken for a light one.
64#[must_use]
65pub fn is_light(palette: &Palette) -> bool {
66    let channel = |c: u8| {
67        let c = f32::from(c) / 255.0;
68        if c <= 0.040_45 {
69            c / 12.92
70        } else {
71            ((c + 0.055) / 1.055).powf(2.4)
72        }
73    };
74    let page = palette.page;
75    let luminance =
76        0.2126 * channel(page.r()) + 0.7152 * channel(page.g()) + 0.0722 * channel(page.b());
77    luminance > 0.5
78}
79
80/// Mix two colours, `t` of the way from `a` to `b`.
81fn mix(from: Color32, to: Color32, at: f32) -> Color32 {
82    let channel = |from: u8, to: u8| {
83        let (from, to) = (f32::from(from), f32::from(to));
84        #[expect(
85            clippy::cast_possible_truncation,
86            clippy::cast_sign_loss,
87            reason = "a mix of two channel values is inside 0..=255 by construction"
88        )]
89        let mixed = (from + (to - from) * at).round() as u8;
90        mixed
91    };
92    Color32::from_rgb(
93        channel(from.r(), to.r()),
94        channel(from.g(), to.g()),
95        channel(from.b(), to.b()),
96    )
97}
98
99/// The colours egui draws its own widgets with, from this palette.
100///
101/// `radius` is the app's own container radius, because how round a thing is
102/// is a house style rather than a fact about the palette. Square is a
103/// defensible default and so is not: this crate does not pick.
104///
105/// Three choices here are worth their reasoning, because each was arrived at
106/// by getting it wrong first.
107///
108/// **The pressed fill stays neutral rather than taking the action colour.**
109/// egui derives `strong_text_color()` from `widgets.active.fg_stroke`, so an
110/// accent-coloured pressed background forces the foreground to contrast with
111/// the accent, and that colour is then what every `RichText::strong()` in the
112/// application is painted in. The accent shows up in the focus ring instead,
113/// where it belongs.
114///
115/// **`weak_bg_fill` is set alongside every `bg_fill`.** egui's `Button` paints
116/// from the weak one and only reaches for `bg_fill` once hovered, so a theme
117/// that sets only `bg_fill` leaves every resting button at the preset default:
118/// near-black under a light theme, near-white under a dark one.
119///
120/// **Nothing expands on hover.** A widget that swells a point under the cursor
121/// drags its own bevel outward while the surface beneath it stays put, which
122/// fights the light model the bevel pair describes. State change is carried by
123/// the bevel inverting, which is a stronger signal and costs no layout.
124#[must_use]
125pub fn visuals(palette: &Palette, radius: CornerRadius) -> Visuals {
126    let mut visuals = if is_light(palette) {
127        Visuals::light()
128    } else {
129        Visuals::dark()
130    };
131
132    visuals.panel_fill = palette.overlay;
133    visuals.window_fill = palette.overlay;
134    // The thing you look *into*, a text edit above all, is the well rather
135    // than the page. `widget::field` fills from the well too, so anything else
136    // here would put a described field and a hand-built one at two colours.
137    visuals.extreme_bg_color = palette.well;
138    visuals.faint_bg_color = mix(palette.page, palette.overlay, 0.3);
139
140    visuals.selection.bg_fill = mix(palette.page, palette.action, 0.3);
141    visuals.selection.stroke = Stroke::new(FOCUS_STROKE, palette.action);
142
143    visuals.widgets.noninteractive.bg_fill = palette.overlay;
144    visuals.widgets.inactive.bg_fill = mix(palette.overlay, palette.sunken, 0.3);
145    visuals.widgets.hovered.bg_fill = palette.sunken;
146    visuals.widgets.active.bg_fill = mix(palette.sunken, palette.content, 0.15);
147
148    visuals.widgets.noninteractive.weak_bg_fill = visuals.widgets.noninteractive.bg_fill;
149    visuals.widgets.inactive.weak_bg_fill = visuals.widgets.inactive.bg_fill;
150    visuals.widgets.hovered.weak_bg_fill = visuals.widgets.hovered.bg_fill;
151    visuals.widgets.active.weak_bg_fill = visuals.widgets.active.bg_fill;
152    visuals.widgets.open.weak_bg_fill = visuals.widgets.inactive.bg_fill;
153
154    visuals.widgets.noninteractive.fg_stroke = Stroke::new(1.0, palette.content_secondary);
155    visuals.widgets.inactive.fg_stroke = Stroke::new(1.0, palette.content);
156    visuals.widgets.hovered.fg_stroke = Stroke::new(1.0, palette.content);
157    visuals.widgets.active.fg_stroke = Stroke::new(1.0, palette.content);
158    visuals.widgets.open.fg_stroke = Stroke::new(1.0, palette.content);
159
160    visuals.window_stroke = Stroke::new(1.0, palette.border);
161    visuals.widgets.inactive.bg_stroke =
162        Stroke::new(0.5, mix(palette.border, palette.overlay, 0.3));
163    visuals.widgets.hovered.bg_stroke = Stroke::new(1.0, palette.border);
164    visuals.widgets.active.bg_stroke = Stroke::new(1.0, palette.action);
165    visuals.widgets.noninteractive.bg_stroke =
166        Stroke::new(0.5, mix(palette.border, palette.overlay, 0.4));
167
168    visuals.widgets.noninteractive.corner_radius = radius;
169    visuals.widgets.inactive.corner_radius = radius;
170    visuals.widgets.hovered.corner_radius = radius;
171    visuals.widgets.active.corner_radius = radius;
172    visuals.widgets.open.corner_radius = radius;
173    visuals.window_corner_radius = radius;
174    visuals.menu_corner_radius = radius;
175
176    // A hard shadow, offset down and right, because that is where the light
177    // the bevel pair assumes is coming from. A blur is a different design
178    // language and reads as a soft web card rather than a physical one.
179    let shadow = Shadow {
180        offset: [2, 2],
181        blur: 0,
182        spread: 0,
183        color: palette.elevation,
184    };
185    visuals.window_shadow = shadow;
186    visuals.popup_shadow = shadow;
187
188    visuals.widgets.hovered.expansion = 0.0;
189    visuals.widgets.active.expansion = 0.0;
190
191    visuals
192}
193
194/// The smallest a control may be painted, in points.
195///
196/// WCAG 2.5.8 and the Mac HIG's floor. egui's own `interact_size` is 18,
197/// which pads out to about 22 on a button, and no one chose either number.
198const TARGET: f32 = 24.0;
199
200/// egui's spacing, from the relational scale.
201///
202/// The mapping is nearly one for one. `item_spacing` separates controls of a
203/// kind, so [`Gap::Peer`] across and [`Gap::Bound`] down, because a stacked
204/// row sits closer to its neighbour than a side-by-side one does.
205/// `button_padding` is the distance from a button's edge to its label, Peer
206/// across and a hair down because a button is wider than it is tall.
207/// `window_margin` is a panel's inner margin, which is a group. `indent`
208/// offsets a child from its parent, a hierarchy rather than a separation, so
209/// it is the width that reads as one pane at this type size.
210///
211/// `interact_size.y` is the one number here that is not a relationship: it is
212/// the target floor, and it is a minimum rather than a size. The width is
213/// left at egui's own, which is about `DragValue` and friends; forcing it on
214/// every control would stretch a one-word button for nothing a reader gains.
215#[must_use]
216pub fn spacing(surface: &Surface, density: Density) -> egui::style::Spacing {
217    #[expect(
218        clippy::cast_precision_loss,
219        reason = "a gap is a small whole number of points; the scale tops out at 32"
220    )]
221    let gap = |g: Gap| surface.gap(g, density) as f32;
222    let default = egui::style::Spacing::default();
223    egui::style::Spacing {
224        item_spacing: egui::vec2(gap(Gap::Peer), gap(Gap::Bound)),
225        button_padding: egui::vec2(gap(Gap::Peer), surface.resolve(Step::Hair.ratio())),
226        window_margin: egui::vec2(gap(Gap::Group), gap(Gap::Group)).into(),
227        indent: gap(Gap::Pane),
228        interact_size: egui::vec2(default.interact_size.x, TARGET),
229        ..default
230    }
231}
232
233/// egui's text styles, from the type scale.
234///
235/// egui ships five roles and the scale has nine, so this maps the five onto
236/// the nearest role rather than inventing names: `Small` is [`Text::Fine`],
237/// `Body` and `Button` are [`Text::Body`], `Monospace` is [`Text::Note`]
238/// because a monospaced face reads larger at the same size, and `Heading` is
239/// [`Text::Head`].
240///
241/// `Small` is the one that has bitten. egui defaults it to 9 points, which is
242/// under the 11 at which text stops being readable, and
243/// [`table`](crate::table) sets every column heading in it. The scale has
244/// nothing below `Fine` on purpose: if a thing is worth painting it is worth
245/// three quarters of the base.
246#[must_use]
247pub fn text_styles(surface: &Surface) -> std::collections::BTreeMap<egui::TextStyle, egui::FontId> {
248    use egui::{FontFamily, FontId, TextStyle};
249    let size = |role: Text| surface.resolve(role.ratio());
250    [
251        (
252            TextStyle::Small,
253            FontId::new(size(Text::Fine), FontFamily::Proportional),
254        ),
255        (
256            TextStyle::Body,
257            FontId::new(size(Text::Body), FontFamily::Proportional),
258        ),
259        (
260            TextStyle::Button,
261            FontId::new(size(Text::Body), FontFamily::Proportional),
262        ),
263        (
264            TextStyle::Monospace,
265            FontId::new(size(Text::Note), FontFamily::Monospace),
266        ),
267        (
268            TextStyle::Heading,
269            FontId::new(size(Text::Head), FontFamily::Proportional),
270        ),
271    ]
272    .into()
273}
274
275/// The whole design system as one egui `Style`.
276///
277/// The three above, composed. An app that wants all of it calls this and sets
278/// the result; an app that wants to keep one of its own calls the pieces.
279#[must_use]
280pub fn style(
281    palette: &Palette,
282    surface: &Surface,
283    density: Density,
284    radius: CornerRadius,
285) -> egui::Style {
286    egui::Style {
287        visuals: visuals(palette, radius),
288        spacing: spacing(surface, density),
289        text_styles: text_styles(surface),
290        ..egui::Style::default()
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    /// A palette with every field distinct, so a mapping that reads the
299    /// wrong token shows up as the wrong colour rather than as a coincidence.
300    fn palette(page: Color32, content: Color32) -> Palette {
301        Palette {
302            page,
303            raised: Color32::from_rgb(2, 2, 2),
304            overlay: Color32::from_rgb(3, 3, 3),
305            well: Color32::from_rgb(4, 4, 4),
306            sunken: Color32::from_rgb(5, 5, 5),
307            bevel_light: Color32::from_rgb(6, 6, 6),
308            bevel_dark: Color32::from_rgb(7, 7, 7),
309            elevation: Color32::from_rgb(8, 8, 8),
310            content,
311            content_secondary: Color32::from_rgb(10, 10, 10),
312            content_muted: Color32::from_rgb(11, 11, 11),
313            action: Color32::from_rgb(12, 12, 12),
314            content_on_action: Color32::from_rgb(250, 250, 250),
315            danger: Color32::from_rgb(13, 13, 13),
316            success: Color32::from_rgb(14, 14, 14),
317            warning: Color32::from_rgb(15, 15, 15),
318            info: Color32::from_rgb(16, 16, 16),
319            border: Color32::from_rgb(17, 17, 17),
320            info_surface: Color32::from_rgb(18, 18, 18),
321            success_surface: Color32::from_rgb(19, 19, 19),
322            warning_surface: Color32::from_rgb(20, 20, 20),
323            danger_surface: Color32::from_rgb(21, 21, 21),
324            row_stripe: Color32::from_rgb(22, 22, 22),
325            row_hover: Color32::from_rgb(23, 23, 23),
326            row_rule: Color32::from_rgb(24, 24, 24),
327            row_selected: Color32::from_rgb(25, 25, 25),
328        }
329    }
330
331    fn light() -> Palette {
332        palette(Color32::from_rgb(200, 200, 200), Color32::BLACK)
333    }
334
335    fn dark() -> Palette {
336        palette(
337            Color32::from_rgb(26, 27, 38),
338            Color32::from_rgb(192, 202, 245),
339        )
340    }
341
342    fn surface() -> Surface {
343        Surface {
344            base: f32::from(makeover_geometry::DEFAULT_BASE_PX),
345            quantum: 1.0,
346        }
347    }
348
349    #[test]
350    fn a_light_page_starts_from_the_light_preset_and_a_dark_one_does_not() {
351        // Not cosmetic even though every widget colour is overridden below it:
352        // `RichText::strong()` resolves through `dark_mode`, so the wrong
353        // preset paints emphasised text white on a light page.
354        assert!(is_light(&light()));
355        assert!(!is_light(&dark()));
356        assert!(!visuals(&light(), CornerRadius::ZERO).dark_mode);
357        assert!(visuals(&dark(), CornerRadius::ZERO).dark_mode);
358    }
359
360    #[test]
361    fn a_saturated_page_is_judged_by_luminance_and_not_by_an_average() {
362        // A plain channel average calls a saturated blue light. Perceptual
363        // luminance does not, and green is what carries the weight.
364        let blue = palette(Color32::from_rgb(0, 0, 255), Color32::WHITE);
365        assert!(!is_light(&blue));
366    }
367
368    #[test]
369    fn every_widget_state_carries_a_weak_fill_as_well_as_a_fill() {
370        // egui's `Button` paints from `weak_bg_fill` and only reaches for
371        // `bg_fill` once hovered, so a theme that sets one and not the other
372        // leaves every resting button at the preset default: near-black under
373        // a light theme, near-white under a dark one.
374        let v = visuals(&light(), CornerRadius::ZERO);
375        for (name, w) in [
376            ("noninteractive", &v.widgets.noninteractive),
377            ("inactive", &v.widgets.inactive),
378            ("hovered", &v.widgets.hovered),
379            ("active", &v.widgets.active),
380        ] {
381            assert_eq!(w.weak_bg_fill, w.bg_fill, "{name} disagrees with itself");
382        }
383    }
384
385    #[test]
386    fn the_pressed_fill_is_not_the_action_colour() {
387        // egui derives `strong_text_color()` from `widgets.active.fg_stroke`,
388        // so an accent-coloured pressed background forces the foreground to
389        // contrast with the accent, and that colour is then what every
390        // `RichText::strong()` in the app is painted in. The accent belongs
391        // in the focus ring, which is where this puts it.
392        let p = light();
393        let v = visuals(&p, CornerRadius::ZERO);
394        assert_ne!(v.widgets.active.bg_fill, p.action);
395        assert_eq!(v.widgets.active.fg_stroke.color, p.content);
396        assert_eq!(v.selection.stroke.color, p.action);
397    }
398
399    #[test]
400    fn nothing_swells_under_the_cursor() {
401        // An expanding widget drags its own bevel outward while the surface
402        // beneath it stays put, which fights the light model the bevel pair
403        // describes.
404        let v = visuals(&light(), CornerRadius::ZERO);
405        assert!(v.widgets.hovered.expansion.abs() < f32::EPSILON);
406        assert!(v.widgets.active.expansion.abs() < f32::EPSILON);
407    }
408
409    #[test]
410    fn a_control_clears_the_target_floor_and_text_clears_the_readable_one() {
411        // The two defects this module exists to stop a consumer rediscovering.
412        // egui ships 18 and 9; both are under a floor and neither was chosen.
413        let s = spacing(&surface(), Density::Pointer);
414        assert!(
415            s.interact_size.y >= 24.0,
416            "interact_size {} is under the target floor",
417            s.interact_size.y
418        );
419
420        let styles = text_styles(&surface());
421        for (role, font) in &styles {
422            assert!(
423                font.size >= 11.0,
424                "{role:?} is {} points, under the readable floor",
425                font.size
426            );
427        }
428    }
429
430    #[test]
431    fn the_scale_orders_the_text_roles() {
432        // Small below body, heading above it. A mapping that inverted one of
433        // these would still clear the floor above and be wrong.
434        let styles = text_styles(&surface());
435        let size = |role: &egui::TextStyle| styles[role].size;
436        assert!(size(&egui::TextStyle::Small) < size(&egui::TextStyle::Body));
437        assert!(size(&egui::TextStyle::Heading) > size(&egui::TextStyle::Body));
438    }
439
440    #[test]
441    fn the_composed_style_is_the_three_pieces() {
442        let p = light();
443        let radius = CornerRadius::same(4);
444        let composed = style(&p, &surface(), Density::Pointer, radius);
445        assert_eq!(composed.visuals, visuals(&p, radius));
446        assert_eq!(composed.spacing, spacing(&surface(), Density::Pointer));
447        assert_eq!(composed.text_styles, text_styles(&surface()));
448    }
449}