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}