Skip to main content

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}