Skip to main content

teksilo_scene/
item.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The [`SceneItem`] trait and its supporting context types.
5//!
6//! Lightweight items live in a [`Scene`](crate::Scene) without arena
7//! overhead. Each carries its own bounds (in **local item coordinates**,
8//! origin at the item's anchor) and paints itself via
9//! [`SceneView`](crate::SceneView)'s paint walk. Apps implement this
10//! trait directly for custom items; built-ins live in
11//! [`crate::items`].
12//!
13//! # Coordinate model
14//!
15//! An item is positioned in its parent's coordinate space by a
16//! `local_pos: Point` plus an optional `transform: Transform2D`
17//! (rotation/scale, applied around the local origin). The Scene
18//! composes those per-item transforms up the parent chain to produce
19//! a `scene_transform` (local→scene). Hit-test inverse-transforms a
20//! scene-coord point into local coords before testing the item's
21//! [`SceneItem::shape`]; paint pushes the scene transform
22//! onto the canvas before calling [`SceneItem::paint`].
23//!
24//! ## When to use
25//!
26//! Implement [`SceneItem`] when you need a lightweight, paint-only
27//! decoration or connector that isn't interactive enough to warrant a
28//! full widget (no keyboard focus, no complex event handling). For
29//! anything that needs focus, animations, drag-and-drop, or AT by
30//! default, prefer the heavyweight tier (`Scene::add_widget`).
31//!
32//! ## Custom item example
33//!
34//! ```ignore
35//! use teksilo_scene::{SceneItem, SceneItemPaintContext};
36//! use teksilo_canvas::{Canvas, Point, Rect};
37//! use teksilo_tokens::Color;
38//!
39//! #[derive(Debug)]
40//! struct DotItem { bounds: Rect }
41//!
42//! impl SceneItem for DotItem {
43//!     fn local_bounds(&self) -> Rect { self.bounds }
44//!     fn set_local_bounds(&mut self, b: Rect) { self.bounds = b; }
45//!     fn paint(&self, canvas: &mut Canvas, _ctx: &SceneItemPaintContext<'_>) {
46//!         canvas.fill_rect(self.bounds, Color::RED);
47//!     }
48//! }
49//! ```
50
51use accesskit::Role;
52use teksilo_canvas::{Canvas, Rect, StrokeStyle, Transform2D};
53use teksilo_core::accessibility::AccessNodeBuilder;
54use teksilo_core::build_context::BuildContext;
55use teksilo_core::color_prop::ColorProp;
56use teksilo_core::styles::Theme;
57use teksilo_core::widget_id::WidgetId;
58
59use crate::flags::ItemFlags;
60
61/// Opaque identifier for a [`SceneItem`] inside a [`Scene`](crate::Scene).
62///
63/// Globally unique within a process, generated by `ItemId::next`.
64/// `ItemId`s are stable across the item's lifetime in a scene, and the scene
65/// never *reuses* one: `ItemId::next` only ever counts up, so a removal can
66/// never be confused with a later, unrelated insertion.
67///
68/// The one door that puts a retired id back is
69/// [`Scene::restore`](crate::Scene::restore), which re-inserts a
70/// [`RemovedItem`](crate::RemovedItem) salvaged by
71/// [`Scene::take`](crate::Scene::take) **at its original id**. That is not
72/// reuse — it is the same item coming back — and it is deliberate: selection,
73/// magnets, the logical AT tree and every app side-map key on the id, so an
74/// undone deletion that minted a fresh id would restore the pixels and lose
75/// all of it. See [`Scene::restore`](crate::Scene::restore).
76#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
77pub struct ItemId(pub(crate) u64);
78
79impl ItemId {
80    /// Mint a fresh globally-unique id. Used internally by Scene.
81    pub(crate) fn next() -> Self {
82        use std::sync::atomic::{AtomicU64, Ordering};
83        static COUNTER: AtomicU64 = AtomicU64::new(1);
84        Self(COUNTER.fetch_add(1, Ordering::Relaxed))
85    }
86
87    /// Raw numeric value, used by AccessKit's synthetic-NodeId derivation.
88    pub fn as_u64(self) -> u64 {
89        self.0
90    }
91}
92
93/// Context handed to [`SceneItem::paint`].
94///
95/// `view_transform` is the composed pan/zoom/rotation of the SceneView
96/// that's painting this item; the canvas already has the item's
97/// `scene_transform` pushed, so paint methods work in local coords
98/// without further matrix math.
99///
100/// `theme`, `window_active`, and `enabled` mirror the widget-tier
101/// [`PaintContext`](teksilo_core::widget::PaintContext) so lightweight items
102/// can resolve theme-aware colours exactly like widgets do — call
103/// `some_color_prop.resolve(ctx.theme, ctx.enabled)` in `paint`. `theme` is
104/// already the fully-projected theme for this pass (the render walker swaps in
105/// the inactive-window / high-contrast variant *before* handing it here), so
106/// items never call `Theme::for_inactive_window` themselves; reading
107/// `ctx.theme` grants automatic window-blur desaturation of accent roles.
108///
109/// `#[non_exhaustive]`: the crate hands this *to* every `SceneItem`
110/// implementation, out-of-tree ones included, and it grew three fields the last
111/// time the paint pass learned something (`theme`, `window_active`, `enabled`).
112/// Build one with [`new`](Self::new) plus the `with_*` setters.
113#[derive(Clone, Copy)]
114#[non_exhaustive]
115pub struct SceneItemPaintContext<'a> {
116    /// View pan/zoom/rotation as a single affine. Items rarely need
117    /// this directly — the canvas's transform stack already accounts
118    /// for it — but custom items wanting to draw at a fixed pixel
119    /// size regardless of zoom can read the zoom factor from here.
120    pub view_transform: Transform2D,
121    /// Optional scene-coord region the renderer is currently
122    /// repainting. Items that internally subdivide their geometry
123    /// can use this to skip work outside the dirty region. `None`
124    /// means "full repaint".
125    pub dirty_scene_rect: Option<Rect>,
126    /// Global accessibility text-scale factor (`1.0` = 100 %), forwarded from
127    /// the widget `PaintContext`. Lightweight text items that opt in (see
128    /// `TextItem::follow_text_scale`) multiply their font size by this so they
129    /// grow with the app-wide "grow all text" setting. Off-by-default because
130    /// the scene already has its own pan/zoom.
131    pub text_scale: f32,
132    /// Fully-projected theme for this paint pass. Already swapped to the
133    /// inactive-window / high-contrast variant by the render walker — read it
134    /// directly and never call [`Theme::for_inactive_window`] yourself. Pass
135    /// to [`ColorProp::resolve`] to turn a role/reactive colour into a
136    /// concrete [`Color`](teksilo_tokens::Color).
137    pub theme: &'a Theme,
138    /// `true` iff the host window is focused AND not occluded (`true` in
139    /// headless tests). Read for behavioural blur cues the accent-desaturation
140    /// theme swap can't cover (e.g. hiding a caret / muting a selection).
141    pub window_active: bool,
142    /// Effective enabled state of the item being painted (derived from
143    /// [`ItemFlags::IS_ENABLED`]). Forwarded to [`ColorProp::resolve`] so a
144    /// role-based colour picks its disabled variant on a disabled item.
145    pub enabled: bool,
146}
147
148impl<'a> SceneItemPaintContext<'a> {
149    /// Construct a paint context with the given view transform, optional dirty
150    /// region, and the active theme. `text_scale` defaults to `1.0`,
151    /// `window_active` and `enabled` to `true`; use the `with_*` builders to
152    /// carry the accessibility scale, window-active state, and per-item enabled
153    /// state from the widget paint pass.
154    pub fn new(
155        view_transform: Transform2D,
156        dirty_scene_rect: Option<Rect>,
157        theme: &'a Theme,
158    ) -> Self {
159        Self {
160            view_transform,
161            dirty_scene_rect,
162            text_scale: 1.0,
163            theme,
164            window_active: true,
165            enabled: true,
166        }
167    }
168
169    /// Set the global accessibility text-scale factor carried to opted-in items.
170    pub fn with_text_scale(mut self, text_scale: f32) -> Self {
171        self.text_scale = text_scale;
172        self
173    }
174
175    /// Set whether the host window is currently active (focused and unoccluded).
176    pub fn with_window_active(mut self, window_active: bool) -> Self {
177        self.window_active = window_active;
178        self
179    }
180
181    /// Set the effective enabled state of the item being painted.
182    pub fn with_enabled(mut self, enabled: bool) -> Self {
183        self.enabled = enabled;
184        self
185    }
186}
187
188/// Context handed to [`SceneItem::accessibility`].
189///
190/// # The space every rectangle here is in
191///
192/// **Scene coordinates, and so is everything the item emits.** An item's
193/// AccessKit node carries its scene-space rectangle, and the view transform is
194/// declared once, on the node at the top of the scene subtree, via
195/// [`to_accesskit_affine`](teksilo_core::accessibility::to_accesskit_affine).
196/// The consumer composes that chain for `bounding_box()` and inverts it per
197/// step when hit-testing, so the projection happens where AccessKit expects it
198/// — which is what makes an item's rectangle, its per-character text geometry
199/// and an explore-by-touch probe all agree at any pan, zoom or rotation.
200///
201/// It is also what keeps the two tiers in one space: a heavyweight card's
202/// arena bounds are scene coordinates too, and the framework walker declares
203/// the same transform on it. An item that projected its own geometry into
204/// window space instead would have it transformed a second time.
205///
206/// So: **never multiply an emitted rectangle by the view transform.** The same
207/// rule the framework states for the device scale factor, one level down.
208///
209/// # A box, not a silhouette
210///
211/// The box reported for an item is its `local_bounds` **rectangle**, not its
212/// [`ItemShape`](crate::ItemShape). AccessKit has no other shape to offer, so
213/// an item whose silhouette is narrower than its box — a rounded
214/// [`RectItem`](crate::RectItem), a stroke-only [`PathItem`](crate::PathItem) —
215/// advertises ground a pointer aimed there would miss. That is deliberate and
216/// it does not cost an AT client the element: a client identifies an element by
217/// node id, announces and spatially navigates by this rectangle, and an
218/// AccessKit action arrives addressed to the node rather than as a synthetic
219/// press at a coordinate. Advertising the narrower rectangle instead would be
220/// strictly worse — it would shrink what the element is *said* to cover without
221/// making any more of it reachable. The per-item magnitude is on
222/// [`RectItem::shape`](crate::RectItem).
223///
224/// `#[non_exhaustive]`: the crate hands this *to* every `SceneItem`
225/// implementation, out-of-tree ones included, and an item's accessibility call
226/// may learn to be told more. Build one with [`new`](Self::new).
227#[non_exhaustive]
228pub struct SceneItemA11yContext {
229    /// View transform (pan/zoom/rotation) at AT-build time.
230    ///
231    /// Not for projecting geometry — see the type's own documentation. It is
232    /// here for an item whose *content* depends on the camera: how much detail
233    /// to describe at the current zoom, say.
234    pub view_transform: Transform2D,
235    /// The item's bounds in scene coordinates — the box written onto its
236    /// AccessKit node.
237    pub scene_bounds: Rect,
238    /// The id under which this item is being emitted.
239    pub item_id: ItemId,
240    /// The item's own local coordinates mapped into scene coordinates — its
241    /// `scene_transform`.
242    ///
243    /// An item emitting sub-element geometry of its own (a
244    /// [`TextItem`](crate::TextItem)'s AccessKit text runs, whose per-character
245    /// positions and widths live in the same space as the node's box) needs
246    /// this rather than [`scene_bounds`](Self::scene_bounds), which is an
247    /// axis-aligned box and says nothing about where a point *inside* the item
248    /// lands.
249    pub local_to_scene: Transform2D,
250}
251
252impl SceneItemA11yContext {
253    /// An accessibility context stated field by field — the constructor
254    /// [`#[non_exhaustive]`](Self) takes the place of a struct literal for.
255    ///
256    /// The view builds its own; this exists so an item author can call their
257    /// own `accessibility` from a test.
258    pub fn new(
259        view_transform: Transform2D,
260        scene_bounds: Rect,
261        item_id: ItemId,
262        local_to_scene: Transform2D,
263    ) -> Self {
264        Self {
265            view_transform,
266            scene_bounds,
267            item_id,
268            local_to_scene,
269        }
270    }
271}
272
273/// What a [`SceneItem`] appearance setter did — and, when it did anything,
274/// the value it overwrote.
275///
276/// # Why the old value comes *out of the setter*
277///
278/// [`ItemChange::AppearanceChanged`](crate::ItemChange) carries an `old`, so a
279/// data layer can reverse a colour edit. That `old` has to come from
280/// somewhere, and the item is the only thing that has it: a fill lives inside
281/// the item, not on the scene entry.
282///
283/// The obvious shape — a `fn fill(&self) -> Option<ColorProp>` getter beside
284/// the setter — cannot be required, because most items have no fill slot at
285/// all, so it would have to be **defaulted**. A defaulted getter returning
286/// `None` would then report "there was no fill" for every third-party item
287/// that implements `set_fill` and forgets the getter, and an undone fill
288/// change would *clear* the fill instead of restoring it. Silent-wrong is the
289/// worst failure a journal can have.
290///
291/// Returning the old value from the setter has no default to get wrong: the
292/// code that overwrites the slot is the code that hands back what it
293/// overwrote, and the compiler will not let an implementor write one without
294/// the other. The default impl is [`Refused`](Self::Refused), which is exactly
295/// true of an item with no such slot and emits no change at all.
296#[derive(Debug, Clone)]
297#[non_exhaustive]
298pub enum AppearanceWrite<T> {
299    /// This item kind has no such slot, or rejected this particular value
300    /// ([`TextItem`](crate::TextItem) always has a foreground colour, so it
301    /// refuses a `None` fill). **Nothing was written**, and the scene emits no
302    /// change.
303    Refused,
304    /// The slot took the value. `was` is what it held immediately before —
305    /// `None` when the slot was empty.
306    Accepted {
307        /// The overwritten value, or `None` if the slot was empty.
308        was: Option<T>,
309    },
310}
311
312impl<T> AppearanceWrite<T> {
313    /// Sugar for `Accepted { was: Some(previous) }`.
314    pub fn replaced(previous: T) -> Self {
315        Self::Accepted {
316            was: Some(previous),
317        }
318    }
319
320    /// Whether the item took the write.
321    pub fn accepted(&self) -> bool {
322        matches!(self, Self::Accepted { .. })
323    }
324
325    /// The overwritten slot contents, or `None` when the write was refused.
326    ///
327    /// The outer `Option` is "did anything happen"; the inner one is "was the
328    /// slot occupied".
329    pub fn into_previous(self) -> Option<Option<T>> {
330        match self {
331            Self::Refused => None,
332            Self::Accepted { was } => Some(was),
333        }
334    }
335}
336
337/// A lightweight, paint-only scene-graph item.
338///
339/// Implementations carry their own bounds (in local coords, anchored
340/// at the origin) and provide a `paint` method that draws into a
341/// [`Canvas`]. The Scene takes care of positioning, transform-chain
342/// composition, hit-test, accessibility, and repaint scheduling.
343///
344/// # Required methods
345///
346/// * [`SceneItem::local_bounds`] — AABB in local item coords.
347/// * [`SceneItem::set_local_bounds`] — write back the bounds field
348///   when the Scene mutates it.
349/// * [`SceneItem::paint`] — draw the item into the canvas. The canvas
350///   already has the item's `scene_transform` pushed, so coordinates
351///   are in local item space.
352///
353/// # Optional methods
354///
355/// * [`SceneItem::shape`] — the item's geometry in local coords, used
356///   for hit-test, marquee, collision and path queries alike. Default
357///   is the local AABB.
358/// * [`SceneItem::label`] — human-readable label for AT and debug.
359/// * [`SceneItem::register_bindings`] — bind reactive signals to the
360///   SceneView's repaint machinery.
361/// * [`SceneItem::accessibility`] — populate the AccessKit node.
362pub trait SceneItem: std::fmt::Debug + 'static {
363    /// AABB in **local item coordinates** (origin at the item's
364    /// anchor). The Scene composes this with the item's
365    /// `scene_transform` to compute its scene-space AABB for the
366    /// spatial index.
367    fn local_bounds(&self) -> Rect;
368
369    /// Write back new bounds. Called by `Scene::set_local_bounds`
370    /// when the bounds change. Implementations update their stored
371    /// bounds field; geometry-bearing items (e.g. [`crate::PathItem`])
372    /// must keep their geometry consistent with the new bounds.
373    ///
374    /// Two obligations, because the Scene reads
375    /// [`local_bounds`](SceneItem::local_bounds) back afterwards and stores
376    /// *that* as the rectangle the spatial index buckets on:
377    ///
378    /// * **Be idempotent.** Calling twice with the same rectangle must leave
379    ///   the item where the first call left it. An item that only ever
380    ///   converges towards the request turns a setter driven per frame into
381    ///   an endless stream of `ItemChange::LocalBoundsChanged`.
382    /// * **Be honest about what you cannot do.** An item that fits geometry
383    ///   to the box rather than adopting the box may be unable to honour an
384    ///   axis (a perfectly horizontal stroke has no height to stretch). Leave
385    ///   `local_bounds` reporting what you settled on rather than what was
386    ///   asked for, and say so in your own documentation — the Scene stores
387    ///   the answer, so a lie here is a lie in the index.
388    fn set_local_bounds(&mut self, bounds: Rect);
389
390    /// Paint the item into the canvas. The canvas already has this
391    /// item's `scene_transform` (parent chain × view) pushed, so
392    /// coordinates are in **local** item space — `(0, 0)` is the
393    /// item's anchor.
394    ///
395    /// `ctx` carries the active [`Theme`],
396    /// `window_active`, and per-item `enabled` state, so colour-bearing items
397    /// resolve their [`ColorProp`] fills/strokes with
398    /// `prop.resolve(ctx.theme, ctx.enabled)`.
399    fn paint(&self, canvas: &mut Canvas, ctx: &SceneItemPaintContext<'_>);
400
401    /// Replace the primary fill colour, returning what was there before. Backs
402    /// [`SceneModel::set_item_fill`](crate::SceneModel::set_item_fill) /
403    /// [`clear_item_fill`](crate::SceneModel::clear_item_fill). Rectangles,
404    /// paths, and groups set their fill; text items map it onto their
405    /// foreground colour (so a `None` is rejected — text always has a colour);
406    /// image items have no fill. Default:
407    /// [`AppearanceWrite::Refused`].
408    ///
409    /// **An implementation that writes the slot must return the value it
410    /// overwrote.** That value is what
411    /// [`ItemChange::AppearanceChanged`](crate::ItemChange)'s `old` carries, and
412    /// therefore what a data layer reverses the edit from; see
413    /// [`AppearanceWrite`] for why it rides out of the setter instead of coming
414    /// from a getter.
415    fn set_fill(&mut self, fill: Option<ColorProp>) -> AppearanceWrite<ColorProp> {
416        let _ = fill;
417        AppearanceWrite::Refused
418    }
419
420    /// Replace the stroke (colour + [`StrokeStyle`]), returning what was there
421    /// before. Backs
422    /// [`SceneModel::set_item_stroke`](crate::SceneModel::set_item_stroke) /
423    /// [`clear_item_stroke`](crate::SceneModel::clear_item_stroke). Rectangles,
424    /// paths, and groups accept it; text and image items refuse. Default:
425    /// [`AppearanceWrite::Refused`].
426    ///
427    /// Same obligation as [`set_fill`](SceneItem::set_fill): an implementation
428    /// that writes the slot returns what it overwrote.
429    fn set_stroke(
430        &mut self,
431        stroke: Option<(ColorProp, StrokeStyle)>,
432    ) -> AppearanceWrite<(ColorProp, StrokeStyle)> {
433        let _ = stroke;
434        AppearanceWrite::Refused
435    }
436
437    /// This item's geometry in **local** coordinates — the single source of
438    /// truth for hit-test, marquee selection, collision and path queries.
439    ///
440    /// Default: [`ItemShape::bounds`](crate::ItemShape::bounds) of
441    /// [`SceneItem::local_bounds`], which
442    /// allocates nothing and is exact for a rectangle. Override it when the
443    /// item's silhouette is not its box: a stroke-only connector whose
444    /// bounding box is mostly empty ([`PathItem`](crate::PathItem)), a rounded
445    /// card whose corners are transparent ([`RectItem`](crate::RectItem)), a
446    /// logical-only container that must let clicks fall through
447    /// ([`GroupItem`](crate::GroupItem) returns
448    /// [`ItemShape::none`](crate::ItemShape::none)).
449    ///
450    /// # Stay inside your own bounds
451    ///
452    /// The shape must lie within [`local_bounds`](SceneItem::local_bounds) —
453    /// that rectangle is what the spatial index buckets on and what every
454    /// query broad-phases against, so a silhouette poking out of it is a
455    /// silhouette that is sometimes never asked. It is also what makes the
456    /// four [`ItemSelectionMode`](crate::ItemSelectionMode)s consistent:
457    /// because the shape is inside the box, anything an `…ItemShape` query
458    /// picks an `…ItemBoundingRect` query picks too. Both built-ins that
459    /// narrow their shape stay inside by construction (a rounded rect is
460    /// inside its rect; a `PathItem` *derives* its bounds from its geometry),
461    /// and an item that widens its clickable area should widen its bounds to
462    /// match — as `PathItem::hit_stroke_width` does.
463    ///
464    /// # Called once per layout pass
465    ///
466    /// The [`SceneView`](crate::SceneView) refreshes a dispatch snapshot from
467    /// this on every layout pass, so **return a cheap clone of state you
468    /// already own** — an `Rc<ShapeGeometry>` built in your constructor —
469    /// rather than building geometry here. Every `ItemShape` is O(1) to clone
470    /// by construction; the only way to make this expensive is to flatten a
471    /// path inside it.
472    ///
473    /// # One value, not two predicates
474    ///
475    /// This returns a *value*, not a closure, and that is the point. Because
476    /// the value is `Clone + 'static` and carries everything a test needs —
477    /// outline, fill rule, stroke band and its
478    /// [`StrokeSpace`](teksilo_canvas::StrokeSpace) — the view's
479    /// snapshot stores the shape itself and every query reads the same one.
480    /// The pair this replaced (a `&self` predicate for the eager path, a
481    /// boxed closure for the snapshot) had to be kept in agreement by hand.
482    fn shape(&self) -> crate::shape::ItemShape {
483        crate::shape::ItemShape::bounds(self.local_bounds())
484    }
485
486    /// Dominant color to draw as the item's representation in
487    /// minimap-style thumbnails. Default: an opaque mid-grey,
488    /// which gives a recognisable but neutral marker for any item.
489    /// Built-in items override: [`RectItem`](crate::items::RectItem)
490    /// returns its fill, [`PathItem`](crate::items::PathItem)
491    /// returns stroke or fill, [`GroupItem`](crate::items::GroupItem)
492    /// returns fill or stroke and paints a logical group fully
493    /// transparent. [`ImageItem`](crate::items::ImageItem) does not
494    /// override — it keeps the neutral grey.
495    ///
496    /// Consumed by [`Scene::item_thumbnails`](crate::Scene::item_thumbnails)
497    /// — the typical minimap input. Apps with non-standard items
498    /// can override on their own `SceneItem` impls.
499    fn thumbnail_color(&self) -> teksilo_tokens::Color {
500        teksilo_tokens::Color::new(0.6, 0.6, 0.6, 1.0)
501    }
502
503    /// The flags this item should carry into the Scene at insert
504    /// time. Default: `ItemFlags::default()` — visible, enabled,
505    /// selectable. Built-ins read their accumulated builder state
506    /// (e.g. `.draggable(true)` flips `IS_DRAGGABLE`); custom items
507    /// override this to opt into hover acceptance, focus, clipping,
508    /// or `IGNORES_TRANSFORMATIONS`.
509    ///
510    /// Read once by [`Scene::add_item`](crate::Scene::add_item) and
511    /// stored on the entry. Subsequent flag changes go through
512    /// [`Scene::set_flag`](crate::Scene::set_flag) /
513    /// [`Scene::set_flags`](crate::Scene::set_flags).
514    fn initial_flags(&self) -> ItemFlags {
515        ItemFlags::default()
516    }
517
518    /// Optional human-readable label, surfaced in debug introspection
519    /// and used by the default [`SceneItem::accessibility`] impl as
520    /// the AT name when an item author hasn't overridden it via the
521    /// per-item `.access_label(...)` chain.
522    fn label(&self) -> Option<String> {
523        None
524    }
525
526    /// AT subtree treatment for descendants.
527    ///
528    /// `Inherit` (default) — descendants emit AT nodes normally.
529    /// `Exclude` — descendants are pruned from the AT tree.
530    /// `Merge` — descendants' labels concatenate into this item's
531    /// AT name and they're pruned from individual emission, so the
532    /// subtree reads as a single AT element. Built-ins read this
533    /// from their per-item `.access_subtree(...)` chain.
534    fn access_subtree_mode(&self) -> crate::items::AccessSubtreeMode {
535        crate::items::AccessSubtreeMode::Inherit
536    }
537
538    /// Per-item paint caching strategy. Default
539    /// [`CacheMode::None`](crate::cache::CacheMode::None): the
540    /// item's `paint` runs every frame.
541    ///
542    /// Returning [`CacheMode::ItemCoordinate`](crate::cache::CacheMode::ItemCoordinate)
543    /// asks the [`SceneView`](crate::SceneView) to record the
544    /// item's paint output in **local item coordinates** as a
545    /// [`RenderFrame`](teksilo_canvas::RenderFrame) and replay it on
546    /// subsequent frames instead of re-running `paint`. Only
547    /// suitable for items whose visual depends solely on data the
548    /// Scene knows about (geometry, flags, opacity) — not on
549    /// arbitrary signal state outside `local_bounds`. The cache
550    /// for an id is evicted on
551    /// [`ItemChange::LocalBoundsChanged`](crate::ItemChange) for
552    /// that id.
553    fn cache_mode(&self) -> crate::cache::CacheMode {
554        crate::cache::CacheMode::None
555    }
556
557    /// Register reactive bindings the item depends on. Called once
558    /// per [`SceneView::build`](crate::SceneView) for every item in
559    /// the scene, with the SceneView's `WidgetId` as `view_id`. Items
560    /// with signal-bound state bind their signals here at the
561    /// appropriate [`BindingLevel`](teksilo_core::binding::BindingLevel).
562    fn register_bindings(&self, _ctx: &mut BuildContext, _view_id: WidgetId) {}
563
564    /// Populate the AccessKit node for this item. Default: role
565    /// [`Role::GraphicsObject`] plus the [`SceneItem::label`] as the
566    /// AT name if set. The per-item `.access_*` builder chain layers
567    /// overrides on top of whatever this method writes.
568    fn accessibility(&self, builder: &mut AccessNodeBuilder, _ctx: &SceneItemA11yContext) {
569        builder.set_role(Role::GraphicsObject);
570        if let Some(label) = self.label() {
571            builder.set_name(label);
572        }
573    }
574}