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::{
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    fn default() -> Self {
73        Self {
74            primary: Color::from_rgb_u8(0xA8, 0xC7, 0xFA),
75            primary_container: Color::from_rgb_u8(0x0B, 0x57, 0xD0),
76            on_primary: Color::from_rgb_u8(0x00, 0x00, 0x00),
77            on_primary_container: Color::from_rgb_u8(0xD3, 0xE3, 0xFD),
78            surface_container: Color::from_rgb_u8(0x1E, 0x1F, 0x20),
79            on_surface: Color::from_rgb_u8(0xE3, 0xE3, 0xE3),
80            on_surface_variant: Color::from_rgb_u8(0xC4, 0xC7, 0xC5),
81            outline: Color::from_rgb_u8(0x8E, 0x91, 0x8F),
82            background: Color::from_rgb_u8(0x00, 0x00, 0x00),
83            on_background: Color::from_rgb_u8(0xE3, 0xE3, 0xE3),
84            content: Color::WHITE,
85            indicator_thumb: Color::WHITE,
86            indicator_track: Color::WHITE,
87        }
88        .with_wear_scroll_indicator()
89    }
90}
91
92impl WearColors {
93    /// The two scroll-indicator colours Wear derives from this scheme's
94    /// `on_background`.
95    ///
96    /// `ScrollIndicatorDefaults.colors()` reads one token and moves it to two
97    /// lightnesses: `setLuminance(fromToken(OnBackground), 80f)` for the thumb
98    /// and `setLuminance(..., 20f)` for the track. Nothing else feeds it — not
99    /// `outline`, not an alpha over the background — and both come from
100    /// `onBackground` rather than from `onSurface` or `primary`.
101    ///
102    /// The move itself is a **CAM16** round trip, not a CIE L\*a\*b\* one; see
103    /// [`crate::widgets::wear::color_appearance::set_luminance`] for the two
104    /// models' disagreement and why substituting L\* in Lab passes a check
105    /// against the thumb and fails against the track.
106    ///
107    /// Overriding either afterwards is what
108    /// `ScrollIndicatorDefaults.colors(indicatorColor, trackColor)` does: it
109    /// copies over the derived pair.
110    pub fn with_wear_scroll_indicator(mut self) -> Self {
111        self.indicator_thumb = set_luminance(self.on_background, 80.0);
112        self.indicator_track = set_luminance(self.on_background, 20.0);
113        self
114    }
115}
116
117/// Wear's `DefaultTextStyle` paragraph policy: no font padding, the leading
118/// centred, nothing trimmed — and the font's own extent as a floor, which is
119/// the part that makes a 16sp/18sp style lay out in 38 pixels rather than 36.
120pub fn wear_line_height_style() -> LineHeightStyle {
121    LineHeightStyle {
122        alignment: LineHeightAlignment::Center,
123        trim: LineHeightTrim::None,
124        mode: LineHeightMode::Minimum,
125    }
126}
127
128/// One of Wear Material 3's type-scale entries.
129///
130/// `size` and `line_height` are in sp; `tracking` is letter spacing in sp.
131/// `weight` is both the `FontWeight` and the `wght` variation axis — the tokens
132/// set them to the same number.
133///
134/// `align` is not part of Wear's type scale — every token leaves it unset and a
135/// call site states it. It lives here anyway because the alternative is for
136/// every caller to reach into the resolved [`TextStyle`]'s paragraph style and
137/// overwrite one field, which is how a type scale stops being the thing that
138/// describes the text.
139#[derive(Clone, Copy, Debug, PartialEq)]
140pub struct WearTextStyle {
141    pub size_sp: f32,
142    pub line_height_sp: f32,
143    pub weight: u16,
144    pub tracking_sp: f32,
145    /// `TextAlign::Unspecified` on every scale entry, as in the tokens.
146    pub align: TextAlign,
147}
148
149impl WearTextStyle {
150    /// The family Wear's type scale resolves to on a real device.
151    ///
152    /// Wear's `TypefaceTokens.Brand` is `DeviceFontFamilyName("roboto-flex")`,
153    /// and naming that here would be the faithful-looking answer and the wrong
154    /// one. On the Wear OS 5 system image these widgets are measured against,
155    /// `/system/etc/fonts.xml` declares a `roboto-flex` family whose every entry
156    /// points at `RobotoFlex-Regular.ttf` — **a file the image does not ship**.
157    /// A family whose files cannot be opened is dropped, so the token does not
158    /// resolve and the platform falls back to `sans-serif`, which is Roboto.
159    /// That is what the pixels show, and it is what this names.
160    ///
161    /// Naming a family at all is not optional. A `TextStyle` that leaves
162    /// `font_family` as `None` only draws if some face happens to answer for the
163    /// default, and an app that registers the system fonts under their own
164    /// families — which is what a port matching Android's text has to do — has
165    /// no such face. The text then measures, lays out and rasterises to nothing:
166    /// a screen with correct geometry and no glyphs on it.
167    pub const BRAND_FAMILY: FontFamily = FontFamily::SansSerif;
168
169    /// `titleMedium` — what a `ListHeader` draws with.
170    pub const TITLE_MEDIUM: Self = Self {
171        size_sp: 16.0,
172        line_height_sp: 18.0,
173        weight: 550,
174        tracking_sp: 0.4,
175        align: TextAlign::Unspecified,
176    };
177    /// `labelMedium` — a `Button` label and a `SwitchButton` label.
178    pub const LABEL_MEDIUM: Self = Self {
179        size_sp: 15.0,
180        line_height_sp: 18.0,
181        weight: 500,
182        tracking_sp: 0.4,
183        align: TextAlign::Unspecified,
184    };
185    /// `labelSmall` — a secondary label.
186    pub const LABEL_SMALL: Self = Self {
187        size_sp: 13.0,
188        line_height_sp: 16.0,
189        weight: 500,
190        tracking_sp: 0.4,
191        align: TextAlign::Unspecified,
192    };
193    /// `bodyLarge` — the theme default, which a bare `Text` inherits. Note that
194    /// a bare `Text` that overrides only its `fontSize` keeps **this** line
195    /// height: a 12sp glyph in an 18sp line box is a real Wear screen, not a
196    /// mistake to unify away.
197    pub const BODY_LARGE: Self = Self {
198        size_sp: 16.0,
199        line_height_sp: 18.0,
200        weight: 450,
201        tracking_sp: 0.4,
202        align: TextAlign::Unspecified,
203    };
204
205    /// The same style at another glyph size, keeping the line height.
206    ///
207    /// This is the shape of Wear's `Text(text, fontSize = 12.sp)` — an override
208    /// of the size alone.
209    pub const fn at_size(self, size_sp: f32) -> Self {
210        Self { size_sp, ..self }
211    }
212
213    /// The same style with its line height stated outright.
214    pub const fn with_line_height(self, line_height_sp: f32) -> Self {
215        Self {
216            line_height_sp,
217            ..self
218        }
219    }
220
221    /// The same style aligned in its own width.
222    ///
223    /// Wear's own `Text(text, textAlign = TextAlign.Center)` — the shape every
224    /// credit line and every centred blurb on a watch screen takes.
225    pub const fn aligned(self, align: TextAlign) -> Self {
226        Self { align, ..self }
227    }
228
229    /// The Cranpose [`TextStyle`] this entry resolves to.
230    ///
231    /// The sizes stay in `Sp`, so the framework applies the user's text-size
232    /// setting once, at measure time. Pre-scaling them here and handing the
233    /// result to a `Text` would apply it twice.
234    pub fn resolve(self, color: Color) -> TextStyle {
235        self.resolve_in(color, Self::BRAND_FAMILY)
236    }
237
238    /// The same, drawn in a family the caller names.
239    ///
240    /// For a device whose `roboto-flex` really does resolve, or an app that
241    /// registered Wear's brand face under a name of its own.
242    pub fn resolve_in(self, color: Color, family: FontFamily) -> TextStyle {
243        TextStyle {
244            span_style: SpanStyle {
245                color: Some(color),
246                font_size: TextUnit::Sp(self.size_sp),
247                font_weight: Some(FontWeight(self.weight)),
248                letter_spacing: TextUnit::Sp(self.tracking_sp),
249                font_family: Some(family),
250                ..SpanStyle::default()
251            },
252            paragraph_style: ParagraphStyle {
253                line_height: TextUnit::Sp(self.line_height_sp),
254                text_align: self.align,
255                line_height_style: Some(wear_line_height_style()),
256                platform_style: Some(PlatformParagraphStyle {
257                    include_font_padding: Some(false),
258                    shaping: None,
259                }),
260                ..ParagraphStyle::default()
261            },
262        }
263    }
264}
265
266#[cfg(test)]
267#[path = "tests/theme_tests.rs"]
268mod tests;