frus_widgets/theme.rs
1//! The theme: *design tokens* (colors, radius, spacing) injected into rendering.
2//!
3//! The theme is handed to [`crate::build_ui`] and passed on to `Widget::paint`;
4//! widgets use it for their default values (text color, text fields, scrollbars…),
5//! without preventing an explicit override.
6
7use frus_core::{Color, FontWeight, TextDirection, TextStyle};
8
9use crate::interaction::Status;
10use crate::media::Brightness;
11
12/// **Which of an application's themes is on display** (`app.dart:57`).
13///
14/// An application supplies a light theme and, if it has one, a dark theme; this says
15/// which of the two the framework picks. It is a *question about the application*, not
16/// about the device: [`Brightness`](crate::Brightness) is what the platform reports, and
17/// [`System`](Self::System) is the mode that agrees to follow it.
18///
19/// The framework resolves this once a frame and fades between the answers, so an
20/// application never has to read the platform's brightness or write a crossfade of its
21/// own.
22#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
23pub enum ThemeMode {
24 /// Follow the platform: the dark theme where the system asks for a dark interface,
25 /// the light one otherwise. **The default**, and the answer most applications want.
26 #[default]
27 System,
28 /// The light theme, whatever the platform says.
29 Light,
30 /// The dark theme, whatever the platform says — falling back to the light one when
31 /// the application has no dark theme to give.
32 Dark,
33}
34
35impl ThemeMode {
36 /// Does this mode follow the platform?
37 pub const fn is_system(self) -> bool {
38 matches!(self, ThemeMode::System)
39 }
40
41 /// Does this mode pin the light theme?
42 pub const fn is_light(self) -> bool {
43 matches!(self, ThemeMode::Light)
44 }
45
46 /// Does this mode pin the dark theme?
47 pub const fn is_dark(self) -> bool {
48 matches!(self, ThemeMode::Dark)
49 }
50
51 /// **Does this mode want a dark interface** on a platform reporting `brightness`?
52 ///
53 /// The one line the whole light/dark decision comes down to (`app.dart:1000`), and
54 /// the reason it lives here rather than in the shell: an application that shows a
55 /// *theme* setting with a *System* entry needs the same answer to tick the right row.
56 pub const fn wants_dark(self, brightness: Brightness) -> bool {
57 match self {
58 ThemeMode::Dark => true,
59 ThemeMode::Light => false,
60 ThemeMode::System => matches!(brightness, Brightness::Dark),
61 }
62 }
63}
64
65/// The **named** typographic scale (Material 3's 15 steps). Widgets pick a step
66/// (`theme.text.title_medium`), never a hardcoded size — changing the scale
67/// retypesets the whole app. The colors stay inherited (`None` → resolved against
68/// the theme at paint time).
69#[derive(Clone, Copy, Debug, PartialEq)]
70pub struct TextTheme {
71 /// The largest step of the display group: short, prominent text such as a hero figure.
72 pub display_large: TextStyle,
73 /// The middle step of the display group: short, prominent text such as a hero figure.
74 pub display_medium: TextStyle,
75 /// The smallest step of the display group: short, prominent text such as a hero figure.
76 pub display_small: TextStyle,
77 /// The largest step of the headline group: the heading of a screen or of a large section.
78 pub headline_large: TextStyle,
79 /// The middle step of the headline group: the heading of a screen or of a large section.
80 pub headline_medium: TextStyle,
81 /// The smallest step of the headline group: the heading of a screen or of a large section.
82 pub headline_small: TextStyle,
83 /// The largest step of the title group: the title of a smaller piece of content: a card, a dialog, a row.
84 pub title_large: TextStyle,
85 /// The middle step of the title group: the title of a smaller piece of content: a card, a dialog, a row.
86 pub title_medium: TextStyle,
87 /// The smallest step of the title group: the title of a smaller piece of content: a card, a dialog, a row.
88 pub title_small: TextStyle,
89 /// The largest step of the body group: running text meant to be read.
90 pub body_large: TextStyle,
91 /// The middle step of the body group: running text meant to be read.
92 pub body_medium: TextStyle,
93 /// The smallest step of the body group: running text meant to be read.
94 pub body_small: TextStyle,
95 /// The largest step of the label group: the text on a control: a button, a tab, a caption.
96 pub label_large: TextStyle,
97 /// The middle step of the label group: the text on a control: a button, a tab, a caption.
98 pub label_medium: TextStyle,
99 /// The smallest step of the label group: the text on a control: a button, a tab, a caption.
100 pub label_small: TextStyle,
101}
102
103impl TextTheme {
104 /// The reference Material 3 scale (sizes in logical pixels; the title and label
105 /// steps carry a medium weight, as the spec has it).
106 ///
107 /// A **const**, and that matters: a widget measured with no theme in hand — the
108 /// un-themed [`Widget::style`](crate::Widget::style) path the transparent wrappers
109 /// take — reads its step from *this* rather than from a private constant of its own.
110 /// Twelve widgets used to carry their own number, and one of them had drifted two
111 /// pixels from the reference without anybody being able to see it.
112 pub const M3: Self = Self {
113 // The reference's English-like 2021 scale (`typography.dart:2097`): size, weight,
114 // letter spacing and line height, step by step (milestone 624).
115 display_large: TextStyle::new(57.0).letter_spacing(-0.25).height(1.12),
116 display_medium: TextStyle::new(45.0).letter_spacing(0.0).height(1.16),
117 display_small: TextStyle::new(36.0).letter_spacing(0.0).height(1.22),
118 headline_large: TextStyle::new(32.0).letter_spacing(0.0).height(1.25),
119 headline_medium: TextStyle::new(28.0).letter_spacing(0.0).height(1.29),
120 headline_small: TextStyle::new(24.0).letter_spacing(0.0).height(1.33),
121 title_large: TextStyle::new(22.0).letter_spacing(0.0).height(1.27),
122 title_medium: TextStyle::new(16.0)
123 .weight(FontWeight::Medium)
124 .letter_spacing(0.15)
125 .height(1.50),
126 title_small: TextStyle::new(14.0)
127 .weight(FontWeight::Medium)
128 .letter_spacing(0.1)
129 .height(1.43),
130 body_large: TextStyle::new(16.0).letter_spacing(0.5).height(1.50),
131 body_medium: TextStyle::new(14.0).letter_spacing(0.25).height(1.43),
132 body_small: TextStyle::new(12.0).letter_spacing(0.4).height(1.33),
133 label_large: TextStyle::new(14.0)
134 .weight(FontWeight::Medium)
135 .letter_spacing(0.1)
136 .height(1.43),
137 label_medium: TextStyle::new(12.0)
138 .weight(FontWeight::Medium)
139 .letter_spacing(0.5)
140 .height(1.33),
141 label_small: TextStyle::new(11.0)
142 .weight(FontWeight::Medium)
143 .letter_spacing(0.5)
144 .height(1.45),
145 };
146}
147
148impl Default for TextTheme {
149 fn default() -> Self {
150 Self::M3
151 }
152}
153
154/// The type scale a widget measures with: the theme's when it has one, the framework's
155/// own when it does not.
156///
157/// `None` is the un-themed [`Widget::style`](crate::Widget::style) path. It answers from
158/// the *same* scale as the themed one — the point of milestone 413 being that a widget
159/// never decides its own type, and a fallback constant beside the scale would be that
160/// decision taken back.
161pub(crate) fn type_scale(theme: Option<&Theme>) -> TextTheme {
162 theme.map_or(TextTheme::M3, |t| t.text)
163}
164
165/// The **color roles** (Material 3) — the **source of truth** for the theme's
166/// colors. Widgets reference roles, never literal colors: changing scheme recolors
167/// the whole app and guarantees the contrast of the `X`/`on_X` pairs. Written by
168/// hand for light and dark; `from_seed` (HCT) comes after.
169#[derive(Clone, Copy, Debug, PartialEq)]
170pub struct ColorScheme {
171 /// **Whether this scheme is a light one or a dark one** (`color_scheme.dart`).
172 ///
173 /// It is written down rather than worked out. The surface's luminance is *usually*
174 /// the same answer, and "usually" is the problem: a dim scheme built from a dark seed
175 /// can sit either side of the halfway line, and a widget picking its opacities from a
176 /// guess picks differently on two schemes a designer considers equally dark.
177 ///
178 /// The reference's scrollbar reads exactly this field to choose the opacity of its
179 /// thumb (`scrollbar.dart:232`); this framework's was guessing at it.
180 pub brightness: Brightness,
181 /// The app's main accent: the colour of prominent controls, such as a filled button, and
182 /// of active states.
183 pub primary: Color,
184 /// What is legible on [`primary`](Self::primary).
185 pub on_primary: Color,
186 /// A quieter fill in the primary hue, for larger surfaces such as a tonal button.
187 pub primary_container: Color,
188 /// What is legible on [`primary_container`](Self::primary_container).
189 pub on_primary_container: Color,
190 /// **A container that does not change when the theme does** (`color_scheme.dart:90`).
191 ///
192 /// Every other accent role is answered twice: once for a light scheme and once for a
193 /// dark one. These four are answered **once**, and both schemes give the same answer.
194 ///
195 /// That is not a shortcut, it is the whole point. A card carrying a brand colour, a
196 /// header the marketing department chose, an onboarding page whose illustration was
197 /// drawn against one particular green — anything that has to keep its colour when
198 /// the reader turns the lights off has nowhere to sit in a scheme where every role
199 /// moves. `primary_container` is the right *emphasis* for those and the wrong
200 /// *promise*. This is the same emphasis with the promise attached.
201 ///
202 /// Tone 90 of the primary palette, which is where a light scheme's
203 /// `primary_container` already sits — so in a light theme it costs nothing, and in
204 /// a dark one it is the thing that stayed put.
205 pub primary_fixed: Color,
206 /// The **stronger** of the pair, for what needs more emphasis than
207 /// [`primary_fixed`](Self::primary_fixed) without giving up the promise. Tone 80,
208 /// which is a dark scheme's `primary`.
209 pub primary_fixed_dim: Color,
210 /// What is legible on both of them — tone 10, dark enough to read on the dimmer of
211 /// the two.
212 pub on_primary_fixed: Color,
213 /// The quieter of the two things that can be written there: a subtitle where
214 /// [`on_primary_fixed`](Self::on_primary_fixed) is the title. Tone 30.
215 pub on_primary_fixed_variant: Color,
216 /// The supporting accent, for the less prominent controls: a filter chip, say.
217 pub secondary: Color,
218 /// What is legible on [`secondary`](Self::secondary).
219 pub on_secondary: Color,
220 /// A quieter fill in the secondary hue, for larger surfaces.
221 pub secondary_container: Color,
222 /// What is legible on [`secondary_container`](Self::secondary_container).
223 pub on_secondary_container: Color,
224 /// The supporting accent's fixed container {em} see
225 /// [`primary_fixed`](Self::primary_fixed) for what "fixed" promises.
226 pub secondary_fixed: Color,
227 /// The stronger of that pair.
228 pub secondary_fixed_dim: Color,
229 /// What is legible on both.
230 pub on_secondary_fixed: Color,
231 /// The quieter thing that can be written there.
232 pub on_secondary_fixed_variant: Color,
233 /// A **third** accent, for what is neither the app's main colour nor its supporting
234 /// one: a highlight that has to stand apart from both. Generated a sixth of the way
235 /// round the wheel from the seed, which is what keeps it from reading as either.
236 pub tertiary: Color,
237 /// What is legible on [`tertiary`](Self::tertiary).
238 pub on_tertiary: Color,
239 /// A quieter fill in the tertiary hue, for larger surfaces.
240 pub tertiary_container: Color,
241 /// What is legible on [`tertiary_container`](Self::tertiary_container).
242 pub on_tertiary_container: Color,
243 /// The third accent's fixed container {em} see
244 /// [`primary_fixed`](Self::primary_fixed) for what "fixed" promises.
245 pub tertiary_fixed: Color,
246 /// The stronger of that pair.
247 pub tertiary_fixed_dim: Color,
248 /// What is legible on both.
249 pub on_tertiary_fixed: Color,
250 /// The quieter thing that can be written there.
251 pub on_tertiary_fixed_variant: Color,
252 /// The colour of the page behind everything.
253 pub background: Color,
254 /// The base colour of the surfaces that sit on the page: cards, sheets, dialogs.
255 pub surface: Color,
256 /// What is legible on [`surface`](Self::surface) and on the background: the main text and
257 /// icon colour.
258 pub on_surface: Color,
259 /// A discreet tonal surface (zone backgrounds, tracks).
260 pub surface_variant: Color,
261 /// Secondary content on surfaces (the historical `muted`).
262 pub on_surface_variant: Color,
263 /// The container ladder's **lowest** rung: the least emphasis against the
264 /// surface. Its neighbour rather than its opposite — in a dark scheme it is
265 /// *darker* than the surface, as the reference's is.
266 pub surface_container_lowest: Color,
267 /// Less emphasis than [`Self::surface_container`]: cards off the page, banners,
268 /// drawers, sheets.
269 pub surface_container_low: Color,
270 /// A distinct area **within** the surface: menus, navigation bars.
271 pub surface_container: Color,
272 /// More emphasis than [`Self::surface_container`]: dialogs, search views.
273 pub surface_container_high: Color,
274 /// The **most** emphasis against the surface: filled cards, filled fields.
275 pub surface_container_highest: Color,
276 /// An inverted surface (toasts and snackbars that stand out from the background).
277 pub inverse_surface: Color,
278 /// What is legible on [`inverse_surface`](Self::inverse_surface).
279 pub on_inverse_surface: Color,
280 /// Outlines at rest.
281 pub outline: Color,
282 /// Discreet outlines (thin separators).
283 pub outline_variant: Color,
284 /// The colour of errors: an invalid field, a failure.
285 pub error: Color,
286 /// What is legible on [`error`](Self::error).
287 pub on_error: Color,
288 /// The **quiet** form of `error`: a field's error surface, a warning that has to be
289 /// read rather than shouted. `on_error_container` is what is legible on it — and it
290 /// is what an errored field's border, label and helper take, not `error` itself
291 /// (`input_decorator.dart:5981`).
292 pub error_container: Color,
293 /// What is legible on [`error_container`](Self::error_container).
294 pub on_error_container: Color,
295 /// The accent as it must be drawn **on `inverse_surface`**: a snack bar's action
296 /// (`snack_bar.dart:954`). `primary` on an inverted surface is the pair the scheme
297 /// guarantees nothing about, which is exactly why this role exists.
298 pub inverse_primary: Color,
299 /// What a raised surface is tinted **towards** as it lifts — `primary` in Material 3.
300 /// The elevation model there is a tint, not a shadow, and this is the colour it tints
301 /// with (`bottom_app_bar.dart:301`).
302 pub surface_tint: Color,
303 /// The **darkest** surface in either theme, and the **lightest** — the two ends the
304 /// container ladder runs between.
305 pub surface_dim: Color,
306 /// The **lightest** surface in either theme — the other end of the ladder from
307 /// [`surface_dim`](Self::surface_dim).
308 pub surface_bright: Color,
309 /// The scrim for modals and drawers (the alpha is applied at the point of use).
310 pub scrim: Color,
311 /// The color of drop shadows (the alpha is applied at the point of use).
312 pub shadow: Color,
313}
314
315impl ColorScheme {
316 /// The dark scheme.
317 pub fn dark() -> Self {
318 Self {
319 brightness: Brightness::Dark,
320 primary: Color::rgb8(96, 200, 130),
321 on_primary: Color::rgb8(16, 28, 20),
322 primary_container: Color::rgb8(30, 64, 44),
323 on_primary_container: Color::rgb8(178, 240, 200),
324 // The four fixed roles: tones 90, 80, 10 and 30 of the primary's own hue
325 // and chroma, read off this crate's HCT. They are byte-for-byte the same
326 // in the light scheme — that is what "fixed" means, and a test holds
327 // the two together so a later touch-up of one cannot quietly break it.
328 primary_fixed: Color::rgb8(139, 249, 175),
329 primary_fixed_dim: Color::rgb8(111, 220, 149),
330 on_primary_fixed: Color::rgb8(0, 33, 14),
331 on_primary_fixed_variant: Color::rgb8(0, 82, 43),
332 secondary: Color::rgb8(150, 170, 200),
333 on_secondary: Color::rgb8(20, 26, 36),
334 // Tone 30 of its own hue and chroma, where the reference puts a dark
335 // secondary container and where the light scheme's already sat. This one was
336 // at tone 22 — under the disabled fill it has to beat, so a slider's rail and
337 // a selected segment read as unavailable. Only checkable once milestone 329
338 // resolved that fill in sRGB; before, it was 14 tones adrift.
339 secondary_container: Color::rgb8(63, 71, 88),
340 on_secondary_container: Color::rgb8(205, 220, 240),
341 secondary_fixed: Color::rgb8(215, 226, 255),
342 secondary_fixed_dim: Color::rgb8(178, 199, 243),
343 on_secondary_fixed: Color::rgb8(2, 27, 62),
344 on_secondary_fixed_variant: Color::rgb8(50, 71, 108),
345 // The tertiary family, a sixth of the wheel from the primary at the chroma
346 // the reference's tonal-spot scheme uses (24), read off this crate's own HCT
347 // rather than picked by eye.
348 tertiary: Color::rgb8(162, 206, 217),
349 on_tertiary: Color::rgb8(1, 54, 63),
350 tertiary_container: Color::rgb8(32, 77, 86),
351 on_tertiary_container: Color::rgb8(190, 234, 246),
352 tertiary_fixed: Color::rgb8(190, 234, 247),
353 tertiary_fixed_dim: Color::rgb8(162, 205, 218),
354 on_tertiary_fixed: Color::rgb8(1, 31, 38),
355 on_tertiary_fixed_variant: Color::rgb8(33, 76, 87),
356 background: Color::rgb8(18, 20, 24),
357 surface: Color::rgb8(30, 33, 40),
358 on_surface: Color::rgb8(230, 232, 236),
359 surface_variant: Color::rgb8(38, 42, 52),
360 on_surface_variant: Color::rgb8(150, 156, 168),
361 surface_container_lowest: Color::rgb8(26, 29, 35),
362 surface_container_low: Color::rgb8(34, 37, 45),
363 surface_container: Color::rgb8(36, 40, 48),
364 surface_container_high: Color::rgb8(44, 48, 58),
365 surface_container_highest: Color::rgb8(52, 56, 68),
366 inverse_surface: Color::rgb8(226, 228, 234),
367 on_inverse_surface: Color::rgb8(28, 32, 38),
368 // Tones 60 and 30 of this palette's neutral-variant family, which is where
369 // the reference puts them in a dark scheme. Anything darker collides with a
370 // disabled outline — `on_surface` at 12 % over this surface is tone 24.
371 outline: Color::rgb8(141, 145, 153),
372 outline_variant: Color::rgb8(67, 71, 78),
373 error: Color::rgb8(224, 108, 108),
374 on_error: Color::rgb8(38, 12, 12),
375 error_container: Color::rgb8(130, 37, 41),
376 on_error_container: Color::rgb8(255, 218, 216),
377 inverse_primary: Color::rgb8(2, 109, 56),
378 surface_tint: Color::rgb8(96, 200, 130),
379 surface_dim: Color::rgb8(30, 33, 40),
380 surface_bright: Color::rgb8(57, 61, 74),
381 scrim: Color::BLACK,
382 shadow: Color::BLACK,
383 }
384 }
385
386 /// The light scheme.
387 pub fn light() -> Self {
388 Self {
389 brightness: Brightness::Light,
390 primary: Color::rgb8(46, 160, 96),
391 on_primary: Color::rgb8(255, 255, 255),
392 primary_container: Color::rgb8(200, 238, 214),
393 on_primary_container: Color::rgb8(10, 64, 36),
394 // The four fixed roles: tones 90, 80, 10 and 30 of the primary's own hue
395 // and chroma, read off this crate's HCT. They are byte-for-byte the same
396 // in the dark scheme — that is what "fixed" means, and a test holds
397 // the two together so a later touch-up of one cannot quietly break it.
398 primary_fixed: Color::rgb8(139, 249, 175),
399 primary_fixed_dim: Color::rgb8(111, 220, 149),
400 on_primary_fixed: Color::rgb8(0, 33, 14),
401 on_primary_fixed_variant: Color::rgb8(0, 82, 43),
402 secondary: Color::rgb8(90, 110, 150),
403 on_secondary: Color::rgb8(255, 255, 255),
404 secondary_container: Color::rgb8(220, 228, 244),
405 on_secondary_container: Color::rgb8(30, 42, 66),
406 secondary_fixed: Color::rgb8(215, 226, 255),
407 secondary_fixed_dim: Color::rgb8(178, 199, 243),
408 on_secondary_fixed: Color::rgb8(2, 27, 62),
409 on_secondary_fixed_variant: Color::rgb8(50, 71, 108),
410 tertiary: Color::rgb8(58, 100, 111),
411 on_tertiary: Color::rgb8(255, 255, 255),
412 tertiary_container: Color::rgb8(190, 234, 246),
413 on_tertiary_container: Color::rgb8(1, 31, 38),
414 tertiary_fixed: Color::rgb8(190, 234, 247),
415 tertiary_fixed_dim: Color::rgb8(162, 205, 218),
416 on_tertiary_fixed: Color::rgb8(1, 31, 38),
417 on_tertiary_fixed_variant: Color::rgb8(33, 76, 87),
418 background: Color::rgb8(245, 246, 248),
419 surface: Color::rgb8(255, 255, 255),
420 on_surface: Color::rgb8(28, 32, 38),
421 surface_variant: Color::rgb8(238, 240, 244),
422 on_surface_variant: Color::rgb8(110, 116, 126),
423 surface_container_lowest: Color::rgb8(255, 255, 255),
424 surface_container_low: Color::rgb8(250, 250, 252),
425 surface_container: Color::rgb8(244, 245, 248),
426 surface_container_high: Color::rgb8(238, 240, 244),
427 surface_container_highest: Color::rgb8(232, 235, 240),
428 inverse_surface: Color::rgb8(45, 50, 58),
429 on_inverse_surface: Color::rgb8(240, 242, 246),
430 // Tones 50 and 80, the reference's light-scheme positions. A disabled
431 // outline here is tone 91, so both must sit well below it.
432 outline: Color::rgb8(115, 119, 127),
433 outline_variant: Color::rgb8(195, 198, 207),
434 error: Color::rgb8(200, 64, 64),
435 on_error: Color::rgb8(255, 255, 255),
436 error_container: Color::rgb8(255, 218, 215),
437 on_error_container: Color::rgb8(65, 0, 5),
438 inverse_primary: Color::rgb8(111, 220, 149),
439 surface_tint: Color::rgb8(46, 160, 96),
440 surface_dim: Color::rgb8(223, 228, 234),
441 surface_bright: Color::rgb8(255, 255, 255),
442 scrim: Color::BLACK,
443 shadow: Color::BLACK,
444 }
445 }
446
447 /// Generates a complete scheme from **a seed color** (Material 3 "dynamic
448 /// color", through [HCT](frus_core::Hct)). The seed's hue feeds five tonal
449 /// palettes (primary, secondary, tertiary — not exposed for now — and the
450 /// neutrals); each role is a precise **tone** of its palette, which guarantees
451 /// the contrast of the `X`/`on_X` pairs.
452 ///
453 /// Deliberate departures from the M3 spec: `surface` sits slightly apart from
454 /// `background` (our cards lay a surface over the background, tones 12/6 in
455 /// dark, 100/98 in light) — the 2023 spec conflates them.
456 ///
457 /// The **container ladder** is anchored on *that* surface rather than on the
458 /// spec's. Its five rungs keep the reference's own tonal steps — going toward
459 /// more emphasis, −4, −2, −2, −2 in light and +6, +2, +5, +5 in dark — measured
460 /// from `surface_container`, so every rung stands off this scheme's surface by
461 /// what it stands off the reference's. In light the top rung lands on tone 100,
462 /// which is where this scheme's `surface` already is: the departure showing
463 /// through.
464 pub fn from_seed(seed: Color, dark: bool) -> Self {
465 use frus_core::{Hct, TonalPalette};
466
467 let hct = Hct::from_color(seed);
468 // M3 chromas: the primary keeps the seed's chroma (with a floor of 48), and
469 // the other palettes are muted variations on the hue.
470 let primary = TonalPalette::new(hct.hue, hct.chroma.max(48.0));
471 let secondary = TonalPalette::new(hct.hue, 16.0);
472 // A sixth of the wheel away, at the chroma the reference's tonal-spot scheme
473 // gives a tertiary: far enough from the primary to read as a third thing, close
474 // enough to belong to the same palette.
475 let tertiary = TonalPalette::new(hct.hue + 60.0, 24.0);
476 let neutral = TonalPalette::new(hct.hue, 4.0);
477 let neutral_variant = TonalPalette::new(hct.hue, 8.0);
478 let error = TonalPalette::new(25.0, 84.0);
479
480 let p = |tone: f64| primary.tone(tone);
481 let s = |tone: f64| secondary.tone(tone);
482 let ter = |tone: f64| tertiary.tone(tone);
483 let n = |tone: f64| neutral.tone(tone);
484 let nv = |tone: f64| neutral_variant.tone(tone);
485 let e = |tone: f64| error.tone(tone);
486
487 if dark {
488 Self {
489 brightness: Brightness::Dark,
490 primary: p(80.0),
491 on_primary: p(20.0),
492 primary_container: p(30.0),
493 on_primary_container: p(90.0),
494 // The fixed four. **The same tones in both branches** — they are read
495 // off the palettes, which do not know which theme is being built, so a
496 // seed gives one answer for a light scheme and a dark one and the
497 // promise holds by construction.
498 primary_fixed: p(90.0),
499 primary_fixed_dim: p(80.0),
500 on_primary_fixed: p(10.0),
501 on_primary_fixed_variant: p(30.0),
502 secondary: s(80.0),
503 on_secondary: s(20.0),
504 secondary_container: s(30.0),
505 on_secondary_container: s(90.0),
506 secondary_fixed: s(90.0),
507 secondary_fixed_dim: s(80.0),
508 on_secondary_fixed: s(10.0),
509 on_secondary_fixed_variant: s(30.0),
510 tertiary: ter(80.0),
511 on_tertiary: ter(20.0),
512 tertiary_container: ter(30.0),
513 on_tertiary_container: ter(90.0),
514 tertiary_fixed: ter(90.0),
515 tertiary_fixed_dim: ter(80.0),
516 on_tertiary_fixed: ter(10.0),
517 on_tertiary_fixed_variant: ter(30.0),
518 background: n(6.0),
519 surface: n(12.0),
520 on_surface: n(90.0),
521 surface_variant: nv(20.0),
522 on_surface_variant: nv(80.0),
523 surface_container_lowest: n(9.0),
524 surface_container_low: n(15.0),
525 surface_container: n(17.0),
526 surface_container_high: n(22.0),
527 surface_container_highest: n(27.0),
528 inverse_surface: n(90.0),
529 on_inverse_surface: n(20.0),
530 outline: nv(60.0),
531 outline_variant: nv(30.0),
532 error: e(80.0),
533 on_error: e(20.0),
534 error_container: e(30.0),
535 on_error_container: e(90.0),
536 // The accent as it reads on the inverted surface: the *other* theme's
537 // tone of the same palette, which is what an inverted surface is.
538 inverse_primary: p(40.0),
539 surface_tint: p(80.0),
540 surface_dim: n(12.0),
541 surface_bright: n(30.0),
542 scrim: n(0.0),
543 shadow: n(0.0),
544 }
545 } else {
546 Self {
547 brightness: Brightness::Light,
548 primary: p(40.0),
549 on_primary: p(100.0),
550 primary_container: p(90.0),
551 on_primary_container: p(10.0),
552 primary_fixed: p(90.0),
553 primary_fixed_dim: p(80.0),
554 on_primary_fixed: p(10.0),
555 on_primary_fixed_variant: p(30.0),
556 secondary: s(40.0),
557 on_secondary: s(100.0),
558 secondary_container: s(90.0),
559 on_secondary_container: s(10.0),
560 secondary_fixed: s(90.0),
561 secondary_fixed_dim: s(80.0),
562 on_secondary_fixed: s(10.0),
563 on_secondary_fixed_variant: s(30.0),
564 tertiary: ter(40.0),
565 on_tertiary: ter(100.0),
566 tertiary_container: ter(90.0),
567 on_tertiary_container: ter(10.0),
568 tertiary_fixed: ter(90.0),
569 tertiary_fixed_dim: ter(80.0),
570 on_tertiary_fixed: ter(10.0),
571 on_tertiary_fixed_variant: ter(30.0),
572 background: n(98.0),
573 surface: n(100.0),
574 on_surface: n(10.0),
575 surface_variant: nv(94.0),
576 on_surface_variant: nv(30.0),
577 surface_container_lowest: n(100.0),
578 surface_container_low: n(98.0),
579 surface_container: n(96.0),
580 surface_container_high: n(94.0),
581 surface_container_highest: n(92.0),
582 inverse_surface: n(20.0),
583 on_inverse_surface: n(95.0),
584 outline: nv(50.0),
585 outline_variant: nv(80.0),
586 error: e(40.0),
587 on_error: e(100.0),
588 error_container: e(90.0),
589 on_error_container: e(10.0),
590 inverse_primary: p(80.0),
591 surface_tint: p(40.0),
592 surface_dim: n(89.0),
593 surface_bright: n(100.0),
594 scrim: n(0.0),
595 shadow: n(0.0),
596 }
597 }
598 }
599
600 /// Interpolates role by role toward `other` (the light/dark switch fade).
601 pub fn lerp(&self, other: &ColorScheme, t: f32) -> ColorScheme {
602 let c = |a: Color, b: Color| a.lerp(b, t);
603 ColorScheme {
604 // Discrete, like the text direction: halfway between light and dark is not a
605 // third brightness, and a widget reading it mid-crossing wants the answer it
606 // will still be right about when the crossing ends.
607 brightness: other.brightness,
608 primary: c(self.primary, other.primary),
609 on_primary: c(self.on_primary, other.on_primary),
610 primary_container: c(self.primary_container, other.primary_container),
611 on_primary_container: c(self.on_primary_container, other.on_primary_container),
612 // The fixed four take part like any other role. A light/dark crossing moves
613 // them nowhere, because both ends hold the same colour — but a **palette**
614 // crossing does, and there they have to travel with everything else.
615 primary_fixed: c(self.primary_fixed, other.primary_fixed),
616 primary_fixed_dim: c(self.primary_fixed_dim, other.primary_fixed_dim),
617 on_primary_fixed: c(self.on_primary_fixed, other.on_primary_fixed),
618 on_primary_fixed_variant: c(
619 self.on_primary_fixed_variant,
620 other.on_primary_fixed_variant,
621 ),
622 secondary: c(self.secondary, other.secondary),
623 on_secondary: c(self.on_secondary, other.on_secondary),
624 secondary_container: c(self.secondary_container, other.secondary_container),
625 on_secondary_container: c(self.on_secondary_container, other.on_secondary_container),
626 secondary_fixed: c(self.secondary_fixed, other.secondary_fixed),
627 secondary_fixed_dim: c(self.secondary_fixed_dim, other.secondary_fixed_dim),
628 on_secondary_fixed: c(self.on_secondary_fixed, other.on_secondary_fixed),
629 on_secondary_fixed_variant: c(
630 self.on_secondary_fixed_variant,
631 other.on_secondary_fixed_variant,
632 ),
633 tertiary: c(self.tertiary, other.tertiary),
634 on_tertiary: c(self.on_tertiary, other.on_tertiary),
635 tertiary_container: c(self.tertiary_container, other.tertiary_container),
636 on_tertiary_container: c(self.on_tertiary_container, other.on_tertiary_container),
637 tertiary_fixed: c(self.tertiary_fixed, other.tertiary_fixed),
638 tertiary_fixed_dim: c(self.tertiary_fixed_dim, other.tertiary_fixed_dim),
639 on_tertiary_fixed: c(self.on_tertiary_fixed, other.on_tertiary_fixed),
640 on_tertiary_fixed_variant: c(
641 self.on_tertiary_fixed_variant,
642 other.on_tertiary_fixed_variant,
643 ),
644 background: c(self.background, other.background),
645 surface: c(self.surface, other.surface),
646 on_surface: c(self.on_surface, other.on_surface),
647 surface_variant: c(self.surface_variant, other.surface_variant),
648 on_surface_variant: c(self.on_surface_variant, other.on_surface_variant),
649 surface_container_lowest: c(
650 self.surface_container_lowest,
651 other.surface_container_lowest,
652 ),
653 surface_container_low: c(self.surface_container_low, other.surface_container_low),
654 surface_container: c(self.surface_container, other.surface_container),
655 surface_container_high: c(self.surface_container_high, other.surface_container_high),
656 surface_container_highest: c(
657 self.surface_container_highest,
658 other.surface_container_highest,
659 ),
660 inverse_surface: c(self.inverse_surface, other.inverse_surface),
661 on_inverse_surface: c(self.on_inverse_surface, other.on_inverse_surface),
662 outline: c(self.outline, other.outline),
663 outline_variant: c(self.outline_variant, other.outline_variant),
664 error: c(self.error, other.error),
665 on_error: c(self.on_error, other.on_error),
666 error_container: c(self.error_container, other.error_container),
667 on_error_container: c(self.on_error_container, other.on_error_container),
668 inverse_primary: c(self.inverse_primary, other.inverse_primary),
669 surface_tint: c(self.surface_tint, other.surface_tint),
670 surface_dim: c(self.surface_dim, other.surface_dim),
671 surface_bright: c(self.surface_bright, other.surface_bright),
672 scrim: c(self.scrim, other.scrim),
673 shadow: c(self.shadow, other.shadow),
674 }
675 }
676}
677
678/// **The smallest box a control that a finger works reserves for it**, in pixels
679/// (`constants.dart:27`).
680///
681/// Not a look: a target. Forty-eight is the number the accessibility scanners on both
682/// mobile platforms check for, and it is what the reference reserves by default for
683/// every switch, checkbox, radio and icon button it draws — whatever those controls
684/// actually paint inside it.
685pub const MIN_TAP_TARGET: f32 = 48.0;
686
687/// The same for a control told to reserve only what the specification requires: the
688/// minimum less eight (`checkbox.dart:522`, `switch.dart:2090`).
689pub const SHRUNK_TAP_TARGET: f32 = 40.0;
690
691/// **How much room a small control reserves for the finger that works it.**
692///
693/// A switch paints a track 32 pixels tall; a checkbox paints a box of 20. Neither is
694/// something a finger can be asked to hit. The reference lays both out inside a
695/// 48-pixel square and paints the small thing in the middle of it, and it makes that a
696/// theme-wide setting with a per-widget override, because it is the kind of decision an
697/// application makes once (`theme_data.dart:172`).
698///
699/// This is a **layout** answer, not a visual one. What the control paints does not
700/// change; the room around it does, and so does the area a click may land in.
701#[derive(Copy, Clone, PartialEq, Eq, Debug, Default)]
702pub enum TapTarget {
703 /// At least [`MIN_TAP_TARGET`] on both sides — the default, as it is over there.
704 #[default]
705 Padded,
706 /// Only [`SHRUNK_TAP_TARGET`]: for the dense interface that has measured its own
707 /// reach and decided, which is a decision rather than an oversight.
708 ShrinkWrap,
709}
710
711impl TapTarget {
712 /// The smallest side, in pixels, this answer reserves.
713 pub fn min_side(self) -> f32 {
714 match self {
715 TapTarget::Padded => MIN_TAP_TARGET,
716 TapTarget::ShrinkWrap => SHRUNK_TAP_TARGET,
717 }
718 }
719}
720
721/// A set of style tokens.
722///
723/// The [`ColorScheme`] (`theme.scheme`) is the **source of truth** for the colors;
724/// the "flat" fields (`background`, `surface`, `primary`, …) are **convenience
725/// views** of the most used roles, derived from the scheme — the widgets'
726/// historical API stays intact. `focus`/`selection` are interaction accents
727/// specific to frus (outside the M3 roles).
728#[derive(Clone, Debug, PartialEq)]
729pub struct Theme {
730 /// The color roles (the source of truth).
731 pub scheme: ColorScheme,
732 /// The application's background (= `scheme.background`).
733 pub background: Color,
734 /// The surfaces' background (= `scheme.surface`).
735 pub surface: Color,
736 /// The accent color (= `scheme.primary`).
737 pub primary: Color,
738 /// Text and content on `primary` (= `scheme.on_primary`).
739 pub on_primary: Color,
740 /// The default text on surfaces (= `scheme.on_surface`).
741 pub on_surface: Color,
742 /// Secondary text and discreet elements (= `scheme.on_surface_variant`).
743 pub muted: Color,
744 /// Borders at rest (= `scheme.outline`).
745 pub border: Color,
746 /// The focus accent (a frus interaction accent, outside the scheme).
747 pub focus: Color,
748 /// The text selection highlight (likewise).
749 pub selection: Color,
750 /// The tonal accent container (= `scheme.primary_container`).
751 pub primary_container: Color,
752 /// Content on `primary_container` (= `scheme.on_primary_container`).
753 pub on_primary_container: Color,
754 /// The error or danger color (= `scheme.error`).
755 pub error: Color,
756 /// Content on `error` (= `scheme.on_error`).
757 pub on_error: Color,
758 /// The discreet outline variant (= `scheme.outline_variant`).
759 pub outline_variant: Color,
760 /// The named typographic scale (Material's 15 steps).
761 pub text: TextTheme,
762 /// The default corner radius.
763 pub radius: f32,
764 /// The base spacing unit.
765 pub spacing: f32,
766 /// The ambient **reading and layout direction** (LTR by default). In RTL, the
767 /// driver mirrors the layout horizontally. Carried here (an ambient context
768 /// threaded down to paint) pending a dedicated `Env` (§2).
769 pub direction: TextDirection,
770 /// **Per-widget defaults**: what a widget looks like when the caller has not said,
771 /// resolved as `caller ?? theme ?? framework`. Empty by default — a theme that sets
772 /// nothing behaves exactly as if there were none. See
773 /// [`WidgetThemes`](crate::WidgetThemes).
774 pub widgets: crate::widgettheme::WidgetThemes,
775 /// **How much room the small controls reserve for a finger** ([`TapTarget`]).
776 /// [`Padded`](TapTarget::Padded) by default, as it is over there: a switch, a
777 /// checkbox, a radio and an icon button each lay out inside at least
778 /// [`MIN_TAP_TARGET`] and paint what they paint in the middle of it.
779 pub tap_target: TapTarget,
780 /// **The platform whose conventions the widgets follow**: how a list scrolls past its
781 /// end, where a bar puts its title, how a page arrives. [`default_target_platform`]
782 /// unless said otherwise, so an application can show another platform's behaviour,
783 /// for the whole of it or for a subtree, and a test can try each
784 /// ([`Theme::with_platform`]).
785 ///
786 /// [`default_target_platform`]: frus_core::default_target_platform
787 pub platform: frus_core::TargetPlatform,
788 /// **How a page arrives and leaves**, platform by platform: the reference's defaults
789 /// unless said otherwise — the desktops zoom, Apple's platforms slide, Android fades
790 /// forwards (milestone 620). The theme's [`Theme::platform`] picks the builder.
791 pub page_transitions: crate::PageTransitionsTheme,
792 /// **How high the subtree sits**: on the window's surface, or raised above it in a sheet
793 /// or a dialog. [`Base`](crate::CupertinoUserInterfaceLevelData::Base) unless a subtree
794 /// says otherwise with
795 /// [`CupertinoUserInterfaceLevelData::around`](crate::CupertinoUserInterfaceLevelData::around);
796 /// the adaptive colours read it (milestone 627).
797 pub user_interface_level: crate::CupertinoUserInterfaceLevelData,
798 /// **What the Apple-style widgets take differently from this theme** — the reference's
799 /// `cupertinoOverrideTheme`. Unset, they take this theme's primary colour, the colour on
800 /// it, its background and its brightness, and Apple's defaults for the rest (milestone
801 /// 628). See [`CupertinoTheme::of`](crate::CupertinoTheme::of).
802 pub cupertino_override_theme: Option<Box<crate::CupertinoThemeData>>,
803 /// **A subtree's own Apple-style theme**, which replaces the one above with Apple's
804 /// defaults under it. Set by [`CupertinoTheme::around`](crate::CupertinoTheme::around).
805 pub cupertino_theme: Option<Box<crate::CupertinoThemeData>>,
806 /// **How compact the controls are**, if the application says
807 /// ([`Theme::with_visual_density`]). Unset, the theme's platform decides: standard on
808 /// phones, compact on the desktops — see [`Theme::visual_density`] (milestone 631).
809 pub visual_density: Option<crate::VisualDensity>,
810}
811
812impl Theme {
813 /// Builds a theme from a scheme: the flat fields are **derived** from the roles,
814 /// so there is a single source of truth.
815 pub fn from_scheme(scheme: ColorScheme, focus: Color, selection: Color) -> Self {
816 Self {
817 scheme,
818 background: scheme.background,
819 surface: scheme.surface,
820 primary: scheme.primary,
821 on_primary: scheme.on_primary,
822 on_surface: scheme.on_surface,
823 muted: scheme.on_surface_variant,
824 border: scheme.outline,
825 focus,
826 selection,
827 primary_container: scheme.primary_container,
828 on_primary_container: scheme.on_primary_container,
829 error: scheme.error,
830 on_error: scheme.on_error,
831 outline_variant: scheme.outline_variant,
832 text: TextTheme::default(),
833 radius: 10.0,
834 spacing: 8.0,
835 direction: TextDirection::Ltr,
836 widgets: crate::widgettheme::WidgetThemes::default(),
837 tap_target: TapTarget::default(),
838 platform: frus_core::default_target_platform(),
839 page_transitions: crate::PageTransitionsTheme::default(),
840 user_interface_level: crate::CupertinoUserInterfaceLevelData::Base,
841 cupertino_override_theme: None,
842 cupertino_theme: None,
843 visual_density: None,
844 }
845 }
846
847 /// The same theme, following `platform`'s conventions ([`Theme::platform`]).
848 pub fn with_platform(mut self, platform: frus_core::TargetPlatform) -> Self {
849 self.platform = platform;
850 self
851 }
852
853 /// The same theme, its Apple-style widgets taking what `data` sets instead of what this
854 /// theme would give them ([`Theme::cupertino_override_theme`]).
855 pub fn with_cupertino_override_theme(mut self, data: crate::CupertinoThemeData) -> Self {
856 self.cupertino_override_theme = Some(Box::new(data));
857 self
858 }
859
860 /// The same theme, its controls at `density` whatever the platform.
861 pub fn with_visual_density(mut self, density: crate::VisualDensity) -> Self {
862 self.visual_density = Some(density);
863 self
864 }
865
866 /// **How compact the controls are**: the density the theme was given, else the one its
867 /// [`platform`](Self::platform) takes (the reference's `ThemeData.visualDensity`,
868 /// `theme_data.dart:412`). Buttons, checkboxes, radios, chips, icon buttons and list
869 /// tiles grow or shrink by it.
870 pub fn visual_density(&self) -> crate::VisualDensity {
871 self.visual_density
872 .unwrap_or_else(|| crate::VisualDensity::default_for_platform(self.platform))
873 }
874
875 /// The same theme in **right-to-left** (Arabic, Hebrew…).
876 pub fn rtl(mut self) -> Self {
877 self.direction = TextDirection::Rtl;
878 self
879 }
880
881 /// The dark theme.
882 pub fn dark() -> Self {
883 Self::from_scheme(
884 ColorScheme::dark(),
885 Color::rgb8(90, 158, 242),
886 Color::rgba(0.35, 0.62, 0.95, 0.40),
887 )
888 }
889
890 /// The light theme.
891 pub fn light() -> Self {
892 Self::from_scheme(
893 ColorScheme::light(),
894 Color::rgb8(40, 120, 220),
895 Color::rgba(0.20, 0.50, 0.90, 0.30),
896 )
897 }
898
899 /// A theme generated from a **seed color** (see [`ColorScheme::from_seed`]). The
900 /// focus ring and the selection derive from the scheme's primary (interaction
901 /// roles specific to frus, outside the M3 scheme).
902 pub fn from_seed(seed: Color, dark: bool) -> Self {
903 let scheme = ColorScheme::from_seed(seed, dark);
904 let focus = scheme.primary;
905 let selection = scheme.primary.with_alpha(if dark { 0.40 } else { 0.30 });
906 Self::from_scheme(scheme, focus, selection)
907 }
908
909 /// **Whether this theme is a light one or a dark one** (`ThemeData.brightness`).
910 ///
911 /// The scheme is the source of truth for the colours, so it is the source of truth
912 /// for this too — a theme cannot be dark and hold a light scheme.
913 ///
914 /// Widgets that have two sets of numbers, one per brightness, ask this. They used to
915 /// compare the surface's luminance against a half, which agrees with it nearly always
916 /// and is a guess either way.
917 pub fn brightness(&self) -> crate::media::Brightness {
918 self.scheme.brightness
919 }
920
921 /// Applies the Material **state layer** over `base`: it overlays the content
922 /// color `on` at low opacity according to the interaction state — hover 8%,
923 /// focus 10%, press 12% — taking the animated progressions into account
924 /// (`hover_progress`/`focus_progress`/`press_progress`). This is the state rule
925 /// **baked** into the theme: widgets stay declarative (they pass their base color and
926 /// their content color, and the theme decides on the overlay).
927 ///
928 /// All three terms are **progressions**, the press included. It read the flag until
929 /// milestone 441, which meant that term could only ever be 0 or 12%: the layer
930 /// arrived whole under a finger and vanished whole when it left. Reading
931 /// `press_progress` is what lets it fade, and reading the flag *as well* would defeat
932 /// that — the term would reach full on the first frame and the fade would never run.
933 pub fn state_layer(&self, base: Color, on: Color, status: &Status) -> Color {
934 let overlay = 0.08 * status.hover_progress.clamp(0.0, 1.0)
935 + 0.10 * status.focus_progress.clamp(0.0, 1.0)
936 + 0.12 * status.press_progress.clamp(0.0, 1.0);
937 base.lerp(on, overlay.min(1.0))
938 }
939}
940
941impl Theme {
942 /// Interpolates toward `other` at progress `t` (`0` = `self`, `1` = `other`).
943 /// Used for the theme fade when switching light and dark. The **scheme** is
944 /// interpolated role by role and the flat fields are re-derived from it (a
945 /// single source of truth, even mid-fade).
946 pub fn lerp(&self, other: &Theme, t: f32) -> Theme {
947 let t = t.clamp(0.0, 1.0);
948 let f = |a: f32, b: f32| a + (b - a) * t;
949 let mut out = Theme::from_scheme(
950 self.scheme.lerp(&other.scheme, t),
951 self.focus.lerp(other.focus, t),
952 self.selection.lerp(other.selection, t),
953 );
954 // Typography takes no part in the fade (it is identical light and dark).
955 out.text = self.text;
956 out.radius = f(self.radius, other.radius);
957 out.spacing = f(self.spacing, other.spacing);
958 // Direction is discrete: keep the fade target's.
959 out.direction = other.direction;
960 // So is the tap target — and so are the per-widget defaults, which the fade used
961 // to **drop**: `from_scheme` starts from an empty set and nothing put them back,
962 // so every override an application had written disappeared for the length of a
963 // light/dark crossing and came back when it ended.
964 out.tap_target = other.tap_target;
965 out.widgets = other.widgets.clone();
966 // A platform cannot be half one and half the other: it changes at mid-fade, as
967 // the reference's does (milestone 614). `from_scheme` would have put the
968 // default back, and a theme set to another platform would have lost it for the
969 // whole of a light/dark crossing.
970 out.page_transitions = other.page_transitions;
971 out.platform = if t < 0.5 {
972 self.platform
973 } else {
974 other.platform
975 };
976 // A level is one or the other too, and so are the Apple-style themes, as the
977 // reference's override is.
978 let near = if t < 0.5 { self } else { other };
979 out.user_interface_level = near.user_interface_level;
980 out.cupertino_override_theme = near.cupertino_override_theme.clone();
981 out.cupertino_theme = near.cupertino_theme.clone();
982 // A density moves smoothly, as the reference's does; a pair that both follow their
983 // platform keeps following it.
984 out.visual_density = match (self.visual_density, other.visual_density) {
985 (None, None) => None,
986 _ => Some(crate::VisualDensity::lerp(
987 self.visual_density(),
988 other.visual_density(),
989 t,
990 )),
991 };
992 out
993 }
994}
995
996impl Default for Theme {
997 fn default() -> Self {
998 Self::dark()
999 }
1000}
1001
1002#[cfg(test)]
1003mod tests {
1004 /// **A theme is eight kilobytes, and it used to be `Copy`.** Every `*theme` in the
1005 /// crate was a silent eight-kilobyte memcpy — in the layout walk, in every themed
1006 /// subtree, once per overlay. Milestone 448 dropped `Copy`, which turned nine of
1007 /// those into `clone()` calls that say what they cost.
1008 ///
1009 /// The number itself is not a promise; the assertion is that it is **large**, since
1010 /// that is the whole argument for the change.
1011 #[test]
1012 fn a_theme_is_far_too_big_to_copy_by_accident() {
1013 assert!(
1014 std::mem::size_of::<super::Theme>() > 4096,
1015 "a theme is {} bytes",
1016 std::mem::size_of::<super::Theme>()
1017 );
1018 }
1019
1020 use super::*;
1021 use crate::disabled::DISABLED_CONTAINER_OPACITY;
1022
1023 /// **A scheme says what it is instead of being measured.**
1024 ///
1025 /// The reference carries `brightness` on both `ColorScheme` and `ThemeData`, and its
1026 /// scrollbar reads it (`scrollbar.dart:232`). This framework had neither, so the one
1027 /// widget that needed the answer compared the surface's luminance against a half.
1028 ///
1029 /// That agrees on the four schemes this crate builds — which is exactly why it
1030 /// survived. It does not agree on a scheme an application writes for itself.
1031 #[test]
1032 fn a_scheme_says_whether_it_is_dark_rather_than_being_measured() {
1033 assert!(ColorScheme::dark().brightness.is_dark());
1034 assert!(ColorScheme::light().brightness.is_light());
1035 let seed = Color::rgb8(0x42, 0x85, 0xF4);
1036 assert!(ColorScheme::from_seed(seed, true).brightness.is_dark());
1037 assert!(ColorScheme::from_seed(seed, false).brightness.is_light());
1038 assert_eq!(Theme::dark().brightness(), Brightness::Dark);
1039 assert_eq!(Theme::light().brightness(), Brightness::Light);
1040
1041 // A dimmed **light** scheme — a reading theme, a low-light one — whose surface
1042 // sits below the halfway line. The guess called this dark; it is not.
1043 let dim = ColorScheme {
1044 surface: Color::rgb8(110, 110, 110),
1045 ..ColorScheme::light()
1046 };
1047 assert!(
1048 dim.surface.compute_luminance() < 0.5,
1049 "the measurement says dark"
1050 );
1051 assert!(dim.brightness.is_light(), "and the scheme says light");
1052 }
1053
1054 /// **A brightness does not interpolate.** Halfway between light and dark is not a
1055 /// third brightness, and a widget reading one mid-crossing wants the answer it will
1056 /// still be right about when the crossing ends — the same rule as the text
1057 /// direction, and for the same reason.
1058 #[test]
1059 fn a_brightness_does_not_interpolate() {
1060 let half = Theme::light().lerp(&Theme::dark(), 0.5);
1061 assert_eq!(half.brightness(), Brightness::Dark);
1062 let back = Theme::dark().lerp(&Theme::light(), 0.01);
1063 assert_eq!(back.brightness(), Brightness::Light);
1064 }
1065
1066 /// The WCAG contrast between two colors (a ratio ≥ 1).
1067 fn contrast(a: Color, b: Color) -> f32 {
1068 let (la, lb) = (a.compute_luminance() + 0.05, b.compute_luminance() + 0.05);
1069 if la > lb {
1070 la / lb
1071 } else {
1072 lb / la
1073 }
1074 }
1075
1076 #[test]
1077 fn from_seed_generates_contrasting_pairs() {
1078 // Every X / on_X pair must stay legible (≥ 4.5:1, the AA requirement), for
1079 // any seed at all — even a barely chromatic one.
1080 for seed in [
1081 Color::rgb8(0x42, 0x85, 0xF4), // Google blue
1082 Color::rgb8(0x9C, 0x27, 0xB0), // violet
1083 Color::rgb8(0x80, 0x80, 0x80), // grey — very nearly no chroma at all
1084 ] {
1085 for dark in [false, true] {
1086 let s = ColorScheme::from_seed(seed, dark);
1087 for (name, base, on) in [
1088 ("primary", s.primary, s.on_primary),
1089 ("secondary", s.secondary, s.on_secondary),
1090 ("surface", s.surface, s.on_surface),
1091 ("tertiary", s.tertiary, s.on_tertiary),
1092 ("error", s.error, s.on_error),
1093 ("inverse", s.inverse_surface, s.on_inverse_surface),
1094 // The containers carry text too — an errored field's helper line is
1095 // `on_error_container` on `error_container`, and it has to be read.
1096 (
1097 "primary_container",
1098 s.primary_container,
1099 s.on_primary_container,
1100 ),
1101 (
1102 "secondary_container",
1103 s.secondary_container,
1104 s.on_secondary_container,
1105 ),
1106 (
1107 "tertiary_container",
1108 s.tertiary_container,
1109 s.on_tertiary_container,
1110 ),
1111 ("error_container", s.error_container, s.on_error_container),
1112 // The fixed pair carries text as well, and it carries the **same**
1113 // text over two surfaces: whatever is written on `primary_fixed`
1114 // has to still be legible when a caller reaches for the dim one.
1115 ("primary_fixed", s.primary_fixed, s.on_primary_fixed),
1116 ("primary_fixed_dim", s.primary_fixed_dim, s.on_primary_fixed),
1117 ("secondary_fixed", s.secondary_fixed, s.on_secondary_fixed),
1118 (
1119 "secondary_fixed_dim",
1120 s.secondary_fixed_dim,
1121 s.on_secondary_fixed,
1122 ),
1123 ("tertiary_fixed", s.tertiary_fixed, s.on_tertiary_fixed),
1124 (
1125 "tertiary_fixed_dim",
1126 s.tertiary_fixed_dim,
1127 s.on_tertiary_fixed,
1128 ),
1129 ] {
1130 let ratio = contrast(base, on);
1131 assert!(
1132 ratio >= 4.5,
1133 "{name} does not contrast enough ({ratio:.2}) — seed {seed:?}, \
1134 dark={dark}"
1135 );
1136 }
1137 }
1138 }
1139 }
1140
1141 /// The twelve roles that promise to stay put, as `(name, light, dark)`.
1142 fn fixed_roles(light: &ColorScheme, dark: &ColorScheme) -> Vec<(&'static str, Color, Color)> {
1143 vec![
1144 ("primary_fixed", light.primary_fixed, dark.primary_fixed),
1145 (
1146 "primary_fixed_dim",
1147 light.primary_fixed_dim,
1148 dark.primary_fixed_dim,
1149 ),
1150 (
1151 "on_primary_fixed",
1152 light.on_primary_fixed,
1153 dark.on_primary_fixed,
1154 ),
1155 (
1156 "on_primary_fixed_variant",
1157 light.on_primary_fixed_variant,
1158 dark.on_primary_fixed_variant,
1159 ),
1160 (
1161 "secondary_fixed",
1162 light.secondary_fixed,
1163 dark.secondary_fixed,
1164 ),
1165 (
1166 "secondary_fixed_dim",
1167 light.secondary_fixed_dim,
1168 dark.secondary_fixed_dim,
1169 ),
1170 (
1171 "on_secondary_fixed",
1172 light.on_secondary_fixed,
1173 dark.on_secondary_fixed,
1174 ),
1175 (
1176 "on_secondary_fixed_variant",
1177 light.on_secondary_fixed_variant,
1178 dark.on_secondary_fixed_variant,
1179 ),
1180 ("tertiary_fixed", light.tertiary_fixed, dark.tertiary_fixed),
1181 (
1182 "tertiary_fixed_dim",
1183 light.tertiary_fixed_dim,
1184 dark.tertiary_fixed_dim,
1185 ),
1186 (
1187 "on_tertiary_fixed",
1188 light.on_tertiary_fixed,
1189 dark.on_tertiary_fixed,
1190 ),
1191 (
1192 "on_tertiary_fixed_variant",
1193 light.on_tertiary_fixed_variant,
1194 dark.on_tertiary_fixed_variant,
1195 ),
1196 ]
1197 }
1198
1199 /// **A fixed role does not move when the lights go out** — the one promise the
1200 /// twelve of them make (`color_scheme.dart:90`).
1201 ///
1202 /// Every other accent role is answered twice, once per brightness. These are
1203 /// answered once. A brand colour on a card, an onboarding illustration drawn against
1204 /// one particular green, a header somebody signed off in a design review: they have
1205 /// nowhere to sit in a scheme where every role moves. `primary_container` is the
1206 /// right emphasis for them and the wrong promise.
1207 ///
1208 /// This holds it for **both** ways a scheme is built — the pair written out by
1209 /// hand, where the promise is two literals that have to agree, and the pair a seed
1210 /// generates, where it holds by construction because the tone is read off a palette
1211 /// that does not know which theme is being built.
1212 #[test]
1213 fn a_fixed_role_does_not_move_when_the_lights_go_out() {
1214 for (name, light, dark) in fixed_roles(&ColorScheme::light(), &ColorScheme::dark()) {
1215 assert_eq!(light, dark, "the written schemes disagree about {name}");
1216 }
1217
1218 for seed in [
1219 Color::rgb8(0x42, 0x85, 0xF4),
1220 Color::rgb8(0x9C, 0x27, 0xB0),
1221 Color::rgb8(0x80, 0x80, 0x80),
1222 ] {
1223 let l = ColorScheme::from_seed(seed, false);
1224 let d = ColorScheme::from_seed(seed, true);
1225 for (name, light, dark) in fixed_roles(&l, &d) {
1226 assert_eq!(light, dark, "seed {seed:?} disagrees about {name}");
1227 }
1228 }
1229 }
1230
1231 /// **The dim one is the stronger one**, and what is written on either is legible on
1232 /// **both** — which is the reason `on_*_fixed` is a single role and not two.
1233 ///
1234 /// A caller that paints `primary_fixed` today and swaps to `primary_fixed_dim` for
1235 /// emphasis tomorrow does not get to re-pick its text colour, so tone 10 has to
1236 /// clear the bar against the darker of the pair as well.
1237 #[test]
1238 fn the_dim_half_is_the_stronger_half() {
1239 for scheme in [
1240 ColorScheme::light(),
1241 ColorScheme::dark(),
1242 ColorScheme::from_seed(Color::rgb8(0x42, 0x85, 0xF4), false),
1243 ColorScheme::from_seed(Color::rgb8(0x9C, 0x27, 0xB0), true),
1244 ] {
1245 for (name, fixed, dim, on, variant) in [
1246 (
1247 "primary",
1248 scheme.primary_fixed,
1249 scheme.primary_fixed_dim,
1250 scheme.on_primary_fixed,
1251 scheme.on_primary_fixed_variant,
1252 ),
1253 (
1254 "secondary",
1255 scheme.secondary_fixed,
1256 scheme.secondary_fixed_dim,
1257 scheme.on_secondary_fixed,
1258 scheme.on_secondary_fixed_variant,
1259 ),
1260 (
1261 "tertiary",
1262 scheme.tertiary_fixed,
1263 scheme.tertiary_fixed_dim,
1264 scheme.on_tertiary_fixed,
1265 scheme.on_tertiary_fixed_variant,
1266 ),
1267 ] {
1268 assert!(
1269 dim.compute_luminance() < fixed.compute_luminance(),
1270 "{name}_fixed_dim is not the stronger of the pair"
1271 );
1272 for (surface, label) in [(fixed, "fixed"), (dim, "fixed_dim")] {
1273 let ratio = contrast(surface, on);
1274 assert!(
1275 ratio >= 4.5,
1276 "on_{name}_fixed does not read on {name}_{label} ({ratio:.2})"
1277 );
1278 }
1279 // The variant is the quieter of the two, so it is allowed less — but
1280 // it is still text, and 3:1 is the floor for large text.
1281 assert!(
1282 contrast(fixed, variant) >= 3.0,
1283 "on_{name}_fixed_variant does not read on {name}_fixed"
1284 );
1285 assert!(
1286 variant.compute_luminance() > on.compute_luminance(),
1287 "on_{name}_fixed_variant is not the quieter of the two"
1288 );
1289 }
1290 }
1291 }
1292
1293 #[test]
1294 fn from_seed_light_and_dark_share_the_hue() {
1295 // Both modes decline the same hue (the dark primary is the tone-80 version
1296 // of the tone-40 light primary).
1297 let seed = Color::rgb8(0x42, 0x85, 0xF4);
1298 let light = ColorScheme::from_seed(seed, false);
1299 let dark = ColorScheme::from_seed(seed, true);
1300 let hue = |c: Color| frus_core::Hct::from_color(c).hue;
1301 let delta = (hue(light.primary) - hue(dark.primary)).abs();
1302 let delta = delta.min(360.0 - delta);
1303 assert!(
1304 delta < 12.0,
1305 "teintes clair/sombre divergentes ({delta:.1}°)"
1306 );
1307 // And the dark one is indeed… dark.
1308 assert!(dark.background.compute_luminance() < 0.1);
1309 assert!(light.background.compute_luminance() > 0.85);
1310 }
1311
1312 #[test]
1313 fn an_outline_is_never_the_colour_of_a_disabled_one() {
1314 // Since milestone 322 a disabled control's outline is `on_surface` at 12 % over
1315 // the surface. If a scheme puts a *live* outline at the same tone, then every
1316 // outlined control in the framework looks available whether it is or not — which
1317 // is what a device found on the dark palette. This is the guard against it.
1318 //
1319 // The bar is the reference's own margin at its narrowest: its baseline schemes
1320 // separate `outline_variant` from a disabled outline by about ten tones, and
1321 // `outline` by nearly forty.
1322 let tone = |c: Color| frus_core::Hct::from_color(c).tone;
1323 let mut checked = 0;
1324 for (name, s) in [
1325 ("dark", ColorScheme::dark()),
1326 ("light", ColorScheme::light()),
1327 (
1328 "seeded dark",
1329 ColorScheme::from_seed(Color::rgb8(0x42, 0x85, 0xF4), true),
1330 ),
1331 (
1332 "seeded light",
1333 ColorScheme::from_seed(Color::rgb8(0x42, 0x85, 0xF4), false),
1334 ),
1335 (
1336 "grey seeded dark",
1337 ColorScheme::from_seed(Color::rgb8(0x80, 0x80, 0x80), true),
1338 ),
1339 ] {
1340 // Opaque over opaque: the lerp is the composite `fade(0.12)` resolves to.
1341 let disabled = tone(s.surface.lerp(s.on_surface, DISABLED_CONTAINER_OPACITY));
1342 for (role, colour, floor) in [
1343 ("outline", s.outline, 24.0),
1344 ("outline_variant", s.outline_variant, 6.0),
1345 ] {
1346 let gap = (tone(colour) - disabled).abs();
1347 assert!(
1348 gap >= floor,
1349 "{name}: {role} is {gap:.1} tones from a disabled outline \
1350 (needs {floor}) — a live control and an unavailable one \
1351 would be drawn the same"
1352 );
1353 checked += 1;
1354 }
1355 }
1356 assert_eq!(checked, 10);
1357 }
1358
1359 /// The same question for a control that is **filled** rather than outlined: it must
1360 /// be tellable from the disabled fill, or the filled half of the disabled rule
1361 /// inverts. A slider's rail was fainter than its own disabled twin in both schemes,
1362 /// which is what sent us looking.
1363 ///
1364 /// Two ways to be tellable, and a fill needs one of them. **Tone**, measured as a
1365 /// distance from the surface rather than from each other — what the eye reads is how
1366 /// far a fill sits from what it lies on, and the disabled fill is by construction a
1367 /// fixed step from it. Or **chroma**: the disabled fill is `on_surface` at 12 %, so
1368 /// it is nearly grey, and a fill carrying real colour is told apart from it at any
1369 /// tone. `primary_container` in the dark scheme is tone 24 against a disabled tone 24
1370 /// and nobody would confuse them, because one is green.
1371 ///
1372 /// What the rail had was neither: a near-neutral of the surface's own hue, sitting
1373 /// *closer* to the surface than the disabled fill.
1374 ///
1375 /// This guard could not be written before milestone 329. It models the disabled fill
1376 /// as an sRGB blend, which was a fiction while the GPU blended the token in linear
1377 /// light — 14 tones adrift in the dark scheme. Now [`crate::disabled::over_surface`]
1378 /// resolves exactly this arithmetic, so the model is the painted truth.
1379 #[test]
1380 fn a_live_container_is_never_quieter_than_a_disabled_fill() {
1381 let tone = |c: Color| frus_core::Hct::from_color(c).tone;
1382 let chroma = |c: Color| frus_core::Hct::from_color(c).chroma;
1383 /// A fill this much more colourful than the disabled one is told apart by its
1384 /// colour whatever its tone. Set where a *saturated* container sits — the two
1385 /// `primary_container`s clear it by a third — and well above the rail that
1386 /// failed, which was only 8 more colourful and would have reached for this
1387 /// escape without deserving it.
1388 const CHROMA_MARGIN: f64 = 15.0;
1389 let mut checked = 0;
1390 for (name, s) in [
1391 ("dark", ColorScheme::dark()),
1392 ("light", ColorScheme::light()),
1393 (
1394 "seeded dark",
1395 ColorScheme::from_seed(Color::rgb8(0x42, 0x85, 0xF4), true),
1396 ),
1397 (
1398 "seeded light",
1399 ColorScheme::from_seed(Color::rgb8(0x42, 0x85, 0xF4), false),
1400 ),
1401 (
1402 "grey seeded dark",
1403 ColorScheme::from_seed(Color::rgb8(0x80, 0x80, 0x80), true),
1404 ),
1405 ] {
1406 let surface = tone(s.surface);
1407 let fill = s.surface.lerp(s.on_surface, DISABLED_CONTAINER_OPACITY);
1408 let dead = (tone(fill) - surface).abs();
1409 // The roles a live control is filled with. `secondary_container` carries a
1410 // slider's rail, a selected chip, a selected segment and a tonal button;
1411 // `primary_container` the louder selections.
1412 for (role, colour) in [
1413 ("secondary_container", s.secondary_container),
1414 ("primary_container", s.primary_container),
1415 ] {
1416 let live = (tone(colour) - surface).abs();
1417 let colourful = chroma(colour) - chroma(fill) >= CHROMA_MARGIN;
1418 assert!(
1419 live >= dead || colourful,
1420 "{name}: {role} is {live:.1} tones off the surface where a disabled \
1421 fill is {dead:.1}, and only {:.1} more colourful — a live control \
1422 would read as an unavailable one",
1423 chroma(colour) - chroma(fill)
1424 );
1425 checked += 1;
1426 }
1427 }
1428 assert_eq!(checked, 10);
1429 }
1430
1431 /// The five container rungs are a **ladder**: each stands further from the
1432 /// surface than the one below it, in whichever direction the scheme's own
1433 /// brightness sends them. A rung out of order — or two on the same tone — is two
1434 /// widgets that cannot be told apart while their roles say they should be.
1435 #[test]
1436 fn the_container_ladder_climbs_in_one_direction() {
1437 let tone = |c: Color| frus_core::Hct::from_color(c).tone;
1438 /// Two rungs closer than this read as the same colour.
1439 const RUNG: f64 = 1.0;
1440 let mut checked = 0;
1441 for (name, s) in [
1442 ("dark", ColorScheme::dark()),
1443 ("light", ColorScheme::light()),
1444 (
1445 "seeded dark",
1446 ColorScheme::from_seed(Color::rgb8(0x42, 0x85, 0xF4), true),
1447 ),
1448 (
1449 "seeded light",
1450 ColorScheme::from_seed(Color::rgb8(0x42, 0x85, 0xF4), false),
1451 ),
1452 ] {
1453 // A dark scheme's containers grow lighter as they take emphasis, a light
1454 // scheme's darker.
1455 let up = if tone(s.surface) < 50.0 { 1.0 } else { -1.0 };
1456 let rungs = [
1457 ("lowest", s.surface_container_lowest),
1458 ("low", s.surface_container_low),
1459 ("container", s.surface_container),
1460 ("high", s.surface_container_high),
1461 ("highest", s.surface_container_highest),
1462 ];
1463 // `surface_dim` and `surface_bright` bracket the **surface**, in either
1464 // theme — "always the darkest" and "always the lightest"
1465 // (`color_scheme.dart:1236`, `:1241`).
1466 //
1467 // They do not bracket the *containers*, and the first draft of this test
1468 // asserted that they did. The reference's own dark scheme puts
1469 // `surfaceContainerLowest` at tone 4 and `surfaceDim` at 6, so the ladder's
1470 // bottom rung is darker than the darkest surface: the two are separate
1471 // families, and dim/bright are a claim about the surface alone.
1472 assert!(
1473 tone(s.surface_dim) <= tone(s.surface) + 1e-6,
1474 "surface_dim is not the darker end"
1475 );
1476 assert!(
1477 tone(s.surface) <= tone(s.surface_bright) + 1e-6,
1478 "surface_bright is not the lighter one"
1479 );
1480 checked += 2;
1481 for pair in rungs.windows(2) {
1482 let ((below, b), (above, a)) = (pair[0], pair[1]);
1483 let step = (tone(a) - tone(b)) * up;
1484 assert!(
1485 step >= RUNG,
1486 "{name}: the {above} rung is {step:.1} tones of emphasis above the \
1487 {below} one — a ladder cannot go back down"
1488 );
1489 checked += 1;
1490 }
1491 }
1492 assert_eq!(checked, 24);
1493 }
1494
1495 #[test]
1496 fn dark_and_light_differ() {
1497 assert_ne!(Theme::dark().background, Theme::light().background);
1498 assert_ne!(Theme::dark().on_surface, Theme::light().on_surface);
1499 }
1500
1501 #[test]
1502 fn flat_fields_mirror_the_scheme() {
1503 // The flat fields are views derived from the scheme — including mid-fade
1504 // (the lerp goes through the scheme).
1505 for theme in [
1506 Theme::dark(),
1507 Theme::light(),
1508 Theme::dark().lerp(&Theme::light(), 0.37),
1509 ] {
1510 assert_eq!(theme.background, theme.scheme.background);
1511 assert_eq!(theme.surface, theme.scheme.surface);
1512 assert_eq!(theme.primary, theme.scheme.primary);
1513 assert_eq!(theme.on_primary, theme.scheme.on_primary);
1514 assert_eq!(theme.on_surface, theme.scheme.on_surface);
1515 assert_eq!(theme.muted, theme.scheme.on_surface_variant);
1516 assert_eq!(theme.border, theme.scheme.outline);
1517 assert_eq!(theme.primary_container, theme.scheme.primary_container);
1518 assert_eq!(theme.error, theme.scheme.error);
1519 assert_eq!(theme.outline_variant, theme.scheme.outline_variant);
1520 }
1521 }
1522
1523 #[test]
1524 fn state_layer_darkens_toward_content_on_interaction() {
1525 let theme = Theme::dark();
1526 let base = Color::rgb(0.4, 0.4, 0.4);
1527 let on = Color::BLACK;
1528
1529 // At rest: no overlay.
1530 let idle = Status::default();
1531 assert_eq!(theme.state_layer(base, on, &idle), base);
1532
1533 // Fully hovered: the base is pulled 8% toward `on` (darker here).
1534 let hovered = Status {
1535 hover_progress: 1.0,
1536 ..Default::default()
1537 };
1538 let h = theme.state_layer(base, on, &hovered);
1539 assert!(h.r < base.r && (base.r - h.r - 0.4 * 0.08).abs() < 1e-4);
1540
1541 // Pressed: a stronger overlay than hover alone.
1542 let pressed = Status {
1543 press_progress: 1.0,
1544 ..Default::default()
1545 };
1546 assert!(theme.state_layer(base, on, &pressed).r < h.r);
1547 }
1548
1549 /// **A press lights by degrees** (milestone 441). The term read
1550 /// `Interaction::Pressed`, a flag, so it could only ever be 0 or 12 %: the layer
1551 /// arrived whole under a finger and left whole with it.
1552 #[test]
1553 fn a_press_lights_by_degrees() {
1554 let theme = Theme::dark();
1555 let base = Color::rgb(0.4, 0.4, 0.4);
1556 let on = Color::BLACK;
1557 let at = |p: f32| {
1558 theme.state_layer(
1559 base,
1560 on,
1561 &Status {
1562 press_progress: p,
1563 ..Default::default()
1564 },
1565 )
1566 };
1567 let (rest, half, full) = (at(0.0), at(0.5), at(1.0));
1568 assert_eq!(rest, base, "nothing at rest");
1569 assert!(full.r < half.r && half.r < rest.r, "half way is half way");
1570 assert!(
1571 (half.r - (rest.r + full.r) * 0.5).abs() < 1e-4,
1572 "and it is the overlay that is halved, not the colour"
1573 );
1574
1575 // And the flag alone no longer lights it. If it were read as well the term would
1576 // reach full on the very first frame, and the fade above could never run.
1577 let flagged = Status {
1578 interaction: crate::interaction::Interaction::Pressed,
1579 ..Default::default()
1580 };
1581 assert_eq!(theme.state_layer(base, on, &flagged), base);
1582 }
1583
1584 /// **A tap target is a theme-wide answer with a per-widget override** (milestone
1585 /// 442), and the default is the reference's: at least 48 pixels for anything a
1586 /// finger works.
1587 #[test]
1588 fn a_theme_reserves_a_tap_target_by_default() {
1589 assert_eq!(Theme::dark().tap_target, TapTarget::Padded);
1590 assert_eq!(Theme::light().tap_target, TapTarget::Padded);
1591 // Read through the enum, which is what a widget asking would get.
1592 let sides: Vec<f32> = [TapTarget::Padded, TapTarget::ShrinkWrap]
1593 .iter()
1594 .map(|t| t.min_side())
1595 .collect();
1596 assert_eq!(sides, vec![MIN_TAP_TARGET, SHRUNK_TAP_TARGET]);
1597 assert!(
1598 sides[1] < sides[0],
1599 "shrink-wrapping is a smaller answer, not a different one"
1600 );
1601 }
1602
1603 /// **A fade used to drop every per-widget default it crossed** (milestone 442).
1604 ///
1605 /// `lerp` rebuilds the theme from the interpolated scheme, and `from_scheme` starts
1606 /// from an empty set of widget defaults — so every override an application had
1607 /// written disappeared for the length of a light/dark crossing and came back when it
1608 /// ended. Discrete, like the direction beside it: a corner radius is not a colour and
1609 /// has no half-way.
1610 #[test]
1611 fn a_fade_keeps_what_it_cannot_interpolate() {
1612 let mut target = Theme::light();
1613 target.widgets.checkbox.radius = Some(3.0);
1614 target.tap_target = TapTarget::ShrinkWrap;
1615
1616 let mid = Theme::dark().lerp(&target, 0.5);
1617 assert_eq!(
1618 mid.widgets.checkbox.radius,
1619 Some(3.0),
1620 "the defaults survive"
1621 );
1622 assert_eq!(mid.tap_target, TapTarget::ShrinkWrap);
1623 // And the colours are still half way across, which is what the fade is for.
1624 assert_ne!(mid.background, Theme::dark().background);
1625 assert_ne!(mid.background, target.background);
1626 }
1627
1628 /// **A theme starts at the default platform**, and is told otherwise by
1629 /// `with_platform`.
1630 #[test]
1631 fn a_theme_follows_the_default_platform_until_told() {
1632 assert_eq!(
1633 Theme::default().platform,
1634 frus_core::default_target_platform()
1635 );
1636 assert_eq!(Theme::dark().platform, frus_core::default_target_platform());
1637 let ios = Theme::default().with_platform(frus_core::TargetPlatform::Ios);
1638 assert_eq!(ios.platform, frus_core::TargetPlatform::Ios);
1639 }
1640
1641 /// **The platform changes at mid-fade**, and survives the rest of it.
1642 #[test]
1643 fn a_fade_keeps_a_platform_whole() {
1644 let from = Theme::default().with_platform(frus_core::TargetPlatform::Ios);
1645 let to = Theme::dark().with_platform(frus_core::TargetPlatform::Windows);
1646 assert_eq!(from.lerp(&to, 0.0).platform, frus_core::TargetPlatform::Ios);
1647 assert_eq!(
1648 from.lerp(&to, 0.49).platform,
1649 frus_core::TargetPlatform::Ios
1650 );
1651 assert_eq!(
1652 from.lerp(&to, 0.5).platform,
1653 frus_core::TargetPlatform::Windows
1654 );
1655 assert_eq!(
1656 from.lerp(&to, 1.0).platform,
1657 frus_core::TargetPlatform::Windows
1658 );
1659 }
1660
1661 /// **The reference's type scale, step by step** (`typography.dart:2097`, milestone
1662 /// 624): size, weight, letter spacing and line height of each of the fifteen.
1663 #[test]
1664 fn the_type_scale_is_the_reference_s() {
1665 use frus_core::FontWeight::{Medium, Regular};
1666 let t = TextTheme::M3;
1667 let table = [
1668 (t.display_large, 57.0, Regular, -0.25, 1.12),
1669 (t.display_medium, 45.0, Regular, 0.0, 1.16),
1670 (t.display_small, 36.0, Regular, 0.0, 1.22),
1671 (t.headline_large, 32.0, Regular, 0.0, 1.25),
1672 (t.headline_medium, 28.0, Regular, 0.0, 1.29),
1673 (t.headline_small, 24.0, Regular, 0.0, 1.33),
1674 (t.title_large, 22.0, Regular, 0.0, 1.27),
1675 (t.title_medium, 16.0, Medium, 0.15, 1.50),
1676 (t.title_small, 14.0, Medium, 0.1, 1.43),
1677 (t.body_large, 16.0, Regular, 0.5, 1.50),
1678 (t.body_medium, 14.0, Regular, 0.25, 1.43),
1679 (t.body_small, 12.0, Regular, 0.4, 1.33),
1680 (t.label_large, 14.0, Medium, 0.1, 1.43),
1681 (t.label_medium, 12.0, Medium, 0.5, 1.33),
1682 (t.label_small, 11.0, Medium, 0.5, 1.45),
1683 ];
1684 for (i, (style, size, weight, spacing, height)) in table.into_iter().enumerate() {
1685 let r = style.resolved();
1686 assert_eq!(
1687 (r.size, r.weight, r.letter_spacing, r.height),
1688 (size, weight, spacing, Some(height)),
1689 "step {i}"
1690 );
1691 }
1692 }
1693
1694 #[test]
1695 fn lerp_hits_endpoints() {
1696 let d = Theme::dark();
1697 let l = Theme::light();
1698 assert_eq!(d.lerp(&l, 0.0).background, d.background);
1699 assert_eq!(d.lerp(&l, 1.0).background, l.background);
1700 // In the middle: neither one nor the other.
1701 let mid = d.lerp(&l, 0.5).background;
1702 assert_ne!(mid, d.background);
1703 assert_ne!(mid, l.background);
1704 }
1705}