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