Skip to main content

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