Skip to main content

teksilo_widgets/primitives/
text_widget.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! TextWidget — a leaf widget that renders a localized text string.
5//!
6//! `TextWidget` is the building block for every visible label in the framework.
7//! It delegates measurement and rasterization to the `TextBackend` and supports
8//! three overflow modes: [`TextOverflow::Wrap`] (default — grows vertically),
9//! [`TextOverflow::Ellipsis`] with trailing, middle, or leading truncation, and
10//! a minimal markup subset (`[label](url)`, `*italic*`, `**bold**`) with
11//! per-link click/hover dispatch.
12//!
13//! Text and color accept either static values or reactive `Signal`/`Prop` bindings.
14//! The default color role is [`TextRole::Primary`], resolved against the active
15//! theme at paint time, so theme switches update text color without any explicit
16//! binding or rebuild.
17//!
18//! Single-line / ellipsis text opts into shrink by default: an over-constrained
19//! stack compresses the label down to the ellipsis-glyph width before the label
20//! overflows. Call [`no_shrink`](TextWidget::no_shrink) to restore rigid behavior,
21//! or [`min_shrink_width`](TextWidget::min_shrink_width) to set a custom floor.
22//! Wrap-mode text is height-variable and therefore always rigid; opt it into
23//! compression with [`Shrinkable`](crate::primitives::Shrinkable).
24//!
25//! ```rust
26//! # use teksilo_widgets::primitives::TextWidget;
27//! # use teksilo_i18n::lit;
28//! // Single-line label that truncates with a trailing ellipsis if too narrow:
29//! let _w = TextWidget::new(lit!("Save document")).single_line();
30//! ```
31
32use std::cell::RefCell;
33use std::rc::Rc;
34
35use teksilo_canvas::text_backend::{HitTarget, TextLayout};
36use teksilo_canvas::{Canvas, EllipsisMode, Rect, Size, SizeProposal, TextOverflow};
37
38use teksilo_core::accessibility::AccessNodeBuilder;
39use teksilo_core::color_prop::{ColorProp, TextStyleProp};
40use teksilo_core::signal::Prop;
41use teksilo_core::widget::{CursorIcon, EventContext, LayoutContext, PaintContext, Widget};
42use teksilo_core::widget_builder::HandlerSet;
43use teksilo_i18n::LocalizedString;
44use teksilo_tokens::TextRole;
45
46/// A leaf widget that renders a localized text string.
47///
48/// See the [module documentation](self) for the full feature description.
49/// Construct with [`TextWidget::new`] and chain builder methods for color,
50/// style, overflow mode, and optional markup/link dispatch.
51/// Closure type for link click/hover dispatch.
52type LinkClickHandler = Rc<dyn Fn(&str, &mut EventContext)>;
53type LinkHoverHandler = Rc<dyn Fn(&str, bool, Rect, &mut EventContext)>;
54
55pub struct TextWidget {
56    text: Prop<String>,
57    /// Foreground color. Defaults to [`TextRole::Primary`], which the paint
58    /// pass resolves against the current theme — so `TextWidget::new("...")`
59    /// follows theme switches without any explicit binding.
60    color: ColorProp,
61    /// Text style. Defaults to [`TextStyleRole::Body`](teksilo_tokens::TextStyleRole);
62    /// resolved against the current typography tokens every time the widget
63    /// is laid out or painted, so theme switches update font metrics without
64    /// a rebuild.
65    style: TextStyleProp,
66    overflow: TextOverflow,
67    /// Whether single-line / ellipsis text reports a shrink weight so an
68    /// over-constrained stack truncates it (down to [`Self::min_shrink_width`]) rather
69    /// than overflowing. Default `true`. Ignored in `Wrap` mode (wrap text is
70    /// height-variable and opts into shrink via `Shrinkable` instead).
71    shrink: bool,
72    /// Explicit compression floor for ellipsis text. `None` measures the
73    /// width of the ellipsis glyph at layout time.
74    min_shrink_width: Option<f32>,
75    max_lines: Option<usize>,
76    text_backend: Option<Rc<RefCell<dyn teksilo_canvas::TextBackend>>>,
77    /// When enabled, text is parsed as inline markup
78    /// (`[label](url)`, `*italic*`, `**bold**`) and link metadata is
79    /// emitted into the layout for hit-testing and per-span coloring.
80    markup: bool,
81    on_link_click: Option<LinkClickHandler>,
82    on_link_hover: Option<LinkHoverHandler>,
83    /// Last laid-out markup layout. Shared with the event handler
84    /// closures via `Rc<RefCell<..>>` so taps can hit-test against the
85    /// most recently measured spans.
86    last_layout: Rc<RefCell<Option<TextLayout>>>,
87    /// Currently-hovered link URL (shared with the pointer-event
88    /// closure). Used to detect enter/leave transitions between link
89    /// spans inside a single widget.
90    hovered_link: Rc<RefCell<Option<String>>>,
91    /// When true, this TextWidget emits no accessibility node at all
92    /// (no role, no name, no synthetic link children). Controls that
93    /// own their accessible name — Button, Checkbox, MenuItem, etc. —
94    /// hide their label children so the text doesn't duplicate the
95    /// parent's announced name in the a11y tree.
96    a11y_hidden: bool,
97}
98
99impl std::fmt::Debug for TextWidget {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        f.debug_struct("TextWidget").finish()
102    }
103}
104
105impl TextWidget {
106    /// Construct a text widget whose content is a `LocalizedString`. The
107    /// text may come from `tr!(...)` (reactive, re-resolves on locale
108    /// change) or from `lit!("…")` for genuinely
109    /// non-translated strings.
110    pub fn new(text: impl Into<LocalizedString>) -> Self {
111        let ls: LocalizedString = text.into();
112        Self {
113            text: Prop::from(ls),
114            color: ColorProp::TextRole(TextRole::Primary),
115            style: TextStyleProp::default(),
116            overflow: TextOverflow::default(),
117            shrink: true,
118            min_shrink_width: None,
119            max_lines: None,
120            text_backend: None,
121            markup: false,
122            on_link_click: None,
123            on_link_hover: None,
124            last_layout: Rc::new(RefCell::new(None)),
125            hovered_link: Rc::new(RefCell::new(None)),
126            a11y_hidden: false,
127        }
128    }
129
130    /// Set the text color. Accepts any `impl Into<ColorProp>`:
131    ///
132    /// - A raw `Color` — a frozen literal.
133    /// - A [`TextRole`] — resolved against the theme at paint time
134    ///   (reactive across theme switches).
135    /// - A `Signal<Color>` — reactive state (typically interaction-driven).
136    ///
137    /// The default role is [`TextRole::Primary`], so `.color(...)` is only
138    /// needed when a label wants a non-default theme role (Secondary,
139    /// Error, Accent, ...) or a custom color.
140    pub fn color(mut self, color: impl Into<ColorProp>) -> Self {
141        self.color = color.into();
142        self
143    }
144
145    /// Set the text style. Accepts a raw `TextStyle`, a
146    /// [`TextStyleRole`](teksilo_tokens::TextStyleRole), or any value implementing
147    /// `Into<TextStyleProp>`. Using a role resolves at paint/layout time, so
148    /// theme typography changes take effect without a rebuild.
149    pub fn style(mut self, style: impl Into<TextStyleProp>) -> Self {
150        self.style = style.into();
151        self
152    }
153
154    /// Set how the widget handles text that doesn't fit in the proposed
155    /// width. Default is [`TextOverflow::Wrap`].
156    pub fn overflow(mut self, overflow: TextOverflow) -> Self {
157        self.overflow = overflow;
158        self
159    }
160
161    /// Shorthand for `.overflow(TextOverflow::Ellipsis(EllipsisMode::Trailing))`.
162    /// Use this on labels inside single-line containers (buttons, menu items,
163    /// tab headers, badges, status bar cells, etc.) so long text truncates
164    /// with a trailing "…" instead of wrapping onto multiple lines.
165    pub fn single_line(self) -> Self {
166        self.overflow(TextOverflow::Ellipsis(EllipsisMode::Trailing))
167    }
168
169    /// Override the compression floor for single-line / ellipsis text — the
170    /// narrowest width an over-constrained stack may shrink this label to
171    /// before truncating stops. Defaults to the ellipsis-glyph width.
172    pub fn min_shrink_width(mut self, min: f32) -> Self {
173        self.min_shrink_width = Some(min.max(0.0));
174        self
175    }
176
177    /// Opt this label out of native shrink: it reports a rigid size and
178    /// overflows (rather than truncating) when its stack is over-constrained.
179    pub fn no_shrink(mut self) -> Self {
180        self.shrink = false;
181        self
182    }
183
184    /// Cap the paragraph at `n` lines when wrapping. Only meaningful
185    /// in [`TextOverflow::Wrap`] mode — ignored for ellipsis modes.
186    /// Lines beyond the cap are silently dropped.
187    pub fn max_lines(mut self, n: usize) -> Self {
188        self.max_lines = Some(n);
189        self
190    }
191
192    /// Override the text backend used for measurement and rasterization.
193    /// In normal app code the framework provides the backend automatically;
194    /// this method is used by headless tests that inject a `MockTextBackend`.
195    pub fn text_backend(mut self, backend: Rc<RefCell<dyn teksilo_canvas::TextBackend>>) -> Self {
196        self.text_backend = Some(backend);
197        self
198    }
199
200    /// Set the text content. Accepts a static `String`/`&str` or a reactive
201    /// `Signal<String>` / `Prop<String>` (resolved and re-rendered on change).
202    pub fn text(mut self, state: impl Into<Prop<String>>) -> Self {
203        self.text = state.into();
204        self
205    }
206
207    /// Get the current text value (resolves from state if bound).
208    pub fn resolved_text(&self) -> String {
209        self.text.get()
210    }
211
212    /// Enable inline markup parsing. When enabled, the text is parsed
213    /// as a minimal markdown subset:
214    /// - `[label](url)` — inline link
215    /// - `*italic*`     — italic run
216    /// - `**bold**`     — bold run
217    ///
218    /// Links are dispatched via [`on_link_click`](Self::on_link_click)
219    /// and colored using `theme.colors.text_link`.
220    pub fn markup(mut self, enabled: bool) -> Self {
221        self.markup = enabled;
222        self
223    }
224
225    /// Called when an inline link is tapped. Enables markup automatically.
226    pub fn on_link_click<F>(mut self, handler: F) -> Self
227    where
228        F: Fn(&str, &mut EventContext) + 'static,
229    {
230        self.on_link_click = Some(Rc::new(handler));
231        self.markup = true;
232        self
233    }
234
235    /// Called when an inline link is hovered (enter/leave). Receives
236    /// the URL, a `bool` indicating whether the pointer entered (`true`)
237    /// or left (`false`), and the widget-local rect of the link span
238    /// (so anchoring popups next to the link is cheap). Enables markup
239    /// automatically.
240    pub fn on_link_hover<F>(mut self, handler: F) -> Self
241    where
242        F: Fn(&str, bool, Rect, &mut EventContext) + 'static,
243    {
244        self.on_link_hover = Some(Rc::new(handler));
245        self.markup = true;
246        self
247    }
248
249    /// Hide this text from the accessibility tree. Use this when the
250    /// TextWidget is a visual label fragment inside another control
251    /// that already owns its accessible name via `set_name` —
252    /// otherwise screen readers announce the same string twice
253    /// (once for the control, once for the embedded Label node).
254    ///
255    /// Standalone body text (dialog descriptions, form instructions,
256    /// read-only display values) should NOT set this — it stays as a
257    /// `Role::Label` node.
258    pub fn a11y_hidden(mut self) -> Self {
259        self.a11y_hidden = true;
260        self
261    }
262}
263
264impl Widget for TextWidget {
265    fn build(
266        &mut self,
267        ctx: &mut teksilo_core::build_context::BuildContext,
268    ) -> Vec<teksilo_core::widget_id::WidgetId> {
269        let self_id = ctx.self_id();
270        let registry = ctx.binding_registry();
271        self.text.register_if_bound(
272            self_id,
273            registry,
274            teksilo_core::binding::BindingLevel::Relayout,
275        );
276        self.color.register_if_bound(
277            self_id,
278            registry,
279            teksilo_core::binding::BindingLevel::RepaintOnly,
280        );
281
282        // Wire link dispatch when markup is enabled and at least one
283        // link handler is registered. Shares the last_layout cell with
284        // the closures so taps can hit-test against the most recently
285        // measured spans.
286        if self.markup && (self.on_link_click.is_some() || self.on_link_hover.is_some()) {
287            let mut handler_set = HandlerSet::new();
288            if let Some(on_click) = self.on_link_click.clone() {
289                let last_layout = self.last_layout.clone();
290                handler_set = handler_set.on_tap(move |event, ctx| {
291                    // `event.position` is already widget-local.
292                    let local = event.position;
293                    if let Some(layout) = last_layout.borrow().as_ref()
294                        && let Some(HitTarget::Link { url }) = layout.hit_test(local)
295                    {
296                        on_click(&url, ctx);
297                    }
298                });
299            }
300
301            // Pointer-move handler — wired *unconditionally* when
302            // markup is enabled with any link handler. It does two
303            // jobs:
304            //
305            // 1. Updates the cursor to `Pointer` while the pointer is
306            //    over a link span, restoring `Default` otherwise. This
307            //    is the visual affordance the catalog was missing.
308            // 2. Drives `on_link_hover` enter/leave transitions when
309            //    that handler is wired, comparing the current
310            //    hit-test URL against `hovered_link`.
311            //
312            // Returns `Ignored` so the gesture arena still receives
313            // PointerDown/Up and the on_tap handler keeps firing.
314            let last_layout_for_pointer = self.last_layout.clone();
315            let hovered = self.hovered_link.clone();
316            let on_hover = self.on_link_hover.clone();
317            handler_set = handler_set.on_pointer_event(move |event, ctx| {
318                use teksilo_core::event::{EventResponse, WidgetEvent};
319                match event {
320                    WidgetEvent::PointerMove { position } => {
321                        // `position` is already widget-local.
322                        let local = *position;
323                        let layout_ref = last_layout_for_pointer.borrow();
324                        let hit = layout_ref.as_ref().and_then(|l| l.hit_test(local));
325                        let new_url = match &hit {
326                            Some(HitTarget::Link { url }) => Some(url.clone()),
327                            _ => None,
328                        };
329
330                        // Update cursor based on link hit.
331                        if new_url.is_some() {
332                            ctx.set_cursor(CursorIcon::Pointer);
333                        } else {
334                            ctx.set_cursor(CursorIcon::Default);
335                        }
336
337                        // Drive enter/leave transitions when an
338                        // on_link_hover handler is wired.
339                        if let Some(handler) = on_hover.as_ref() {
340                            let new_rect = if let Some(url) = new_url.as_ref() {
341                                layout_ref
342                                    .as_ref()
343                                    .and_then(|l| {
344                                        l.spans.iter().find_map(|sp| {
345                                            if let teksilo_canvas::text_backend::TextSpanKind::Link {
346                                                url: u,
347                                            } = &sp.kind
348                                                && u == url
349                                            {
350                                                Some(Rect::new(
351                                                    sp.rect[0], sp.rect[1], sp.rect[2], sp.rect[3],
352                                                ))
353                                            } else {
354                                                None
355                                            }
356                                        })
357                                    })
358                                    .unwrap_or_else(|| Rect::new(0.0, 0.0, 0.0, 0.0))
359                            } else {
360                                Rect::new(0.0, 0.0, 0.0, 0.0)
361                            };
362
363                            drop(layout_ref);
364
365                            let mut slot = hovered.borrow_mut();
366                            if slot.as_deref() != new_url.as_deref() {
367                                if let Some(old) = slot.take() {
368                                    handler(&old, false, Rect::new(0.0, 0.0, 0.0, 0.0), ctx);
369                                }
370                                if let Some(u) = new_url {
371                                    handler(&u, true, new_rect, ctx);
372                                    *slot = Some(u);
373                                }
374                            }
375                        }
376                        EventResponse::Ignored
377                    }
378                    WidgetEvent::PointerLeave => {
379                        ctx.set_cursor(CursorIcon::Default);
380                        if let Some(handler) = on_hover.as_ref() {
381                            let mut slot = hovered.borrow_mut();
382                            if let Some(old) = slot.take() {
383                                handler(&old, false, Rect::new(0.0, 0.0, 0.0, 0.0), ctx);
384                            }
385                        }
386                        EventResponse::Ignored
387                    }
388                    _ => EventResponse::Ignored,
389                }
390            });
391
392            ctx.apply_self_handlers(handler_set);
393        }
394
395        Vec::new()
396    }
397
398    fn layout_response(
399        &self,
400        proposal: SizeProposal,
401        ctx: &LayoutContext,
402    ) -> teksilo_core::widget::LayoutResponse {
403        let text = self.text.get();
404        let style = self.style.resolve(&ctx.theme.typography);
405        let Some(backend) = self.text_backend.as_ref().or(ctx.text_backend) else {
406            // Mock fallback when no backend is available (e.g. very early
407            // bootstrap). Assume 8px/char for measurement.
408            let width = text.len() as f32 * 8.0;
409            let height = 16.0;
410            let w = match proposal.width {
411                Some(max) => width.min(max),
412                None => width,
413            };
414            return (Size::new(w, height)).into();
415        };
416        let mut backend = backend.borrow_mut();
417
418        // Add a small epsilon to the proposal width before passing it as
419        // max_width. The same epsilon is applied in Canvas::draw_text /
420        // draw_paragraph so both measurement and paint produce the same
421        // TypesetterBridge cache key, avoiding duplicate cache entries
422        // and inconsistent truncation from float precision loss in the
423        // scale_factor roundtrip (logical → physical → logical).
424        let max_width = proposal.width.map(|w| w + 0.5);
425
426        // Markup path: only reachable in Wrap mode. The backend parses
427        // the source internally and returns a TextLayout whose `spans`
428        // field carries per-run rects (including links) that we stash
429        // for hit-testing during event dispatch.
430        if self.markup {
431            let layout = match max_width {
432                Some(w) => backend.layout_paragraph_markup(&text, &style, w, self.max_lines),
433                None => backend.layout_single_line_markup(&text, &style, None),
434            };
435            let size = Size::new(layout.width, layout.height);
436            *self.last_layout.borrow_mut() = Some(layout);
437            return (size).into();
438        }
439
440        let is_ellipsis = matches!(self.overflow, TextOverflow::Ellipsis(_));
441
442        let size = match self.overflow {
443            TextOverflow::Wrap => match max_width {
444                Some(w) => {
445                    let layout = backend.layout_paragraph(&text, &style, w, self.max_lines);
446                    Size::new(layout.width, layout.height)
447                }
448                None => {
449                    // Unconstrained width: no basis for wrapping, so measure
450                    // as a single line.
451                    let layout = backend.layout_single_line(&text, &style, None);
452                    Size::new(layout.width, layout.height)
453                }
454            },
455            TextOverflow::Ellipsis(EllipsisMode::Trailing) => {
456                // text-typeset truncates with trailing "…" when a max_width
457                // is supplied — let it do the work.
458                let layout = backend.layout_single_line(&text, &style, max_width);
459                Size::new(layout.width, layout.height)
460            }
461            TextOverflow::Ellipsis(mode) => match max_width {
462                // Middle / Leading: compute the truncated display string
463                // first, then measure it unconstrained.
464                None => {
465                    let layout = backend.layout_single_line(&text, &style, None);
466                    Size::new(layout.width, layout.height)
467                }
468                Some(max_w) => {
469                    let truncated = teksilo_canvas::ellipsis::ellipsize(
470                        &text,
471                        &style,
472                        max_w,
473                        mode,
474                        &mut *backend,
475                    );
476                    let layout = backend.layout_single_line(&truncated, &style, None);
477                    Size::new(layout.width, layout.height)
478                }
479            },
480        };
481
482        // Single-line / ellipsis labels opt into shrink (they are height-stable
483        // — truncating never changes their line height). An over-constrained
484        // stack compresses them down to the ellipsis-glyph width (or the
485        // caller's `min_shrink_width`) and they ellipsize instead of
486        // overflowing. Wrap text stays rigid; opt it in with `Shrinkable`.
487        if is_ellipsis && self.shrink {
488            let min_w = self
489                .min_shrink_width
490                .unwrap_or_else(|| backend.layout_single_line("…", &style, None).width)
491                .min(size.width);
492            teksilo_core::widget::LayoutResponse::shrinkable(
493                size,
494                Size::new(min_w, size.height),
495                1.0,
496            )
497        } else {
498            size.into()
499        }
500    }
501
502    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
503        let text = self.text.get();
504        let color = self.color.resolve(ctx.theme, ctx.effective_enabled);
505        let style = self.style.resolve(&ctx.theme.typography);
506
507        // Markup path: re-measure through the markup pipeline (the
508        // backend's cache makes this a no-op after the size pass) and
509        // draw with per-span coloring so link glyphs pick up the
510        // theme's `text_link` token.
511        if self.markup {
512            let Some(backend_rc) = canvas.text_backend() else {
513                return;
514            };
515            let layout = {
516                let mut backend = backend_rc.borrow_mut();
517                match self.overflow {
518                    TextOverflow::Wrap => backend.layout_paragraph_markup(
519                        &text,
520                        &style,
521                        (bounds.width + 0.5).max(0.0),
522                        self.max_lines,
523                    ),
524                    _ => backend.layout_single_line_markup(&text, &style, Some(bounds.width + 0.5)),
525                }
526            };
527            let link_color = ctx.theme.colors.text_link;
528            canvas.draw_text_layout_markup(
529                &layout,
530                teksilo_canvas::Point::new(bounds.x, bounds.y),
531                color,
532                link_color,
533            );
534            // Keep the cached layout in sync so tap hit-testing sees
535            // the same rects that were painted.
536            *self.last_layout.borrow_mut() = Some(layout);
537            return;
538        }
539
540        match self.overflow {
541            TextOverflow::Wrap => {
542                canvas.draw_paragraph(&text, bounds, &style, color, self.max_lines);
543            }
544            TextOverflow::Ellipsis(EllipsisMode::Trailing) => {
545                canvas.draw_text(&text, bounds, &style, color);
546            }
547            TextOverflow::Ellipsis(mode) => {
548                // Produce the truncated display string via the canvas's
549                // backend and hand it to draw_text.
550                let truncated = match canvas.text_backend() {
551                    Some(backend) => teksilo_canvas::ellipsis::ellipsize(
552                        &text,
553                        &style,
554                        bounds.width,
555                        mode,
556                        &mut *backend.borrow_mut(),
557                    ),
558                    None => text.clone(),
559                };
560                canvas.draw_text(&truncated, bounds, &style, color);
561            }
562        }
563    }
564
565    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
566        if self.a11y_hidden {
567            return;
568        }
569        let text = self.text.get();
570        builder.set_role(teksilo_core::accesskit::Role::Label);
571        builder.set_name(&text);
572
573        // Markup mode: surface inline links as synthetic `Role::Link`
574        // children so screen readers can focus them individually. The
575        // rects from `last_layout` are in widget-local space and carry
576        // enough information to identify each unique URL.
577        if self.markup
578            && let Some(layout) = self.last_layout.borrow().as_ref()
579        {
580            // Dedupe by (url, byte_range.start): a link that wraps
581            // across two lines produces two LaidOutSpan entries sharing
582            // the same URL and byte range, but we only want one
583            // accessible node per source link.
584            let mut seen: Vec<(String, usize)> = Vec::new();
585            for span in &layout.spans {
586                if let teksilo_canvas::text_backend::TextSpanKind::Link { url } = &span.kind {
587                    let key = (url.clone(), span.byte_range.start);
588                    if seen.iter().any(|k| k == &key) {
589                        continue;
590                    }
591                    seen.push(key.clone());
592                    // Use the byte offset as the element_id so the
593                    // synthetic NodeId is stable across re-layouts.
594                    let element_id = span.byte_range.start as u64;
595                    let label = text
596                        .get(span.byte_range.clone())
597                        .map(|s| s.to_string())
598                        .unwrap_or_else(|| url.clone());
599                    builder.push_link_child(element_id, label, url.clone());
600                }
601            }
602        }
603    }
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609    use std::cell::RefCell;
610    use std::rc::Rc;
611    use teksilo_canvas::MockTextBackend;
612    use teksilo_core::signal::Signal;
613    use teksilo_core::widget_tree::WidgetTree;
614    use teksilo_i18n::lit;
615
616    fn tree_with_mock_backend() -> WidgetTree {
617        WidgetTree::new().with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())))
618    }
619
620    #[test]
621    fn text_renders_state_value() {
622        let text = Signal::new("Hello".to_string());
623        let mut tree = WidgetTree::new();
624        let w = tree.add(TextWidget::new(lit!("")).text(text.clone()));
625        text.bind_to(
626            w,
627            tree.binding_registry(),
628            teksilo_core::binding::BindingLevel::Relayout,
629        );
630        tree.layout(SizeProposal::exact(200.0, 40.0));
631
632        assert_eq!(tree.text_content(w), Some("Hello".to_string()));
633    }
634
635    #[test]
636    fn text_updates_on_state_change() {
637        let text = Signal::new("Hello".to_string());
638        let mut tree = WidgetTree::new();
639        let w = tree.add(TextWidget::new(lit!("")).text(text.clone()));
640        text.bind_to(
641            w,
642            tree.binding_registry(),
643            teksilo_core::binding::BindingLevel::Relayout,
644        );
645
646        tree.layout(SizeProposal::exact(200.0, 40.0));
647        assert_eq!(tree.text_content(w), Some("Hello".to_string()));
648
649        text.set("World".to_string());
650        tree.layout(SizeProposal::exact(200.0, 40.0));
651        assert_eq!(tree.text_content(w), Some("World".to_string()));
652    }
653
654    // -------------------------------------------------------------------
655    // Overflow modes
656    // -------------------------------------------------------------------
657
658    #[test]
659    fn wrap_is_the_default_mode() {
660        let w = TextWidget::new(lit!("Hello"));
661        assert_eq!(w.overflow, TextOverflow::Wrap);
662    }
663
664    #[test]
665    fn ellipsis_label_shrinks_to_fit_in_narrow_stack() {
666        use crate::primitives::hstack::HStack;
667        let mut tree = tree_with_mock_backend();
668        // "hello world" = 11 chars × 8px = 88px natural width.
669        let label = tree.add(TextWidget::new(lit!("hello world")).single_line());
670        let _stack = tree.add(HStack::new().add_child(label));
671        tree.layout(SizeProposal::exact(40.0, 20.0));
672        // Single-line text opts into shrink: it compresses to fit the 40px
673        // stack and ellipsizes, instead of overflowing to 88px.
674        assert!(
675            (tree.bounds(label).width - 40.0).abs() < 1.0,
676            "ellipsis label should shrink to ~40, got {}",
677            tree.bounds(label).width
678        );
679    }
680
681    #[test]
682    fn no_shrink_label_overflows_in_narrow_stack() {
683        use crate::primitives::hstack::HStack;
684        let mut tree = tree_with_mock_backend();
685        let label = tree.add(
686            TextWidget::new(lit!("hello world"))
687                .single_line()
688                .no_shrink(),
689        );
690        let _stack = tree.add(HStack::new().add_child(label));
691        tree.layout(SizeProposal::exact(40.0, 20.0));
692        // Opted out → keeps its full 88px and overflows.
693        assert!(
694            (tree.bounds(label).width - 88.0).abs() < 1.0,
695            "no_shrink label should overflow at 88, got {}",
696            tree.bounds(label).width
697        );
698    }
699
700    #[test]
701    fn wrap_text_does_not_shrink_natively() {
702        use crate::primitives::hstack::HStack;
703        let mut tree = tree_with_mock_backend();
704        // Wrap mode (default) is height-variable → rigid; overflows rather than
705        // shrinking. (Opt in via `Shrinkable`.)
706        let label = tree.add(TextWidget::new(lit!("hello world")));
707        let _stack = tree.add(HStack::new().add_child(label));
708        tree.layout(SizeProposal::exact(40.0, 20.0));
709        assert!(
710            (tree.bounds(label).width - 88.0).abs() < 1.0,
711            "wrap label should not natively shrink, got {}",
712            tree.bounds(label).width
713        );
714    }
715
716    #[test]
717    fn wrap_grows_vertically_in_narrow_proposal() {
718        // MockTextBackend: 8px/char, 16px line height. "one two three four"
719        // = 18 bytes × 8 = 144px wide single-line. At max_width 50 it
720        // should wrap across several lines.
721        let mut tree = tree_with_mock_backend();
722        let w = tree.add(TextWidget::new(lit!("one two three four")));
723        tree.layout(SizeProposal {
724            width: Some(50.0),
725            height: None,
726        });
727        let b = tree.bounds(w);
728        assert!(
729            b.height > 16.0,
730            "wrapped text should be taller than one line (got {})",
731            b.height
732        );
733        assert!(
734            b.width <= 50.0 + 1.0,
735            "wrapped text should stay within proposal width (got {})",
736            b.width
737        );
738    }
739
740    #[test]
741    fn wrap_falls_back_to_single_line_when_proposal_width_is_none() {
742        let mut tree = tree_with_mock_backend();
743        let w = tree.add(TextWidget::new(lit!("one two three")));
744        tree.layout(SizeProposal {
745            width: None,
746            height: None,
747        });
748        let b = tree.bounds(w);
749        assert!(
750            (b.height - 16.0).abs() < 0.1,
751            "unbounded width should produce a single line (got {})",
752            b.height
753        );
754    }
755
756    #[test]
757    fn wrap_max_lines_caps_paragraph_height() {
758        // "one two three four five" wraps to multiple lines; cap at 2.
759        let mut tree = tree_with_mock_backend();
760        let w = tree.add(TextWidget::new(lit!("one two three four five six seven")).max_lines(2));
761        tree.layout(SizeProposal {
762            width: Some(40.0),
763            height: None,
764        });
765        let b = tree.bounds(w);
766        assert!(
767            b.height <= 32.0 + 0.1,
768            "max_lines(2) should cap height at 2 × 16px (got {})",
769            b.height
770        );
771    }
772
773    #[test]
774    fn trailing_ellipsis_clamps_width_to_proposal() {
775        // MockTextBackend clamps at max_width for single-line measurement.
776        let mut tree = tree_with_mock_backend();
777        let w = tree.add(
778            TextWidget::new(lit!("a very long piece of text"))
779                .overflow(TextOverflow::Ellipsis(EllipsisMode::Trailing)),
780        );
781        tree.layout(SizeProposal {
782            width: Some(40.0),
783            height: None,
784        });
785        let b = tree.bounds(w);
786        assert!(b.width <= 40.0 + 0.1);
787        assert!(
788            (b.height - 16.0).abs() < 0.1,
789            "ellipsized text should stay on one line"
790        );
791    }
792
793    #[test]
794    fn middle_ellipsis_produces_narrow_single_line_layout() {
795        let mut tree = tree_with_mock_backend();
796        let w = tree.add(
797            TextWidget::new(lit!("abcdefghijklmnop"))
798                .overflow(TextOverflow::Ellipsis(EllipsisMode::Middle)),
799        );
800        tree.layout(SizeProposal {
801            width: Some(80.0),
802            height: None,
803        });
804        let b = tree.bounds(w);
805        assert!(
806            b.width <= 80.0 + 0.1,
807            "middle-ellipsized width should fit the proposal (got {})",
808            b.width
809        );
810        assert!(
811            (b.height - 16.0).abs() < 0.1,
812            "ellipsized text should stay on one line"
813        );
814    }
815
816    #[test]
817    fn leading_ellipsis_produces_narrow_single_line_layout() {
818        let mut tree = tree_with_mock_backend();
819        let w = tree.add(
820            TextWidget::new(lit!("abcdefghijklmnop"))
821                .overflow(TextOverflow::Ellipsis(EllipsisMode::Leading)),
822        );
823        tree.layout(SizeProposal {
824            width: Some(80.0),
825            height: None,
826        });
827        let b = tree.bounds(w);
828        assert!(
829            b.width <= 80.0 + 0.1,
830            "leading-ellipsized width should fit the proposal (got {})",
831            b.width
832        );
833    }
834
835    #[test]
836    fn single_line_shorthand_matches_trailing_ellipsis() {
837        let a = TextWidget::new(lit!("hi")).single_line();
838        let b =
839            TextWidget::new(lit!("hi")).overflow(TextOverflow::Ellipsis(EllipsisMode::Trailing));
840        assert_eq!(a.overflow, b.overflow);
841    }
842}