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}