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}