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