teksilo_scene/items/text.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! [`TextItem`] — text in a local-coord rectangle, with alignment + rotation.
5//!
6//! `TextItem` renders text that wraps within a caller-specified rectangle in
7//! local item coordinates. Text can be a static localized string (constructed
8//! via `TextItem::new`) or a live `Signal<String>` (constructed via
9//! `TextItem::with_signal_text`). Signal-bound and locale-reactive text both
10//! register bindings at `RepaintOnly` so changes dirty the `SceneView`'s
11//! paint pass without triggering a full rebuild.
12//!
13//! The foreground colour is a [`ColorProp`], so it accepts a plain
14//! [`Color`], a theme role
15//! ([`TextRole`](teksilo_tokens::TextRole)), a reactive `Signal<Color>`, or a
16//! `Signal<Role>` — resolved against the active theme at paint time.
17//!
18//! Horizontal [alignment](TextAlign) (leading / center / trailing) and a free
19//! [rotation](TextItem::rotation) let a text item self-place value tags, axis
20//! labels, and rotated titles without the caller hand-measuring; [`measure`]
21//! reports the item's single-line intrinsic size when the caller does want to
22//! size around it.
23//!
24//! Text scale: the global accessibility "grow all text" setting is **off** by
25//! default for scene text, since a scene has its own pan/zoom. Opt in via
26//! `.follow_text_scale(true)` for labels that should track the app-wide
27//! setting instead.
28//!
29//! ## When to use
30//!
31//! Use `TextItem` for card labels, node titles, annotation text, or any text
32//! decoration in the lightweight tier. For editable text or text that needs
33//! focus, selection, and full accessibility, embed a `RichTextEditor` or
34//! `TextInput` as a heavyweight scene widget instead.
35//!
36//! ## Example
37//!
38//! ```ignore
39//! use teksilo_scene::{SceneModel, TextItem, TextAlign};
40//! use teksilo_canvas::{Point, Rect};
41//! use teksilo_tokens::Color;
42//! use teksilo_i18n::lit;
43//!
44//! let model = SceneModel::new();
45//!
46//! let item = TextItem::new(lit!("Scene node"), Rect::new(0.0, 0.0, 120.0, 30.0))
47//! .color(Color::new(0.1, 0.1, 0.1, 1.0))
48//! .align(TextAlign::Center);
49//!
50//! model.add_item(item, Point::new(40.0, 40.0));
51//! ```
52//!
53//! [`measure`]: TextItem::measure
54
55use accesskit::Role;
56use teksilo_canvas::{Canvas, Rect, Size, TextBackend, Transform2D};
57use teksilo_core::accessibility::AccessNodeBuilder;
58use teksilo_core::binding::BindingLevel;
59use teksilo_core::build_context::BuildContext;
60use teksilo_core::color_prop::ColorProp;
61use teksilo_core::signal::Signal;
62use teksilo_core::widget_id::WidgetId;
63use teksilo_tokens::Color;
64
65use crate::flags::ItemFlags;
66use crate::item::{SceneItem, SceneItemA11yContext, SceneItemPaintContext};
67use crate::items::{AccessSubtreeMode, ItemA11yOverrides};
68use teksilo_i18n::LocalizedString;
69
70/// Horizontal alignment of a [`TextItem`] within its `local_bounds`.
71///
72/// Alignment shifts the text's draw origin by the leftover width
73/// (`bounds.width − measured_width`); it needs a text backend to measure, so a
74/// mock/headless canvas with no backend renders leading-aligned regardless.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
76pub enum TextAlign {
77 /// Left edge in LTR (the default).
78 #[default]
79 Leading,
80 /// Centred within the bounds.
81 Center,
82 /// Right edge in LTR.
83 Trailing,
84}
85
86/// Text source for [`TextItem`]: either a static string or a live
87/// `Signal<String>`. Signal-bound text refreshes on each paint and
88/// dirties the SceneView via `register_bindings`.
89#[derive(Debug)]
90enum TextSource {
91 Bound(Signal<String>),
92 /// Localized text; resolved against the active locale on each paint.
93 /// `register_bindings` ties the locale signal to the SceneView so a
94 /// locale switch repaints and re-resolves.
95 Localized(LocalizedString),
96}
97
98impl TextSource {
99 fn current(&self) -> String {
100 match self {
101 TextSource::Bound(signal) => signal.get(),
102 TextSource::Localized(ls) => ls.resolve_now(),
103 }
104 }
105}
106
107/// Leftover-width offset for a horizontal [`TextAlign`]. Pure so it is unit
108/// testable in isolation from the text backend.
109fn align_offset(align: TextAlign, available: f32, text_width: f32) -> f32 {
110 let extra = (available - text_width).max(0.0);
111 match align {
112 TextAlign::Leading => 0.0,
113 TextAlign::Center => extra * 0.5,
114 TextAlign::Trailing => extra,
115 }
116}
117
118/// Text in a local-coord rectangle, with optional alignment and rotation.
119///
120/// Text wraps within the `local_bounds` rectangle; the caller is responsible
121/// for sizing the rect so all text is visible. Content is either a static
122/// localized string (see [`TextItem::new`]) or a reactive `Signal<String>`
123/// (see [`TextItem::with_signal_text`]). Both sources trigger a repaint on
124/// change without rebuilding the scene.
125#[derive(Debug)]
126pub struct TextItem {
127 text: TextSource,
128 local_bounds: Rect,
129 color: ColorProp,
130 align: TextAlign,
131 /// Rotation about the item's centre, in radians. `0.0` = upright.
132 rotation: f32,
133 label: Option<LocalizedString>,
134 flags: ItemFlags,
135 a11y: ItemA11yOverrides,
136 /// When `true`, the font size grows with the global accessibility text
137 /// scale (`ctx.text_scale`). Off by default: a scene has its own pan/zoom,
138 /// so most scene text should stay at its authored size. Opt in via
139 /// [`follow_text_scale`](Self::follow_text_scale) for labels that should
140 /// track the app-wide "grow all text" setting.
141 follow_text_scale: bool,
142}
143
144impl TextItem {
145 /// A static-text item in local coordinates. The `text` is
146 /// resolved eagerly via `LocalizedString::resolve_now` at
147 /// construction; locale changes rebuild the composite parent,
148 /// which re-creates this `TextItem` with a fresh translation.
149 pub fn new(text: impl Into<LocalizedString>, local_bounds: Rect) -> Self {
150 let ls: LocalizedString = text.into();
151 Self {
152 text: TextSource::Localized(ls),
153 local_bounds,
154 color: ColorProp::Static(Color::BLACK),
155 align: TextAlign::Leading,
156 rotation: 0.0,
157 label: None,
158 flags: ItemFlags::default(),
159 a11y: ItemA11yOverrides::default(),
160 follow_text_scale: false,
161 }
162 }
163
164 /// A text item whose content is driven by a `Signal<String>`.
165 /// `register_bindings` ties the signal to the SceneView at
166 /// `BindingLevel::RepaintOnly` so changes dirty paint and the
167 /// next walk reads the current value.
168 pub fn with_signal_text(text: Signal<String>, local_bounds: Rect) -> Self {
169 Self {
170 text: TextSource::Bound(text),
171 local_bounds,
172 color: ColorProp::Static(Color::BLACK),
173 align: TextAlign::Leading,
174 rotation: 0.0,
175 label: None,
176 flags: ItemFlags::default(),
177 a11y: ItemA11yOverrides::default(),
178 follow_text_scale: false,
179 }
180 }
181
182 /// Opt the text into drag-to-move.
183 pub fn draggable(mut self, draggable: bool) -> Self {
184 self.flags.set(ItemFlags::IS_DRAGGABLE, draggable);
185 self
186 }
187
188 /// Override the foreground colour. Accepts a plain [`Color`], a theme role,
189 /// a `Signal<Color>`, or a `Signal<Role>` — resolved against the active
190 /// theme at paint time.
191 pub fn color(mut self, color: impl Into<ColorProp>) -> Self {
192 self.color = color.into();
193 self
194 }
195
196 /// Horizontal alignment within `local_bounds`. Default
197 /// [`TextAlign::Leading`]. Needs a text backend to measure the text width;
198 /// a headless canvas with no backend renders leading-aligned.
199 pub fn align(mut self, align: TextAlign) -> Self {
200 self.align = align;
201 self
202 }
203
204 /// Rotate the text about the item's centre by `radians`. Default `0.0`
205 /// (upright). Pair with `Signal::animate_to` on a driving signal for
206 /// animated rotation, or set a fixed angle for a vertical axis title.
207 pub fn rotation(mut self, radians: f32) -> Self {
208 self.rotation = radians;
209 self
210 }
211
212 /// Opt this text into the global accessibility text scale, so it grows with
213 /// the app-wide "grow all text" setting. Off by default — the scene's own
214 /// pan/zoom usually governs scene text size.
215 pub fn follow_text_scale(mut self, follow: bool) -> Self {
216 self.follow_text_scale = follow;
217 self
218 }
219
220 /// Override the AT label (defaults to the current text content).
221 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
222 let ls: LocalizedString = label.into();
223 self.label = Some(ls);
224 self
225 }
226
227 /// Measure the current text's single-line intrinsic size against `backend`
228 /// at the authored [`TextStyle`](teksilo_tokens::TextStyle). Lets a
229 /// consumer size a slot around a label (axis labels, value tags) before
230 /// placing it. Does not apply the global text scale — measure at the
231 /// authored size.
232 pub fn measure(&self, backend: &mut dyn TextBackend) -> Size {
233 let text = self.text.current();
234 let style = teksilo_tokens::TextStyle::default();
235 let layout = backend.layout_single_line(&text, &style, None);
236 Size::new(layout.width, layout.height)
237 }
238
239 crate::items::item_a11y_builders!();
240}
241
242impl SceneItem for TextItem {
243 fn local_bounds(&self) -> Rect {
244 self.local_bounds
245 }
246
247 fn set_local_bounds(&mut self, bounds: Rect) {
248 self.local_bounds = bounds;
249 }
250
251 fn paint(&self, canvas: &mut Canvas, ctx: &SceneItemPaintContext<'_>) {
252 let text = self.text.current();
253 let mut style = teksilo_tokens::TextStyle::default();
254 if self.follow_text_scale {
255 style.size *= ctx.text_scale;
256 }
257 let color = self.color.resolve(ctx.theme, ctx.enabled);
258 let lb = self.local_bounds;
259
260 // One `text_backend()` query serves both the alignment measure and the
261 // paragraph-vs-plain draw choice. Scoped so the `&self` canvas borrow
262 // is released before the `&mut self` draw calls below.
263 let (has_backend, x_offset) = {
264 let backend = canvas.text_backend();
265 let has_backend = backend.is_some();
266 // Horizontal alignment: shift the draw rect by the leftover width.
267 // Needs a backend to measure; leading needs no measure at all.
268 let x_offset = if self.align != TextAlign::Leading {
269 backend
270 .map(|tb| {
271 let w = tb
272 .borrow_mut()
273 .layout_single_line(&text, &style, None)
274 .width;
275 align_offset(self.align, lb.width, w)
276 })
277 .unwrap_or(0.0)
278 } else {
279 0.0
280 };
281 (has_backend, x_offset)
282 };
283 let draw_rect = Rect::new(
284 lb.x + x_offset,
285 lb.y,
286 (lb.width - x_offset).max(0.0),
287 lb.height,
288 );
289
290 // Rotation about the item's centre wraps the whole draw.
291 //
292 // This MUST compose in the item's **local** space: `paint_band` has
293 // already pushed the item's scene→screen transform onto the canvas, and
294 // `Canvas::translate`/`rotate` POST-multiply (they compose in *output*
295 // space). Using them here would rotate about the screen origin offset by
296 // a local-coordinate amount — the wrong pivot for any item that isn't at
297 // the scene origin at zoom 1. `apply_transform` PRE-multiplies
298 // (`new = t.then(current)`), so the rotate-about-centre matrix is applied
299 // to local points *before* the outer transform, keeping the item's centre
300 // a fixed point at any placement, pan, or zoom.
301 let rotated = self.rotation.abs() > f32::EPSILON;
302 if rotated {
303 let cx = lb.x + lb.width * 0.5;
304 let cy = lb.y + lb.height * 0.5;
305 canvas.save();
306 canvas.apply_transform(
307 Transform2D::translate(-cx, -cy)
308 .then(&Transform2D::rotate(self.rotation))
309 .then(&Transform2D::translate(cx, cy)),
310 );
311 }
312 if has_backend {
313 canvas.draw_paragraph(&text, draw_rect, &style, color, None);
314 } else {
315 canvas.draw_text(&text, draw_rect, &style, color);
316 }
317 if rotated {
318 canvas.restore();
319 }
320 }
321
322 fn set_fill(&mut self, fill: Option<ColorProp>) -> bool {
323 // A text item's "fill" is its foreground colour — it always has one,
324 // so a `None` (clear) is rejected.
325 match fill {
326 Some(c) => {
327 self.color = c;
328 true
329 }
330 None => false,
331 }
332 }
333
334 fn label(&self) -> Option<String> {
335 self.label
336 .as_ref()
337 .map(|l| l.resolve_now())
338 .or_else(|| Some(self.text.current()))
339 }
340
341 fn initial_flags(&self) -> ItemFlags {
342 self.flags
343 }
344
345 fn access_subtree_mode(&self) -> AccessSubtreeMode {
346 self.a11y.subtree_mode()
347 }
348
349 fn accessibility(&self, builder: &mut AccessNodeBuilder, _ctx: &SceneItemA11yContext) {
350 builder.set_role(Role::Label);
351 if let Some(label) = self.label() {
352 builder.set_name(label);
353 }
354 self.a11y.apply(builder);
355 }
356
357 fn register_bindings(&self, ctx: &mut BuildContext, view_id: WidgetId) {
358 let registry = ctx.binding_registry();
359 if let TextSource::Bound(signal) = &self.text {
360 signal.bind_to(view_id, registry, BindingLevel::RepaintOnly);
361 }
362 if matches!(self.text, TextSource::Localized(_)) {
363 ctx.locale_signal()
364 .bind_to(view_id, ctx.binding_registry(), BindingLevel::RepaintOnly);
365 }
366 // A signal-/role-bound foreground colour repaints on change too.
367 self.color
368 .register_if_bound(view_id, ctx.binding_registry(), BindingLevel::RepaintOnly);
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375 use teksilo_canvas::{DrawCommand, Point};
376 use teksilo_i18n::lit;
377
378 /// Minimal fixed-metrics text backend (8px per char) for align / measure /
379 /// rotation tests. Mirrors the stub in `view/tests/raster_scale_tests.rs`.
380 #[derive(Default)]
381 struct StubBackend {
382 raster_scale: f32,
383 }
384
385 impl TextBackend for StubBackend {
386 fn set_raster_scale(&mut self, raster_scale: f32) {
387 self.raster_scale = raster_scale;
388 }
389 fn raster_scale(&self) -> f32 {
390 self.raster_scale
391 }
392 fn layout_single_line(
393 &mut self,
394 text: &str,
395 _style: &teksilo_tokens::TextStyle,
396 _max_width: Option<f32>,
397 ) -> teksilo_canvas::TextLayout {
398 teksilo_canvas::TextLayout {
399 width: text.chars().count() as f32 * 8.0,
400 height: 16.0,
401 ascent: 12.0,
402 descent: 4.0,
403 underline_offset: 1.0,
404 underline_thickness: 1.0,
405 layout_key: 1,
406 line_count: 1,
407 spans: Vec::new(),
408 raster_scale: self.raster_scale,
409 }
410 }
411 fn ensure_glyphs(
412 &mut self,
413 _layout: &teksilo_canvas::TextLayout,
414 ) -> Vec<teksilo_canvas::GlyphQuad> {
415 Vec::new()
416 }
417 }
418
419 fn ctx<'a>(theme: &'a teksilo_core::styles::Theme) -> SceneItemPaintContext<'a> {
420 SceneItemPaintContext::new(Transform2D::identity(), None, theme)
421 }
422
423 #[test]
424 fn text_item_label_falls_back_to_text() {
425 let item = TextItem::new(lit!("Hello"), Rect::new(0.0, 0.0, 100.0, 30.0));
426 assert_eq!(SceneItem::label(&item).as_deref(), Some("Hello"));
427 }
428
429 #[test]
430 fn follow_text_scale_defaults_off_and_opts_in() {
431 let item = TextItem::new(lit!("Hi"), Rect::new(0.0, 0.0, 100.0, 30.0));
432 assert!(!item.follow_text_scale);
433 let opted = item.follow_text_scale(true);
434 assert!(opted.follow_text_scale);
435 }
436
437 #[test]
438 fn align_offset_centers_and_trails() {
439 // #7: pure alignment maths.
440 assert_eq!(align_offset(TextAlign::Leading, 100.0, 40.0), 0.0);
441 assert_eq!(align_offset(TextAlign::Center, 100.0, 40.0), 30.0);
442 assert_eq!(align_offset(TextAlign::Trailing, 100.0, 40.0), 60.0);
443 // No negative offset when the text overflows.
444 assert_eq!(align_offset(TextAlign::Center, 30.0, 40.0), 0.0);
445 }
446
447 #[test]
448 fn measure_reports_single_line_size() {
449 // #7: measure against a fixed-metrics backend.
450 let item = TextItem::new(lit!("abcd"), Rect::new(0.0, 0.0, 200.0, 30.0));
451 let mut backend = StubBackend::default();
452 let size = item.measure(&mut backend);
453 assert_eq!(size.width, 32.0); // 4 chars × 8px
454 assert_eq!(size.height, 16.0);
455 }
456
457 #[test]
458 fn rotation_emits_a_transform_command() {
459 // #7: a rotated text item wraps its draw in a transform scope.
460 let theme = teksilo_core::presets::intui::light();
461 let item = TextItem::new(lit!("Title"), Rect::new(0.0, 0.0, 100.0, 20.0))
462 .rotation(std::f32::consts::FRAC_PI_2);
463 let mut canvas = Canvas::new();
464 item.paint(&mut canvas, &ctx(&theme));
465 let frame = canvas.into_render_frame();
466 assert!(
467 frame
468 .draw_order
469 .iter()
470 .any(|c| matches!(c, DrawCommand::SetTransform(_))),
471 "rotation must push a transform"
472 );
473 }
474
475 #[test]
476 fn rotation_pivots_about_the_item_centre() {
477 // #7 regression guard: the rotation must keep the item's OWN centre a
478 // fixed point. `Canvas::translate`/`rotate` post-multiply (compose in
479 // output space), so the naive translate/rotate/translate idiom pivots
480 // about the wrong point for any item not at the origin — this asserts
481 // the composed transform actually fixes the centre.
482 let theme = teksilo_core::presets::intui::light();
483 // Deliberately off-origin bounds so a wrong pivot moves the centre.
484 let lb = Rect::new(120.0, 60.0, 100.0, 40.0);
485 let (cx, cy) = (lb.x + lb.width * 0.5, lb.y + lb.height * 0.5);
486
487 for angle in [0.25_f32, std::f32::consts::FRAC_PI_2, 2.4] {
488 let item = TextItem::new(lit!("Axis label"), lb).rotation(angle);
489 let mut canvas = Canvas::new();
490 item.paint(&mut canvas, &ctx(&theme));
491 let frame = canvas.into_render_frame();
492 let xform = frame
493 .draw_order
494 .iter()
495 .find_map(|c| match c {
496 DrawCommand::SetTransform(t) => Some(*t),
497 _ => None,
498 })
499 .expect("rotation must push a transform");
500 let centre = xform.apply_point(Point::new(cx, cy));
501 assert!(
502 (centre.x - cx).abs() < 0.01 && (centre.y - cy).abs() < 0.01,
503 "centre must be a fixed point of the rotation (angle {angle}): \
504 expected ({cx}, {cy}), got ({}, {})",
505 centre.x,
506 centre.y
507 );
508 }
509 }
510
511 #[test]
512 fn no_rotation_emits_no_transform_command() {
513 let theme = teksilo_core::presets::intui::light();
514 let item = TextItem::new(lit!("Title"), Rect::new(0.0, 0.0, 100.0, 20.0));
515 let mut canvas = Canvas::new();
516 item.paint(&mut canvas, &ctx(&theme));
517 let frame = canvas.into_render_frame();
518 assert!(
519 !frame
520 .draw_order
521 .iter()
522 .any(|c| matches!(c, DrawCommand::SetTransform(_))),
523 "upright text must not push a transform"
524 );
525 }
526
527 #[test]
528 fn set_fill_maps_to_foreground_colour() {
529 // #2: the SceneItem fill hook sets the text colour; None is rejected.
530 let mut item = TextItem::new(lit!("Hi"), Rect::new(0.0, 0.0, 100.0, 30.0));
531 assert!(item.set_fill(Some(ColorProp::from(Color::RED))));
532 assert!(!item.set_fill(None));
533 }
534
535 #[test]
536 fn signal_colour_re_resolves() {
537 // #2: a Signal<Color> foreground re-resolves each paint.
538 let theme = teksilo_core::presets::intui::light();
539 let sig = Signal::new(Color::RED);
540 let item = TextItem::new(lit!("Hi"), Rect::new(0.0, 0.0, 100.0, 30.0)).color(sig.clone());
541 // Paint twice with different signal values; both must succeed without
542 // panicking and read the current value (visual assertion lives at the
543 // view level where a real backend emits glyph colours).
544 let mut c1 = Canvas::new();
545 item.paint(&mut c1, &ctx(&theme));
546 sig.set(Color::BLUE);
547 let mut c2 = Canvas::new();
548 item.paint(&mut c2, &ctx(&theme));
549 assert_eq!(sig.get(), Color::BLUE);
550 }
551}