Skip to main content

fret_ui_kit/
typography.rs

1use fret_core::{
2    Color, FontId, FontWeight, Px, TextLineHeightPolicy, TextSlant, TextStrutStyle, TextStyle,
3    TextStyleRefinement, TextVerticalPlacement,
4};
5use fret_ui::element::AnyElement;
6
7use crate::style::ThemeTokenRead;
8use crate::theme_tokens;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum UiTextSize {
12    Xs,
13    Sm,
14    Base,
15    Prose,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum UiTextFamily {
20    Ui,
21    Monospace,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum TextIntent {
26    Control,
27    Content,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct TypographyPreset {
32    pub intent: TextIntent,
33    pub family: UiTextFamily,
34    pub size: UiTextSize,
35}
36
37impl TypographyPreset {
38    pub const fn new(intent: TextIntent, family: UiTextFamily, size: UiTextSize) -> Self {
39        Self {
40            intent,
41            family,
42            size,
43        }
44    }
45
46    pub const fn control_ui(size: UiTextSize) -> Self {
47        Self::new(TextIntent::Control, UiTextFamily::Ui, size)
48    }
49
50    pub const fn content_ui(size: UiTextSize) -> Self {
51        Self::new(TextIntent::Content, UiTextFamily::Ui, size)
52    }
53
54    pub const fn control_monospace(size: UiTextSize) -> Self {
55        Self::new(TextIntent::Control, UiTextFamily::Monospace, size)
56    }
57
58    pub const fn content_monospace(size: UiTextSize) -> Self {
59        Self::new(TextIntent::Content, UiTextFamily::Monospace, size)
60    }
61
62    pub fn resolve(self, theme: &impl ThemeTokenRead) -> TextStyle {
63        match self.intent {
64            TextIntent::Control => control_text_style_with_family(theme, self.size, self.family),
65            TextIntent::Content => content_text_style_with_family(theme, self.size, self.family),
66        }
67    }
68}
69
70fn font_size(theme: &impl ThemeTokenRead) -> Px {
71    theme
72        .metric_by_key("font.size")
73        .unwrap_or_else(|| theme.metric_token("font.size"))
74}
75
76fn font_line_height(theme: &impl ThemeTokenRead) -> Px {
77    theme
78        .metric_by_key("font.line_height")
79        .unwrap_or_else(|| theme.metric_token("font.line_height"))
80}
81
82fn base_line_height_ratio(theme: &impl ThemeTokenRead) -> f32 {
83    let base_size_px = font_size(theme).0;
84    let base_line_height_px = font_line_height(theme).0;
85    if base_size_px.is_finite()
86        && base_line_height_px.is_finite()
87        && base_size_px > 0.0
88        && base_line_height_px > 0.0
89    {
90        base_line_height_px / base_size_px
91    } else {
92        1.25
93    }
94}
95
96/// Creates a `TextStyle` with an explicit fixed line box.
97///
98/// This is intended for UI control text where layout stability is preferred over accommodating
99/// taller fallback glyphs.
100pub fn fixed_line_box_style(font: FontId, size: Px, line_height: Px) -> TextStyle {
101    TextStyle {
102        font,
103        size,
104        line_height: Some(line_height),
105        line_height_policy: TextLineHeightPolicy::FixedFromStyle,
106        vertical_placement: TextVerticalPlacement::BoundsAsLineBox,
107        ..Default::default()
108    }
109}
110
111/// Applies a high-level intent to an existing `TextStyle`.
112///
113/// Notes:
114/// - `TextIntent::Control` only produces stable fixed line boxes when the style has an explicit
115///   `line_height` or `line_height_em` (see `TextLineHeightPolicy` contract).
116pub fn with_intent(mut style: TextStyle, intent: TextIntent) -> TextStyle {
117    match intent {
118        TextIntent::Control => {
119            style.line_height_policy = TextLineHeightPolicy::FixedFromStyle;
120            style.vertical_placement = TextVerticalPlacement::BoundsAsLineBox;
121        }
122        TextIntent::Content => {
123            style.line_height_policy = TextLineHeightPolicy::ExpandToFit;
124            style.vertical_placement = TextVerticalPlacement::CenterMetricsBox;
125        }
126    }
127    style
128}
129
130pub fn as_control_text(style: TextStyle) -> TextStyle {
131    with_intent(style, TextIntent::Control)
132}
133
134pub fn as_content_text(style: TextStyle) -> TextStyle {
135    with_intent(style, TextIntent::Content)
136}
137
138fn force_strut_from_style(style: &TextStyle) -> Option<TextStrutStyle> {
139    if style.line_height.is_none() && style.line_height_em.is_none() {
140        return None;
141    }
142
143    Some(TextStrutStyle {
144        line_height: style.line_height,
145        line_height_em: style.line_height_em,
146        force: true,
147        ..Default::default()
148    })
149}
150
151/// Returns a theme-based text style intended for content-like multiline text areas.
152///
153/// This leaves `TextLineHeightPolicy` as `ExpandToFit` to avoid clipping.
154pub fn text_area_content_text_style(theme: &impl ThemeTokenRead) -> TextStyle {
155    TextStyle {
156        font: FontId::ui(),
157        size: font_size(theme),
158        line_height: Some(font_line_height(theme)),
159        ..Default::default()
160    }
161}
162
163/// Returns a theme-based text style intended for content-like multiline text areas, scaled to an
164/// explicit size.
165pub fn text_area_content_text_style_scaled(
166    theme: &impl ThemeTokenRead,
167    font: FontId,
168    size: Px,
169) -> TextStyle {
170    let ratio = base_line_height_ratio(theme);
171    let line_height = Px((size.0 * ratio).max(size.0));
172
173    let mut style = TextStyle {
174        font,
175        size,
176        line_height: Some(line_height),
177        ..Default::default()
178    };
179    style.line_height_policy = TextLineHeightPolicy::ExpandToFit;
180    style.vertical_placement = TextVerticalPlacement::CenterMetricsBox;
181    style
182}
183
184/// Returns an opt-in text style intended for UI/form multiline text areas.
185///
186/// This favors stable per-line metrics via:
187/// - fixed line height policy (stable line boxes),
188/// - and a forced strut derived from the chosen style line height (stable baseline across mixed
189///   scripts / emoji fallback runs).
190pub fn text_area_control_text_style(theme: &impl ThemeTokenRead) -> TextStyle {
191    let mut style = text_area_content_text_style(theme);
192    style.line_height_policy = TextLineHeightPolicy::FixedFromStyle;
193    style.strut_style = force_strut_from_style(&style);
194    style
195}
196
197/// Returns an opt-in text style intended for UI/form multiline text areas, scaled to an explicit
198/// size.
199pub fn text_area_control_text_style_scaled(
200    theme: &impl ThemeTokenRead,
201    font: FontId,
202    size: Px,
203) -> TextStyle {
204    let ratio = base_line_height_ratio(theme);
205    let line_height = Px((size.0 * ratio).max(size.0));
206
207    let mut style = TextStyle {
208        font,
209        size,
210        line_height: Some(line_height),
211        ..Default::default()
212    };
213    style.line_height_policy = TextLineHeightPolicy::FixedFromStyle;
214    style.vertical_placement = TextVerticalPlacement::BoundsAsLineBox;
215    style.strut_style = force_strut_from_style(&style);
216    style
217}
218
219/// Returns a control-text style intended for UI components (stable line box).
220///
221/// This is a policy helper for ecosystem components. It is intentionally not a `fret-ui` runtime
222/// commitment (see ADR 0066).
223pub fn control_text_style(theme: &impl ThemeTokenRead, size: UiTextSize) -> TextStyle {
224    control_text_style_with_family(theme, size, UiTextFamily::Ui)
225}
226
227pub fn preset_text_style_with_overrides(
228    theme: &impl ThemeTokenRead,
229    preset: TypographyPreset,
230    weight: Option<FontWeight>,
231    slant: Option<TextSlant>,
232) -> TextStyle {
233    let mut style = preset.resolve(theme);
234    if let Some(weight) = weight {
235        style.weight = weight;
236    }
237    if let Some(slant) = slant {
238        style.slant = slant;
239    }
240    style
241}
242
243/// Returns a control-text style intended for UI components (stable line box).
244pub fn control_text_style_with_family(
245    theme: &impl ThemeTokenRead,
246    size: UiTextSize,
247    family: UiTextFamily,
248) -> TextStyle {
249    let font = match family {
250        UiTextFamily::Ui => FontId::ui(),
251        UiTextFamily::Monospace => FontId::monospace(),
252    };
253
254    match size {
255        UiTextSize::Xs => {
256            let px = theme
257                .metric_by_key(theme_tokens::metric::COMPONENT_TEXT_XS_PX)
258                .unwrap_or(Px(12.0));
259            let line_height = theme
260                .metric_by_key(theme_tokens::metric::COMPONENT_TEXT_XS_LINE_HEIGHT)
261                .unwrap_or(Px(16.0));
262            fixed_line_box_style(font, px, line_height)
263        }
264        UiTextSize::Sm => {
265            let px = theme
266                .metric_by_key(theme_tokens::metric::COMPONENT_TEXT_SM_PX)
267                .unwrap_or_else(|| font_size(theme));
268            let line_height = theme
269                .metric_by_key(theme_tokens::metric::COMPONENT_TEXT_SM_LINE_HEIGHT)
270                .unwrap_or_else(|| font_line_height(theme));
271            fixed_line_box_style(font, px, line_height)
272        }
273        UiTextSize::Base => {
274            let px = theme
275                .metric_by_key(theme_tokens::metric::COMPONENT_TEXT_BASE_PX)
276                .unwrap_or_else(|| Px(font_size(theme).0 + 1.0));
277
278            let line_height = theme
279                .metric_by_key(theme_tokens::metric::COMPONENT_TEXT_BASE_LINE_HEIGHT)
280                .unwrap_or_else(|| Px((px.0 * base_line_height_ratio(theme)).max(px.0)));
281
282            fixed_line_box_style(font, px, line_height)
283        }
284        UiTextSize::Prose => {
285            let px = theme
286                .metric_by_key(theme_tokens::metric::COMPONENT_TEXT_PROSE_PX)
287                .unwrap_or_else(|| Px(font_size(theme).0 + 2.0));
288            let line_height = theme
289                .metric_by_key(theme_tokens::metric::COMPONENT_TEXT_PROSE_LINE_HEIGHT)
290                .unwrap_or_else(|| Px(font_line_height(theme).0 + 4.0));
291            fixed_line_box_style(font, px, line_height)
292        }
293    }
294}
295
296/// Returns a content-text style intended for prose surfaces (avoid clipping).
297pub fn content_text_style(theme: &impl ThemeTokenRead, size: UiTextSize) -> TextStyle {
298    let mut style = control_text_style(theme, size);
299    style.line_height_policy = TextLineHeightPolicy::ExpandToFit;
300    style.vertical_placement = TextVerticalPlacement::CenterMetricsBox;
301    style
302}
303
304/// Returns a content-text style intended for prose surfaces (avoid clipping).
305pub fn content_text_style_with_family(
306    theme: &impl ThemeTokenRead,
307    size: UiTextSize,
308    family: UiTextFamily,
309) -> TextStyle {
310    let mut style = control_text_style_with_family(theme, size, family);
311    style.line_height_policy = TextLineHeightPolicy::ExpandToFit;
312    style.vertical_placement = TextVerticalPlacement::CenterMetricsBox;
313    style
314}
315
316/// Returns a control-text style scaled to an explicit font size, using the theme's baseline
317/// `font.line_height / font.size` ratio.
318///
319/// This is intended for widget surfaces that take `TextStyle` directly (e.g. text inputs) where the
320/// component decides the font size but still wants stable line box behavior.
321pub fn control_text_style_scaled(theme: &impl ThemeTokenRead, font: FontId, size: Px) -> TextStyle {
322    let ratio = base_line_height_ratio(theme);
323    let line_height = Px((size.0 * ratio).max(size.0));
324    fixed_line_box_style(font, size, line_height)
325}
326
327/// Returns a control-text style for a caller-chosen font size using the theme's `font.line_height`
328/// metric directly (no scaling).
329///
330/// This matches common “UI control” usage where size and line height are independently tokenized.
331pub fn control_text_style_for_font_size(
332    theme: &impl ThemeTokenRead,
333    font: FontId,
334    size: Px,
335) -> TextStyle {
336    fixed_line_box_style(font, size, font_line_height(theme))
337}
338
339fn color_by_aliases(theme: &impl ThemeTokenRead, aliases: &[&str], fallback_token: &str) -> Color {
340    aliases
341        .iter()
342        .find_map(|key| theme.color_by_key(key))
343        .unwrap_or_else(|| theme.color_token(fallback_token))
344}
345
346pub fn muted_foreground_color(theme: &impl ThemeTokenRead) -> Color {
347    color_by_aliases(
348        theme,
349        &["muted.foreground", "muted-foreground", "muted_foreground"],
350        "muted-foreground",
351    )
352}
353
354pub fn scope_text_style(element: AnyElement, refinement: TextStyleRefinement) -> AnyElement {
355    element.inherit_text_style(refinement)
356}
357
358pub fn scope_text_style_with_color(
359    element: AnyElement,
360    refinement: TextStyleRefinement,
361    foreground: Color,
362) -> AnyElement {
363    element
364        .inherit_foreground(foreground)
365        .inherit_text_style(refinement)
366}
367
368pub fn title_text_refinement(
369    theme: &impl ThemeTokenRead,
370    metric_prefix: &str,
371) -> TextStyleRefinement {
372    title_text_refinement_with_fallbacks(theme, metric_prefix, None, None)
373}
374
375pub fn title_text_refinement_with_fallbacks(
376    theme: &impl ThemeTokenRead,
377    metric_prefix: &str,
378    fallback_size_key: Option<&str>,
379    fallback_line_height_key: Option<&str>,
380) -> TextStyleRefinement {
381    let size_key = format!("{metric_prefix}_px");
382    let line_height_key = format!("{metric_prefix}_line_height");
383
384    let size = theme
385        .metric_by_key(&size_key)
386        .or_else(|| fallback_size_key.and_then(|key| theme.metric_by_key(key)))
387        .or_else(|| theme.metric_by_key("font.size"))
388        .unwrap_or_else(|| theme.metric_token("font.size"));
389    let line_height = theme
390        .metric_by_key(&line_height_key)
391        .or_else(|| fallback_line_height_key.and_then(|key| theme.metric_by_key(key)))
392        .unwrap_or(size);
393
394    TextStyleRefinement {
395        font: Some(FontId::ui()),
396        size: Some(size),
397        weight: Some(FontWeight::SEMIBOLD),
398        line_height: Some(line_height),
399        line_height_policy: Some(TextLineHeightPolicy::FixedFromStyle),
400        ..Default::default()
401    }
402}
403
404pub fn description_text_refinement(
405    theme: &impl ThemeTokenRead,
406    metric_prefix: &str,
407) -> TextStyleRefinement {
408    description_text_refinement_with_fallbacks(theme, metric_prefix, None, None)
409}
410
411pub fn description_text_refinement_with_fallbacks(
412    theme: &impl ThemeTokenRead,
413    metric_prefix: &str,
414    fallback_size_key: Option<&str>,
415    fallback_line_height_key: Option<&str>,
416) -> TextStyleRefinement {
417    let size_key = format!("{metric_prefix}_px");
418    let line_height_key = format!("{metric_prefix}_line_height");
419
420    let size = theme
421        .metric_by_key(&size_key)
422        .or_else(|| fallback_size_key.and_then(|key| theme.metric_by_key(key)))
423        .or_else(|| theme.metric_by_key("font.size"))
424        .unwrap_or_else(|| theme.metric_token("font.size"));
425    let line_height = theme
426        .metric_by_key(&line_height_key)
427        .or_else(|| fallback_line_height_key.and_then(|key| theme.metric_by_key(key)))
428        .or_else(|| theme.metric_by_key("font.line_height"))
429        .unwrap_or_else(|| theme.metric_token("font.line_height"));
430
431    TextStyleRefinement {
432        size: Some(size),
433        line_height: Some(line_height),
434        line_height_policy: Some(TextLineHeightPolicy::FixedFromStyle),
435        ..Default::default()
436    }
437}
438
439pub fn scope_description_text(
440    element: AnyElement,
441    theme: &impl ThemeTokenRead,
442    metric_prefix: &str,
443) -> AnyElement {
444    scope_description_text_with_fallbacks(element, theme, metric_prefix, None, None)
445}
446
447pub fn scope_description_text_with_fallbacks(
448    element: AnyElement,
449    theme: &impl ThemeTokenRead,
450    metric_prefix: &str,
451    fallback_size_key: Option<&str>,
452    fallback_line_height_key: Option<&str>,
453) -> AnyElement {
454    scope_text_style_with_color(
455        element,
456        description_text_refinement_with_fallbacks(
457            theme,
458            metric_prefix,
459            fallback_size_key,
460            fallback_line_height_key,
461        ),
462        muted_foreground_color(theme),
463    )
464}
465
466pub fn refinement_from_style(style: &TextStyle) -> TextStyleRefinement {
467    TextStyleRefinement {
468        font: Some(style.font.clone()),
469        size: Some(style.size),
470        weight: Some(style.weight),
471        slant: Some(style.slant),
472        line_height: style.line_height,
473        line_height_em: style.line_height_em,
474        line_height_policy: Some(style.line_height_policy),
475        letter_spacing_em: style.letter_spacing_em,
476        vertical_placement: Some(style.vertical_placement),
477        leading_distribution: Some(style.leading_distribution),
478    }
479}
480
481/// Builds a refinement intended for composition with parent subtree defaults.
482///
483/// Unlike [`refinement_from_style`], this keeps semantic size / line-height fields while leaving
484/// default-equivalent emphasis and family fields unset so parent scopes can still contribute.
485pub fn composable_refinement_from_style(style: &TextStyle) -> TextStyleRefinement {
486    let default = TextStyle::default();
487
488    TextStyleRefinement {
489        font: (style.font != default.font).then(|| style.font.clone()),
490        size: Some(style.size),
491        weight: (style.weight != default.weight).then_some(style.weight),
492        slant: (style.slant != default.slant).then_some(style.slant),
493        line_height: style.line_height,
494        line_height_em: style.line_height_em,
495        line_height_policy: (style.line_height_policy != default.line_height_policy)
496            .then_some(style.line_height_policy),
497        letter_spacing_em: style.letter_spacing_em,
498        vertical_placement: (style.vertical_placement != default.vertical_placement)
499            .then_some(style.vertical_placement),
500        leading_distribution: (style.leading_distribution != default.leading_distribution)
501            .then_some(style.leading_distribution),
502    }
503}
504
505pub fn preset_text_refinement(
506    theme: &impl ThemeTokenRead,
507    preset: TypographyPreset,
508) -> TextStyleRefinement {
509    refinement_from_style(&preset.resolve(theme))
510}
511
512pub fn composable_preset_text_refinement(
513    theme: &impl ThemeTokenRead,
514    preset: TypographyPreset,
515) -> TextStyleRefinement {
516    composable_refinement_from_style(&preset.resolve(theme))
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522    use fret_ui::element::{ContainerProps, ElementKind};
523    use fret_ui::elements::GlobalElementId;
524    use fret_ui::{Theme, ThemeConfig};
525
526    #[test]
527    fn title_text_refinement_uses_ui_semibold_and_tight_line_height_fallback() {
528        let mut app = fret_app::App::default();
529        Theme::with_global_mut(&mut app, |theme| {
530            theme.apply_config(&ThemeConfig {
531                name: "Test".to_string(),
532                metrics: std::collections::HashMap::from([(
533                    "component.card.title_px".to_string(),
534                    18.0,
535                )]),
536                ..ThemeConfig::default()
537            });
538        });
539        let theme = Theme::global(&app).snapshot();
540
541        let refinement = title_text_refinement(&theme, "component.card.title");
542        assert_eq!(refinement.font, Some(FontId::ui()));
543        assert_eq!(refinement.size, Some(Px(18.0)));
544        assert_eq!(refinement.weight, Some(FontWeight::SEMIBOLD));
545        assert_eq!(refinement.line_height, Some(Px(18.0)));
546        assert_eq!(
547            refinement.line_height_policy,
548            Some(TextLineHeightPolicy::FixedFromStyle)
549        );
550        assert_eq!(refinement.vertical_placement, None);
551    }
552
553    #[test]
554    fn description_text_refinement_uses_component_metrics_and_fixed_policy() {
555        let mut app = fret_app::App::default();
556        Theme::with_global_mut(&mut app, |theme| {
557            theme.apply_config(&ThemeConfig {
558                name: "Test".to_string(),
559                metrics: std::collections::HashMap::from([
560                    ("font.size".to_string(), 14.0),
561                    ("font.line_height".to_string(), 20.0),
562                    ("component.dialog.description_px".to_string(), 13.0),
563                    ("component.dialog.description_line_height".to_string(), 18.0),
564                ]),
565                ..ThemeConfig::default()
566            });
567        });
568        let theme = Theme::global(&app).snapshot();
569
570        let refinement = description_text_refinement(&theme, "component.dialog.description");
571        assert_eq!(refinement.size, Some(Px(13.0)));
572        assert_eq!(refinement.line_height, Some(Px(18.0)));
573        assert_eq!(
574            refinement.line_height_policy,
575            Some(TextLineHeightPolicy::FixedFromStyle)
576        );
577    }
578
579    #[test]
580    fn scope_description_text_attaches_color_and_inherited_refinement() {
581        let mut app = fret_app::App::default();
582        Theme::with_global_mut(&mut app, |theme| {
583            theme.apply_config(&ThemeConfig {
584                name: "Test".to_string(),
585                metrics: std::collections::HashMap::from([
586                    ("font.size".to_string(), 14.0),
587                    ("font.line_height".to_string(), 20.0),
588                    ("component.card.description_px".to_string(), 12.0),
589                    ("component.card.description_line_height".to_string(), 17.0),
590                ]),
591                colors: std::collections::HashMap::from([(
592                    "muted-foreground".to_string(),
593                    "#778899".to_string(),
594                )]),
595                ..ThemeConfig::default()
596            });
597        });
598        let theme = Theme::global(&app).snapshot();
599        let element = scope_description_text(
600            AnyElement::new(
601                GlobalElementId(1),
602                ElementKind::Container(ContainerProps::default()),
603                Vec::new(),
604            ),
605            &theme,
606            "component.card.description",
607        );
608
609        assert_eq!(
610            element.inherited_foreground,
611            Some(muted_foreground_color(&theme))
612        );
613        assert_eq!(
614            element.inherited_text_style,
615            Some(description_text_refinement(
616                &theme,
617                "component.card.description"
618            ))
619        );
620    }
621
622    #[test]
623    fn preset_text_refinement_matches_resolved_preset() {
624        let mut app = fret_app::App::default();
625        Theme::with_global_mut(&mut app, |theme| {
626            theme.apply_config(&ThemeConfig {
627                name: "Test".to_string(),
628                metrics: std::collections::HashMap::from([
629                    ("font.size".to_string(), 14.0),
630                    ("font.line_height".to_string(), 20.0),
631                    (
632                        crate::theme_tokens::metric::COMPONENT_TEXT_SM_PX.to_string(),
633                        13.0,
634                    ),
635                    (
636                        crate::theme_tokens::metric::COMPONENT_TEXT_SM_LINE_HEIGHT.to_string(),
637                        18.0,
638                    ),
639                ]),
640                ..ThemeConfig::default()
641            });
642        });
643        let theme = Theme::global(&app).snapshot();
644        let preset = TypographyPreset::control_ui(UiTextSize::Sm);
645        let style = preset.resolve(&theme);
646
647        assert_eq!(
648            preset_text_refinement(&theme, preset),
649            refinement_from_style(&style)
650        );
651    }
652
653    #[test]
654    fn composable_refinement_keeps_size_but_omits_default_emphasis() {
655        let style = TextStyle {
656            font: FontId::ui(),
657            size: Px(13.0),
658            line_height: Some(Px(18.0)),
659            line_height_policy: TextLineHeightPolicy::FixedFromStyle,
660            vertical_placement: TextVerticalPlacement::BoundsAsLineBox,
661            ..Default::default()
662        };
663
664        let refinement = composable_refinement_from_style(&style);
665        assert_eq!(refinement.font, None);
666        assert_eq!(refinement.size, Some(Px(13.0)));
667        assert_eq!(refinement.weight, None);
668        assert_eq!(refinement.slant, None);
669        assert_eq!(refinement.line_height, Some(Px(18.0)));
670        assert_eq!(
671            refinement.line_height_policy,
672            Some(TextLineHeightPolicy::FixedFromStyle)
673        );
674        assert_eq!(
675            refinement.vertical_placement,
676            Some(TextVerticalPlacement::BoundsAsLineBox)
677        );
678    }
679
680    #[test]
681    fn preset_text_style_with_overrides_updates_weight_and_slant() {
682        let app = fret_app::App::default();
683        let theme = fret_ui::Theme::global(&app).clone();
684        let preset = TypographyPreset::control_ui(UiTextSize::Sm);
685        let mut expected = preset.resolve(&theme);
686        expected.weight = FontWeight::MEDIUM;
687        expected.slant = TextSlant::Italic;
688
689        assert_eq!(
690            preset_text_style_with_overrides(
691                &theme,
692                preset,
693                Some(FontWeight::MEDIUM),
694                Some(TextSlant::Italic),
695            ),
696            expected
697        );
698    }
699
700    #[test]
701    fn composable_preset_refinement_matches_composable_resolved_preset() {
702        let mut app = fret_app::App::default();
703        Theme::with_global_mut(&mut app, |theme| {
704            theme.apply_config(&ThemeConfig {
705                name: "Test".to_string(),
706                metrics: std::collections::HashMap::from([
707                    ("font.size".to_string(), 14.0),
708                    ("font.line_height".to_string(), 20.0),
709                    (
710                        crate::theme_tokens::metric::COMPONENT_TEXT_XS_PX.to_string(),
711                        12.0,
712                    ),
713                    (
714                        crate::theme_tokens::metric::COMPONENT_TEXT_XS_LINE_HEIGHT.to_string(),
715                        16.0,
716                    ),
717                ]),
718                ..ThemeConfig::default()
719            });
720        });
721        let theme = Theme::global(&app).snapshot();
722        let preset = TypographyPreset::control_ui(UiTextSize::Xs);
723        let style = preset.resolve(&theme);
724
725        assert_eq!(
726            composable_preset_text_refinement(&theme, preset),
727            composable_refinement_from_style(&style)
728        );
729    }
730
731    #[test]
732    fn with_intent_updates_line_height_policy() {
733        let base = TextStyle {
734            font: FontId::ui(),
735            size: Px(12.0),
736            line_height: Some(Px(16.0)),
737            ..Default::default()
738        };
739
740        let control = with_intent(base.clone(), TextIntent::Control);
741        assert_eq!(
742            control.line_height_policy,
743            TextLineHeightPolicy::FixedFromStyle
744        );
745        assert_eq!(
746            control.vertical_placement,
747            TextVerticalPlacement::BoundsAsLineBox
748        );
749
750        let content = with_intent(base, TextIntent::Content);
751        assert_eq!(
752            content.line_height_policy,
753            TextLineHeightPolicy::ExpandToFit
754        );
755        assert_eq!(
756            content.vertical_placement,
757            TextVerticalPlacement::CenterMetricsBox
758        );
759    }
760
761    #[test]
762    fn typography_preset_resolves_to_intended_policy() {
763        let mut app = fret_app::App::default();
764        Theme::with_global_mut(&mut app, |theme| {
765            theme.apply_config(&ThemeConfig {
766                name: "Test".to_string(),
767                metrics: std::collections::HashMap::from([
768                    ("font.size".to_string(), 10.0),
769                    ("font.line_height".to_string(), 15.0),
770                ]),
771                ..ThemeConfig::default()
772            });
773        });
774        let theme = Theme::global(&app).clone();
775
776        let control = TypographyPreset::control_ui(UiTextSize::Sm).resolve(&theme);
777        assert_eq!(
778            control.line_height_policy,
779            TextLineHeightPolicy::FixedFromStyle
780        );
781        assert_eq!(
782            control.vertical_placement,
783            TextVerticalPlacement::BoundsAsLineBox
784        );
785
786        let content = TypographyPreset::content_ui(UiTextSize::Sm).resolve(&theme);
787        assert_eq!(
788            content.line_height_policy,
789            TextLineHeightPolicy::ExpandToFit
790        );
791        assert_eq!(
792            content.vertical_placement,
793            TextVerticalPlacement::CenterMetricsBox
794        );
795    }
796
797    #[test]
798    fn control_text_styles_use_fixed_line_boxes() {
799        let mut app = fret_app::App::default();
800        Theme::with_global_mut(&mut app, |theme| {
801            theme.apply_config(&ThemeConfig {
802                name: "Test".to_string(),
803                metrics: std::collections::HashMap::from([
804                    ("font.size".to_string(), 10.0),
805                    ("font.line_height".to_string(), 15.0),
806                ]),
807                ..ThemeConfig::default()
808            });
809        });
810        let theme = Theme::global(&app).clone();
811
812        for size in [
813            UiTextSize::Xs,
814            UiTextSize::Sm,
815            UiTextSize::Base,
816            UiTextSize::Prose,
817        ] {
818            let style = control_text_style(&theme, size);
819            assert_eq!(
820                style.line_height_policy,
821                TextLineHeightPolicy::FixedFromStyle,
822                "expected control text styles to use fixed line boxes: size={size:?}, style={style:?}"
823            );
824            assert!(
825                style.line_height.is_some(),
826                "expected control text styles to set an explicit line height: size={size:?}, style={style:?}"
827            );
828        }
829    }
830
831    #[test]
832    fn content_text_styles_expand_to_fit() {
833        let mut app = fret_app::App::default();
834        Theme::with_global_mut(&mut app, |theme| {
835            theme.apply_config(&ThemeConfig {
836                name: "Test".to_string(),
837                metrics: std::collections::HashMap::from([
838                    ("font.size".to_string(), 10.0),
839                    ("font.line_height".to_string(), 15.0),
840                ]),
841                ..ThemeConfig::default()
842            });
843        });
844        let theme = Theme::global(&app).clone();
845
846        for size in [
847            UiTextSize::Xs,
848            UiTextSize::Sm,
849            UiTextSize::Base,
850            UiTextSize::Prose,
851        ] {
852            let style = content_text_style(&theme, size);
853            assert_eq!(
854                style.line_height_policy,
855                TextLineHeightPolicy::ExpandToFit,
856                "expected content text styles to expand to fit: size={size:?}, style={style:?}"
857            );
858            assert!(
859                style.line_height.is_some(),
860                "expected content text styles to keep an explicit line height: size={size:?}, style={style:?}"
861            );
862        }
863    }
864
865    #[test]
866    fn control_text_style_scaled_uses_theme_ratio_and_fixed_line_box() {
867        let mut app = fret_app::App::default();
868        Theme::with_global_mut(&mut app, |theme| {
869            theme.apply_config(&ThemeConfig {
870                name: "Test".to_string(),
871                metrics: std::collections::HashMap::from([
872                    ("font.size".to_string(), 10.0),
873                    ("font.line_height".to_string(), 15.0),
874                ]),
875                ..ThemeConfig::default()
876            });
877        });
878        let theme = Theme::global(&app).clone();
879
880        let style = control_text_style_scaled(&theme, FontId::ui(), Px(20.0));
881        assert_eq!(style.size, Px(20.0));
882        assert_eq!(style.line_height, Some(Px(30.0)));
883        assert_eq!(
884            style.line_height_policy,
885            TextLineHeightPolicy::FixedFromStyle
886        );
887    }
888
889    #[test]
890    fn text_area_control_text_style_scaled_uses_bounds_line_box_and_strut() {
891        let mut app = fret_app::App::default();
892        Theme::with_global_mut(&mut app, |theme| {
893            theme.apply_config(&ThemeConfig {
894                name: "Test".to_string(),
895                metrics: std::collections::HashMap::from([
896                    ("font.size".to_string(), 12.0),
897                    ("font.line_height".to_string(), 18.0),
898                ]),
899                ..ThemeConfig::default()
900            });
901        });
902        let theme = Theme::global(&app).clone();
903
904        let style = text_area_control_text_style_scaled(&theme, FontId::ui(), Px(18.0));
905        assert_eq!(
906            style.line_height_policy,
907            TextLineHeightPolicy::FixedFromStyle
908        );
909        assert_eq!(
910            style.vertical_placement,
911            TextVerticalPlacement::BoundsAsLineBox
912        );
913        assert!(style.strut_style.is_some());
914    }
915}