Skip to main content

wisp/text/
presets.rs

1//! Curated [`WispTextStyle`] presets for common screen-recording
2//! captions (M-TEXT.12 / AUT-86).
3//!
4//! Each preset is a `const fn` returning a [`WispTextStyle`] — no
5//! allocation, no GPU dependency. Pick one with [`TextPreset::style`]
6//! or by calling the named accessor directly.
7//!
8//! Presets are tuned for a 16:9 export at the storybook default
9//! viewport (256×256 / 640×360). Resize them by chaining
10//! [`WispTextStyle::with_size`] if you need a different scale.
11
12use crate::color::Color;
13use crate::text::{WispFontStyle, WispFontWeight, WispTextAlign, WispTextStyle};
14
15/// Named text style — pick one and `.style()` to a [`WispTextStyle`].
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub enum TextPreset {
18    /// Headline / hero line — large, bold, centered.
19    SectionTitle,
20    /// Caption / subtitle — left-aligned body copy.
21    Caption,
22    /// Vertical callout — italic medium emphasis.
23    Callout,
24    /// Keyboard shortcut chip — small monospaced (the renderer maps
25    /// `WispFontHandle::default()` to system mono via cosmic-text's
26    /// generic fallback when the named font isn't loaded).
27    KeyboardShortcut,
28    /// Step / hint badge — tiny bold uppercase-y label.
29    StepBadge,
30    /// Warning / privacy label — uppercase, high-contrast.
31    WarningPrivacyLabel,
32    /// Watermark — small, low-contrast, italic.
33    Watermark,
34}
35
36impl TextPreset {
37    /// Pick the [`WispTextStyle`] matching this preset.
38    #[must_use]
39    pub fn style(self) -> WispTextStyle {
40        match self {
41            Self::SectionTitle => section_title(),
42            Self::Caption => caption(),
43            Self::Callout => callout(),
44            Self::KeyboardShortcut => keyboard_shortcut(),
45            Self::StepBadge => step_badge(),
46            Self::WarningPrivacyLabel => warning_privacy_label(),
47            Self::Watermark => watermark(),
48        }
49    }
50
51    /// Iterator over every preset, in display order. Useful for the
52    /// preset-gallery story and any test that wants to exercise the
53    /// full set.
54    #[must_use]
55    pub fn all() -> [Self; 7] {
56        [
57            Self::SectionTitle,
58            Self::Caption,
59            Self::Callout,
60            Self::KeyboardShortcut,
61            Self::StepBadge,
62            Self::WarningPrivacyLabel,
63            Self::Watermark,
64        ]
65    }
66
67    /// Short human-readable label for the preset (story titles, docs).
68    #[must_use]
69    pub const fn label(self) -> &'static str {
70        match self {
71            Self::SectionTitle => "Section title",
72            Self::Caption => "Caption",
73            Self::Callout => "Callout",
74            Self::KeyboardShortcut => "Keyboard shortcut",
75            Self::StepBadge => "Step badge",
76            Self::WarningPrivacyLabel => "Warning / privacy label",
77            Self::Watermark => "Watermark",
78        }
79    }
80}
81
82/// Headline style — large, bold, centered.
83#[must_use]
84pub fn section_title() -> WispTextStyle {
85    WispTextStyle {
86        size_ndc: 0.18,
87        color: Color::rgba_u8(248, 250, 255, 255),
88        line_height: 1.1,
89        letter_spacing_ndc: -0.002,
90        weight: WispFontWeight::Bold,
91        style: WispFontStyle::Normal,
92        align: WispTextAlign::Center,
93        ..WispTextStyle::default()
94    }
95}
96
97/// Body caption — left-aligned, medium weight, ample line height.
98#[must_use]
99pub fn caption() -> WispTextStyle {
100    WispTextStyle {
101        size_ndc: 0.075,
102        color: Color::rgba_u8(240, 240, 245, 255),
103        line_height: 1.30,
104        letter_spacing_ndc: 0.0,
105        weight: WispFontWeight::Regular,
106        style: WispFontStyle::Normal,
107        align: WispTextAlign::Left,
108        ..WispTextStyle::default()
109    }
110}
111
112/// Callout — italic, medium-bold, tighter spacing.
113#[must_use]
114pub fn callout() -> WispTextStyle {
115    WispTextStyle {
116        size_ndc: 0.085,
117        color: Color::rgba_u8(255, 235, 200, 255),
118        line_height: 1.25,
119        letter_spacing_ndc: 0.0,
120        weight: WispFontWeight::Medium,
121        style: WispFontStyle::Italic,
122        align: WispTextAlign::Left,
123        ..WispTextStyle::default()
124    }
125}
126
127/// Keyboard shortcut chip — small, tight, monospace-feel.
128#[must_use]
129pub fn keyboard_shortcut() -> WispTextStyle {
130    WispTextStyle {
131        size_ndc: 0.055,
132        color: Color::rgba_u8(220, 230, 240, 255),
133        line_height: 1.0,
134        letter_spacing_ndc: 0.002,
135        weight: WispFontWeight::Medium,
136        style: WispFontStyle::Normal,
137        align: WispTextAlign::Center,
138        ..WispTextStyle::default()
139    }
140}
141
142/// Step / hint badge — bold, tight, slightly wider spacing.
143#[must_use]
144pub fn step_badge() -> WispTextStyle {
145    WispTextStyle {
146        size_ndc: 0.05,
147        color: Color::rgba_u8(255, 240, 215, 255),
148        line_height: 1.0,
149        letter_spacing_ndc: 0.004,
150        weight: WispFontWeight::Bold,
151        style: WispFontStyle::Normal,
152        align: WispTextAlign::Center,
153        ..WispTextStyle::default()
154    }
155}
156
157/// Warning / privacy label — bold, wide spacing, signal-red.
158#[must_use]
159pub fn warning_privacy_label() -> WispTextStyle {
160    WispTextStyle {
161        size_ndc: 0.06,
162        color: Color::rgba_u8(255, 100, 90, 255),
163        line_height: 1.0,
164        letter_spacing_ndc: 0.006,
165        weight: WispFontWeight::Bold,
166        style: WispFontStyle::Normal,
167        align: WispTextAlign::Center,
168        ..WispTextStyle::default()
169    }
170}
171
172/// Watermark — small, italic, low-contrast.
173#[must_use]
174pub fn watermark() -> WispTextStyle {
175    WispTextStyle {
176        size_ndc: 0.04,
177        color: Color::rgba_u8(200, 200, 210, 160), // 160/255 ≈ 63% alpha
178        line_height: 1.0,
179        letter_spacing_ndc: 0.001,
180        weight: WispFontWeight::Light,
181        style: WispFontStyle::Italic,
182        align: WispTextAlign::Right,
183        ..WispTextStyle::default()
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn all_returns_seven_presets() {
193        assert_eq!(TextPreset::all().len(), 7);
194    }
195
196    #[test]
197    fn every_preset_has_positive_size() {
198        for p in TextPreset::all() {
199            let style = p.style();
200            assert!(style.size_ndc > 0.0, "{} has non-positive size", p.label());
201            assert!(style.line_height > 0.0);
202        }
203    }
204
205    #[test]
206    fn section_title_is_centered_bold() {
207        let s = section_title();
208        assert_eq!(s.align, WispTextAlign::Center);
209        assert_eq!(s.weight, WispFontWeight::Bold);
210    }
211
212    #[test]
213    fn warning_is_signal_red() {
214        let c = warning_privacy_label().color;
215        // Red channel dominant, green + blue much lower.
216        assert!(c.r > 0.8 && c.g < 0.5 && c.b < 0.5);
217    }
218
219    #[test]
220    fn watermark_is_low_contrast_and_italic() {
221        let s = watermark();
222        assert_eq!(s.style, WispFontStyle::Italic);
223        assert!(s.color.a < 1.0, "watermark should have alpha < 1");
224    }
225
226    #[test]
227    fn callout_is_italic() {
228        assert_eq!(callout().style, WispFontStyle::Italic);
229    }
230
231    #[test]
232    fn distinct_sizes_across_presets() {
233        // No two presets should be byte-identical.
234        let styles: Vec<_> = TextPreset::all().iter().map(|p| p.style()).collect();
235        for i in 0..styles.len() {
236            for j in (i + 1)..styles.len() {
237                assert_ne!(styles[i], styles[j], "presets {i} and {j} are identical");
238            }
239        }
240    }
241
242    #[test]
243    fn labels_are_unique_and_non_empty() {
244        let mut labels: Vec<_> = TextPreset::all().iter().map(|p| p.label()).collect();
245        labels.sort_unstable();
246        labels.dedup();
247        assert_eq!(labels.len(), TextPreset::all().len());
248        for label in &labels {
249            assert!(!label.is_empty());
250        }
251    }
252}