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}