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}