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//! # Accessibility
19//!
20//! A `TextWidget` is reviewable by default: it emits AccessKit text runs —
21//! one per visual line, with a position and an advance for every character
22//! — so a screen reader's review cursor can walk it, a braille cell can be
23//! routed to the word under it, and `AXBoundsForRange` and its Windows and
24//! Linux equivalents can answer. Runs are excluded from object navigation
25//! by `accesskit_consumer`'s own filter, so they add no stops to anyone's
26//! traversal and no targets to any hit test.
27//!
28//! Geometry is always present: real when a text backend measured the label,
29//! and a zero-width box at its leading edge when none did. Never absent —
30//! `Range::bounding_boxes()` discards every box it has collected on the
31//! first run missing any of bounds, direction, positions or widths, so one
32//! geometry-less run would take the whole label's geometry with it.
33//!
34//! A label *inside* a control that owns the same accessible name must call
35//! [`a11y_hidden`](TextWidget::a11y_hidden), or a reader hears the string
36//! twice. Standalone body text should not.
37//!
38//! Single-line / ellipsis text opts into shrink by default: an over-constrained
39//! stack compresses the label down to the ellipsis-glyph width before the label
40//! overflows. Call [`no_shrink`](TextWidget::no_shrink) to restore rigid behavior,
41//! or [`min_shrink_width`](TextWidget::min_shrink_width) to set a custom floor.
42//! Wrap-mode text is height-variable and therefore always rigid; opt it into
43//! compression with [`Shrinkable`](crate::primitives::Shrinkable).
44//!
45//! ```rust
46//! # use teksilo_widgets::primitives::TextWidget;
47//! # use teksilo_i18n::lit;
48//! // Single-line label that truncates with a trailing ellipsis if too narrow:
49//! let _w = TextWidget::new(lit!("Save document")).single_line();
50//! ```
51//!
52//! ## Touch and pen
53//!
54//! Inline links are reachable by a plain tap: the handler at `on_tap` follows
55//! the link run under the press with no modifier of any kind. (The touch
56//! inventory recorded this file as Ctrl-gated and therefore unreachable by
57//! touch; that gate was `rich_text/mouse.rs`'s — `modifiers.command() ||
58//! read_only`, since widened to `kind.is_direct() || read_only ||
59//! command_held` — and never existed here.) The hover cursor over a link run
60//! is a mouse and pen affordance and costs a finger nothing.
61//!
62//! A link run is text-height and its size is constrained by the line height of
63//! the text around it, which is exactly WCAG 2.2 SC 2.5.8's *inline* exception,
64//! so it is not raised to the 24 dp floor and is not reported as a target
65//! region.
66
67use std::cell::RefCell;
68use std::rc::Rc;
69
70use teksilo_canvas::ellipsis::{ELLIPSIS, Ellipsized, ellipsize_ranges};
71use teksilo_canvas::text_backend::{
72    CharGeom, HitTarget, LineEnd, LineTruncation, TextDirection, TextGeometry, TextLayout,
73    TextLine, TextLineSegment,
74};
75use teksilo_canvas::{Canvas, EllipsisMode, Point, Rect, Size, SizeProposal, TextOverflow};
76
77use teksilo_core::accessibility::AccessNodeBuilder;
78use teksilo_core::accessibility::text_runs::{
79    RetainedText, TextGeometryHandle, TextRunSource, push_text_runs,
80};
81use teksilo_core::color_prop::{ColorProp, TextStyleProp};
82use teksilo_core::signal::Prop;
83use teksilo_core::widget::{CursorIcon, EventContext, LayoutContext, PaintContext, Widget};
84use teksilo_core::widget_builder::HandlerSet;
85use teksilo_i18n::LocalizedString;
86use teksilo_tokens::TextRole;
87
88/// Closure type for link click dispatch.
89/// Closure type for link hover dispatch.
90/// A leaf widget that renders a localized text string.
91///
92/// See the [module documentation](self) for the full feature description.
93/// Construct with [`TextWidget::new`] and chain builder methods for color,
94/// style, overflow mode, and optional markup/link dispatch.
95type LinkClickHandler = Rc<dyn Fn(&str, &mut EventContext)>;
96type LinkHoverHandler = Rc<dyn Fn(&str, bool, Rect, &mut EventContext)>;
97
98pub struct TextWidget {
99    text: Prop<String>,
100    /// Foreground color. Defaults to [`TextRole::Primary`], which the paint
101    /// pass resolves against the current theme — so `TextWidget::new("...")`
102    /// follows theme switches without any explicit binding.
103    color: ColorProp,
104    /// Text style. Defaults to [`TextStyleRole::Body`](teksilo_tokens::TextStyleRole);
105    /// resolved against the current typography tokens every time the widget
106    /// is laid out or painted, so theme switches update font metrics without
107    /// a rebuild.
108    style: TextStyleProp,
109    overflow: TextOverflow,
110    /// Whether single-line / ellipsis text reports a shrink weight so an
111    /// over-constrained stack truncates it (down to [`Self::min_shrink_width`]) rather
112    /// than overflowing. Default `true`. Ignored in `Wrap` mode (wrap text is
113    /// height-variable and opts into shrink via `Shrinkable` instead).
114    shrink: bool,
115    /// Explicit compression floor for ellipsis text. `None` measures the
116    /// width of the ellipsis glyph at layout time.
117    min_shrink_width: Option<f32>,
118    max_lines: Option<usize>,
119    text_backend: Option<Rc<RefCell<dyn teksilo_canvas::TextBackend>>>,
120    /// When enabled, text is parsed as inline markup
121    /// (`[label](url)`, `*italic*`, `**bold**`) and link metadata is
122    /// emitted into the layout for hit-testing and per-span coloring.
123    markup: bool,
124    on_link_click: Option<LinkClickHandler>,
125    on_link_hover: Option<LinkHoverHandler>,
126    /// Last laid-out markup layout. Shared with the event handler
127    /// closures via `Rc<RefCell<..>>` so taps can hit-test against the
128    /// most recently measured spans.
129    last_layout: Rc<RefCell<Option<TextLayout>>>,
130    /// Currently-hovered link URL (shared with the pointer-event
131    /// closure). Used to detect enter/leave transitions between link
132    /// spans inside a single widget.
133    hovered_link: Rc<RefCell<Option<String>>>,
134    /// What the last placement measured: the text as painted, the
135    /// geometry behind it, and the box it occupies — everything the
136    /// accessibility pass needs to describe this label one character at a
137    /// time, and everything a control that paints through a *hidden*
138    /// `TextWidget` needs to describe it on the label's behalf (see
139    /// [`geometry_handle`](TextWidget::geometry_handle)).
140    ///
141    /// Written from `place_children`, the first point at which the final
142    /// width is known: `layout_response` runs speculatively, more than
143    /// once, at proposals the widget may not end up with.
144    retained: TextGeometryHandle,
145    /// Absolute line box of every text run the last accessibility pass
146    /// emitted. An assistive technology asking to scroll a run into view
147    /// names the *run*, not the label, so the reveal needs its line.
148    run_lines: Rc<RefCell<Vec<(teksilo_core::accesskit::NodeId, Rect)>>>,
149    /// When true, this TextWidget emits no accessibility node at all
150    /// (no role, no name, no synthetic link children). Controls that
151    /// own their accessible name — Button, Checkbox, MenuItem, etc. —
152    /// hide their label children so the text doesn't duplicate the
153    /// parent's announced name in the a11y tree.
154    a11y_hidden: bool,
155}
156
157impl std::fmt::Debug for TextWidget {
158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        f.debug_struct("TextWidget").finish()
160    }
161}
162
163impl TextWidget {
164    /// Construct a text widget whose content is a `LocalizedString`. The
165    /// text may come from `tr!(...)` (reactive, re-resolves on locale
166    /// change) or from `lit!("…")` for genuinely
167    /// non-translated strings.
168    pub fn new(text: impl Into<LocalizedString>) -> Self {
169        let ls: LocalizedString = text.into();
170        Self {
171            text: Prop::from(ls),
172            color: ColorProp::TextRole(TextRole::Primary),
173            style: TextStyleProp::default(),
174            overflow: TextOverflow::default(),
175            shrink: true,
176            min_shrink_width: None,
177            max_lines: None,
178            text_backend: None,
179            markup: false,
180            on_link_click: None,
181            on_link_hover: None,
182            last_layout: Rc::new(RefCell::new(None)),
183            retained: Rc::new(RefCell::new(None)),
184            run_lines: Rc::new(RefCell::new(Vec::new())),
185            hovered_link: Rc::new(RefCell::new(None)),
186            a11y_hidden: false,
187        }
188    }
189
190    /// Set the text color. Accepts any `impl Into<ColorProp>`:
191    ///
192    /// - A raw `Color` — a frozen literal.
193    /// - A [`TextRole`] — resolved against the theme at paint time
194    ///   (reactive across theme switches).
195    /// - A `Signal<Color>` — reactive state (typically interaction-driven).
196    ///
197    /// The default role is [`TextRole::Primary`], so `.color(...)` is only
198    /// needed when a label wants a non-default theme role (Secondary,
199    /// Error, Accent, ...) or a custom color.
200    pub fn color(mut self, color: impl Into<ColorProp>) -> Self {
201        self.color = color.into();
202        self
203    }
204
205    /// Set the text style. Accepts a raw `TextStyle`, a
206    /// [`TextStyleRole`](teksilo_tokens::TextStyleRole), or any value implementing
207    /// `Into<TextStyleProp>`. Using a role resolves at paint/layout time, so
208    /// theme typography changes take effect without a rebuild.
209    pub fn style(mut self, style: impl Into<TextStyleProp>) -> Self {
210        self.style = style.into();
211        self
212    }
213
214    /// Set how the widget handles text that doesn't fit in the proposed
215    /// width. Default is [`TextOverflow::Wrap`].
216    pub fn overflow(mut self, overflow: TextOverflow) -> Self {
217        self.overflow = overflow;
218        self
219    }
220
221    /// Shorthand for `.overflow(TextOverflow::Ellipsis(EllipsisMode::Trailing))`.
222    /// Use this on labels inside single-line containers (buttons, menu items,
223    /// tab headers, badges, status bar cells, etc.) so long text truncates
224    /// with a trailing "…" instead of wrapping onto multiple lines.
225    pub fn single_line(self) -> Self {
226        self.overflow(TextOverflow::Ellipsis(EllipsisMode::Trailing))
227    }
228
229    /// Override the compression floor for single-line / ellipsis text — the
230    /// narrowest width an over-constrained stack may shrink this label to
231    /// before truncating stops. Defaults to the ellipsis-glyph width.
232    pub fn min_shrink_width(mut self, min: f32) -> Self {
233        self.min_shrink_width = Some(min.max(0.0));
234        self
235    }
236
237    /// Opt this label out of native shrink: it reports a rigid size and
238    /// overflows (rather than truncating) when its stack is over-constrained.
239    pub fn no_shrink(mut self) -> Self {
240        self.shrink = false;
241        self
242    }
243
244    /// Cap the paragraph at `n` lines when wrapping. Only meaningful
245    /// in [`TextOverflow::Wrap`] mode — ignored for ellipsis modes.
246    /// Lines beyond the cap are silently dropped.
247    pub fn max_lines(mut self, n: usize) -> Self {
248        self.max_lines = Some(n);
249        self
250    }
251
252    /// Override the text backend used for measurement and rasterization.
253    /// In normal app code the framework provides the backend automatically;
254    /// this method is used by headless tests that inject a `MockTextBackend`.
255    pub fn text_backend(mut self, backend: Rc<RefCell<dyn teksilo_canvas::TextBackend>>) -> Self {
256        self.text_backend = Some(backend);
257        self
258    }
259
260    /// Set the text content. Accepts a static `String`/`&str` or a reactive
261    /// `Signal<String>` / `Prop<String>` (resolved and re-rendered on change).
262    pub fn text(mut self, state: impl Into<Prop<String>>) -> Self {
263        self.text = state.into();
264        self
265    }
266
267    /// Get the current text value (resolves from state if bound).
268    pub fn resolved_text(&self) -> String {
269        self.text.get()
270    }
271
272    /// Enable inline markup parsing. When enabled, the text is parsed
273    /// as a minimal markdown subset:
274    /// - `[label](url)` — inline link
275    /// - `*italic*`     — italic run
276    /// - `**bold**`     — bold run
277    ///
278    /// Links are dispatched via [`on_link_click`](Self::on_link_click)
279    /// and colored using `theme.colors.text_link`.
280    pub fn markup(mut self, enabled: bool) -> Self {
281        self.markup = enabled;
282        self
283    }
284
285    /// Called when an inline link is tapped. Enables markup automatically.
286    pub fn on_link_click<F>(mut self, handler: F) -> Self
287    where
288        F: Fn(&str, &mut EventContext) + 'static,
289    {
290        self.on_link_click = Some(Rc::new(handler));
291        self.markup = true;
292        self
293    }
294
295    /// Called when an inline link is hovered (enter/leave). Receives
296    /// the URL, a `bool` indicating whether the pointer entered (`true`)
297    /// or left (`false`), and the widget-local rect of the link span
298    /// (so anchoring popups next to the link is cheap). Enables markup
299    /// automatically.
300    pub fn on_link_hover<F>(mut self, handler: F) -> Self
301    where
302        F: Fn(&str, bool, Rect, &mut EventContext) + 'static,
303    {
304        self.on_link_hover = Some(Rc::new(handler));
305        self.markup = true;
306        self
307    }
308
309    /// Hide this text from the accessibility tree. Use this when the
310    /// TextWidget is a visual label fragment inside another control
311    /// that already owns its accessible name via `set_name` —
312    /// otherwise screen readers announce the same string twice
313    /// (once for the control, once for the embedded Label node).
314    ///
315    /// Standalone body text (dialog descriptions, form instructions,
316    /// read-only display values) should NOT set this — it stays as a
317    /// `Role::Label` node.
318    pub fn a11y_hidden(mut self) -> Self {
319        self.a11y_hidden = true;
320        self
321    }
322
323    /// A handle onto what this label last measured.
324    ///
325    /// For the controls that own their accessible name but paint their
326    /// text through a hidden `TextWidget` — `Badge`, `GroupHeader` — and
327    /// so have to emit that text's runs from their *own* node. The handle
328    /// is empty until the label has been placed, and the geometry it
329    /// carries is in window space, so the borrower reports it as absolute.
330    ///
331    /// The borrower must place the donor rigidly at its own origin: a
332    /// move re-places absolute rects by the *borrower's* delta, so a
333    /// composite that repositions its donor independently should register
334    /// local rects instead.
335    pub fn geometry_handle(&self) -> TextGeometryHandle {
336        self.retained.clone()
337    }
338}
339
340/// Map a laid-out ellipsis display string back onto the source it stands for.
341///
342/// Middle and leading ellipsis paint a *different string* from the
343/// source: `"Lorem…amet"` for `"Lorem ipsum dolor sit amet"`. The
344/// geometry the backend returns indexes that display string, but a reader
345/// must be able to review the whole source — so the kept spans keep their
346/// real measurements, re-labelled with source offsets, and the elided
347/// span is left uncovered for the emitter to anchor at the ellipsis glyph
348/// it was replaced by.
349///
350/// Yields no segments for a right-to-left line: the kept spans would have
351/// to be re-derived from the right edge, and a degenerate label is honest
352/// where a wrong rectangle is not.
353fn remap_ellipsized(source: &str, display: &TextGeometry, cut: &Ellipsized) -> TextGeometry {
354    /// One contiguous stretch of source characters kept from the display.
355    struct Bucket {
356        bytes: std::ops::Range<usize>,
357        chars: std::ops::Range<usize>,
358        geometry: Vec<CharGeom>,
359    }
360
361    let head_len = cut.head.len();
362    let ellipsis_len = ELLIPSIS.len_utf8();
363    let display_text = cut.display.as_str();
364    // How many source characters the ellipsis stands for, so the kept
365    // tail's char indices carry on from the head's.
366    let elided_chars = source
367        .get(cut.elided.clone())
368        .map(|s| s.chars().count())
369        .unwrap_or(0);
370
371    let mut lines = Vec::with_capacity(display.lines.len());
372    for line in &display.lines {
373        let mut segments: Vec<TextLineSegment> = Vec::new();
374        let mut ellipsis_x: Option<f32> = None;
375        let mut usable = true;
376
377        for segment in &line.segments {
378            if segment.direction == TextDirection::RightToLeft {
379                usable = false;
380                break;
381            }
382            let Some(slice) = display_text.get(segment.byte_range.clone()) else {
383                usable = false;
384                break;
385            };
386
387            let mut buckets: Vec<Bucket> = Vec::new();
388            let mut display_char = segment.char_range.start;
389            for ((offset, ch), geometry) in slice.char_indices().zip(&segment.characters) {
390                let display_byte = segment.byte_range.start + offset;
391                let in_head = display_byte < head_len;
392                if !in_head && display_byte < head_len + ellipsis_len {
393                    ellipsis_x.get_or_insert(segment.rect[0] + geometry.position);
394                    display_char += 1;
395                    continue;
396                }
397                let (source_byte, source_char) = if in_head {
398                    (display_byte, display_char)
399                } else {
400                    (
401                        cut.tail.start + (display_byte - head_len - ellipsis_len),
402                        display_char - 1 + elided_chars,
403                    )
404                };
405                let end_byte = source_byte + ch.len_utf8();
406                match buckets.last_mut() {
407                    Some(bucket) if bucket.bytes.end == source_byte => {
408                        bucket.bytes.end = end_byte;
409                        bucket.chars.end = source_char + 1;
410                        bucket.geometry.push(*geometry);
411                    }
412                    _ => buckets.push(Bucket {
413                        bytes: source_byte..end_byte,
414                        chars: source_char..source_char + 1,
415                        geometry: vec![*geometry],
416                    }),
417                }
418                display_char += 1;
419            }
420
421            for bucket in buckets {
422                let base = bucket.geometry.first().map(|c| c.position).unwrap_or(0.0);
423                let extent = bucket
424                    .geometry
425                    .last()
426                    .map(|c| c.position + c.width - base)
427                    .unwrap_or(0.0);
428                segments.push(TextLineSegment {
429                    byte_range: bucket.bytes,
430                    char_range: bucket.chars,
431                    direction: segment.direction,
432                    rect: [
433                        segment.rect[0] + base,
434                        segment.rect[1],
435                        extent,
436                        segment.rect[3],
437                    ],
438                    characters: bucket
439                        .geometry
440                        .iter()
441                        .map(|c| CharGeom {
442                            position: c.position - base,
443                            width: c.width,
444                        })
445                        .collect(),
446                });
447            }
448        }
449
450        if !usable {
451            segments.clear();
452        }
453        lines.push(TextLine {
454            index: line.index,
455            byte_range: 0..source.len(),
456            char_range: 0..source.chars().count(),
457            rect: line.rect,
458            baseline: line.baseline,
459            caret_x: line.caret_x,
460            segments,
461            end: LineEnd::EndOfText,
462            truncation: ellipsis_x.map(|x| LineTruncation {
463                ellipsis_x: x,
464                ellipsis_width: 0.0,
465            }),
466        });
467    }
468
469    TextGeometry {
470        lines,
471        dropped_lines: display.dropped_lines,
472        source_len: source.len(),
473        rendered_text: None,
474        links: Vec::new(),
475    }
476}
477
478impl Widget for TextWidget {
479    fn build(
480        &mut self,
481        ctx: &mut teksilo_core::build_context::BuildContext,
482    ) -> Vec<teksilo_core::widget_id::WidgetId> {
483        let self_id = ctx.self_id();
484        let registry = ctx.binding_registry();
485        self.text.register_if_bound(
486            self_id,
487            registry,
488            teksilo_core::binding::BindingLevel::Relayout,
489        );
490        // A text change is a relayout AND a semantic change. `Relayout`
491        // alone never dirties the accessibility tree — deliberately, so a
492        // per-frame animation does not re-walk it — so a label bound to a
493        // signal would re-shape, repaint, and go on announcing the old
494        // string.
495        self.text.register_if_bound(
496            self_id,
497            registry,
498            teksilo_core::binding::BindingLevel::AccessibilityOnly,
499        );
500        self.color.register_if_bound(
501            self_id,
502            registry,
503            teksilo_core::binding::BindingLevel::RepaintOnly,
504        );
505
506        // Wire link dispatch when markup is enabled and at least one
507        // link handler is registered. Shares the last_layout cell with
508        // the closures so taps can hit-test against the most recently
509        // measured spans.
510        let mut handler_set = HandlerSet::new();
511
512        // Reveal-on-request. An assistive technology scrolling a run into
513        // view names the *run*, not the label, and every adapter offers
514        // the action on any node that carries text ranges whether or not
515        // it was advertised — so handle it, and do not advertise it: an
516        // advertised `ScrollIntoView` puts a scroll pattern on every
517        // label on Windows and macOS.
518        //
519        // `SetTextSelection` is likewise offered everywhere and is
520        // meaningless on a label; taking it and doing nothing is the
521        // honest answer, and keeps a stray request from bubbling to an
522        // ancestor that would act on it.
523        {
524            let run_lines = self.run_lines.clone();
525            let retained = self.retained.clone();
526            handler_set = handler_set.on_access_action_request(move |action, target, data, ctx| {
527                use teksilo_core::accesskit::{Action, ActionData, ScrollHint};
528                use teksilo_core::event::{EventResponse, ScrollMotion};
529                match action {
530                    Action::ScrollIntoView => {
531                        let rect = run_lines
532                            .borrow()
533                            .iter()
534                            .find(|(id, _)| *id == target)
535                            .map(|(_, rect)| *rect)
536                            .or_else(|| retained.borrow().as_ref().map(|r| r.bounds));
537                        let Some(rect) = rect else {
538                            return EventResponse::Ignored;
539                        };
540                        match data {
541                            Some(ActionData::ScrollHint(
542                                ScrollHint::TopLeft | ScrollHint::TopEdge,
543                            )) => ctx.ensure_visible_aligned(rect, 0.0, ScrollMotion::Instant),
544                            Some(ActionData::ScrollHint(
545                                ScrollHint::BottomRight | ScrollHint::BottomEdge,
546                            )) => ctx.ensure_visible_aligned(rect, 1.0, ScrollMotion::Instant),
547                            _ => ctx.ensure_visible(rect),
548                        }
549                        EventResponse::Handled
550                    }
551                    Action::SetTextSelection => EventResponse::Handled,
552                    _ => EventResponse::Ignored,
553                }
554            });
555        }
556
557        if self.markup && (self.on_link_click.is_some() || self.on_link_hover.is_some()) {
558            if let Some(on_click) = self.on_link_click.clone() {
559                let last_layout = self.last_layout.clone();
560                handler_set = handler_set.on_tap(move |event, ctx| {
561                    // `event.position` is already widget-local.
562                    let local = event.position;
563                    if let Some(layout) = last_layout.borrow().as_ref()
564                        && let Some(HitTarget::Link { url }) = layout.hit_test(local)
565                    {
566                        on_click(&url, ctx);
567                    }
568                });
569            }
570
571            // Pointer-move handler — wired *unconditionally* when
572            // markup is enabled with any link handler. It does two
573            // jobs:
574            //
575            // 1. Updates the cursor to `Pointer` while the pointer is
576            //    over a link span, restoring `Default` otherwise. This
577            //    is the visual affordance the catalog was missing.
578            // 2. Drives `on_link_hover` enter/leave transitions when
579            //    that handler is wired, comparing the current
580            //    hit-test URL against `hovered_link`.
581            //
582            // Returns `Ignored` so the gesture arena still receives
583            // PointerDown/Up and the on_tap handler keeps firing.
584            let last_layout_for_pointer = self.last_layout.clone();
585            let hovered = self.hovered_link.clone();
586            let on_hover = self.on_link_hover.clone();
587            handler_set = handler_set.on_pointer_event(move |event, ctx| {
588                use teksilo_core::event::{EventResponse, WidgetEvent};
589                match event {
590                    WidgetEvent::PointerMove { position, .. } => {
591                        // `position` is already widget-local.
592                        let local = *position;
593                        let layout_ref = last_layout_for_pointer.borrow();
594                        let hit = layout_ref.as_ref().and_then(|l| l.hit_test(local));
595                        let new_url = match &hit {
596                            Some(HitTarget::Link { url }) => Some(url.clone()),
597                            _ => None,
598                        };
599
600                        // Update cursor based on link hit.
601                        if new_url.is_some() {
602                            ctx.set_cursor(CursorIcon::Pointer);
603                        } else {
604                            ctx.set_cursor(CursorIcon::Default);
605                        }
606
607                        // Drive enter/leave transitions when an
608                        // on_link_hover handler is wired.
609                        if let Some(handler) = on_hover.as_ref() {
610                            let new_rect = if let Some(url) = new_url.as_ref() {
611                                layout_ref
612                                    .as_ref()
613                                    .and_then(|l| {
614                                        l.spans.iter().find_map(|sp| {
615                                            if let teksilo_canvas::text_backend::TextSpanKind::Link {
616                                                url: u,
617                                            } = &sp.kind
618                                                && u == url
619                                            {
620                                                Some(Rect::new(
621                                                    sp.rect[0], sp.rect[1], sp.rect[2], sp.rect[3],
622                                                ))
623                                            } else {
624                                                None
625                                            }
626                                        })
627                                    })
628                                    .unwrap_or_else(|| Rect::new(0.0, 0.0, 0.0, 0.0))
629                            } else {
630                                Rect::new(0.0, 0.0, 0.0, 0.0)
631                            };
632
633                            drop(layout_ref);
634
635                            let mut slot = hovered.borrow_mut();
636                            if slot.as_deref() != new_url.as_deref() {
637                                if let Some(old) = slot.take() {
638                                    handler(&old, false, Rect::new(0.0, 0.0, 0.0, 0.0), ctx);
639                                }
640                                if let Some(u) = new_url {
641                                    handler(&u, true, new_rect, ctx);
642                                    *slot = Some(u);
643                                }
644                            }
645                        }
646                        EventResponse::Ignored
647                    }
648                    WidgetEvent::PointerLeave { .. } => {
649                        ctx.set_cursor(CursorIcon::Default);
650                        if let Some(handler) = on_hover.as_ref() {
651                            let mut slot = hovered.borrow_mut();
652                            if let Some(old) = slot.take() {
653                                handler(&old, false, Rect::new(0.0, 0.0, 0.0, 0.0), ctx);
654                            }
655                        }
656                        EventResponse::Ignored
657                    }
658                    _ => EventResponse::Ignored,
659                }
660            });
661        }
662
663        ctx.apply_self_handlers(handler_set);
664        Vec::new()
665    }
666
667    fn layout_response(
668        &self,
669        proposal: SizeProposal,
670        ctx: &LayoutContext,
671    ) -> teksilo_core::widget::LayoutResponse {
672        let text = self.text.get();
673        let style = self.style.resolve(&ctx.theme.typography);
674        let Some(backend) = self.text_backend.as_ref().or(ctx.text_backend) else {
675            // Mock fallback when no backend is available (e.g. very early
676            // bootstrap). Assume 8px/char for measurement.
677            let width = text.len() as f32 * 8.0;
678            let height = 16.0;
679            let w = match proposal.width {
680                Some(max) => width.min(max),
681                None => width,
682            };
683            return (Size::new(w, height)).into();
684        };
685        let mut backend = backend.borrow_mut();
686
687        // Add a small epsilon to the proposal width before passing it as
688        // max_width. The same epsilon is applied in Canvas::draw_text /
689        // draw_paragraph so both measurement and paint produce the same
690        // TypesetterBridge cache key, avoiding duplicate cache entries
691        // and inconsistent truncation from float precision loss in the
692        // scale_factor roundtrip (logical → physical → logical).
693        let max_width = proposal.width.map(|w| w + 0.5);
694
695        // Markup path: the backend parses the source internally and
696        // returns a TextLayout whose `spans` field carries per-run rects
697        // (including links). `place_children` and `paint` stash the final
698        // one for hit-testing during event dispatch.
699        if self.markup {
700            // Branch on the overflow mode, exactly as `paint` does. Keying
701            // on whether a width was proposed instead meant an `Ellipsis`
702            // markup label measured as a wrapped paragraph and painted as
703            // one clipped line, so it reserved height for lines that were
704            // never drawn.
705            let layout = match self.overflow {
706                TextOverflow::Wrap => match max_width {
707                    Some(w) => backend.layout_paragraph_markup(&text, &style, w, self.max_lines),
708                    None => backend.layout_single_line_markup(&text, &style, None),
709                },
710                _ => backend.layout_single_line_markup(&text, &style, max_width),
711            };
712            return Size::new(layout.width, layout.height).into();
713        }
714
715        let is_ellipsis = matches!(self.overflow, TextOverflow::Ellipsis(_));
716
717        let size = match self.overflow {
718            TextOverflow::Wrap => match max_width {
719                Some(w) => {
720                    let layout = backend.layout_paragraph(&text, &style, w, self.max_lines);
721                    Size::new(layout.width, layout.height)
722                }
723                None => {
724                    // Unconstrained width: no basis for wrapping, so measure
725                    // as a single line.
726                    let layout = backend.layout_single_line(&text, &style, None);
727                    Size::new(layout.width, layout.height)
728                }
729            },
730            TextOverflow::Ellipsis(EllipsisMode::Trailing) => {
731                // text-typeset truncates with trailing "…" when a max_width
732                // is supplied — let it do the work.
733                let layout = backend.layout_single_line(&text, &style, max_width);
734                Size::new(layout.width, layout.height)
735            }
736            TextOverflow::Ellipsis(mode) => match max_width {
737                // Middle / Leading: compute the truncated display string
738                // first, then measure it unconstrained.
739                None => {
740                    let layout = backend.layout_single_line(&text, &style, None);
741                    Size::new(layout.width, layout.height)
742                }
743                Some(max_w) => {
744                    let truncated = teksilo_canvas::ellipsis::ellipsize(
745                        &text,
746                        &style,
747                        max_w,
748                        mode,
749                        &mut *backend,
750                    );
751                    let layout = backend.layout_single_line(&truncated, &style, None);
752                    Size::new(layout.width, layout.height)
753                }
754            },
755        };
756
757        // Single-line / ellipsis labels opt into shrink (they are height-stable
758        // — truncating never changes their line height). An over-constrained
759        // stack compresses them down to the ellipsis-glyph width (or the
760        // caller's `min_shrink_width`) and they ellipsize instead of
761        // overflowing. Wrap text stays rigid; opt it in with `Shrinkable`.
762        if is_ellipsis && self.shrink {
763            let min_w = self
764                .min_shrink_width
765                .unwrap_or_else(|| backend.layout_single_line("…", &style, None).width)
766                .min(size.width);
767            teksilo_core::widget::LayoutResponse::shrinkable(
768                size,
769                Size::new(min_w, size.height),
770                1.0,
771            )
772        } else {
773            size.into()
774        }
775    }
776
777    /// Shape once more at the final width, and keep what came back.
778    ///
779    /// This is the only pass that sees the width the label actually got:
780    /// `layout_response` runs speculatively, more than once, at proposals
781    /// the parent may not honour. The accessibility tree needs the real
782    /// one — a label that wraps at 200 dp has a different set of lines,
783    /// and therefore of text runs, from the same label at 400 dp.
784    ///
785    /// The call mirrors `paint`'s exactly, including its `+ 0.5` width
786    /// epsilon, so both land on the same backend cache entry. Under a
787    /// scale transform (a zoomed `SceneView`, a `Scale` wrapper) the
788    /// ambient raster scale differs between this pass and paint, so the
789    /// two hit different entries: the metrics are identical — the cache
790    /// keys on raster scale only for glyph density — and the geometry is
791    /// right either way, at the cost of one extra shaping per (text,
792    /// scale).
793    fn place_children(
794        &self,
795        bounds: Rect,
796        _proposal: SizeProposal,
797        _children: &mut [teksilo_core::widget::WidgetPlacement],
798        ctx: &LayoutContext,
799    ) {
800        let text = self.text.get();
801        let base_direction = match ctx.layout_direction {
802            teksilo_core::environment::LayoutDirection::RightToLeft => {
803                teksilo_core::accesskit::TextDirection::RightToLeft
804            }
805            _ => teksilo_core::accesskit::TextDirection::LeftToRight,
806        };
807        let style = self.style.resolve(&ctx.theme.typography);
808        let Some(backend) = self.text_backend.as_ref().or(ctx.text_backend) else {
809            // Painted without a measuring backend. The label is still
810            // announced and still reviewable; its characters simply have
811            // no extents, which the emitter reports as a zero-width box at
812            // the label's leading edge rather than as no box at all.
813            *self.retained.borrow_mut() = Some(RetainedText {
814                text,
815                geometry: None,
816                bounds,
817                base_direction,
818            });
819            return;
820        };
821        let mut backend = backend.borrow_mut();
822        let max_width = bounds.width + 0.5;
823
824        let (layout, retained_text, remapped) = if self.markup {
825            let layout = match self.overflow {
826                TextOverflow::Wrap => backend.layout_paragraph_markup(
827                    &text,
828                    &style,
829                    max_width.max(0.0),
830                    self.max_lines,
831                ),
832                _ => backend.layout_single_line_markup(&text, &style, Some(max_width)),
833            };
834            // Markup ranges index the rendered text, so that is what the
835            // label announces and what a reader reviews — not the source
836            // with its brackets and parentheses in it.
837            let rendered = layout
838                .geometry
839                .as_deref()
840                .and_then(|g| g.rendered_text.clone())
841                .unwrap_or_else(|| text.clone());
842            (Some(layout), rendered, None)
843        } else {
844            match self.overflow {
845                TextOverflow::Wrap => {
846                    let layout =
847                        backend.layout_paragraph(&text, &style, max_width.max(0.0), self.max_lines);
848                    (Some(layout), text.clone(), None)
849                }
850                TextOverflow::Ellipsis(EllipsisMode::Trailing) => {
851                    let layout = backend.layout_single_line(&text, &style, Some(max_width));
852                    (Some(layout), text.clone(), None)
853                }
854                TextOverflow::Ellipsis(mode) => {
855                    // Middle and leading ellipsis paint a different string
856                    // from the source, so the geometry has to be mapped
857                    // back onto it before anyone can review the label.
858                    let cut = ellipsize_ranges(&text, &style, bounds.width, mode, &mut *backend);
859                    let layout = backend.layout_single_line(&cut.display, &style, None);
860                    let remapped = layout
861                        .geometry
862                        .as_deref()
863                        .map(|g| Rc::new(remap_ellipsized(&text, g, &cut)));
864                    (Some(layout), text.clone(), remapped)
865                }
866            }
867        };
868
869        let Some(layout) = layout else { return };
870        let geometry = remapped.or_else(|| layout.geometry.clone());
871        if self.markup {
872            // Hit-testing reads the spans the label was actually placed
873            // at, so a tap lands on the right link before the first paint.
874            *self.last_layout.borrow_mut() = Some(layout);
875        }
876        *self.retained.borrow_mut() = Some(RetainedText {
877            text: retained_text,
878            geometry,
879            bounds,
880            base_direction,
881        });
882    }
883
884    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
885        let text = self.text.get();
886        let color = self.color.resolve(ctx.theme, ctx.effective_enabled);
887        let style = self.style.resolve(&ctx.theme.typography);
888
889        // Markup path: re-measure through the markup pipeline (the
890        // backend's cache makes this a no-op after the size pass) and
891        // draw with per-span coloring so link glyphs pick up the
892        // theme's `text_link` token.
893        if self.markup {
894            let Some(backend_rc) = canvas.text_backend() else {
895                return;
896            };
897            let layout = {
898                let mut backend = backend_rc.borrow_mut();
899                match self.overflow {
900                    TextOverflow::Wrap => backend.layout_paragraph_markup(
901                        &text,
902                        &style,
903                        (bounds.width + 0.5).max(0.0),
904                        self.max_lines,
905                    ),
906                    _ => backend.layout_single_line_markup(&text, &style, Some(bounds.width + 0.5)),
907                }
908            };
909            let link_color = ctx.theme.colors.text_link;
910            canvas.draw_text_layout_markup(
911                &layout,
912                teksilo_canvas::Point::new(bounds.x, bounds.y),
913                color,
914                link_color,
915            );
916            // Keep the cached layout in sync so tap hit-testing sees
917            // the same rects that were painted.
918            *self.last_layout.borrow_mut() = Some(layout);
919            return;
920        }
921
922        match self.overflow {
923            TextOverflow::Wrap => {
924                canvas.draw_paragraph(&text, bounds, &style, color, self.max_lines);
925            }
926            TextOverflow::Ellipsis(EllipsisMode::Trailing) => {
927                canvas.draw_text(&text, bounds, &style, color);
928            }
929            TextOverflow::Ellipsis(mode) => {
930                // Produce the truncated display string via the canvas's
931                // backend and hand it to draw_text.
932                let truncated = match canvas.text_backend() {
933                    Some(backend) => teksilo_canvas::ellipsis::ellipsize(
934                        &text,
935                        &style,
936                        bounds.width,
937                        mode,
938                        &mut *backend.borrow_mut(),
939                    ),
940                    None => text.clone(),
941                };
942                canvas.draw_text(&truncated, bounds, &style, color);
943            }
944        }
945    }
946
947    /// Exposed so a test can read what a label draws. A label hidden from
948    /// assistive technology publishes no node, so there is nowhere else to
949    /// read its text from.
950    fn as_any(&self) -> Option<&dyn std::any::Any> {
951        Some(self)
952    }
953
954    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
955        if self.a11y_hidden {
956            return;
957        }
958        builder.set_role(teksilo_core::accesskit::Role::Label);
959
960        // A `Role::Label` with `Role::TextRun` children is what makes a
961        // label reviewable by character, word and line — and braille-
962        // routable, and trackable by a magnifier. The runs are excluded
963        // from object navigation by `accesskit_consumer`'s own filter, so
964        // they add stops to no reader's tab order and targets to no
965        // hit-test.
966        let retained = self.retained.borrow();
967        let unplaced;
968        let source = match retained.as_ref() {
969            Some(placed) => match placed.geometry.as_deref() {
970                // Rects are reported in the label's own space; the builder
971                // translates them once the walker has written the label's
972                // window-space box.
973                Some(geometry) => {
974                    TextRunSource::from_geometry(&placed.text, geometry, Point::ZERO, 0)
975                        .with_base_direction(placed.base_direction)
976                }
977                None => TextRunSource::flat(&placed.text, 0)
978                    .with_fallback_rect(Rect::new(
979                        0.0,
980                        0.0,
981                        placed.bounds.width,
982                        placed.bounds.height,
983                    ))
984                    .with_base_direction(placed.base_direction),
985            },
986            // Never placed — a name probe, or a tree asked for its
987            // accessibility before its first layout.
988            None => {
989                unplaced = self.text.get();
990                TextRunSource::flat(&unplaced, 0)
991            }
992        };
993
994        let emission = push_text_runs(builder, None, &source);
995        // The name must be byte-identical to the runs' concatenation: the
996        // consumer derives the document text from the runs, and every
997        // divergence is a place where what a reader reviews and what it
998        // announces disagree.
999        builder.set_name(&emission.value);
1000
1001        let origin = retained
1002            .as_ref()
1003            .map(|r| r.bounds.origin())
1004            .unwrap_or(Point::ZERO);
1005        let mut run_lines = self.run_lines.borrow_mut();
1006        run_lines.clear();
1007        for run in &emission.runs {
1008            let Some(line) = source
1009                .lines
1010                .iter()
1011                .find(|line| line.byte_range.contains(&run.byte_range.start))
1012                .or_else(|| source.lines.last())
1013            else {
1014                continue;
1015            };
1016            run_lines.push((
1017                run.id,
1018                Rect::new(
1019                    origin.x + line.rect.x,
1020                    origin.y + line.rect.y,
1021                    line.rect.width,
1022                    line.rect.height,
1023                ),
1024            ));
1025        }
1026        drop(run_lines);
1027
1028        // Markup links become `Role::Link` children beside the runs, so a
1029        // reader can reach each one as an object. Identified and labelled
1030        // by their *rendered* offsets: the source offsets shift when the
1031        // label rewraps, and slicing the source with a rendered range gave
1032        // "[doc" for a link labelled "docs".
1033        if self.markup
1034            && let Some(geometry) = retained.as_ref().and_then(|r| r.geometry.as_deref())
1035        {
1036            for link in &geometry.links {
1037                let label = source
1038                    .text
1039                    .get(link.rendered_byte_range.clone())
1040                    .unwrap_or(&link.url);
1041                builder.push_link_child(
1042                    link.rendered_byte_range.start as u64,
1043                    label.to_string(),
1044                    link.url.clone(),
1045                );
1046            }
1047        }
1048    }
1049}
1050
1051#[cfg(test)]
1052mod tests {
1053    use super::*;
1054    use std::cell::RefCell;
1055    use std::rc::Rc;
1056    use teksilo_canvas::MockTextBackend;
1057    use teksilo_core::signal::Signal;
1058    use teksilo_core::widget_tree::WidgetTree;
1059    use teksilo_i18n::lit;
1060
1061    fn tree_with_mock_backend() -> WidgetTree {
1062        WidgetTree::new().with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())))
1063    }
1064
1065    #[test]
1066    fn text_renders_state_value() {
1067        let text = Signal::new("Hello".to_string());
1068        let mut tree = WidgetTree::new();
1069        let w = tree.add(TextWidget::new(lit!("")).text(text.clone()));
1070        text.bind_to(
1071            w,
1072            tree.binding_registry(),
1073            teksilo_core::binding::BindingLevel::Relayout,
1074        );
1075        tree.layout(SizeProposal::exact(200.0, 40.0));
1076
1077        assert_eq!(tree.text_content(w), Some("Hello".to_string()));
1078    }
1079
1080    #[test]
1081    fn text_updates_on_state_change() {
1082        let text = Signal::new("Hello".to_string());
1083        let mut tree = WidgetTree::new();
1084        let w = tree.add(TextWidget::new(lit!("")).text(text.clone()));
1085        text.bind_to(
1086            w,
1087            tree.binding_registry(),
1088            teksilo_core::binding::BindingLevel::Relayout,
1089        );
1090
1091        tree.layout(SizeProposal::exact(200.0, 40.0));
1092        assert_eq!(tree.text_content(w), Some("Hello".to_string()));
1093
1094        text.set("World".to_string());
1095        tree.layout(SizeProposal::exact(200.0, 40.0));
1096        assert_eq!(tree.text_content(w), Some("World".to_string()));
1097    }
1098
1099    // -------------------------------------------------------------------
1100    // Overflow modes
1101    // -------------------------------------------------------------------
1102
1103    #[test]
1104    fn wrap_is_the_default_mode() {
1105        let w = TextWidget::new(lit!("Hello"));
1106        assert_eq!(w.overflow, TextOverflow::Wrap);
1107    }
1108
1109    #[test]
1110    fn ellipsis_label_shrinks_to_fit_in_narrow_stack() {
1111        use crate::primitives::hstack::HStack;
1112        let mut tree = tree_with_mock_backend();
1113        // "hello world" = 11 chars × 8px = 88px natural width.
1114        let label = tree.add(TextWidget::new(lit!("hello world")).single_line());
1115        let _stack = tree.add(HStack::new().child(label));
1116        tree.layout(SizeProposal::exact(40.0, 20.0));
1117        // Single-line text opts into shrink: it compresses to fit the 40px
1118        // stack and ellipsizes, instead of overflowing to 88px.
1119        assert!(
1120            (tree.bounds(label).width - 40.0).abs() < 1.0,
1121            "ellipsis label should shrink to ~40, got {}",
1122            tree.bounds(label).width
1123        );
1124    }
1125
1126    #[test]
1127    fn no_shrink_label_overflows_in_narrow_stack() {
1128        use crate::primitives::hstack::HStack;
1129        let mut tree = tree_with_mock_backend();
1130        let label = tree.add(
1131            TextWidget::new(lit!("hello world"))
1132                .single_line()
1133                .no_shrink(),
1134        );
1135        let _stack = tree.add(HStack::new().child(label));
1136        tree.layout(SizeProposal::exact(40.0, 20.0));
1137        // Opted out → keeps its full 88px and overflows.
1138        assert!(
1139            (tree.bounds(label).width - 88.0).abs() < 1.0,
1140            "no_shrink label should overflow at 88, got {}",
1141            tree.bounds(label).width
1142        );
1143    }
1144
1145    #[test]
1146    fn wrap_text_does_not_shrink_natively() {
1147        use crate::primitives::hstack::HStack;
1148        let mut tree = tree_with_mock_backend();
1149        // Wrap mode (default) is height-variable → rigid; overflows rather than
1150        // shrinking. (Opt in via `Shrinkable`.)
1151        let label = tree.add(TextWidget::new(lit!("hello world")));
1152        let _stack = tree.add(HStack::new().child(label));
1153        tree.layout(SizeProposal::exact(40.0, 20.0));
1154        assert!(
1155            (tree.bounds(label).width - 88.0).abs() < 1.0,
1156            "wrap label should not natively shrink, got {}",
1157            tree.bounds(label).width
1158        );
1159    }
1160
1161    #[test]
1162    fn wrap_grows_vertically_in_narrow_proposal() {
1163        // MockTextBackend: 8px/char, 16px line height. "one two three four"
1164        // = 18 bytes × 8 = 144px wide single-line. At max_width 50 it
1165        // should wrap across several lines.
1166        let mut tree = tree_with_mock_backend();
1167        let w = tree.add(TextWidget::new(lit!("one two three four")));
1168        tree.layout(SizeProposal {
1169            width: Some(50.0),
1170            height: None,
1171        });
1172        let b = tree.bounds(w);
1173        assert!(
1174            b.height > 16.0,
1175            "wrapped text should be taller than one line (got {})",
1176            b.height
1177        );
1178        assert!(
1179            b.width <= 50.0 + 1.0,
1180            "wrapped text should stay within proposal width (got {})",
1181            b.width
1182        );
1183    }
1184
1185    #[test]
1186    fn wrap_falls_back_to_single_line_when_proposal_width_is_none() {
1187        let mut tree = tree_with_mock_backend();
1188        let w = tree.add(TextWidget::new(lit!("one two three")));
1189        tree.layout(SizeProposal {
1190            width: None,
1191            height: None,
1192        });
1193        let b = tree.bounds(w);
1194        assert!(
1195            (b.height - 16.0).abs() < 0.1,
1196            "unbounded width should produce a single line (got {})",
1197            b.height
1198        );
1199    }
1200
1201    #[test]
1202    fn wrap_max_lines_caps_paragraph_height() {
1203        // "one two three four five" wraps to multiple lines; cap at 2.
1204        let mut tree = tree_with_mock_backend();
1205        let w = tree.add(TextWidget::new(lit!("one two three four five six seven")).max_lines(2));
1206        tree.layout(SizeProposal {
1207            width: Some(40.0),
1208            height: None,
1209        });
1210        let b = tree.bounds(w);
1211        assert!(
1212            b.height <= 32.0 + 0.1,
1213            "max_lines(2) should cap height at 2 × 16px (got {})",
1214            b.height
1215        );
1216    }
1217
1218    #[test]
1219    fn trailing_ellipsis_clamps_width_to_proposal() {
1220        // MockTextBackend clamps at max_width for single-line measurement.
1221        let mut tree = tree_with_mock_backend();
1222        let w = tree.add(
1223            TextWidget::new(lit!("a very long piece of text"))
1224                .overflow(TextOverflow::Ellipsis(EllipsisMode::Trailing)),
1225        );
1226        tree.layout(SizeProposal {
1227            width: Some(40.0),
1228            height: None,
1229        });
1230        let b = tree.bounds(w);
1231        assert!(b.width <= 40.0 + 0.1);
1232        assert!(
1233            (b.height - 16.0).abs() < 0.1,
1234            "ellipsized text should stay on one line"
1235        );
1236    }
1237
1238    #[test]
1239    fn middle_ellipsis_produces_narrow_single_line_layout() {
1240        let mut tree = tree_with_mock_backend();
1241        let w = tree.add(
1242            TextWidget::new(lit!("abcdefghijklmnop"))
1243                .overflow(TextOverflow::Ellipsis(EllipsisMode::Middle)),
1244        );
1245        tree.layout(SizeProposal {
1246            width: Some(80.0),
1247            height: None,
1248        });
1249        let b = tree.bounds(w);
1250        assert!(
1251            b.width <= 80.0 + 0.1,
1252            "middle-ellipsized width should fit the proposal (got {})",
1253            b.width
1254        );
1255        assert!(
1256            (b.height - 16.0).abs() < 0.1,
1257            "ellipsized text should stay on one line"
1258        );
1259    }
1260
1261    #[test]
1262    fn leading_ellipsis_produces_narrow_single_line_layout() {
1263        let mut tree = tree_with_mock_backend();
1264        let w = tree.add(
1265            TextWidget::new(lit!("abcdefghijklmnop"))
1266                .overflow(TextOverflow::Ellipsis(EllipsisMode::Leading)),
1267        );
1268        tree.layout(SizeProposal {
1269            width: Some(80.0),
1270            height: None,
1271        });
1272        let b = tree.bounds(w);
1273        assert!(
1274            b.width <= 80.0 + 0.1,
1275            "leading-ellipsized width should fit the proposal (got {})",
1276            b.width
1277        );
1278    }
1279
1280    #[test]
1281    fn single_line_shorthand_matches_trailing_ellipsis() {
1282        let a = TextWidget::new(lit!("hi")).single_line();
1283        let b =
1284            TextWidget::new(lit!("hi")).overflow(TextOverflow::Ellipsis(EllipsisMode::Trailing));
1285        assert_eq!(a.overflow, b.overflow);
1286    }
1287}