Skip to main content

fret_ui_kit/declarative/
text.rs

1use std::sync::Arc;
2
3use fret_core::{
4    FontId, FontWeight, Px, TextAlign, TextOverflow, TextStyle, TextStyleRefinement, TextWrap,
5};
6use fret_ui::element::{AnyElement, LayoutStyle, TextInkOverflow, TextProps};
7use fret_ui::{ElementContext, Theme, UiHost};
8
9use crate::typography as ui_typography;
10use crate::typography::UiTextSize;
11
12pub(crate) fn text_xs_style(theme: &Theme) -> TextStyle {
13    ui_typography::control_text_style(theme, UiTextSize::Xs)
14}
15
16pub(crate) fn text_sm_style(theme: &Theme) -> TextStyle {
17    ui_typography::control_text_style(theme, UiTextSize::Sm)
18}
19
20pub(crate) fn text_base_style(theme: &Theme) -> TextStyle {
21    ui_typography::control_text_style(theme, UiTextSize::Base)
22}
23
24pub(crate) fn text_prose_style(theme: &Theme) -> TextStyle {
25    ui_typography::control_text_style(theme, UiTextSize::Prose)
26}
27
28pub(crate) fn text_xs_refinement(theme: &Theme) -> TextStyleRefinement {
29    ui_typography::composable_refinement_from_style(&text_xs_style(theme))
30}
31
32pub(crate) fn text_sm_refinement(theme: &Theme) -> TextStyleRefinement {
33    ui_typography::composable_refinement_from_style(&text_sm_style(theme))
34}
35
36pub(crate) fn text_base_refinement(theme: &Theme) -> TextStyleRefinement {
37    ui_typography::composable_refinement_from_style(&text_base_style(theme))
38}
39
40pub(crate) fn text_prose_refinement(theme: &Theme) -> TextStyleRefinement {
41    ui_typography::composable_refinement_from_style(&text_prose_style(theme))
42}
43
44fn scoped_text<H: UiHost>(
45    cx: &mut ElementContext<'_, H>,
46    text: impl Into<Arc<str>>,
47    refinement: TextStyleRefinement,
48    wrap: TextWrap,
49    overflow: TextOverflow,
50) -> AnyElement {
51    ui_typography::scope_text_style(
52        cx.text_props(TextProps {
53            layout: LayoutStyle::default(),
54            text: text.into(),
55            style: None,
56            color: None,
57            wrap,
58            overflow,
59            align: TextAlign::Start,
60            ink_overflow: TextInkOverflow::None,
61        }),
62        refinement,
63    )
64}
65
66/// Declarative text helper that matches Tailwind's `truncate` semantics:
67/// - `whitespace-nowrap`
68/// - `text-overflow: ellipsis`
69///
70/// Note: ellipsis only applies when the text is laid out with a constrained width.
71pub fn text_truncate<H: UiHost>(
72    cx: &mut ElementContext<'_, H>,
73    text: impl Into<Arc<str>>,
74) -> AnyElement {
75    cx.text_props(TextProps {
76        layout: LayoutStyle::default(),
77        text: text.into(),
78        style: None,
79        color: None,
80        wrap: TextWrap::None,
81        overflow: TextOverflow::Ellipsis,
82        align: TextAlign::Start,
83        ink_overflow: TextInkOverflow::None,
84    })
85}
86
87/// Declarative text helper that matches Tailwind's `whitespace-nowrap` semantics.
88pub fn text_nowrap<H: UiHost>(
89    cx: &mut ElementContext<'_, H>,
90    text: impl Into<Arc<str>>,
91) -> AnyElement {
92    cx.text_props(TextProps {
93        layout: LayoutStyle::default(),
94        text: text.into(),
95        style: None,
96        color: None,
97        wrap: TextWrap::None,
98        overflow: TextOverflow::Clip,
99        align: TextAlign::Start,
100        ink_overflow: TextInkOverflow::None,
101    })
102}
103
104/// Declarative text helper that matches Tailwind's `text-sm` default usage in shadcn recipes.
105///
106/// Note: We intentionally map `font.size` to the "sm" baseline by default (editor-friendly).
107/// Themes can override this via:
108/// - `component.text.sm_px`
109/// - `component.text.sm_line_height`
110#[track_caller]
111pub fn text_sm<H: UiHost>(cx: &mut ElementContext<'_, H>, text: impl Into<Arc<str>>) -> AnyElement {
112    let refinement = {
113        let theme = Theme::global(&*cx.app);
114        text_sm_refinement(theme)
115    };
116    scoped_text(cx, text, refinement, TextWrap::Word, TextOverflow::Clip)
117}
118
119/// Declarative text helper that matches Tailwind's `text-xs` default usage in shadcn recipes.
120///
121/// Themes can override this via:
122/// - `component.text.xs_px`
123/// - `component.text.xs_line_height`
124#[track_caller]
125pub fn text_xs<H: UiHost>(cx: &mut ElementContext<'_, H>, text: impl Into<Arc<str>>) -> AnyElement {
126    let refinement = {
127        let theme = Theme::global(&*cx.app);
128        text_xs_refinement(theme)
129    };
130    scoped_text(cx, text, refinement, TextWrap::Word, TextOverflow::Clip)
131}
132
133/// Declarative text helper that matches Tailwind's `text-base` default usage in shadcn recipes.
134///
135/// Themes can override this via:
136/// - `component.text.base_px`
137/// - `component.text.base_line_height`
138pub fn text_base<H: UiHost>(
139    cx: &mut ElementContext<'_, H>,
140    text: impl Into<Arc<str>>,
141) -> AnyElement {
142    let refinement = {
143        let theme = Theme::global(&*cx.app);
144        text_base_refinement(theme)
145    };
146    scoped_text(cx, text, refinement, TextWrap::Word, TextOverflow::Clip)
147}
148
149/// Declarative text helper intended for typography pages (`prose`-like body copy).
150///
151/// This uses a larger baseline than `text_base` so examples like `typography-table` can match
152/// upstream web goldens (16px / 24px by default under the shadcn theme).
153///
154/// Wrapping notes:
155/// - This defaults to `TextWrap::Word` (wrap at word boundaries; do not break long tokens).
156/// - For body copy that may contain long URLs/paths/identifiers, prefer [`text_prose_break_words`]
157///   so a single token cannot force horizontal overflow.
158/// - For editor-like surfaces that must always wrap even within tokens, prefer `TextWrap::Grapheme`.
159/// - `WordBreak`/`Grapheme` behave best when the parent provides a definite width (`w_full`,
160///   `Length::Fill`, `max_w`, etc.); in shrink-wrapped layouts they can legitimately measure
161///   narrower under min-content constraints.
162pub fn text_prose<H: UiHost>(
163    cx: &mut ElementContext<'_, H>,
164    text: impl Into<Arc<str>>,
165) -> AnyElement {
166    let refinement = {
167        let theme = Theme::global(&*cx.app);
168        text_prose_refinement(theme)
169    };
170    scoped_text(cx, text, refinement, TextWrap::Word, TextOverflow::Clip)
171}
172
173/// `text_prose` variant that matches Tailwind's `break-words` intent:
174/// prefer wrapping at word boundaries, but allow breaking long tokens when needed.
175pub fn text_prose_break_words<H: UiHost>(
176    cx: &mut ElementContext<'_, H>,
177    text: impl Into<Arc<str>>,
178) -> AnyElement {
179    let refinement = {
180        let theme = Theme::global(&*cx.app);
181        text_prose_refinement(theme)
182    };
183    scoped_text(
184        cx,
185        text,
186        refinement,
187        TextWrap::WordBreak,
188        TextOverflow::Clip,
189    )
190}
191
192/// Bold variant of [`text_prose`], intended for typography table headers (`<th className="... font-bold">`).
193pub fn text_prose_bold<H: UiHost>(
194    cx: &mut ElementContext<'_, H>,
195    text: impl Into<Arc<str>>,
196) -> AnyElement {
197    let mut refinement = {
198        let theme = Theme::global(&*cx.app);
199        text_prose_refinement(theme)
200    };
201    refinement.weight = Some(FontWeight::BOLD);
202
203    scoped_text(cx, text, refinement, TextWrap::Word, TextOverflow::Clip)
204}
205
206/// Returns the default label style and line-height baseline used by `primitives::label`.
207pub(crate) fn label_style(theme: &Theme) -> (TextStyle, Px) {
208    let px = theme
209        .metric_by_key("component.label.text_px")
210        .or_else(|| theme.metric_by_key("font.size"))
211        .unwrap_or_else(|| theme.metric_token("font.size"));
212    let line_height = theme
213        .metric_by_key("component.label.line_height")
214        .or_else(|| theme.metric_by_key("font.line_height"))
215        .unwrap_or_else(|| theme.metric_token("font.line_height"));
216
217    let mut style = ui_typography::fixed_line_box_style(FontId::ui(), px, line_height);
218    style.weight = FontWeight::MEDIUM;
219    (style, line_height)
220}
221
222pub(crate) fn label_text_refinement(theme: &Theme) -> (TextStyleRefinement, Px) {
223    let (style, line_height) = label_style(theme);
224    let mut refinement = ui_typography::composable_refinement_from_style(&style);
225    refinement.font = Some(FontId::ui());
226    (refinement, line_height)
227}
228
229/// Declarative helper intended for code-like inline text.
230///
231/// Defaults:
232/// - monospace font (`metric.font.mono_size` / `metric.font.mono_line_height`)
233/// - `TextWrap::Grapheme` so long tokens (paths/URLs/identifiers) can still wrap when needed
234pub fn text_code_wrap<H: UiHost>(
235    cx: &mut ElementContext<'_, H>,
236    text: impl Into<Arc<str>>,
237) -> AnyElement {
238    let refinement = {
239        let theme = Theme::global(&*cx.app);
240        ui_typography::composable_refinement_from_style(&ui_typography::fixed_line_box_style(
241            FontId::monospace(),
242            theme.metric_token("metric.font.mono_size"),
243            theme.metric_token("metric.font.mono_line_height"),
244        ))
245    };
246
247    scoped_text(cx, text, refinement, TextWrap::Grapheme, TextOverflow::Clip)
248}
249
250/// `text_prose` variant that forces single-line layout (`whitespace-nowrap`-like behavior).
251pub fn text_prose_nowrap<H: UiHost>(
252    cx: &mut ElementContext<'_, H>,
253    text: impl Into<Arc<str>>,
254) -> AnyElement {
255    let refinement = {
256        let theme = Theme::global(&*cx.app);
257        text_prose_refinement(theme)
258    };
259    scoped_text(cx, text, refinement, TextWrap::None, TextOverflow::Clip)
260}
261
262/// Bold variant of [`text_prose_nowrap`].
263pub fn text_prose_bold_nowrap<H: UiHost>(
264    cx: &mut ElementContext<'_, H>,
265    text: impl Into<Arc<str>>,
266) -> AnyElement {
267    let mut refinement = {
268        let theme = Theme::global(&*cx.app);
269        text_prose_refinement(theme)
270    };
271    refinement.weight = Some(FontWeight::BOLD);
272
273    scoped_text(cx, text, refinement, TextWrap::None, TextOverflow::Clip)
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    use fret_app::App;
281    use fret_core::{AppWindowId, Point, Rect, Size};
282    use fret_ui::element::ElementKind;
283    use fret_ui::elements;
284    use fret_ui::{Theme, ThemeConfig};
285
286    fn test_app() -> App {
287        let mut app = App::new();
288        Theme::with_global_mut(&mut app, |theme| {
289            theme.apply_config(&ThemeConfig {
290                name: "Text Helpers Test".to_string(),
291                metrics: std::collections::HashMap::from([
292                    ("font.size".to_string(), 13.0),
293                    ("font.line_height".to_string(), 20.0),
294                    (
295                        crate::theme_tokens::metric::COMPONENT_TEXT_XS_PX.to_string(),
296                        12.0,
297                    ),
298                    (
299                        crate::theme_tokens::metric::COMPONENT_TEXT_XS_LINE_HEIGHT.to_string(),
300                        16.0,
301                    ),
302                    (
303                        crate::theme_tokens::metric::COMPONENT_TEXT_SM_PX.to_string(),
304                        13.0,
305                    ),
306                    (
307                        crate::theme_tokens::metric::COMPONENT_TEXT_SM_LINE_HEIGHT.to_string(),
308                        18.0,
309                    ),
310                    (
311                        crate::theme_tokens::metric::COMPONENT_TEXT_BASE_PX.to_string(),
312                        14.0,
313                    ),
314                    (
315                        crate::theme_tokens::metric::COMPONENT_TEXT_BASE_LINE_HEIGHT.to_string(),
316                        20.0,
317                    ),
318                    (
319                        crate::theme_tokens::metric::COMPONENT_TEXT_PROSE_PX.to_string(),
320                        16.0,
321                    ),
322                    (
323                        crate::theme_tokens::metric::COMPONENT_TEXT_PROSE_LINE_HEIGHT.to_string(),
324                        24.0,
325                    ),
326                    ("metric.font.mono_size".to_string(), 13.0),
327                    ("metric.font.mono_line_height".to_string(), 18.0),
328                ]),
329                ..ThemeConfig::default()
330            });
331        });
332        app
333    }
334
335    fn test_bounds() -> Rect {
336        Rect::new(
337            Point::new(Px(0.0), Px(0.0)),
338            Size::new(Px(320.0), Px(160.0)),
339        )
340    }
341
342    #[test]
343    fn text_sm_scopes_inherited_refinement_without_leaf_style() {
344        let window = AppWindowId::default();
345        let mut app = test_app();
346        let bounds = test_bounds();
347
348        let el =
349            elements::with_element_cx(&mut app, window, bounds, "test", |cx| text_sm(cx, "Hello"));
350        let theme = Theme::global(&app);
351
352        let ElementKind::Text(props) = &el.kind else {
353            panic!("expected text_sm(...) to build a Text element");
354        };
355
356        assert!(props.style.is_none());
357        assert!(props.color.is_none());
358        assert_eq!(props.wrap, TextWrap::Word);
359        assert_eq!(props.overflow, TextOverflow::Clip);
360        assert_eq!(el.inherited_text_style, Some(text_sm_refinement(&theme)));
361    }
362
363    #[test]
364    fn prose_variants_and_code_wrap_install_semantic_inherited_overrides() {
365        let window = AppWindowId::default();
366        let mut app = test_app();
367        let bounds = test_bounds();
368        let mut expected_prose = {
369            let theme = Theme::global(&app);
370            text_prose_refinement(theme)
371        };
372        expected_prose.weight = Some(FontWeight::BOLD);
373
374        let prose_bold = elements::with_element_cx(&mut app, window, bounds, "test", |cx| {
375            text_prose_bold(cx, "Heading")
376        });
377        let ElementKind::Text(props) = &prose_bold.kind else {
378            panic!("expected text_prose_bold(...) to build a Text element");
379        };
380        assert!(props.style.is_none());
381        assert!(props.color.is_none());
382        assert_eq!(prose_bold.inherited_text_style, Some(expected_prose));
383
384        let code = elements::with_element_cx(&mut app, window, bounds, "test", |cx| {
385            text_code_wrap(cx, "let answer = 42;")
386        });
387        let ElementKind::Text(props) = &code.kind else {
388            panic!("expected text_code_wrap(...) to build a Text element");
389        };
390        assert!(props.style.is_none());
391        assert!(props.color.is_none());
392        assert_eq!(props.wrap, TextWrap::Grapheme);
393        assert_eq!(props.overflow, TextOverflow::Clip);
394        assert_eq!(
395            code.inherited_text_style
396                .as_ref()
397                .and_then(|style| style.font.clone()),
398            Some(FontId::monospace())
399        );
400    }
401}