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//! ## Accessibility
30//!
31//! The item emits a `Role::Label` carrying `Role::TextRun` children, so a
32//! screen reader can review it by character, word and line, braille can be
33//! routed into it, and a magnifier can follow it. The per-character extents
34//! are the layout the last paint drew, in scene coordinates — real when the
35//! item is upright under a pan/zoom view, degenerate (present but
36//! zero-width) when it is rotated, when a label override announces a
37//! different string from the one that was measured, or before the first
38//! paint.
39//!
40//! ## When to use
41//!
42//! Use `TextItem` for card labels, node titles, annotation text, or any text
43//! decoration in the lightweight tier. For editable text or text that needs
44//! focus, selection, and full accessibility, embed a `RichTextEditor` or
45//! `TextInput` as a heavyweight scene widget instead.
46//!
47//! ## Example
48//!
49//! ```ignore
50//! use teksilo_scene::{SceneModel, TextItem, TextAlign};
51//! use teksilo_canvas::{Point, Rect};
52//! use teksilo_tokens::Color;
53//! use teksilo_i18n::lit;
54//!
55//! let model = SceneModel::new();
56//!
57//! let item = TextItem::new(lit!("Scene node"), Rect::new(0.0, 0.0, 120.0, 30.0))
58//!     .color(Color::new(0.1, 0.1, 0.1, 1.0))
59//!     .align(TextAlign::Center);
60//!
61//! model.add_item(item, Point::new(40.0, 40.0));
62//! ```
63//!
64//! [`measure`]: TextItem::measure
65
66use std::cell::RefCell;
67use std::rc::Rc;
68
69use accesskit::Role;
70use teksilo_canvas::text_backend::{
71    CharGeom, LineTruncation, TextGeometry, TextLine, TextLineSegment,
72};
73use teksilo_canvas::{Canvas, Point, Rect, Size, TextBackend, Transform2D};
74use teksilo_core::accessibility::AccessNodeBuilder;
75use teksilo_core::accessibility::text_runs::{TextRunSource, push_text_runs};
76use teksilo_core::binding::BindingLevel;
77use teksilo_core::build_context::BuildContext;
78use teksilo_core::color_prop::ColorProp;
79use teksilo_core::signal::Signal;
80use teksilo_core::widget_id::WidgetId;
81use teksilo_tokens::Color;
82
83use crate::flags::ItemFlags;
84use crate::item::{AppearanceWrite, SceneItem, SceneItemA11yContext, SceneItemPaintContext};
85use crate::items::{AccessSubtreeMode, ItemA11yOverrides};
86use teksilo_i18n::LocalizedString;
87
88/// Horizontal alignment of a [`TextItem`] within its `local_bounds`.
89///
90/// Alignment shifts the text's draw origin by the leftover width
91/// (`bounds.width − measured_width`); it needs a text backend to measure, so a
92/// mock/headless canvas with no backend renders leading-aligned regardless.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
94pub enum TextAlign {
95    /// Left edge in LTR (the default).
96    #[default]
97    Leading,
98    /// Centred within the bounds.
99    Center,
100    /// Right edge in LTR.
101    Trailing,
102}
103
104/// Text source for [`TextItem`]: either a static string or a live
105/// `Signal<String>`. Signal-bound text refreshes on each paint and
106/// dirties the SceneView via `register_bindings`.
107#[derive(Debug)]
108enum TextSource {
109    Bound(Signal<String>),
110    /// Localized text; resolved against the active locale on each paint.
111    /// `register_bindings` ties the locale signal to the SceneView so a
112    /// locale switch repaints and re-resolves.
113    Localized(LocalizedString),
114}
115
116impl TextSource {
117    fn current(&self) -> String {
118        match self {
119            TextSource::Bound(signal) => signal.get(),
120            TextSource::Localized(ls) => ls.resolve_now(),
121        }
122    }
123}
124
125/// Leftover-width offset for a horizontal [`TextAlign`]. Pure so it is unit
126/// testable in isolation from the text backend.
127fn align_offset(align: TextAlign, available: f32, text_width: f32) -> f32 {
128    let extra = (available - text_width).max(0.0);
129    match align {
130        TextAlign::Leading => 0.0,
131        TextAlign::Center => extra * 0.5,
132        TextAlign::Trailing => extra,
133    }
134}
135
136/// The layout one paint produced, kept for the accessibility pass.
137///
138/// AccessKit's text runs carry a position and a width per character, and
139/// [`SceneItem::accessibility`] runs with neither a text backend nor a
140/// draw rect to derive them from. Paint is the one place both are in
141/// hand.
142#[derive(Debug)]
143struct PlacedText {
144    /// The string that was drawn.
145    text: String,
146    /// Per-character geometry, in coordinates local to `origin`.
147    geometry: Rc<TextGeometry>,
148    /// Draw origin in the item's own coordinates.
149    origin: Point,
150}
151
152/// The scale factor of a transform that is a translation plus a uniform
153/// positive scale, or `None` for anything else.
154///
155/// A text run describes its characters as offsets along one reading
156/// direction inside one axis-aligned box, which cannot express a rotated
157/// baseline, a shear, or a mirrored axis. Under any of those the honest
158/// answer is a degenerate box: a wrong rectangle sends a magnifier
159/// somewhere the text is not, while a zero-width one only stops it
160/// magnifying.
161fn uniform_scale(transform: &Transform2D) -> Option<f32> {
162    let [a, b, c, d, _, _] = transform.m;
163    if !a.is_finite() || a <= 0.0 {
164        return None;
165    }
166    let tolerance = 1e-4 * a;
167    if b.abs() > tolerance || c.abs() > tolerance || (a - d).abs() > tolerance {
168        return None;
169    }
170    Some(a)
171}
172
173/// The same geometry with every coordinate multiplied by `scale`.
174///
175/// A scale never reflows scene text — one logical layout is painted
176/// larger — so the scene-space extents AT reads are the layout's own,
177/// scaled by the item's own `local_to_scene`.
178fn scale_geometry(geometry: &TextGeometry, scale: f32) -> TextGeometry {
179    let scale_rect = |r: [f32; 4]| [r[0] * scale, r[1] * scale, r[2] * scale, r[3] * scale];
180    TextGeometry {
181        lines: geometry
182            .lines
183            .iter()
184            .map(|line| TextLine {
185                index: line.index,
186                byte_range: line.byte_range.clone(),
187                char_range: line.char_range.clone(),
188                rect: scale_rect(line.rect),
189                baseline: line.baseline * scale,
190                caret_x: line.caret_x * scale,
191                segments: line
192                    .segments
193                    .iter()
194                    .map(|segment| TextLineSegment {
195                        byte_range: segment.byte_range.clone(),
196                        char_range: segment.char_range.clone(),
197                        direction: segment.direction,
198                        rect: scale_rect(segment.rect),
199                        characters: segment
200                            .characters
201                            .iter()
202                            .map(|c| CharGeom {
203                                position: c.position * scale,
204                                width: c.width * scale,
205                            })
206                            .collect(),
207                    })
208                    .collect(),
209                end: line.end,
210                truncation: line.truncation.map(|t| LineTruncation {
211                    ellipsis_x: t.ellipsis_x * scale,
212                    ellipsis_width: t.ellipsis_width * scale,
213                }),
214            })
215            .collect(),
216        dropped_lines: geometry.dropped_lines,
217        source_len: geometry.source_len,
218        rendered_text: geometry.rendered_text.clone(),
219        links: geometry.links.clone(),
220    }
221}
222
223/// Text in a local-coord rectangle, with optional alignment and rotation.
224///
225/// Text wraps within the `local_bounds` rectangle; the caller is responsible
226/// for sizing the rect so all text is visible. Content is either a static
227/// localized string (see [`TextItem::new`]) or a reactive `Signal<String>`
228/// (see [`TextItem::with_signal_text`]). Both sources trigger a repaint on
229/// change without rebuilding the scene.
230#[derive(Debug)]
231pub struct TextItem {
232    text: TextSource,
233    local_bounds: Rect,
234    color: ColorProp,
235    align: TextAlign,
236    /// Rotation about the item's centre, in radians. `0.0` = upright.
237    rotation: f32,
238    label: Option<LocalizedString>,
239    flags: ItemFlags,
240    a11y: ItemA11yOverrides,
241    /// When `true`, the font size grows with the global accessibility text
242    /// scale (`ctx.text_scale`). Off by default: a scene has its own pan/zoom,
243    /// so most scene text should stay at its authored size. Opt in via
244    /// [`follow_text_scale`](Self::follow_text_scale) for labels that should
245    /// track the app-wide "grow all text" setting.
246    follow_text_scale: bool,
247    /// What the last paint laid out. Feeds the AccessKit text runs; see
248    /// [`PlacedText`].
249    placed: RefCell<Option<PlacedText>>,
250}
251
252impl TextItem {
253    /// A static-text item in local coordinates. The `text` is
254    /// resolved against the active locale on each paint;
255    /// `register_bindings` ties the locale signal to the SceneView at
256    /// `BindingLevel::RepaintOnly`, so a locale switch repaints and
257    /// re-resolves without rebuilding the composite parent.
258    pub fn new(text: impl Into<LocalizedString>, local_bounds: Rect) -> Self {
259        let ls: LocalizedString = text.into();
260        Self {
261            text: TextSource::Localized(ls),
262            local_bounds,
263            color: ColorProp::Static(Color::BLACK),
264            align: TextAlign::Leading,
265            rotation: 0.0,
266            label: None,
267            flags: ItemFlags::default(),
268            a11y: ItemA11yOverrides::default(),
269            follow_text_scale: false,
270            placed: RefCell::new(None),
271        }
272    }
273
274    /// A text item whose content is driven by a `Signal<String>`.
275    /// `register_bindings` ties the signal to the SceneView at
276    /// `BindingLevel::RepaintOnly` so changes dirty paint and the
277    /// next walk reads the current value.
278    pub fn with_signal_text(text: Signal<String>, local_bounds: Rect) -> Self {
279        Self {
280            text: TextSource::Bound(text),
281            local_bounds,
282            color: ColorProp::Static(Color::BLACK),
283            align: TextAlign::Leading,
284            rotation: 0.0,
285            label: None,
286            flags: ItemFlags::default(),
287            a11y: ItemA11yOverrides::default(),
288            follow_text_scale: false,
289            placed: RefCell::new(None),
290        }
291    }
292
293    /// Opt the text into drag-to-move.
294    pub fn draggable(mut self, draggable: bool) -> Self {
295        self.flags.set(ItemFlags::IS_DRAGGABLE, draggable);
296        self
297    }
298
299    /// Override the foreground colour. Accepts a plain [`Color`], a theme role,
300    /// a `Signal<Color>`, or a `Signal<Role>` — resolved against the active
301    /// theme at paint time.
302    pub fn color(mut self, color: impl Into<ColorProp>) -> Self {
303        self.color = color.into();
304        self
305    }
306
307    /// Horizontal alignment within `local_bounds`. Default
308    /// [`TextAlign::Leading`]. Needs a text backend to measure the text width;
309    /// a headless canvas with no backend renders leading-aligned.
310    pub fn align(mut self, align: TextAlign) -> Self {
311        self.align = align;
312        self
313    }
314
315    /// Rotate the text about the item's centre by `radians`. Default `0.0`
316    /// (upright). Pair with `Signal::animate_to` on a driving signal for
317    /// animated rotation, or set a fixed angle for a vertical axis title.
318    pub fn rotation(mut self, radians: f32) -> Self {
319        self.rotation = radians;
320        self
321    }
322
323    /// Opt this text into the global accessibility text scale, so it grows with
324    /// the app-wide "grow all text" setting. Off by default — the scene's own
325    /// pan/zoom usually governs scene text size.
326    pub fn follow_text_scale(mut self, follow: bool) -> Self {
327        self.follow_text_scale = follow;
328        self
329    }
330
331    /// Override the AT label (defaults to the current text content).
332    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
333        let ls: LocalizedString = label.into();
334        self.label = Some(ls);
335        self
336    }
337
338    /// Measure the current text's single-line intrinsic size against `backend`
339    /// at the authored [`TextStyle`](teksilo_tokens::TextStyle). Lets a
340    /// consumer size a slot around a label (axis labels, value tags) before
341    /// placing it. Does not apply the global text scale — measure at the
342    /// authored size.
343    pub fn measure(&self, backend: &mut dyn TextBackend) -> Size {
344        let text = self.text.current();
345        let style = teksilo_tokens::TextStyle::default();
346        let layout = backend.layout_single_line(&text, &style, None);
347        Size::new(layout.width, layout.height)
348    }
349
350    crate::items::item_a11y_builders!();
351}
352
353impl SceneItem for TextItem {
354    fn local_bounds(&self) -> Rect {
355        self.local_bounds
356    }
357
358    fn set_local_bounds(&mut self, bounds: Rect) {
359        self.local_bounds = bounds;
360    }
361
362    fn paint(&self, canvas: &mut Canvas, ctx: &SceneItemPaintContext<'_>) {
363        let text = self.text.current();
364        let mut style = teksilo_tokens::TextStyle::default();
365        if self.follow_text_scale {
366            style.size *= ctx.text_scale;
367        }
368        let color = self.color.resolve(ctx.theme, ctx.enabled);
369        let lb = self.local_bounds;
370
371        // One `text_backend()` query serves both the alignment measure and the
372        // paragraph-vs-plain draw choice. Scoped so the `&self` canvas borrow
373        // is released before the `&mut self` draw calls below.
374        let (has_backend, x_offset) = {
375            let backend = canvas.text_backend();
376            let has_backend = backend.is_some();
377            // Horizontal alignment: shift the draw rect by the leftover width.
378            // Needs a backend to measure; leading needs no measure at all.
379            let x_offset = if self.align != TextAlign::Leading {
380                backend
381                    .map(|tb| {
382                        let w = tb
383                            .borrow_mut()
384                            .layout_single_line(&text, &style, None)
385                            .width;
386                        align_offset(self.align, lb.width, w)
387                    })
388                    .unwrap_or(0.0)
389            } else {
390                0.0
391            };
392            (has_backend, x_offset)
393        };
394        let draw_rect = Rect::new(
395            lb.x + x_offset,
396            lb.y,
397            (lb.width - x_offset).max(0.0),
398            lb.height,
399        );
400
401        // Retain what is about to be drawn, for the accessibility pass.
402        // The layout call is `draw_paragraph`'s own, argument for
403        // argument, so the backend answers the draw from its layout
404        // cache and the geometry AT reports is the geometry of the
405        // glyphs on screen.
406        // Cleared when there is no backend, or none that measures
407        // characters: an item that painted nothing must not keep
408        // advertising a previous frame's extents.
409        {
410            let placed = canvas.text_backend().and_then(|backend| {
411                let layout = backend.borrow_mut().layout_paragraph(
412                    &text,
413                    &style,
414                    (draw_rect.width + 0.5).max(0.0),
415                    None,
416                );
417                layout.geometry.map(|geometry| PlacedText {
418                    text: text.clone(),
419                    geometry,
420                    origin: draw_rect.origin(),
421                })
422            });
423            *self.placed.borrow_mut() = placed;
424        }
425
426        // Rotation about the item's centre wraps the whole draw.
427        //
428        // This MUST compose in the item's **local** space: `paint_band` has
429        // already pushed the item's scene→screen transform onto the canvas, and
430        // `Canvas::translate`/`rotate` POST-multiply (they compose in *output*
431        // space). Using them here would rotate about the screen origin offset by
432        // a local-coordinate amount — the wrong pivot for any item that isn't at
433        // the scene origin at zoom 1. `apply_transform` PRE-multiplies
434        // (`new = t.then(current)`), so the rotate-about-centre matrix is applied
435        // to local points *before* the outer transform, keeping the item's centre
436        // a fixed point at any placement, pan, or zoom.
437        let rotated = self.rotation.abs() > f32::EPSILON;
438        if rotated {
439            let cx = lb.x + lb.width * 0.5;
440            let cy = lb.y + lb.height * 0.5;
441            canvas.save();
442            canvas.apply_transform(
443                Transform2D::translate(-cx, -cy)
444                    .then(&Transform2D::rotate(self.rotation))
445                    .then(&Transform2D::translate(cx, cy)),
446            );
447        }
448        if has_backend {
449            canvas.draw_paragraph(&text, draw_rect, &style, color, None);
450        } else {
451            canvas.draw_text(&text, draw_rect, &style, color);
452        }
453        if rotated {
454            canvas.restore();
455        }
456    }
457
458    fn set_fill(&mut self, fill: Option<ColorProp>) -> AppearanceWrite<ColorProp> {
459        // A text item's "fill" is its foreground colour — it always has one,
460        // so a `None` (clear) is rejected. The colour it replaces is never
461        // absent, so the accepted branch always reports `Some`.
462        match fill {
463            Some(c) => AppearanceWrite::replaced(std::mem::replace(&mut self.color, c)),
464            None => AppearanceWrite::Refused,
465        }
466    }
467
468    fn label(&self) -> Option<String> {
469        self.label
470            .as_ref()
471            .map(|l| l.resolve_now())
472            .or_else(|| Some(self.text.current()))
473    }
474
475    fn initial_flags(&self) -> ItemFlags {
476        self.flags
477    }
478
479    fn access_subtree_mode(&self) -> AccessSubtreeMode {
480        self.a11y.subtree_mode()
481    }
482
483    /// A `Role::Label` carrying `Role::TextRun` children — what makes the
484    /// item reviewable by character, word and line, braille-routable, and
485    /// trackable by a magnifier. The runs are direct children of the
486    /// label: `accesskit_consumer` routes a run's update to its filtered
487    /// parent, and any node between them that does not support text
488    /// ranges makes every platform drop the text-change event.
489    fn accessibility(&self, builder: &mut AccessNodeBuilder, ctx: &SceneItemA11yContext) {
490        builder.set_role(Role::Label);
491
492        // What AT announces: an `access_label` override, else the item's
493        // own label override, else the painted text. The runs must spell
494        // out that same string — a consumer derives the node's document
495        // text from its runs, so any divergence is a place where what a
496        // reader reviews and what it hears disagree.
497        let painted = self.text.current();
498        let announced = self
499            .a11y
500            .label
501            .as_ref()
502            .or(self.label.as_ref())
503            .map(|l| l.resolve_now())
504            .unwrap_or_else(|| painted.clone());
505
506        let placed = self.placed.borrow();
507        // Real extents only when they describe *this* string, under a mapping
508        // a text run can express. A label override announces a different
509        // string from the one that was measured, and an axis-aligned run of
510        // character boxes cannot say where a rotated item's characters are.
511        //
512        // The *camera* is not part of this test. Runs are emitted in scene
513        // coordinates like every other rectangle in the scene subtree, and the
514        // view transform is declared once on the node above — so a rotated or
515        // non-uniformly zoomed camera is expressed exactly rather than ruling
516        // real geometry out. Only the item's own `scene_transform` has to be
517        // expressible here, and it almost always is: a plain placement.
518        let measured = placed
519            .as_ref()
520            .filter(|p| p.text == announced && self.rotation.abs() <= f32::EPSILON)
521            .zip(uniform_scale(&ctx.local_to_scene));
522
523        // Rects are absolute (scene-space) here, and the nested builder a
524        // scene item is emitted through discards local ones — a run with
525        // no box at all would empty `bounding_boxes()` for every range
526        // touching it.
527        let scaled;
528        let source = match measured {
529            Some((p, scale)) => {
530                scaled = scale_geometry(&p.geometry, scale);
531                TextRunSource::from_geometry(
532                    &announced,
533                    &scaled,
534                    ctx.local_to_scene.apply_point(p.origin),
535                    ctx.item_id.as_u64(),
536                )
537                .with_absolute_rects()
538            }
539            None => TextRunSource::flat(&announced, ctx.item_id.as_u64())
540                .with_fallback_rect(ctx.scene_bounds)
541                .with_absolute_rects(),
542        };
543
544        let emission = push_text_runs(builder, None, &source);
545        builder.set_name(&emission.value);
546
547        self.a11y.apply(builder);
548    }
549
550    fn register_bindings(&self, ctx: &mut BuildContext, view_id: WidgetId) {
551        let registry = ctx.binding_registry();
552        if let TextSource::Bound(signal) = &self.text {
553            signal.bind_to(view_id, registry, BindingLevel::RepaintOnly);
554        }
555        if matches!(self.text, TextSource::Localized(_)) {
556            ctx.locale_signal()
557                .bind_to(view_id, ctx.binding_registry(), BindingLevel::RepaintOnly);
558        }
559        // A signal-/role-bound foreground colour repaints on change too.
560        self.color
561            .register_if_bound(view_id, ctx.binding_registry(), BindingLevel::RepaintOnly);
562    }
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568    use teksilo_canvas::{DrawCommand, Point};
569    use teksilo_i18n::lit;
570
571    /// Minimal fixed-metrics text backend (8px per char) for align / measure /
572    /// rotation tests. Mirrors the stub in `view/tests/raster_scale_tests.rs`.
573    #[derive(Default)]
574    struct StubBackend {
575        raster_scale: f32,
576    }
577
578    impl TextBackend for StubBackend {
579        fn set_raster_scale(&mut self, raster_scale: f32) {
580            self.raster_scale = raster_scale;
581        }
582        fn raster_scale(&self) -> f32 {
583            self.raster_scale
584        }
585        fn layout_single_line(
586            &mut self,
587            text: &str,
588            _style: &teksilo_tokens::TextStyle,
589            _max_width: Option<f32>,
590        ) -> teksilo_canvas::TextLayout {
591            teksilo_canvas::TextLayout {
592                width: text.chars().count() as f32 * 8.0,
593                height: 16.0,
594                ascent: 12.0,
595                descent: 4.0,
596                underline_offset: 1.0,
597                underline_thickness: 1.0,
598                layout_key: 1,
599                line_count: 1,
600                spans: Vec::new(),
601                raster_scale: self.raster_scale,
602                geometry: None,
603            }
604        }
605        fn ensure_glyphs(
606            &mut self,
607            _layout: &teksilo_canvas::TextLayout,
608        ) -> Vec<teksilo_canvas::GlyphQuad> {
609            Vec::new()
610        }
611    }
612
613    fn ctx<'a>(theme: &'a teksilo_core::styles::Theme) -> SceneItemPaintContext<'a> {
614        SceneItemPaintContext::new(Transform2D::identity(), None, theme)
615    }
616
617    #[test]
618    fn text_item_label_falls_back_to_text() {
619        let item = TextItem::new(lit!("Hello"), Rect::new(0.0, 0.0, 100.0, 30.0));
620        assert_eq!(SceneItem::label(&item).as_deref(), Some("Hello"));
621    }
622
623    #[test]
624    fn follow_text_scale_defaults_off_and_opts_in() {
625        let item = TextItem::new(lit!("Hi"), Rect::new(0.0, 0.0, 100.0, 30.0));
626        assert!(!item.follow_text_scale);
627        let opted = item.follow_text_scale(true);
628        assert!(opted.follow_text_scale);
629    }
630
631    #[test]
632    fn align_offset_centers_and_trails() {
633        // #7: pure alignment maths.
634        assert_eq!(align_offset(TextAlign::Leading, 100.0, 40.0), 0.0);
635        assert_eq!(align_offset(TextAlign::Center, 100.0, 40.0), 30.0);
636        assert_eq!(align_offset(TextAlign::Trailing, 100.0, 40.0), 60.0);
637        // No negative offset when the text overflows.
638        assert_eq!(align_offset(TextAlign::Center, 30.0, 40.0), 0.0);
639    }
640
641    #[test]
642    fn measure_reports_single_line_size() {
643        // #7: measure against a fixed-metrics backend.
644        let item = TextItem::new(lit!("abcd"), Rect::new(0.0, 0.0, 200.0, 30.0));
645        let mut backend = StubBackend::default();
646        let size = item.measure(&mut backend);
647        assert_eq!(size.width, 32.0); // 4 chars × 8px
648        assert_eq!(size.height, 16.0);
649    }
650
651    #[test]
652    fn rotation_emits_a_transform_command() {
653        // #7: a rotated text item wraps its draw in a transform scope.
654        let theme = teksilo_core::presets::intui::light();
655        let item = TextItem::new(lit!("Title"), Rect::new(0.0, 0.0, 100.0, 20.0))
656            .rotation(std::f32::consts::FRAC_PI_2);
657        let mut canvas = Canvas::new();
658        item.paint(&mut canvas, &ctx(&theme));
659        let frame = canvas.into_render_frame();
660        assert!(
661            frame
662                .draw_order
663                .iter()
664                .any(|c| matches!(c, DrawCommand::SetTransform(_))),
665            "rotation must push a transform"
666        );
667    }
668
669    #[test]
670    fn rotation_pivots_about_the_item_centre() {
671        // #7 regression guard: the rotation must keep the item's OWN centre a
672        // fixed point. `Canvas::translate`/`rotate` post-multiply (compose in
673        // output space), so the naive translate/rotate/translate idiom pivots
674        // about the wrong point for any item not at the origin — this asserts
675        // the composed transform actually fixes the centre.
676        let theme = teksilo_core::presets::intui::light();
677        // Deliberately off-origin bounds so a wrong pivot moves the centre.
678        let lb = Rect::new(120.0, 60.0, 100.0, 40.0);
679        let (cx, cy) = (lb.x + lb.width * 0.5, lb.y + lb.height * 0.5);
680
681        for angle in [0.25_f32, std::f32::consts::FRAC_PI_2, 2.4] {
682            let item = TextItem::new(lit!("Axis label"), lb).rotation(angle);
683            let mut canvas = Canvas::new();
684            item.paint(&mut canvas, &ctx(&theme));
685            let frame = canvas.into_render_frame();
686            let xform = frame
687                .draw_order
688                .iter()
689                .find_map(|c| match c {
690                    DrawCommand::SetTransform(t) => Some(*t),
691                    _ => None,
692                })
693                .expect("rotation must push a transform");
694            let centre = xform.apply_point(Point::new(cx, cy));
695            assert!(
696                (centre.x - cx).abs() < 0.01 && (centre.y - cy).abs() < 0.01,
697                "centre must be a fixed point of the rotation (angle {angle}): \
698                 expected ({cx}, {cy}), got ({}, {})",
699                centre.x,
700                centre.y
701            );
702        }
703    }
704
705    #[test]
706    fn no_rotation_emits_no_transform_command() {
707        let theme = teksilo_core::presets::intui::light();
708        let item = TextItem::new(lit!("Title"), Rect::new(0.0, 0.0, 100.0, 20.0));
709        let mut canvas = Canvas::new();
710        item.paint(&mut canvas, &ctx(&theme));
711        let frame = canvas.into_render_frame();
712        assert!(
713            !frame
714                .draw_order
715                .iter()
716                .any(|c| matches!(c, DrawCommand::SetTransform(_))),
717            "upright text must not push a transform"
718        );
719    }
720
721    #[test]
722    fn uniform_scale_accepts_translation_and_scale_and_nothing_else() {
723        // The predicate that decides whether a text run can express the
724        // mapping at all. Anything it lets through is reported as real
725        // per-character geometry, so a false positive puts a magnifier
726        // somewhere the text is not.
727        assert_eq!(uniform_scale(&Transform2D::identity()), Some(1.0));
728        assert_eq!(
729            uniform_scale(&Transform2D::translate(10.0, -4.0)),
730            Some(1.0)
731        );
732        assert_eq!(
733            uniform_scale(&Transform2D::scale(2.0, 2.0).then(&Transform2D::translate(5.0, 5.0))),
734            Some(2.0)
735        );
736        assert_eq!(uniform_scale(&Transform2D::scale(2.0, 3.0)), None);
737        assert_eq!(uniform_scale(&Transform2D::scale(-1.0, -1.0)), None);
738        assert_eq!(uniform_scale(&Transform2D::scale(0.0, 0.0)), None);
739        assert_eq!(
740            uniform_scale(&Transform2D::rotate(std::f32::consts::FRAC_PI_2)),
741            None
742        );
743        assert_eq!(uniform_scale(&Transform2D::rotate(0.4)), None);
744    }
745
746    #[test]
747    fn scale_geometry_multiplies_every_coordinate() {
748        let mut backend = teksilo_canvas::MockTextBackend::new();
749        let layout = backend.layout_single_line("ab", &teksilo_tokens::TextStyle::default(), None);
750        let geometry = layout.geometry.expect("the mock measures characters");
751        let scaled = scale_geometry(&geometry, 2.0);
752
753        let before = &geometry.lines[0];
754        let after = &scaled.lines[0];
755        assert_eq!(after.rect[2], before.rect[2] * 2.0);
756        assert_eq!(after.rect[3], before.rect[3] * 2.0);
757        assert_eq!(after.baseline, before.baseline * 2.0);
758        for (a, b) in after.segments[0]
759            .characters
760            .iter()
761            .zip(&before.segments[0].characters)
762        {
763            assert_eq!(a.position, b.position * 2.0);
764            assert_eq!(a.width, b.width * 2.0);
765        }
766        // Byte and character ranges index the text, not the screen.
767        assert_eq!(after.byte_range, before.byte_range);
768        assert_eq!(after.char_range, before.char_range);
769    }
770
771    #[test]
772    fn paint_retains_the_layout_it_drew() {
773        // `accessibility` has neither a text backend nor a draw rect;
774        // paint is the one place both are in hand.
775        let theme = teksilo_core::presets::intui::light();
776        let item = TextItem::new(lit!("abcd"), Rect::new(4.0, 6.0, 100.0, 30.0));
777        let backend: std::rc::Rc<std::cell::RefCell<dyn TextBackend>> = std::rc::Rc::new(
778            std::cell::RefCell::new(teksilo_canvas::MockTextBackend::new()),
779        );
780        let mut canvas = Canvas::with_text_backend(backend);
781        item.paint(&mut canvas, &ctx(&theme));
782
783        let placed = item.placed.borrow();
784        let placed = placed.as_ref().expect("a measuring backend was present");
785        assert_eq!(placed.text, "abcd");
786        assert_eq!(placed.origin, Point::new(4.0, 6.0));
787        assert_eq!(placed.geometry.lines.len(), 1);
788    }
789
790    #[test]
791    fn paint_without_a_measuring_backend_retains_nothing() {
792        // A stale layout would keep advertising extents for glyphs that
793        // are no longer on screen.
794        let theme = teksilo_core::presets::intui::light();
795        let item = TextItem::new(lit!("abcd"), Rect::new(0.0, 0.0, 100.0, 30.0));
796        let mut canvas = Canvas::new();
797        item.paint(&mut canvas, &ctx(&theme));
798        assert!(item.placed.borrow().is_none());
799
800        // A backend that measures but reports no geometry is the same
801        // case: there is nothing to report per character.
802        let backend: std::rc::Rc<std::cell::RefCell<dyn TextBackend>> =
803            std::rc::Rc::new(std::cell::RefCell::new(StubBackend::default()));
804        let mut canvas = Canvas::with_text_backend(backend);
805        item.paint(&mut canvas, &ctx(&theme));
806        assert!(item.placed.borrow().is_none());
807    }
808
809    #[test]
810    fn set_fill_maps_to_foreground_colour() {
811        // #2: the SceneItem fill hook sets the text colour; None is rejected.
812        let mut item = TextItem::new(lit!("Hi"), Rect::new(0.0, 0.0, 100.0, 30.0));
813        assert!(item.set_fill(Some(ColorProp::from(Color::RED))).accepted());
814        assert!(!item.set_fill(None).accepted());
815    }
816
817    #[test]
818    fn signal_colour_re_resolves() {
819        // #2: a Signal<Color> foreground re-resolves each paint.
820        let theme = teksilo_core::presets::intui::light();
821        let sig = Signal::new(Color::RED);
822        let item = TextItem::new(lit!("Hi"), Rect::new(0.0, 0.0, 100.0, 30.0)).color(sig.clone());
823        // Paint twice with different signal values; both must succeed without
824        // panicking and read the current value (visual assertion lives at the
825        // view level where a real backend emits glyph colours).
826        let mut c1 = Canvas::new();
827        item.paint(&mut c1, &ctx(&theme));
828        sig.set(Color::BLUE);
829        let mut c2 = Canvas::new();
830        item.paint(&mut c2, &ctx(&theme));
831        assert_eq!(sig.get(), Color::BLUE);
832    }
833}