Skip to main content

fret_core/text/
mod.rs

1use crate::{
2    TextBlobId,
3    geometry::{Point, Px, Rect},
4    ids::FontId,
5};
6use serde::{Deserialize, Serialize};
7use smol_str::SmolStr;
8use std::sync::Arc;
9
10use crate::scene::Color;
11
12/// Overrides for the default font family selection used by the text system.
13///
14/// This is intended to be persisted in settings/config files and applied by the host/runner.
15/// It configures the three generic families used by `TextStyle.font` (`Ui`/`Serif`/`Monospace`).
16///
17/// Notes:
18/// - Entries are treated as ordered "try this first" candidates; backends will pick the first
19///   installed family name and ignore unknown ones.
20/// - This does not attempt to model per-script fallback chains yet (ADR 0029); for now, we expose
21///   a single `common_fallback` list for cross-script "no tofu" baseline behavior.
22#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
23pub struct TextFontFamilyConfig {
24    #[serde(default, skip_serializing_if = "Vec::is_empty")]
25    pub ui_sans: Vec<String>,
26    #[serde(default, skip_serializing_if = "Vec::is_empty")]
27    pub ui_serif: Vec<String>,
28    #[serde(default, skip_serializing_if = "Vec::is_empty")]
29    pub ui_mono: Vec<String>,
30    /// Controls how `common_fallback` is injected into the effective font stack.
31    ///
32    /// - `platform_default`: prefer deterministic injection on wasm/bundled-only environments; on
33    ///   native system-font builds, keep named families on the system-fallback lane but inject the
34    ///   framework no-tofu baseline into generic UI families.
35    /// - `none`: never inject `common_fallback` into the explicit stack (system fallback only).
36    /// - `common_fallback`: inject `common_fallback` into both generic and named family stacks to
37    ///   enforce a "no tofu" baseline (may override system fallback selection on desktop).
38    #[serde(
39        default,
40        skip_serializing_if = "TextCommonFallbackInjection::is_platform_default"
41    )]
42    pub common_fallback_injection: TextCommonFallbackInjection,
43    /// Additional family candidates appended to the framework fallback stack.
44    ///
45    /// This list is intended to cover "missing glyph" cases for mixed-script UIs (CJK + emoji +
46    /// RTL) without requiring per-span font selection.
47    #[serde(default, skip_serializing_if = "Vec::is_empty")]
48    pub common_fallback: Vec<String>,
49}
50
51#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum TextCommonFallbackInjection {
54    #[default]
55    PlatformDefault,
56    None,
57    CommonFallback,
58}
59
60impl TextCommonFallbackInjection {
61    fn is_platform_default(v: &TextCommonFallbackInjection) -> bool {
62        *v == TextCommonFallbackInjection::PlatformDefault
63    }
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
67#[serde(transparent)]
68pub struct FontWeight(pub u16);
69
70impl FontWeight {
71    pub const THIN: Self = Self(100);
72    pub const EXTRA_LIGHT: Self = Self(200);
73    pub const LIGHT: Self = Self(300);
74    pub const NORMAL: Self = Self(400);
75    pub const MEDIUM: Self = Self(500);
76    pub const SEMIBOLD: Self = Self(600);
77    pub const BOLD: Self = Self(700);
78    pub const EXTRA_BOLD: Self = Self(800);
79    pub const BLACK: Self = Self(900);
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
83pub enum TextWrap {
84    None,
85    Word,
86    /// Attempt to balance line breaks for wrapped text.
87    ///
88    /// This is intended to approximate CSS `text-wrap: balance` / Tailwind `text-balance`:
89    /// keep the same overall wrapping behavior as `Word`, but avoid a very short last line when
90    /// possible.
91    ///
92    /// Note: this is an outcome-driven policy; implementations may use heuristics.
93    Balance,
94    /// Wrap at word boundaries, but allow breaking long tokens when necessary.
95    ///
96    /// This is similar to CSS `overflow-wrap: break-word` (with `word-break: normal`): prefer
97    /// wrapping at whitespace/line-break opportunities, but fall back to mid-token breaks when a
98    /// single "word" exceeds the available width.
99    WordBreak,
100    /// Break between grapheme clusters when needed.
101    ///
102    /// This is intended for editor surfaces (CJK, file paths/URLs, code identifiers) where long
103    /// "tokens" must still wrap without relying on whitespace or word boundaries.
104    Grapheme,
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
108pub enum TextOverflow {
109    #[default]
110    Clip,
111    Ellipsis,
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
115pub enum TextAlign {
116    #[default]
117    Start,
118    Center,
119    End,
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
123#[serde(rename_all = "snake_case")]
124pub enum TextVerticalPlacement {
125    /// Center the prepared text box (`TextMetrics.size.height`) within the allocated bounds.
126    ///
127    /// This is the historical Fret behavior and remains the default in v1.
128    #[default]
129    CenterMetricsBox,
130    /// Treat the allocated bounds height as the effective line box height for single-line text
131    /// and compute baseline placement via a CSS/GPUI-like "half-leading" model:
132    ///
133    /// - `padding_top = (bounds_h - ascent - descent) / 2`
134    /// - `baseline_y = padding_top + ascent`
135    ///
136    /// Notes:
137    /// - This mode is intended for fixed-height controls (tabs, pills, buttons) where authors
138    ///   want a stable baseline placement without per-component y-offset hacks.
139    /// - Implementations should fall back to `CenterMetricsBox` when line metrics are unavailable
140    ///   or the prepared text contains multiple lines.
141    BoundsAsLineBox,
142}
143
144impl TextVerticalPlacement {
145    fn is_center_metrics_box(v: &TextVerticalPlacement) -> bool {
146        *v == TextVerticalPlacement::CenterMetricsBox
147    }
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
151#[serde(rename_all = "snake_case")]
152pub enum TextLineHeightPolicy {
153    /// Expand the line box to fit font extents (never reduce below ascent+descent).
154    ///
155    /// This avoids clipping but can cause line height to vary when fallback fonts or emoji
156    /// participate in shaping.
157    #[default]
158    ExpandToFit,
159    /// Keep a fixed line box derived from style (px or ratio) and compute baseline placement via
160    /// a CSS/GPUI-like "half-leading" model.
161    ///
162    /// This favors stable layout for UI surfaces (forms, lists, buttons). Glyphs whose ink
163    /// extends beyond the line box may be clipped by the caller's bounds.
164    FixedFromStyle,
165}
166
167impl TextLineHeightPolicy {
168    fn is_expand_to_fit(v: &TextLineHeightPolicy) -> bool {
169        *v == TextLineHeightPolicy::ExpandToFit
170    }
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
174#[serde(rename_all = "snake_case")]
175pub enum TextLeadingDistribution {
176    /// Distribute extra leading evenly above and below the text box ("half-leading").
177    #[default]
178    Even,
179    /// Distribute extra leading proportionally by ascent/descent.
180    Proportional,
181}
182
183impl TextLeadingDistribution {
184    fn is_even(v: &TextLeadingDistribution) -> bool {
185        *v == TextLeadingDistribution::Even
186    }
187}
188
189/// Paragraph-level strut style used to stabilize line box metrics across fallback runs.
190///
191/// This is a mechanism-only surface. Ecosystem presets (e.g. `fret-ui-kit::typography`) decide
192/// when to enable it (see ADR 0287 and related workstreams).
193#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
194pub struct TextStrutStyle {
195    /// Optional font override used for strut metrics (defaults to `TextStyle.font`).
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub font: Option<FontId>,
198    /// Optional font size override used for strut metrics (defaults to `TextStyle.size`).
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub size: Option<Px>,
201    /// Optional line height override, in logical px.
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub line_height: Option<Px>,
204    /// Optional line height override, expressed as a multiple of the effective size.
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub line_height_em: Option<f32>,
207    /// Optional leading distribution override (defaults to `TextStyle.leading_distribution`).
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub leading_distribution: Option<TextLeadingDistribution>,
210    /// If true, force the strut line box even when the style's policy is `ExpandToFit`.
211    ///
212    /// This mirrors the intent of Flutter's `forceStrutHeight`: stabilize layout for UI-like
213    /// multiline surfaces, at the cost of potential glyph ink clipping.
214    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
215    pub force: bool,
216}
217
218#[derive(Debug, Clone, Copy, PartialEq)]
219pub struct TextConstraints {
220    pub max_width: Option<Px>,
221    pub wrap: TextWrap,
222    pub overflow: TextOverflow,
223    pub align: TextAlign,
224    /// Window/device scale factor used for rasterization and caching.
225    ///
226    /// UI/layout coordinates remain in logical pixels. Implementations should rasterize at
227    /// `style.size * scale_factor` (and any other scale-dependent parameters), then return metrics
228    /// back in logical units.
229    pub scale_factor: f32,
230}
231
232impl Default for TextConstraints {
233    fn default() -> Self {
234        Self {
235            max_width: None,
236            wrap: TextWrap::Word,
237            overflow: TextOverflow::Clip,
238            align: TextAlign::Start,
239            scale_factor: 1.0,
240        }
241    }
242}
243
244#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
245pub struct TextStyle {
246    pub font: FontId,
247    pub size: Px,
248    pub weight: FontWeight,
249    pub slant: TextSlant,
250    /// Optional line height override, in logical px.
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub line_height: Option<Px>,
253    /// Optional line height override, expressed as a multiple of `size` (CSS-like).
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub line_height_em: Option<f32>,
256    /// Controls whether the line box can expand beyond the style-provided line height.
257    #[serde(
258        default,
259        skip_serializing_if = "TextLineHeightPolicy::is_expand_to_fit"
260    )]
261    pub line_height_policy: TextLineHeightPolicy,
262    /// Optional tracking (letter spacing) override, in EM.
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub letter_spacing_em: Option<f32>,
265    /// Optional OpenType feature overrides applied to the whole text run.
266    ///
267    /// This is intended for UI authoring ergonomics (e.g. `tabular-nums`) and editor-grade
268    /// surfaces that want a stable default without requiring attributed spans.
269    #[serde(default, skip_serializing_if = "Vec::is_empty")]
270    pub features: Vec<TextFontFeatureSetting>,
271    /// Optional variable font axis overrides applied to the whole text run.
272    ///
273    /// Note: `wght` overlaps with `weight`. Shaping backends should interpret `wght` as an
274    /// override (mapping it to `FontWeight`) and exclude it from variation lists.
275    #[serde(default, skip_serializing_if = "Vec::is_empty")]
276    pub axes: Vec<TextFontAxisSetting>,
277    /// Controls how the prepared text is vertically placed inside an allocated bounds height.
278    ///
279    /// This is a mechanism-level knob intended for fixed-height controls. See
280    /// `TextVerticalPlacement` for details.
281    #[serde(
282        default,
283        skip_serializing_if = "TextVerticalPlacement::is_center_metrics_box"
284    )]
285    pub vertical_placement: TextVerticalPlacement,
286    /// Controls how extra leading is distributed above/below the text box when a line height is
287    /// larger than font extents.
288    #[serde(default, skip_serializing_if = "TextLeadingDistribution::is_even")]
289    pub leading_distribution: TextLeadingDistribution,
290    /// Optional paragraph-level strut style used to stabilize line box metrics across fallback
291    /// runs (especially useful for multiline UI-like surfaces).
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub strut_style: Option<TextStrutStyle>,
294}
295
296#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
297#[serde(rename_all = "snake_case")]
298pub enum TextSlant {
299    #[default]
300    Normal,
301    Italic,
302    Oblique,
303}
304
305impl Default for TextStyle {
306    fn default() -> Self {
307        Self {
308            font: FontId::default(),
309            size: Px(13.0),
310            weight: FontWeight::NORMAL,
311            slant: TextSlant::Normal,
312            line_height: None,
313            line_height_em: None,
314            line_height_policy: TextLineHeightPolicy::ExpandToFit,
315            letter_spacing_em: None,
316            features: Vec::new(),
317            axes: Vec::new(),
318            vertical_placement: TextVerticalPlacement::CenterMetricsBox,
319            leading_distribution: TextLeadingDistribution::Even,
320            strut_style: None,
321        }
322    }
323}
324
325/// Partial, mergeable subtree text-style refinement used for inherited typography defaults.
326///
327/// This is intentionally narrower than [`TextStyle`]: v1 only carries the portable fields needed
328/// for passive-text cascade (`Text`, `StyledText`, `SelectableText`).
329#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
330pub struct TextStyleRefinement {
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub font: Option<FontId>,
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    pub size: Option<Px>,
335    #[serde(default, skip_serializing_if = "Option::is_none")]
336    pub weight: Option<FontWeight>,
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    pub slant: Option<TextSlant>,
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub line_height: Option<Px>,
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    pub line_height_em: Option<f32>,
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub line_height_policy: Option<TextLineHeightPolicy>,
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub letter_spacing_em: Option<f32>,
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub vertical_placement: Option<TextVerticalPlacement>,
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub leading_distribution: Option<TextLeadingDistribution>,
351}
352
353impl TextStyleRefinement {
354    pub fn is_empty(&self) -> bool {
355        self.font.is_none()
356            && self.size.is_none()
357            && self.weight.is_none()
358            && self.slant.is_none()
359            && self.line_height.is_none()
360            && self.line_height_em.is_none()
361            && self.line_height_policy.is_none()
362            && self.letter_spacing_em.is_none()
363            && self.vertical_placement.is_none()
364            && self.leading_distribution.is_none()
365    }
366
367    pub fn merge(&mut self, other: &Self) {
368        if let Some(font) = other.font.clone() {
369            self.font = Some(font);
370        }
371        if let Some(size) = other.size {
372            self.size = Some(size);
373        }
374        if let Some(weight) = other.weight {
375            self.weight = Some(weight);
376        }
377        if let Some(slant) = other.slant {
378            self.slant = Some(slant);
379        }
380        if let Some(line_height) = other.line_height {
381            self.line_height = Some(line_height);
382            self.line_height_em = None;
383        } else if let Some(line_height_em) = other.line_height_em {
384            self.line_height_em = Some(line_height_em);
385            self.line_height = None;
386        }
387        if let Some(line_height_policy) = other.line_height_policy {
388            self.line_height_policy = Some(line_height_policy);
389        }
390        if let Some(letter_spacing_em) = other.letter_spacing_em {
391            self.letter_spacing_em = Some(letter_spacing_em);
392        }
393        if let Some(vertical_placement) = other.vertical_placement {
394            self.vertical_placement = Some(vertical_placement);
395        }
396        if let Some(leading_distribution) = other.leading_distribution {
397            self.leading_distribution = Some(leading_distribution);
398        }
399    }
400
401    pub fn merged(&self, other: &Self) -> Self {
402        let mut merged = self.clone();
403        merged.merge(other);
404        merged
405    }
406}
407
408impl TextStyle {
409    pub fn refine(&mut self, refinement: &TextStyleRefinement) {
410        if let Some(font) = refinement.font.clone() {
411            self.font = font;
412        }
413        if let Some(size) = refinement.size {
414            self.size = size;
415        }
416        if let Some(weight) = refinement.weight {
417            self.weight = weight;
418        }
419        if let Some(slant) = refinement.slant {
420            self.slant = slant;
421        }
422        if let Some(line_height) = refinement.line_height {
423            self.line_height = Some(line_height);
424            self.line_height_em = None;
425        } else if let Some(line_height_em) = refinement.line_height_em {
426            self.line_height_em = Some(line_height_em);
427            self.line_height = None;
428        }
429        if let Some(line_height_policy) = refinement.line_height_policy {
430            self.line_height_policy = line_height_policy;
431        }
432        if let Some(letter_spacing_em) = refinement.letter_spacing_em {
433            self.letter_spacing_em = Some(letter_spacing_em);
434        }
435        if let Some(vertical_placement) = refinement.vertical_placement {
436            self.vertical_placement = vertical_placement;
437        }
438        if let Some(leading_distribution) = refinement.leading_distribution {
439            self.leading_distribution = leading_distribution;
440        }
441    }
442
443    pub fn refined(mut self, refinement: &TextStyleRefinement) -> Self {
444        self.refine(refinement);
445        self
446    }
447}
448
449#[derive(Debug, Clone, Copy, PartialEq)]
450pub struct TextMetrics {
451    pub size: crate::Size,
452    pub baseline: Px,
453}
454
455#[derive(Debug, Clone, Copy, PartialEq)]
456pub struct TextLineMetrics {
457    pub ascent: Px,
458    pub descent: Px,
459    pub line_height: Px,
460}
461
462#[derive(Debug, Clone, Copy, PartialEq)]
463pub struct TextInkMetrics {
464    pub ascent: Px,
465    pub descent: Px,
466}
467
468#[derive(Debug, Clone, Copy, PartialEq, Eq)]
469pub enum CaretAffinity {
470    Upstream,
471    Downstream,
472}
473
474#[derive(Debug, Clone, Copy, PartialEq)]
475pub struct HitTestResult {
476    pub index: usize,
477    pub affinity: CaretAffinity,
478}
479
480#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
481#[serde(rename_all = "snake_case")]
482pub enum DecorationLineStyle {
483    #[default]
484    Solid,
485}
486
487#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
488pub struct UnderlineStyle {
489    #[serde(default, skip_serializing_if = "Option::is_none")]
490    pub color: Option<Color>,
491    #[serde(default)]
492    pub style: DecorationLineStyle,
493}
494
495#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
496pub struct StrikethroughStyle {
497    #[serde(default, skip_serializing_if = "Option::is_none")]
498    pub color: Option<Color>,
499    #[serde(default)]
500    pub style: DecorationLineStyle,
501}
502
503#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
504pub struct TextShapingStyle {
505    #[serde(default, skip_serializing_if = "Option::is_none")]
506    pub font: Option<FontId>,
507    #[serde(default, skip_serializing_if = "Option::is_none")]
508    pub weight: Option<FontWeight>,
509    #[serde(default, skip_serializing_if = "Option::is_none")]
510    pub slant: Option<TextSlant>,
511    #[serde(default, skip_serializing_if = "Option::is_none")]
512    pub letter_spacing_em: Option<f32>,
513    /// Explicit OpenType feature overrides (best-effort).
514    ///
515    /// This is intended for editor-grade text surfaces (e.g. ligature policy in code) and
516    /// diagnostics. Callers should treat this as best-effort: if the resolved face does not
517    /// support a requested tag, it will be ignored by shaping backends.
518    #[serde(default, skip_serializing_if = "Vec::is_empty")]
519    pub features: Vec<TextFontFeatureSetting>,
520    /// Explicit variable font axis overrides.
521    ///
522    /// This is an advanced surface intended for code editors and diagnostics. Callers should treat
523    /// this as best-effort: if the requested axis is not supported by the resolved font face, it
524    /// will be ignored by the shaping backend.
525    #[serde(default, skip_serializing_if = "Vec::is_empty")]
526    pub axes: Vec<TextFontAxisSetting>,
527}
528
529/// A single OpenType font feature setting, identified by a 4-byte OpenType feature tag.
530#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
531pub struct TextFontFeatureSetting {
532    /// 4-byte OpenType feature tag (e.g. "liga", "calt", "ss01").
533    pub tag: SmolStr,
534    /// OpenType feature value (best-effort). Conventionally 0=off, 1=on.
535    pub value: u32,
536}
537
538/// A single variable font axis setting, identified by a 4-byte OpenType axis tag.
539#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
540pub struct TextFontAxisSetting {
541    pub tag: SmolStr,
542    pub value: f32,
543}
544
545#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
546pub struct TextPaintStyle {
547    #[serde(default, skip_serializing_if = "Option::is_none")]
548    pub fg: Option<Color>,
549    #[serde(default, skip_serializing_if = "Option::is_none")]
550    pub bg: Option<Color>,
551    #[serde(default, skip_serializing_if = "Option::is_none")]
552    pub underline: Option<UnderlineStyle>,
553    #[serde(default, skip_serializing_if = "Option::is_none")]
554    pub strikethrough: Option<StrikethroughStyle>,
555}
556
557#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
558pub struct TextSpan {
559    /// Span length in UTF-8 bytes.
560    pub len: usize,
561    #[serde(default)]
562    pub shaping: TextShapingStyle,
563    #[serde(default)]
564    pub paint: TextPaintStyle,
565}
566
567impl TextSpan {
568    pub fn new(len: usize) -> Self {
569        Self {
570            len,
571            shaping: TextShapingStyle::default(),
572            paint: TextPaintStyle::default(),
573        }
574    }
575}
576
577impl TextShapingStyle {
578    pub fn with_font(mut self, font: FontId) -> Self {
579        self.font = Some(font);
580        self
581    }
582
583    pub fn with_weight(mut self, weight: FontWeight) -> Self {
584        self.weight = Some(weight);
585        self
586    }
587
588    pub fn with_slant(mut self, slant: TextSlant) -> Self {
589        self.slant = Some(slant);
590        self
591    }
592
593    pub fn with_letter_spacing_em(mut self, letter_spacing_em: f32) -> Self {
594        self.letter_spacing_em = Some(letter_spacing_em);
595        self
596    }
597
598    pub fn with_axis(mut self, tag: impl Into<String>, value: f32) -> Self {
599        self.axes.push(TextFontAxisSetting {
600            tag: tag.into().into(),
601            value,
602        });
603        self
604    }
605
606    pub fn with_feature(mut self, tag: impl Into<String>, value: u32) -> Self {
607        self.features.push(TextFontFeatureSetting {
608            tag: tag.into().into(),
609            value,
610        });
611        self
612    }
613}
614
615impl TextPaintStyle {
616    pub fn with_fg(mut self, fg: Color) -> Self {
617        self.fg = Some(fg);
618        self
619    }
620
621    pub fn with_bg(mut self, bg: Color) -> Self {
622        self.bg = Some(bg);
623        self
624    }
625
626    pub fn with_underline(mut self, underline: UnderlineStyle) -> Self {
627        self.underline = Some(underline);
628        self
629    }
630
631    pub fn with_strikethrough(mut self, strikethrough: StrikethroughStyle) -> Self {
632        self.strikethrough = Some(strikethrough);
633        self
634    }
635}
636
637#[derive(Debug, Clone, PartialEq)]
638pub struct AttributedText {
639    pub text: Arc<str>,
640    pub spans: Arc<[TextSpan]>,
641}
642
643fn spans_are_valid(text: &str, spans: &[TextSpan]) -> bool {
644    let mut offset = 0usize;
645    for span in spans {
646        let end = offset.saturating_add(span.len);
647        if end > text.len() {
648            return false;
649        }
650        if !text.is_char_boundary(offset) || !text.is_char_boundary(end) {
651            return false;
652        }
653        offset = end;
654    }
655    offset == text.len()
656}
657
658impl AttributedText {
659    pub fn new(text: impl Into<Arc<str>>, spans: impl Into<Arc<[TextSpan]>>) -> Self {
660        let text: Arc<str> = text.into();
661        let spans: Arc<[TextSpan]> = spans.into();
662        debug_assert!(spans_are_valid(text.as_ref(), spans.as_ref()));
663        Self { text, spans }
664    }
665
666    /// Returns true if `self` and `other` have identical shaping-relevant content.
667    ///
668    /// This intentionally ignores paint-only fields (e.g. colors, underlines). It is useful for
669    /// caching/layout decisions where theme-driven paint changes should not force reshaping.
670    pub fn shaping_eq(&self, other: &Self) -> bool {
671        if self.text != other.text {
672            return false;
673        }
674        if self.spans.len() != other.spans.len() {
675            return false;
676        }
677        self.spans
678            .iter()
679            .zip(other.spans.iter())
680            .all(|(a, b)| a.len == b.len && a.shaping == b.shaping)
681    }
682
683    pub fn is_valid(&self) -> bool {
684        spans_are_valid(self.text.as_ref(), self.spans.as_ref())
685    }
686}
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691
692    #[test]
693    fn attributed_text_shaping_eq_ignores_paint() {
694        let text: Arc<str> = Arc::<str>::from("hello");
695        let base = TextSpan {
696            len: text.len(),
697            shaping: Default::default(),
698            paint: Default::default(),
699        };
700
701        let mut spans_a = vec![base.clone()];
702        spans_a[0].paint.fg = Some(Color {
703            r: 1.0,
704            g: 0.0,
705            b: 0.0,
706            a: 1.0,
707        });
708        let mut spans_b = vec![base];
709        spans_b[0].paint.fg = Some(Color {
710            r: 0.0,
711            g: 1.0,
712            b: 0.0,
713            a: 1.0,
714        });
715
716        let a = AttributedText::new(Arc::clone(&text), Arc::<[TextSpan]>::from(spans_a));
717        let b = AttributedText::new(Arc::clone(&text), Arc::<[TextSpan]>::from(spans_b));
718        assert_ne!(a, b, "full equality should include paint");
719        assert!(
720            a.shaping_eq(&b),
721            "shaping_eq should ignore paint-only changes"
722        );
723    }
724
725    #[test]
726    fn attributed_text_shaping_eq_detects_shaping_changes() {
727        let text: Arc<str> = Arc::<str>::from("hello");
728        let spans_a = vec![TextSpan {
729            len: text.len(),
730            shaping: Default::default(),
731            paint: Default::default(),
732        }];
733        let mut spans_b = spans_a.clone();
734        spans_b[0].shaping.weight = Some(FontWeight(700));
735
736        let a = AttributedText::new(Arc::clone(&text), Arc::<[TextSpan]>::from(spans_a));
737        let b = AttributedText::new(Arc::clone(&text), Arc::<[TextSpan]>::from(spans_b));
738        assert!(
739            !a.shaping_eq(&b),
740            "shaping_eq must treat shaping changes as unequal"
741        );
742    }
743}
744
745#[derive(Debug, Clone, Copy, PartialEq)]
746pub enum TextInputRef<'a> {
747    Plain {
748        text: &'a str,
749        style: &'a TextStyle,
750    },
751    Attributed {
752        text: &'a str,
753        base: &'a TextStyle,
754        spans: &'a [TextSpan],
755    },
756}
757
758impl<'a> TextInputRef<'a> {
759    pub fn plain(text: &'a str, style: &'a TextStyle) -> Self {
760        Self::Plain { text, style }
761    }
762
763    pub fn attributed(text: &'a str, base: &'a TextStyle, spans: &'a [TextSpan]) -> Self {
764        debug_assert!(spans_are_valid(text, spans));
765        Self::Attributed { text, base, spans }
766    }
767}
768
769#[derive(Debug, Clone, PartialEq)]
770#[non_exhaustive]
771pub enum TextInput {
772    Plain {
773        text: Arc<str>,
774        style: TextStyle,
775    },
776    Attributed {
777        text: Arc<str>,
778        base: TextStyle,
779        spans: Arc<[TextSpan]>,
780    },
781}
782
783impl TextInput {
784    pub fn plain(text: impl Into<Arc<str>>, style: TextStyle) -> Self {
785        Self::Plain {
786            text: text.into(),
787            style,
788        }
789    }
790
791    pub fn attributed(
792        text: impl Into<Arc<str>>,
793        base: TextStyle,
794        spans: impl Into<Arc<[TextSpan]>>,
795    ) -> Self {
796        Self::Attributed {
797            text: text.into(),
798            base,
799            spans: spans.into(),
800        }
801    }
802
803    pub fn text(&self) -> &str {
804        match self {
805            Self::Plain { text, .. } => text.as_ref(),
806            Self::Attributed { text, .. } => text.as_ref(),
807        }
808    }
809}
810
811pub trait TextService {
812    fn prepare(
813        &mut self,
814        input: &TextInput,
815        constraints: TextConstraints,
816    ) -> (TextBlobId, TextMetrics);
817
818    fn prepare_str(
819        &mut self,
820        text: &str,
821        style: &TextStyle,
822        constraints: TextConstraints,
823    ) -> (TextBlobId, TextMetrics) {
824        let input = TextInput::plain(Arc::<str>::from(text), style.clone());
825        self.prepare(&input, constraints)
826    }
827
828    fn prepare_rich(
829        &mut self,
830        rich: &AttributedText,
831        base_style: &TextStyle,
832        constraints: TextConstraints,
833    ) -> (TextBlobId, TextMetrics) {
834        let input =
835            TextInput::attributed(rich.text.clone(), base_style.clone(), rich.spans.clone());
836        self.prepare(&input, constraints)
837    }
838
839    fn measure(&mut self, input: &TextInput, constraints: TextConstraints) -> TextMetrics {
840        let (blob, metrics) = self.prepare(input, constraints);
841        self.release(blob);
842        metrics
843    }
844
845    fn measure_str(
846        &mut self,
847        text: &str,
848        style: &TextStyle,
849        constraints: TextConstraints,
850    ) -> TextMetrics {
851        let input = TextInput::plain(Arc::<str>::from(text), style.clone());
852        self.measure(&input, constraints)
853    }
854
855    fn measure_rich(
856        &mut self,
857        rich: &AttributedText,
858        base_style: &TextStyle,
859        constraints: TextConstraints,
860    ) -> TextMetrics {
861        let (blob, metrics) = self.prepare_rich(rich, base_style, constraints);
862        self.release(blob);
863        metrics
864    }
865
866    /// Returns the X offset (in logical px) of the caret at `index` within the prepared text blob.
867    ///
868    /// Coordinate space: relative to the text origin (x=0 at the beginning of the line).
869    ///
870    /// Notes:
871    /// - `index` is a byte offset into the UTF-8 text, clamped to valid char boundaries (ADR 0044).
872    /// - Implementations may clamp to the nearest representable caret position.
873    fn caret_x(&mut self, _blob: TextBlobId, _index: usize) -> Px {
874        Px(0.0)
875    }
876
877    /// Performs hit-testing for a single-line text blob and returns the nearest caret byte index.
878    ///
879    /// Coordinate space: `x` is relative to the text origin (x=0 at the beginning of the line).
880    fn hit_test_x(&mut self, _blob: TextBlobId, _x: Px) -> usize {
881        0
882    }
883
884    /// Computes selection rectangles for a single-line selection range.
885    ///
886    /// Coordinate space: rects are relative to the text origin (x=0, y=0 at top of text box).
887    ///
888    /// Geometry contract:
889    /// - For a non-empty range (`start != end`), conforming implementations should emit rectangles
890    ///   with positive height (and should avoid emitting zero-width rectangles).
891    fn selection_rects(&mut self, _blob: TextBlobId, _range: (usize, usize), _out: &mut Vec<Rect>) {
892    }
893
894    /// Best-effort first-line font extents for a prepared text blob.
895    ///
896    /// This is primarily intended for mechanism-level vertical placement policies in fixed-height
897    /// controls. Implementations should return `None` if the data is unavailable or expensive to
898    /// compute.
899    fn first_line_metrics(&mut self, _blob: TextBlobId) -> Option<TextLineMetrics> {
900        None
901    }
902
903    /// Best-effort first-line ink extents (ascent/descent) for a prepared text blob.
904    ///
905    /// This differs from `first_line_metrics` when the line box is fixed (e.g.
906    /// `TextLineHeightPolicy::FixedFromStyle`) but the shaped content includes taller fallback
907    /// glyphs (emoji/CJK/etc). Callers may use this to detect potential clipping and apply
908    /// padding or a different line-height preset.
909    fn first_line_ink_metrics(&mut self, _blob: TextBlobId) -> Option<TextInkMetrics> {
910        None
911    }
912
913    /// Best-effort last-line font extents for a prepared multi-line text blob.
914    ///
915    /// This is primarily intended for mechanism-level vertical placement and overflow handling
916    /// policies. Implementations should return `None` if the data is unavailable or expensive to
917    /// compute.
918    fn last_line_metrics(&mut self, _blob: TextBlobId) -> Option<TextLineMetrics> {
919        None
920    }
921
922    /// Best-effort last-line ink extents (ascent/descent) for a prepared multi-line text blob.
923    ///
924    /// This is intended for avoiding bottom-edge clipping in fixed line-box layouts when the last
925    /// line contains tall fallback glyphs (emoji/CJK/etc).
926    fn last_line_ink_metrics(&mut self, _blob: TextBlobId) -> Option<TextInkMetrics> {
927        None
928    }
929
930    /// Computes selection rectangles and clips them to `clip` in the same coordinate space.
931    ///
932    /// This is intended for large multi-line selections where generating rectangles for off-screen
933    /// lines is wasteful. Implementations may override this to cull work earlier.
934    ///
935    /// Coordinate space: rects and `clip` are relative to the text origin (x=0, y=0 at top of text box).
936    fn selection_rects_clipped(
937        &mut self,
938        blob: TextBlobId,
939        range: (usize, usize),
940        clip: Rect,
941        out: &mut Vec<Rect>,
942    ) {
943        self.selection_rects(blob, range, out);
944        clip_rects_in_place(clip, out);
945    }
946
947    /// Extracts the precomputed caret stop table (byte index -> x offset) for a single-line blob.
948    ///
949    /// This is primarily intended for UI hit-testing in event handlers, which do not have access
950    /// to the text service.
951    fn caret_stops(&mut self, _blob: TextBlobId, _out: &mut Vec<(usize, Px)>) {}
952
953    /// Returns the caret rectangle (in logical px) for the given `index`.
954    ///
955    /// Coordinate space: rect is relative to the text origin (x=0, y=0 at the top of the text box).
956    ///
957    /// Notes:
958    /// - Single-line implementations may ignore affinity.
959    /// - Multi-line implementations should use affinity to disambiguate positions at line breaks.
960    /// - Conforming implementations should return a rectangle with positive height.
961    fn caret_rect(&mut self, _blob: TextBlobId, _index: usize, _affinity: CaretAffinity) -> Rect {
962        Rect::default()
963    }
964
965    /// Hit-test a point in the text's local coordinate space and return a caret index and affinity.
966    ///
967    /// Coordinate space: `point` is relative to the text origin (x=0, y=0 at the top of the text box).
968    fn hit_test_point(&mut self, _blob: TextBlobId, _point: Point) -> HitTestResult {
969        HitTestResult {
970            index: 0,
971            affinity: CaretAffinity::Downstream,
972        }
973    }
974
975    fn release(&mut self, blob: TextBlobId);
976}
977
978fn clip_rects_in_place(clip: Rect, out: &mut Vec<Rect>) {
979    let clip_x0 = clip.origin.x.0;
980    let clip_y0 = clip.origin.y.0;
981    let clip_x1 = clip_x0 + clip.size.width.0;
982    let clip_y1 = clip_y0 + clip.size.height.0;
983
984    if clip_x1 <= clip_x0 || clip_y1 <= clip_y0 {
985        out.clear();
986        return;
987    }
988
989    out.retain_mut(|r| {
990        let x0 = r.origin.x.0;
991        let y0 = r.origin.y.0;
992        let x1 = x0 + r.size.width.0;
993        let y1 = y0 + r.size.height.0;
994
995        let ix0 = x0.max(clip_x0);
996        let iy0 = y0.max(clip_y0);
997        let ix1 = x1.min(clip_x1);
998        let iy1 = y1.min(clip_y1);
999
1000        if ix1 <= ix0 || iy1 <= iy0 {
1001            return false;
1002        }
1003
1004        r.origin.x = Px(ix0);
1005        r.origin.y = Px(iy0);
1006        r.size.width = Px(ix1 - ix0);
1007        r.size.height = Px(iy1 - iy0);
1008        true
1009    });
1010}