Skip to main content

wisp/text/
mod.rs

1//! Wisp text abstraction + backend boundary (M-TEXT.1 / AUT-75).
2//!
3//! Backend modules live under this one — see [`atlas`] for the
4//! bitmap-font path (M-TEXT.4 / AUT-78). M-TEXT.2/.3 will add a
5//! `flexible` module wrapping `cosmic_text` + `glyphon`.
6//!
7//! Wisp owns the text data model. App, editor, and project state never
8//! see `cosmic_text::*` or `glyphon::*` types — they see [`WispText`],
9//! [`WispTextStyle`], [`WispTextLayout`], and a few related value types.
10//! Backends ([`WispTextEngine`] + [`WispTextRenderer`]) plug in behind
11//! this surface; today the only backend is the M0.15 bitmap path
12//! (preserved as `AtlasText` in M-TEXT.4 / AUT-78). M-TEXT.2/.3 add a
13//! Cosmic Text + Glyphon `FlexibleText` backend.
14//!
15//! The boundary is what makes "improve text rendering" possible without
16//! a project-format breaking change.
17//!
18//! ## Type relationships
19//!
20//! ```text
21//!   WispText { content, style, position }
22//!         │
23//!         │ engine.layout(text)
24//!         ▼
25//!   Box<dyn WispTextLayout>           ── line-broken, per-glyph data
26//!         │ metrics()                       (engine-specific concrete type)
27//!         │
28//!         ▼
29//!   renderer.draw(layout, transform)   ── GPU side
30//! ```
31//!
32//! For most users [`WispText`] is the only type they construct directly.
33//! Backends are selected through whichever method on
34//! [`crate::render::Renderer`] consumes the text (e.g. `apply_atlas_text`
35//! today; `apply_flexible_text` after M-TEXT.3).
36
37use glam::Vec2;
38
39use crate::color::Color;
40
41pub mod atlas;
42pub mod caption;
43pub mod flexible;
44pub mod flexible_renderer;
45pub mod presets;
46pub mod stroke;
47pub mod texture;
48
49pub use atlas::{AtlasGlyphInstance, AtlasTextEngine, AtlasTextLayout};
50pub use caption::{CaptionBlock, CaptionLayout};
51pub use flexible::{FlexibleTextEngine, FlexibleTextLayout};
52pub use flexible_renderer::FlexibleTextRenderer;
53pub use presets::TextPreset;
54pub use stroke::{StrokedTextLayer, stroked_text_sprites};
55pub use texture::{TextTextureCache, TextTextureKey, TextTexturePipeline};
56
57/// Font weight on a 100..=900 scale matching CSS / OpenType.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
59#[non_exhaustive]
60pub enum WispFontWeight {
61    /// 100.
62    Thin,
63    /// 300.
64    Light,
65    /// 400. Default.
66    #[default]
67    Regular,
68    /// 500.
69    Medium,
70    /// 700.
71    Bold,
72    /// 900.
73    Black,
74    /// Custom numeric weight, clamped to `[100, 900]`.
75    Custom(u16),
76}
77
78impl WispFontWeight {
79    /// Numeric weight value (CSS-style integer, 100..=900).
80    #[must_use]
81    pub fn value(self) -> u16 {
82        match self {
83            Self::Thin => 100,
84            Self::Light => 300,
85            Self::Regular => 400,
86            Self::Medium => 500,
87            Self::Bold => 700,
88            Self::Black => 900,
89            Self::Custom(v) => v.clamp(100, 900),
90        }
91    }
92}
93
94/// Italic / oblique state.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
96pub enum WispFontStyle {
97    /// Upright. Default.
98    #[default]
99    Normal,
100    /// Italic.
101    Italic,
102}
103
104/// Horizontal alignment of laid-out lines within their box.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
106pub enum WispTextAlign {
107    /// Default.
108    #[default]
109    Left,
110    /// Center.
111    Center,
112    /// Right.
113    Right,
114}
115
116/// Font reference. Opaque identifier — the backend knows how to resolve
117/// it. Atlas backend treats it as a slot id; Cosmic Text backend
118/// treats it as a `Family + Weight + Style` query.
119///
120/// `Default` returns the renderer's "default font" (whatever the active
121/// backend exposes). Most callers should use [`WispFontHandle::default`]
122/// unless they're explicitly fan-outing across multiple fonts.
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
124pub struct WispFontHandle(u32);
125
126impl WispFontHandle {
127    /// Construct from a backend-supplied numeric id.
128    #[must_use]
129    pub const fn new(id: u32) -> Self {
130        Self(id)
131    }
132
133    /// Underlying backend id.
134    #[must_use]
135    pub const fn id(self) -> u32 {
136        self.0
137    }
138}
139
140/// Style applied to a `WispText`. Pure data — no third-party types.
141#[derive(Debug, Clone, Copy, PartialEq)]
142pub struct WispTextStyle {
143    /// Font selection.
144    pub font: WispFontHandle,
145    /// Size in NDC units (e.g. 0.06 ≈ 6% of canvas height).
146    pub size_ndc: f32,
147    /// Solid fill color.
148    pub color: Color,
149    /// Line height as a multiple of `size_ndc`. `1.2` is comfortable
150    /// for body copy; `1.0` is tight.
151    pub line_height: f32,
152    /// Letter spacing in NDC units (positive = wider).
153    pub letter_spacing_ndc: f32,
154    /// Font weight.
155    pub weight: WispFontWeight,
156    /// Italic / normal.
157    pub style: WispFontStyle,
158    /// Horizontal alignment within the layout box.
159    pub align: WispTextAlign,
160}
161
162impl Default for WispTextStyle {
163    fn default() -> Self {
164        Self {
165            font: WispFontHandle::default(),
166            size_ndc: 0.06,
167            color: Color::WHITE,
168            line_height: 1.2,
169            letter_spacing_ndc: 0.0,
170            weight: WispFontWeight::Regular,
171            style: WispFontStyle::Normal,
172            align: WispTextAlign::Left,
173        }
174    }
175}
176
177impl WispTextStyle {
178    /// Builder — set the font.
179    #[must_use]
180    pub fn with_font(mut self, font: WispFontHandle) -> Self {
181        self.font = font;
182        self
183    }
184
185    /// Builder — set the size in NDC units.
186    #[must_use]
187    pub fn with_size(mut self, size_ndc: f32) -> Self {
188        self.size_ndc = size_ndc;
189        self
190    }
191
192    /// Builder — set the fill color.
193    #[must_use]
194    pub fn with_color(mut self, color: Color) -> Self {
195        self.color = color;
196        self
197    }
198
199    /// Builder — set the weight.
200    #[must_use]
201    pub fn with_weight(mut self, weight: WispFontWeight) -> Self {
202        self.weight = weight;
203        self
204    }
205
206    /// Builder — set italic.
207    #[must_use]
208    pub fn italic(mut self) -> Self {
209        self.style = WispFontStyle::Italic;
210        self
211    }
212
213    /// Builder — set alignment.
214    #[must_use]
215    pub fn with_align(mut self, align: WispTextAlign) -> Self {
216        self.align = align;
217        self
218    }
219}
220
221/// Layout-time metrics for a piece of text — output of
222/// [`WispTextEngine::layout`].
223#[derive(Debug, Clone, Copy, PartialEq, Default)]
224pub struct WispTextMetrics {
225    /// Number of layout lines (1 for non-wrapped text).
226    pub line_count: u32,
227    /// Maximum line width in NDC units.
228    pub max_width_ndc: f32,
229    /// Total height (`line_count` × `line_height_ndc`) in NDC units.
230    pub total_height_ndc: f32,
231    /// Baseline of the FIRST line measured from `position.y`, NDC
232    /// units (positive moves down toward the bottom of the canvas).
233    pub baseline_ndc: f32,
234}
235
236/// User-facing text primitive. Owns content + style + position; the
237/// engine + renderer turn it into pixels.
238///
239/// `position` is the top-left of the layout box in NDC. `max_width_ndc`
240/// (when `Some`) wraps the content; `None` lays out a single line.
241#[derive(Debug, Clone, PartialEq)]
242pub struct WispText {
243    /// String to render.
244    pub content: String,
245    /// Style.
246    pub style: WispTextStyle,
247    /// Top-left of the layout box in NDC.
248    pub position: Vec2,
249    /// Optional wrap width (NDC). `None` = single line.
250    pub max_width_ndc: Option<f32>,
251    /// Optional family-name override. `None` falls back to the
252    /// backend's default sans-serif family. Backends that match by
253    /// CSS-style family names (`FlexibleText` / cosmic-text) honor
254    /// this; `AtlasText` ignores it (single bitmap face).
255    pub font_family: Option<String>,
256}
257
258impl WispText {
259    /// Convenience constructor with default style.
260    #[must_use]
261    pub fn new(content: impl Into<String>) -> Self {
262        Self {
263            content: content.into(),
264            style: WispTextStyle::default(),
265            position: Vec2::ZERO,
266            max_width_ndc: None,
267            font_family: None,
268        }
269    }
270
271    /// Builder — set the style.
272    #[must_use]
273    pub fn with_style(mut self, style: WispTextStyle) -> Self {
274        self.style = style;
275        self
276    }
277
278    /// Builder — set the position.
279    #[must_use]
280    pub fn with_position(mut self, position: Vec2) -> Self {
281        self.position = position;
282        self
283    }
284
285    /// Builder — enable word-wrapping at a given NDC max width.
286    #[must_use]
287    pub fn with_wrap(mut self, max_width_ndc: f32) -> Self {
288        self.max_width_ndc = Some(max_width_ndc);
289        self
290    }
291
292    /// Builder — override the font family by CSS-style family name.
293    ///
294    /// Honored by `FlexibleText` (cosmic-text); ignored by
295    /// `AtlasText` (single bitmap face). `None` (the default) falls
296    /// back to the backend's default sans-serif.
297    #[must_use]
298    pub fn with_font_family(mut self, family: impl Into<String>) -> Self {
299        self.font_family = Some(family.into());
300        self
301    }
302}
303
304/// A laid-out piece of text — ready to be rendered.
305///
306/// Backends return their own concrete layout type (e.g. an
307/// `AtlasLayout` of glyph instances, or a Cosmic Text `Buffer`),
308/// hidden behind this trait. The renderer reads `metrics()` for
309/// composition decisions (sizing the RT in M-TEXT.5, fitting captions
310/// in M-TEXT.9) and downcasts to the backend type via the
311/// engine/renderer pair.
312pub trait WispTextLayout: std::fmt::Debug + Send + Sync {
313    /// Per-layout metrics.
314    fn metrics(&self) -> WispTextMetrics;
315}
316
317/// Text-layout engine — turns a [`WispText`] into a backend-specific
318/// [`WispTextLayout`] implementor.
319pub trait WispTextEngine {
320    /// Lay out `text`. Backend returns a `Box<dyn WispTextLayout>`
321    /// whose concrete type is recognized by the matching renderer.
322    fn layout(&self, text: &WispText) -> Box<dyn WispTextLayout>;
323}
324
325/// Text renderer — consumes a layout and emits GPU draw calls.
326///
327/// `target_width_ndc` and `target_height_ndc` describe the destination
328/// surface so the renderer can convert NDC sizes back into pixels for
329/// glyph rasterization (matters for `FlexibleText` — atlas text is
330/// already-rasterized).
331pub trait WispTextRenderer {
332    /// Draw `layout` at `text.position` (NDC). Implementations are
333    /// expected to short-circuit no-ops gracefully (empty content,
334    /// out-of-view position, etc.).
335    fn draw(&self, layout: &dyn WispTextLayout, text: &WispText);
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[test]
343    fn weight_value_is_clamped_for_custom() {
344        assert_eq!(WispFontWeight::Custom(0).value(), 100);
345        assert_eq!(WispFontWeight::Custom(2000).value(), 900);
346        assert_eq!(WispFontWeight::Custom(550).value(), 550);
347    }
348
349    #[test]
350    fn weight_named_values_match_css_scale() {
351        assert_eq!(WispFontWeight::Thin.value(), 100);
352        assert_eq!(WispFontWeight::Regular.value(), 400);
353        assert_eq!(WispFontWeight::Bold.value(), 700);
354        assert_eq!(WispFontWeight::Black.value(), 900);
355    }
356
357    #[test]
358    fn style_default_is_regular_left_white_normal() {
359        let s = WispTextStyle::default();
360        assert_eq!(s.weight, WispFontWeight::Regular);
361        assert_eq!(s.style, WispFontStyle::Normal);
362        assert_eq!(s.align, WispTextAlign::Left);
363        assert!((s.size_ndc - 0.06).abs() < f32::EPSILON);
364        assert!((s.line_height - 1.2).abs() < f32::EPSILON);
365    }
366
367    #[test]
368    fn style_builder_chains() {
369        let s = WispTextStyle::default()
370            .with_size(0.1)
371            .with_color(Color::rgba(1.0, 0.0, 0.0, 1.0))
372            .with_weight(WispFontWeight::Bold)
373            .italic()
374            .with_align(WispTextAlign::Center);
375        assert!((s.size_ndc - 0.1).abs() < f32::EPSILON);
376        assert_eq!(s.weight, WispFontWeight::Bold);
377        assert_eq!(s.style, WispFontStyle::Italic);
378        assert_eq!(s.align, WispTextAlign::Center);
379    }
380
381    #[test]
382    fn wisp_text_builder_chains() {
383        let t = WispText::new("Hello world")
384            .with_position(Vec2::new(-0.5, 0.2))
385            .with_wrap(0.8);
386        assert_eq!(t.content, "Hello world");
387        assert_eq!(t.position, Vec2::new(-0.5, 0.2));
388        assert_eq!(t.max_width_ndc, Some(0.8));
389        assert!(t.font_family.is_none());
390    }
391
392    #[test]
393    fn with_font_family_sets_field() {
394        let t = WispText::new("hi").with_font_family("Inter");
395        assert_eq!(t.font_family.as_deref(), Some("Inter"));
396        let t2 = WispText::new("hi").with_font_family("JetBrains Mono");
397        assert_eq!(t2.font_family.as_deref(), Some("JetBrains Mono"));
398    }
399}