cranpose_ui/widgets/wear/theme.rs
1//! Colours and text styles a Wear widget is handed.
2//!
3//! Cranpose has no theme system, and this module does not start one. There is
4//! no composition local, no `MaterialTheme`, no ambient lookup: every Wear
5//! widget takes a [`WearColors`] in its spec and a caller passes one down.
6//! Building a theme system is a much larger design than a widget set, and the
7//! widgets do not need it.
8//!
9//! What is worth encoding is the part a port gets wrong from reading the Kotlin:
10//! which role each slot draws with. `MaterialTheme` provides eight composition
11//! locals and `LocalContentColor` is **not** among them — its declaration is
12//! `compositionLocalOf { Color.White }` and only `AppScaffold` overrides it. So
13//! a bare `Text` on a Wear screen is white, while a `ListHeader` on the same
14//! screen is `onBackground`. Two different whites side by side, and a port that
15//! resolves both to `onBackground` is wrong by 8 counts of red and 9 of green
16//! on every bare `Text`. [`WearColors::content`] is that colour, kept separate
17//! for exactly that reason.
18
19use crate::{
20 modifier::Color,
21 text::{
22 FontFamily, FontWeight, TextUnit,
23 paragraph::TextAlign,
24 style::{
25 LineHeightAlignment, LineHeightMode, LineHeightStyle, LineHeightTrim, ParagraphStyle,
26 PlatformParagraphStyle, SpanStyle, TextStyle,
27 },
28 },
29 widgets::wear::color_appearance::set_luminance,
30};
31
32/// The colour roles these widgets read.
33///
34/// This is the subset of Wear Material 3's `ColorScheme` that the Settings and
35/// Credits screens reach, not the whole scheme — a role nothing draws with is
36/// a role nobody can get wrong.
37#[derive(Clone, Copy, Debug, PartialEq)]
38pub struct WearColors {
39 /// A filled `Button`'s container, and a checked switch's track.
40 pub primary: Color,
41 /// A checked `SwitchButton`'s container, and its thumb.
42 pub primary_container: Color,
43 /// A filled `Button`'s label.
44 pub on_primary: Color,
45 /// A checked `SwitchButton`'s label.
46 pub on_primary_container: Color,
47 /// An unchecked `SwitchButton`'s container and track.
48 pub surface_container: Color,
49 pub on_surface: Color,
50 /// A secondary label on an unchecked row.
51 pub on_surface_variant: Color,
52 /// An unchecked switch's track border and thumb.
53 pub outline: Color,
54 pub background: Color,
55 /// A `ListHeader`'s label.
56 pub on_background: Color,
57 /// `LocalContentColor`, which `MaterialTheme` does not provide and which is
58 /// therefore plain white unless an `AppScaffold` says otherwise. A bare
59 /// `Text` in the list draws with this, **not** with `on_background`.
60 pub content: Color,
61 /// The scroll indicator's thumb: `onBackground` taken to L\* 80.
62 ///
63 /// Derive it with [`WearColors::with_wear_scroll_indicator`] rather than
64 /// picking it — see that method for what Wear's own derivation is and why
65 /// the obvious substitute for it is wrong.
66 pub indicator_thumb: Color,
67 /// The scroll indicator's track: the same colour at L\* 20.
68 pub indicator_track: Color,
69}
70
71impl Default for WearColors {
72 /// A plain dark scheme. It is not any app's palette — a caller with a
73 /// palette passes it in.
74 fn default() -> Self {
75 Self {
76 primary: Color::from_rgb_u8(0xA8, 0xC7, 0xFA),
77 primary_container: Color::from_rgb_u8(0x0B, 0x57, 0xD0),
78 on_primary: Color::from_rgb_u8(0x00, 0x00, 0x00),
79 on_primary_container: Color::from_rgb_u8(0xD3, 0xE3, 0xFD),
80 surface_container: Color::from_rgb_u8(0x1E, 0x1F, 0x20),
81 on_surface: Color::from_rgb_u8(0xE3, 0xE3, 0xE3),
82 on_surface_variant: Color::from_rgb_u8(0xC4, 0xC7, 0xC5),
83 outline: Color::from_rgb_u8(0x8E, 0x91, 0x8F),
84 background: Color::from_rgb_u8(0x00, 0x00, 0x00),
85 on_background: Color::from_rgb_u8(0xE3, 0xE3, 0xE3),
86 content: Color::WHITE,
87 // Not written out: the scheme's own `on_background` through the
88 // rule below, so the default cannot say something the derivation
89 // does not.
90 indicator_thumb: Color::WHITE,
91 indicator_track: Color::WHITE,
92 }
93 .with_wear_scroll_indicator()
94 }
95}
96
97impl WearColors {
98 /// The two scroll-indicator colours Wear derives from this scheme's
99 /// `on_background`.
100 ///
101 /// `ScrollIndicatorDefaults.colors()` reads one token and moves it to two
102 /// lightnesses: `setLuminance(fromToken(OnBackground), 80f)` for the thumb
103 /// and `setLuminance(..., 20f)` for the track. Nothing else feeds it — not
104 /// `outline`, not an alpha over the background — and both come from
105 /// `onBackground` rather than from `onSurface` or `primary`.
106 ///
107 /// The move itself is a **CAM16** round trip, not a CIE L\*a\*b\* one; see
108 /// [`crate::widgets::wear::color_appearance::set_luminance`] for the two
109 /// models' disagreement and why substituting L\* in Lab passes a check
110 /// against the thumb and fails against the track.
111 ///
112 /// Overriding either afterwards is what
113 /// `ScrollIndicatorDefaults.colors(indicatorColor, trackColor)` does: it
114 /// copies over the derived pair.
115 pub fn with_wear_scroll_indicator(mut self) -> Self {
116 self.indicator_thumb = set_luminance(self.on_background, 80.0);
117 self.indicator_track = set_luminance(self.on_background, 20.0);
118 self
119 }
120}
121
122/// Wear's `DefaultTextStyle` paragraph policy: no font padding, the leading
123/// centred, nothing trimmed — and the font's own extent as a floor, which is
124/// the part that makes a 16sp/18sp style lay out in 38 pixels rather than 36.
125pub fn wear_line_height_style() -> LineHeightStyle {
126 LineHeightStyle {
127 alignment: LineHeightAlignment::Center,
128 trim: LineHeightTrim::None,
129 mode: LineHeightMode::Minimum,
130 }
131}
132
133/// One of Wear Material 3's type-scale entries.
134///
135/// `size` and `line_height` are in sp; `tracking` is letter spacing in sp.
136/// `weight` is both the `FontWeight` and the `wght` variation axis — the tokens
137/// set them to the same number.
138///
139/// `align` is not part of Wear's type scale — every token leaves it unset and a
140/// call site states it. It lives here anyway because the alternative is for
141/// every caller to reach into the resolved [`TextStyle`]'s paragraph style and
142/// overwrite one field, which is how a type scale stops being the thing that
143/// describes the text.
144#[derive(Clone, Copy, Debug, PartialEq)]
145pub struct WearTextStyle {
146 pub size_sp: f32,
147 pub line_height_sp: f32,
148 pub weight: u16,
149 pub tracking_sp: f32,
150 /// `TextAlign::Unspecified` on every scale entry, as in the tokens.
151 pub align: TextAlign,
152}
153
154impl WearTextStyle {
155 /// The family Wear's type scale resolves to on a real device.
156 ///
157 /// Wear's `TypefaceTokens.Brand` is `DeviceFontFamilyName("roboto-flex")`,
158 /// and naming that here would be the faithful-looking answer and the wrong
159 /// one. On the Wear OS 5 system image these widgets are measured against,
160 /// `/system/etc/fonts.xml` declares a `roboto-flex` family whose every entry
161 /// points at `RobotoFlex-Regular.ttf` — **a file the image does not ship**.
162 /// A family whose files cannot be opened is dropped, so the token does not
163 /// resolve and the platform falls back to `sans-serif`, which is Roboto.
164 /// That is what the pixels show, and it is what this names.
165 ///
166 /// Naming a family at all is not optional. A `TextStyle` that leaves
167 /// `font_family` as `None` only draws if some face happens to answer for the
168 /// default, and an app that registers the system fonts under their own
169 /// families — which is what a port matching Android's text has to do — has
170 /// no such face. The text then measures, lays out and rasterises to nothing:
171 /// a screen with correct geometry and no glyphs on it.
172 pub const BRAND_FAMILY: FontFamily = FontFamily::SansSerif;
173
174 /// `titleMedium` — what a `ListHeader` draws with.
175 pub const TITLE_MEDIUM: Self = Self {
176 size_sp: 16.0,
177 line_height_sp: 18.0,
178 weight: 550,
179 tracking_sp: 0.4,
180 align: TextAlign::Unspecified,
181 };
182 /// `labelMedium` — a `Button` label and a `SwitchButton` label.
183 pub const LABEL_MEDIUM: Self = Self {
184 size_sp: 15.0,
185 line_height_sp: 18.0,
186 weight: 500,
187 tracking_sp: 0.4,
188 align: TextAlign::Unspecified,
189 };
190 /// `labelSmall` — a secondary label.
191 pub const LABEL_SMALL: Self = Self {
192 size_sp: 13.0,
193 line_height_sp: 16.0,
194 weight: 500,
195 tracking_sp: 0.4,
196 align: TextAlign::Unspecified,
197 };
198 /// `bodyLarge` — the theme default, which a bare `Text` inherits. Note that
199 /// a bare `Text` that overrides only its `fontSize` keeps **this** line
200 /// height: a 12sp glyph in an 18sp line box is a real Wear screen, not a
201 /// mistake to unify away.
202 pub const BODY_LARGE: Self = Self {
203 size_sp: 16.0,
204 line_height_sp: 18.0,
205 weight: 450,
206 tracking_sp: 0.4,
207 align: TextAlign::Unspecified,
208 };
209
210 /// The same style at another glyph size, keeping the line height.
211 ///
212 /// This is the shape of Wear's `Text(text, fontSize = 12.sp)` — an override
213 /// of the size alone.
214 pub const fn at_size(self, size_sp: f32) -> Self {
215 Self { size_sp, ..self }
216 }
217
218 /// The same style with its line height stated outright.
219 pub const fn with_line_height(self, line_height_sp: f32) -> Self {
220 Self {
221 line_height_sp,
222 ..self
223 }
224 }
225
226 /// The same style aligned in its own width.
227 ///
228 /// Wear's own `Text(text, textAlign = TextAlign.Center)` — the shape every
229 /// credit line and every centred blurb on a watch screen takes.
230 pub const fn aligned(self, align: TextAlign) -> Self {
231 Self { align, ..self }
232 }
233
234 /// The Cranpose [`TextStyle`] this entry resolves to.
235 ///
236 /// The sizes stay in `Sp`, so the framework applies the user's text-size
237 /// setting once, at measure time. Pre-scaling them here and handing the
238 /// result to a `Text` would apply it twice.
239 pub fn resolve(self, color: Color) -> TextStyle {
240 self.resolve_in(color, Self::BRAND_FAMILY)
241 }
242
243 /// The same, drawn in a family the caller names.
244 ///
245 /// For a device whose `roboto-flex` really does resolve, or an app that
246 /// registered Wear's brand face under a name of its own.
247 pub fn resolve_in(self, color: Color, family: FontFamily) -> TextStyle {
248 TextStyle {
249 span_style: SpanStyle {
250 color: Some(color),
251 font_size: TextUnit::Sp(self.size_sp),
252 font_weight: Some(FontWeight(self.weight)),
253 letter_spacing: TextUnit::Sp(self.tracking_sp),
254 font_family: Some(family),
255 ..SpanStyle::default()
256 },
257 paragraph_style: ParagraphStyle {
258 line_height: TextUnit::Sp(self.line_height_sp),
259 text_align: self.align,
260 line_height_style: Some(wear_line_height_style()),
261 platform_style: Some(PlatformParagraphStyle {
262 include_font_padding: Some(false),
263 shaping: None,
264 }),
265 ..ParagraphStyle::default()
266 },
267 }
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274
275 #[test]
276 fn the_indicator_colours_come_from_on_background_and_nothing_else() {
277 // Measured against the shipping Compose build with this palette: the
278 // thumb is (180, 202, 211) and the track (30, 51, 58). A Lab round trip
279 // agrees on the thumb and gives 31 of red on the track, so a scheme
280 // whose track lands on 30 is the one that read Wear's own rule.
281 let colors = WearColors {
282 on_background: Color::from_rgb_u8(0xDF, 0xF6, 0xFF),
283 ..WearColors::default()
284 }
285 .with_wear_scroll_indicator();
286 assert_eq!(colors.indicator_thumb, Color::from_rgb_u8(180, 202, 211));
287 assert_eq!(colors.indicator_track, Color::from_rgb_u8(30, 51, 58));
288 // And the default scheme is derived by the same rule rather than
289 // written out beside it.
290 let default = WearColors::default();
291 assert_eq!(default, default.with_wear_scroll_indicator());
292 }
293
294 #[test]
295 fn a_bare_text_is_white_and_a_header_is_not() {
296 let colors = WearColors::default();
297 assert_eq!(colors.content, Color::WHITE);
298 assert_ne!(colors.content, colors.on_background);
299 }
300
301 #[test]
302 fn the_type_scale_carries_the_sizes_wear_declares() {
303 assert_eq!(WearTextStyle::TITLE_MEDIUM.size_sp, 16.0);
304 assert_eq!(WearTextStyle::TITLE_MEDIUM.line_height_sp, 18.0);
305 assert_eq!(WearTextStyle::TITLE_MEDIUM.weight, 550);
306 assert_eq!(WearTextStyle::LABEL_MEDIUM.size_sp, 15.0);
307 assert_eq!(WearTextStyle::LABEL_SMALL.size_sp, 13.0);
308 assert_eq!(WearTextStyle::LABEL_SMALL.line_height_sp, 16.0);
309 assert_eq!(WearTextStyle::BODY_LARGE.weight, 450);
310 for style in [
311 WearTextStyle::TITLE_MEDIUM,
312 WearTextStyle::LABEL_MEDIUM,
313 WearTextStyle::LABEL_SMALL,
314 WearTextStyle::BODY_LARGE,
315 ] {
316 assert_eq!(style.tracking_sp, 0.4);
317 }
318 }
319
320 #[test]
321 fn overriding_the_size_keeps_the_line_height_it_inherited() {
322 // A 12sp glyph in an 18sp line box. Unifying the two is the quirk this
323 // exists to preserve.
324 let small = WearTextStyle::BODY_LARGE.at_size(12.0);
325 assert_eq!(small.size_sp, 12.0);
326 assert_eq!(small.line_height_sp, 18.0);
327 let credit_line = small.with_line_height(16.0);
328 assert_eq!(credit_line.line_height_sp, 16.0);
329 }
330
331 #[test]
332 fn the_scale_itself_states_no_alignment_and_a_call_site_can() {
333 // Wear's tokens set no `textAlign`; `Text(textAlign = Center)` at the
334 // call site is what centres a credit line.
335 for style in [
336 WearTextStyle::TITLE_MEDIUM,
337 WearTextStyle::LABEL_MEDIUM,
338 WearTextStyle::LABEL_SMALL,
339 WearTextStyle::BODY_LARGE,
340 ] {
341 assert_eq!(style.align, TextAlign::Unspecified);
342 assert_eq!(
343 style.resolve(Color::WHITE).paragraph_style.text_align,
344 TextAlign::Unspecified
345 );
346 }
347 let centred = WearTextStyle::BODY_LARGE
348 .at_size(12.0)
349 .with_line_height(16.0)
350 .aligned(TextAlign::Center);
351 assert_eq!(centred.align, TextAlign::Center);
352 assert_eq!(
353 centred.resolve(Color::WHITE).paragraph_style.text_align,
354 TextAlign::Center
355 );
356 // Aligning must not disturb the rest of the entry.
357 assert_eq!(centred.size_sp, 12.0);
358 assert_eq!(centred.line_height_sp, 16.0);
359 assert_eq!(centred.weight, WearTextStyle::BODY_LARGE.weight);
360 }
361
362 #[test]
363 fn every_entry_names_a_family_so_its_text_has_a_face_to_draw_with() {
364 // A `TextStyle` with no family draws only if some face answers for the
365 // default. An app that registers the system fonts under their own
366 // families has none, and the text then measures, lays out and
367 // rasterises to nothing -- correct geometry, no glyphs.
368 for style in [
369 WearTextStyle::TITLE_MEDIUM,
370 WearTextStyle::LABEL_MEDIUM,
371 WearTextStyle::LABEL_SMALL,
372 WearTextStyle::BODY_LARGE,
373 ] {
374 assert_eq!(
375 style.resolve(Color::WHITE).span_style.font_family,
376 Some(FontFamily::SansSerif),
377 "Wear's brand token resolves to sans-serif on a device with no \
378 RobotoFlex-Regular.ttf, which is every Wear OS 5 image measured"
379 );
380 }
381 assert_eq!(
382 WearTextStyle::BODY_LARGE
383 .resolve_in(Color::WHITE, FontFamily::Monospace)
384 .span_style
385 .font_family,
386 Some(FontFamily::Monospace),
387 "a caller can still name its own"
388 );
389 }
390
391 #[test]
392 fn a_resolved_style_keeps_its_sizes_in_sp_for_the_framework_to_scale() {
393 let style = WearTextStyle::LABEL_MEDIUM.resolve(Color::WHITE);
394 assert_eq!(style.span_style.font_size, TextUnit::Sp(15.0));
395 assert_eq!(style.paragraph_style.line_height, TextUnit::Sp(18.0));
396 assert_eq!(style.span_style.font_weight, Some(FontWeight(500)));
397 assert_eq!(style.span_style.letter_spacing, TextUnit::Sp(0.4));
398 }
399
400 #[test]
401 fn a_resolved_style_asks_for_the_wear_line_box_rule() {
402 let style = WearTextStyle::TITLE_MEDIUM.resolve(Color::WHITE);
403 let policy = style
404 .paragraph_style
405 .line_height_style
406 .expect("a Wear style names its line-height policy");
407 assert_eq!(policy.alignment, LineHeightAlignment::Center);
408 assert_eq!(policy.trim, LineHeightTrim::None);
409 assert_eq!(policy.mode, LineHeightMode::Minimum);
410 assert_eq!(
411 style
412 .paragraph_style
413 .platform_style
414 .and_then(|platform| platform.include_font_padding),
415 Some(false)
416 );
417 }
418}