Skip to main content

frust_theme/
typefaces.rs

1//! [`NativeTypefaces`]: the native-control typeface binding a design system
2//! attaches as a [`crate::extensions::ThemeExtensions`] payload.
3//!
4//! Every widget this repo paints itself resolves its face from
5//! [`crate::typography::TypeScale`] (a `frust_text::TextStyle` family name,
6//! resolved by the text engine's own font stack). A **native** control —
7//! `frust-native-widgets`' Android `TextView`/iOS `UILabel` family — cannot:
8//! the platform resolves fonts itself, so its host half needs the raw face
9//! *bytes* to register with `Typeface.createFromFile`/CoreText before any
10//! control can name the face at all.
11//!
12//! This extension is that seam, and the **only** route those bytes take: a
13//! design system attaches its two faces here, and a native host reads them
14//! back with `theme.extension::<NativeTypefaces>()`. Two slots — a
15//! button/display face and a body face — mirroring the split
16//! `frust-native-widgets`' theme ladder already applies (`Button` on one
17//! face, `Label`/`Switch` on the other), and matching the two-payload shape
18//! its platform publish seam already carries; a design system with one face
19//! for everything uses [`NativeTypefaces::uniform`].
20//!
21//! # No CORE baseline attaches this
22//!
23//! Unlike [`StatusPalette`](crate::status::StatusPalette) (attached to
24//! [`Theme::neutral`](crate::theme::Theme::neutral)), nothing this crate
25//! constructs attaches `NativeTypefaces`. Attaching it is exclusively a
26//! design system's job, and its absence is what tells a native host to leave
27//! the platform's own face alone — `frust_glyph`'s baseline is the shipped
28//! example of a design system that *does* attach it, which is how its
29//! monospace faces reach native controls at all. See
30//! `docs/NATIVE_WIDGETS_ARCHITECTURE.md`'s theme ladder.
31//!
32//! # Why `&'static [u8]`
33//!
34//! A design system embeds its faces with `include_bytes!`, so `'static` bytes
35//! are what a caller already has and what a platform registration call
36//! already wants (no copy, no `Arc`, no lifetime threading through the
37//! theme). It also keeps [`FontFace`] `Copy` and keeps
38//! [`Theme`](crate::theme::Theme)'s own `Clone`/`PartialEq` cheap.
39//!
40//! Note that [`ThemeExtensions`](crate::extensions::ThemeExtensions)'
41//! `PartialEq` compares the *set of attached types*, never their values (see
42//! that module) — so two `Theme`s differing only in which faces this
43//! extension carries compare **equal**. A consumer that must react to a face
44//! swap therefore keys off the payload itself (identity or content), never
45//! off `Theme` equality.
46//!
47//! # Examples
48//!
49//! ```
50//! use frust_theme::{FontFace, NativeTypefaces, Theme};
51//!
52//! // A design system's own embedded faces (`include_bytes!` in real code).
53//! static DISPLAY: &[u8] = b"<display face bytes>";
54//! static BODY: &[u8] = b"<body face bytes>";
55//!
56//! let theme = Theme::builder(Theme::neutral())
57//!     .extension(NativeTypefaces {
58//!         button: Some(FontFace::new("Acme Display", DISPLAY)),
59//!         body: Some(FontFace::new("Acme Text", BODY)),
60//!     })
61//!     .build();
62//!
63//! let faces = theme.extension::<NativeTypefaces>().expect("attached above");
64//! assert_eq!(faces.button.map(|f| f.family), Some("Acme Display"));
65//! ```
66
67/// One font face a native host can register: a stable family name plus the
68/// raw face bytes.
69///
70/// `family` is for diagnostics and de-duplication only — it is never handed
71/// to a platform font *lookup* (the whole point of carrying bytes is that the
72/// platform has no way to look the family up), so it may be any stable,
73/// human-meaningful name for the face.
74///
75/// `bytes` is one face's complete font file (TTF/OTF). Equality is by
76/// *content* (derived), which is rarely what a hot path wants — see the
77/// module doc's note on keying off payload identity instead.
78#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
79pub struct FontFace {
80    /// A stable family name for this face — diagnostics/de-duplication only.
81    pub family: &'static str,
82    /// The face's complete font-file bytes (TTF/OTF).
83    pub bytes: &'static [u8],
84}
85
86impl FontFace {
87    /// A face from its family name and embedded bytes — `const` so a design
88    /// system can declare its faces as `const`/`static` items alongside the
89    /// `include_bytes!` payload they wrap.
90    pub const fn new(family: &'static str, bytes: &'static [u8]) -> Self {
91        Self { family, bytes }
92    }
93}
94
95/// The native-control typeface binding: a display/button face and a body
96/// face, either of which may be left unset (falling back to whatever the
97/// native host resolves without it — see the module doc).
98///
99/// [`Default`] is both slots unset, i.e. "attached but selecting nothing" —
100/// equivalent to not attaching the extension at all as far as a host's
101/// fallback ladder is concerned.
102#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
103pub struct NativeTypefaces {
104    /// The face a native *button*/display control resolves to.
105    pub button: Option<FontFace>,
106    /// The face a native *body*-text control (label, switch, and any other
107    /// text-bearing control) resolves to.
108    pub body: Option<FontFace>,
109}
110
111impl NativeTypefaces {
112    /// Both slots from one face — the common case for a design system with a
113    /// single UI face.
114    pub const fn uniform(face: FontFace) -> Self {
115        Self {
116            button: Some(face),
117            body: Some(face),
118        }
119    }
120
121    /// Whether neither slot carries a face (see [`Default`]).
122    pub const fn is_empty(&self) -> bool {
123        self.button.is_none() && self.body.is_none()
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::theme::Theme;
131
132    static DISPLAY: &[u8] = b"display-face-bytes";
133    static BODY: &[u8] = b"body-face-bytes";
134
135    fn display() -> FontFace {
136        FontFace::new("Acme Display", DISPLAY)
137    }
138
139    fn body() -> FontFace {
140        FontFace::new("Acme Text", BODY)
141    }
142
143    #[test]
144    fn builder_attach_then_theme_extension_round_trips() {
145        let theme = Theme::builder(Theme::neutral())
146            .extension(NativeTypefaces {
147                button: Some(display()),
148                body: Some(body()),
149            })
150            .build();
151
152        let faces = theme
153            .extension::<NativeTypefaces>()
154            .expect("attached just above");
155        assert_eq!(faces.button, Some(display()));
156        assert_eq!(faces.body, Some(body()));
157        assert_eq!(faces.button.expect("set").bytes, DISPLAY);
158    }
159
160    #[test]
161    fn direct_insert_round_trips_too() {
162        // The `extensions.insert` route an app takes on an already-built
163        // `Theme`, rather than through the builder.
164        let mut theme = Theme::neutral();
165        theme.extensions.insert(NativeTypefaces::uniform(body()));
166        assert_eq!(
167            theme.extension::<NativeTypefaces>(),
168            Some(&NativeTypefaces::uniform(body()))
169        );
170    }
171
172    #[test]
173    fn re_attaching_replaces_last_write_wins() {
174        let theme = Theme::builder(Theme::neutral())
175            .extension(NativeTypefaces::uniform(display()))
176            .extension(NativeTypefaces::uniform(body()))
177            .build();
178        assert_eq!(
179            theme.extension::<NativeTypefaces>(),
180            Some(&NativeTypefaces::uniform(body()))
181        );
182    }
183
184    #[test]
185    fn uniform_sets_both_slots_and_is_not_empty() {
186        let faces = NativeTypefaces::uniform(body());
187        assert_eq!(faces.button, Some(body()));
188        assert_eq!(faces.body, Some(body()));
189        assert!(!faces.is_empty());
190    }
191
192    #[test]
193    fn default_leaves_both_slots_unset() {
194        let faces = NativeTypefaces::default();
195        assert!(faces.button.is_none());
196        assert!(faces.body.is_none());
197        assert!(faces.is_empty());
198        // A half-filled binding is still not empty.
199        assert!(
200            !NativeTypefaces {
201                button: Some(display()),
202                ..NativeTypefaces::default()
203            }
204            .is_empty()
205        );
206    }
207
208    #[test]
209    fn no_core_baseline_attaches_this_extension() {
210        // The module doc's contract: attaching is exclusively a design
211        // system's job, and absence is what tells a native host to leave the
212        // platform's own face alone. `Theme::neutral()` is the only baseline
213        // this crate constructs, so it is the whole of "no CORE baseline"; a
214        // design system's baseline (`frust_glyph`'s, say) deliberately DOES
215        // attach one, and carries its own test for that.
216        assert!(Theme::neutral().extension::<NativeTypefaces>().is_none());
217    }
218
219    #[test]
220    fn theme_equality_ignores_a_face_swap() {
221        // `ThemeExtensions`' PartialEq compares the attached *type set*, so a
222        // consumer must not diff themes to notice a face swap (module doc).
223        let with_display = Theme::builder(Theme::neutral())
224            .extension(NativeTypefaces::uniform(display()))
225            .build();
226        let with_body = Theme::builder(Theme::neutral())
227            .extension(NativeTypefaces::uniform(body()))
228            .build();
229        assert_eq!(with_display, with_body);
230        assert_ne!(
231            with_display.extension::<NativeTypefaces>(),
232            with_body.extension::<NativeTypefaces>(),
233            "the payloads themselves still differ — which is what a consumer \
234             keys off"
235        );
236    }
237}