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 calling
21//! [`SceneItem::shape_contains`]; 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, Point, 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; removing
65/// an item retires its id permanently (`Scene::remove` does not reuse).
66#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
67pub struct ItemId(pub(crate) u64);
68
69impl ItemId {
70    /// Mint a fresh globally-unique id. Used internally by Scene.
71    pub(crate) fn next() -> Self {
72        use std::sync::atomic::{AtomicU64, Ordering};
73        static COUNTER: AtomicU64 = AtomicU64::new(1);
74        Self(COUNTER.fetch_add(1, Ordering::Relaxed))
75    }
76
77    /// Raw numeric value, used by AccessKit's synthetic-NodeId derivation.
78    pub fn as_u64(self) -> u64 {
79        self.0
80    }
81}
82
83/// Context handed to [`SceneItem::paint`].
84///
85/// `view_transform` is the composed pan/zoom/rotation of the SceneView
86/// that's painting this item; the canvas already has the item's
87/// `scene_transform` pushed, so paint methods work in local coords
88/// without further matrix math.
89///
90/// `theme`, `window_active`, and `enabled` mirror the widget-tier
91/// [`PaintContext`](teksilo_core::widget::PaintContext) so lightweight items
92/// can resolve theme-aware colours exactly like widgets do — call
93/// `some_color_prop.resolve(ctx.theme, ctx.enabled)` in `paint`. `theme` is
94/// already the fully-projected theme for this pass (the render walker swaps in
95/// the inactive-window / high-contrast variant *before* handing it here), so
96/// items never call `Theme::for_inactive_window` themselves; reading
97/// `ctx.theme` grants automatic window-blur desaturation of accent roles.
98#[derive(Clone, Copy)]
99pub struct SceneItemPaintContext<'a> {
100    /// View pan/zoom/rotation as a single affine. Items rarely need
101    /// this directly — the canvas's transform stack already accounts
102    /// for it — but custom items wanting to draw at a fixed pixel
103    /// size regardless of zoom can read the zoom factor from here.
104    pub view_transform: Transform2D,
105    /// Optional scene-coord region the renderer is currently
106    /// repainting. Items that internally subdivide their geometry
107    /// can use this to skip work outside the dirty region. `None`
108    /// means "full repaint".
109    pub dirty_scene_rect: Option<Rect>,
110    /// Global accessibility text-scale factor (`1.0` = 100 %), forwarded from
111    /// the widget `PaintContext`. Lightweight text items that opt in (see
112    /// `TextItem::follow_text_scale`) multiply their font size by this so they
113    /// grow with the app-wide "grow all text" setting. Off-by-default because
114    /// the scene already has its own pan/zoom.
115    pub text_scale: f32,
116    /// Fully-projected theme for this paint pass. Already swapped to the
117    /// inactive-window / high-contrast variant by the render walker — read it
118    /// directly and never call [`Theme::for_inactive_window`] yourself. Pass
119    /// to [`ColorProp::resolve`] to turn a role/reactive colour into a
120    /// concrete [`Color`](teksilo_tokens::Color).
121    pub theme: &'a Theme,
122    /// `true` iff the host window is focused AND not occluded (`true` in
123    /// headless tests). Read for behavioural blur cues the accent-desaturation
124    /// theme swap can't cover (e.g. hiding a caret / muting a selection).
125    pub window_active: bool,
126    /// Effective enabled state of the item being painted (derived from
127    /// [`ItemFlags::IS_ENABLED`]). Forwarded to [`ColorProp::resolve`] so a
128    /// role-based colour picks its disabled variant on a disabled item.
129    pub enabled: bool,
130}
131
132impl<'a> SceneItemPaintContext<'a> {
133    /// Construct a paint context with the given view transform, optional dirty
134    /// region, and the active theme. `text_scale` defaults to `1.0`,
135    /// `window_active` and `enabled` to `true`; use the `with_*` builders to
136    /// carry the accessibility scale, window-active state, and per-item enabled
137    /// state from the widget paint pass.
138    pub fn new(
139        view_transform: Transform2D,
140        dirty_scene_rect: Option<Rect>,
141        theme: &'a Theme,
142    ) -> Self {
143        Self {
144            view_transform,
145            dirty_scene_rect,
146            text_scale: 1.0,
147            theme,
148            window_active: true,
149            enabled: true,
150        }
151    }
152
153    /// Set the global accessibility text-scale factor carried to opted-in items.
154    pub fn with_text_scale(mut self, text_scale: f32) -> Self {
155        self.text_scale = text_scale;
156        self
157    }
158
159    /// Set whether the host window is currently active (focused and unoccluded).
160    pub fn with_window_active(mut self, window_active: bool) -> Self {
161        self.window_active = window_active;
162        self
163    }
164
165    /// Set the effective enabled state of the item being painted.
166    pub fn with_enabled(mut self, enabled: bool) -> Self {
167        self.enabled = enabled;
168        self
169    }
170}
171
172/// Context handed to [`SceneItem::accessibility`].
173///
174/// Carries the item's screen-projected bounds (so items wanting to
175/// emit AT-relative coordinates can read them) and its `ItemId` so
176/// implementations can derive synthetic AT NodeIds for sub-elements.
177pub struct SceneItemA11yContext {
178    /// View transform (pan/zoom/rotation) at AT-build time.
179    pub view_transform: Transform2D,
180    /// Item's bounds projected into screen space.
181    pub screen_bounds: Rect,
182    /// The id under which this item is being emitted.
183    pub item_id: ItemId,
184}
185
186/// A lightweight, paint-only scene-graph item.
187///
188/// Implementations carry their own bounds (in local coords, anchored
189/// at the origin) and provide a `paint` method that draws into a
190/// [`Canvas`]. The Scene takes care of positioning, transform-chain
191/// composition, hit-test, accessibility, and repaint scheduling.
192///
193/// # Required methods
194///
195/// * [`SceneItem::local_bounds`] — AABB in local item coords.
196/// * [`SceneItem::set_local_bounds`] — write back the bounds field
197///   when the Scene mutates it.
198/// * [`SceneItem::paint`] — draw the item into the canvas. The canvas
199///   already has the item's `scene_transform` pushed, so coordinates
200///   are in local item space.
201///
202/// # Optional methods
203///
204/// * [`SceneItem::shape_contains`] — exact-shape hit-test in local
205///   coords. Default is AABB containment.
206/// * [`SceneItem::label`] — human-readable label for AT and debug.
207/// * [`SceneItem::register_bindings`] — bind reactive signals to the
208///   SceneView's repaint machinery.
209/// * [`SceneItem::accessibility`] — populate the AccessKit node.
210pub trait SceneItem: std::fmt::Debug + 'static {
211    /// AABB in **local item coordinates** (origin at the item's
212    /// anchor). The Scene composes this with the item's
213    /// `scene_transform` to compute its scene-space AABB for the
214    /// spatial index.
215    fn local_bounds(&self) -> Rect;
216
217    /// Write back new bounds. Called by `Scene::set_local_bounds`
218    /// when the bounds change. Implementations update their stored
219    /// bounds field; geometry-bearing items (e.g. [`crate::PathItem`])
220    /// must keep their geometry consistent with the new bounds.
221    fn set_local_bounds(&mut self, bounds: Rect);
222
223    /// Paint the item into the canvas. The canvas already has this
224    /// item's `scene_transform` (parent chain × view) pushed, so
225    /// coordinates are in **local** item space — `(0, 0)` is the
226    /// item's anchor.
227    ///
228    /// `ctx` carries the active [`Theme`],
229    /// `window_active`, and per-item `enabled` state, so colour-bearing items
230    /// resolve their [`ColorProp`] fills/strokes with
231    /// `prop.resolve(ctx.theme, ctx.enabled)`.
232    fn paint(&self, canvas: &mut Canvas, ctx: &SceneItemPaintContext<'_>);
233
234    /// Replace the primary fill colour, returning `true` if this item kind has
235    /// a fill slot that accepted the change. Backs
236    /// [`SceneModel::set_item_fill`](crate::SceneModel::set_item_fill) /
237    /// [`clear_item_fill`](crate::SceneModel::clear_item_fill). Rectangles,
238    /// paths, and groups set their fill; text items map it onto their
239    /// foreground colour (so a `None` is rejected — text always has a colour);
240    /// image items have no fill and return `false`. Default: no-op.
241    fn set_fill(&mut self, fill: Option<ColorProp>) -> bool {
242        let _ = fill;
243        false
244    }
245
246    /// Replace the stroke (colour + [`StrokeStyle`]), returning `true` if this
247    /// item kind has a stroke slot that accepted the change. Backs
248    /// [`SceneModel::set_item_stroke`](crate::SceneModel::set_item_stroke) /
249    /// [`clear_item_stroke`](crate::SceneModel::clear_item_stroke). Rectangles,
250    /// paths, and groups accept it; text and image items return `false`.
251    /// Default: no-op.
252    fn set_stroke(&mut self, stroke: Option<(ColorProp, StrokeStyle)>) -> bool {
253        let _ = stroke;
254        false
255    }
256
257    /// Exact-shape hit-test in **local** coordinates. Default: AABB
258    /// containment via [`SceneItem::local_bounds`]. Path-based items
259    /// override this to do per-segment distance checks so users can
260    /// click along a stroke even when the AABB is huge.
261    fn shape_contains(&self, local_pt: Point) -> bool {
262        self.local_bounds().contains(local_pt)
263    }
264
265    /// Produce a stand-alone `Fn(Point, f32) -> bool` that closes over
266    /// whatever state this item needs to answer `shape_contains`
267    /// without retaining a borrow on `self`. The
268    /// [`SceneView`](crate::SceneView) snapshots one of these for
269    /// every item at layout time and consults it on every pointer
270    /// event — direct calls to `shape_contains(&self, ...)` can't
271    /// be cached because `&dyn SceneItem` is not `Clone`.
272    ///
273    /// The closure's second argument is the **view scale** (zoom)
274    /// active when the pointer event arrives — passed at call time
275    /// (not baked at snapshot time) because zoom changes without
276    /// rebuilding the snapshot. Most items ignore it; stroke-distance
277    /// hit-testing ([`PathItem`](crate::items::PathItem)) uses it so a
278    /// **cosmetic** stroke (constant device-pixel width) keeps a
279    /// proportionate hit band in scene coordinates at any zoom.
280    ///
281    /// Default: AABB containment of `local_bounds()`. Items with a
282    /// non-AABB shape (notably [`crate::items::PathItem`] for
283    /// stroke-only paths and [`crate::items::GroupItem`] for the
284    /// logical-only / pass-through case) override this so dispatch
285    /// hits along the actual painted geometry. Returning the
286    /// default for an item with a custom `shape_contains` is a
287    /// silent dispatch bug — the eager `Scene::item_at` path still
288    /// calls `shape_contains` correctly, but pointer-event routing
289    /// goes through the snapshot.
290    fn clone_shape_test(&self) -> Box<dyn Fn(Point, f32) -> bool + 'static> {
291        let bounds = self.local_bounds();
292        Box::new(move |p, _view_scale| bounds.contains(p))
293    }
294
295    /// Dominant color to draw as the item's representation in
296    /// minimap-style thumbnails. Default: an opaque mid-grey,
297    /// which gives a recognisable but neutral marker for any item.
298    /// Built-in items override: [`RectItem`](crate::items::RectItem)
299    /// returns its fill, [`PathItem`](crate::items::PathItem)
300    /// returns stroke or fill, [`ImageItem`](crate::items::ImageItem)
301    /// returns the image's dominant tint placeholder.
302    ///
303    /// Consumed by [`Scene::item_thumbnails`](crate::Scene::item_thumbnails)
304    /// — the typical minimap input. Apps with non-standard items
305    /// can override on their own `SceneItem` impls.
306    fn thumbnail_color(&self) -> teksilo_tokens::Color {
307        teksilo_tokens::Color::new(0.6, 0.6, 0.6, 1.0)
308    }
309
310    /// The flags this item should carry into the Scene at insert
311    /// time. Default: `ItemFlags::default()` — visible, enabled,
312    /// selectable. Built-ins read their accumulated builder state
313    /// (e.g. `.draggable(true)` flips `IS_DRAGGABLE`); custom items
314    /// override this to opt into hover acceptance, focus, clipping,
315    /// or `IGNORES_TRANSFORMATIONS`.
316    ///
317    /// Read once by [`Scene::add_item`](crate::Scene::add_item) and
318    /// stored on the entry. Subsequent flag changes go through
319    /// [`Scene::set_flag`](crate::Scene::set_flag) /
320    /// [`Scene::set_flags`](crate::Scene::set_flags).
321    fn initial_flags(&self) -> ItemFlags {
322        ItemFlags::default()
323    }
324
325    /// Optional human-readable label, surfaced in debug introspection
326    /// and used by the default [`SceneItem::accessibility`] impl as
327    /// the AT name when an item author hasn't overridden it via the
328    /// per-item `.access_label(...)` chain.
329    fn label(&self) -> Option<String> {
330        None
331    }
332
333    /// AT subtree treatment for descendants.
334    ///
335    /// `Inherit` (default) — descendants emit AT nodes normally.
336    /// `Exclude` — descendants are pruned from the AT tree.
337    /// `Merge` — descendants' labels concatenate into this item's
338    /// AT name and they're pruned from individual emission, so the
339    /// subtree reads as a single AT element. Built-ins read this
340    /// from their per-item `.access_subtree(...)` chain.
341    fn access_subtree_mode(&self) -> crate::items::AccessSubtreeMode {
342        crate::items::AccessSubtreeMode::Inherit
343    }
344
345    /// Per-item paint caching strategy. Default
346    /// [`CacheMode::None`](crate::cache::CacheMode::None): the
347    /// item's `paint` runs every frame.
348    ///
349    /// Returning [`CacheMode::ItemCoordinate`](crate::cache::CacheMode::ItemCoordinate)
350    /// asks the [`SceneView`](crate::SceneView) to record the
351    /// item's paint output in **local item coordinates** as a
352    /// [`RenderFrame`](teksilo_canvas::RenderFrame) and replay it on
353    /// subsequent frames instead of re-running `paint`. Only
354    /// suitable for items whose visual depends solely on data the
355    /// Scene knows about (geometry, flags, opacity) — not on
356    /// arbitrary signal state outside `local_bounds`. The cache
357    /// for an id is evicted on
358    /// [`ItemChange::LocalBoundsChanged`](crate::ItemChange) for
359    /// that id.
360    fn cache_mode(&self) -> crate::cache::CacheMode {
361        crate::cache::CacheMode::None
362    }
363
364    /// Register reactive bindings the item depends on. Called once
365    /// per [`SceneView::build`](crate::SceneView) for every item in
366    /// the scene, with the SceneView's `WidgetId` as `view_id`. Items
367    /// with signal-bound state bind their signals here at the
368    /// appropriate [`BindingLevel`](teksilo_core::binding::BindingLevel).
369    fn register_bindings(&self, _ctx: &mut BuildContext, _view_id: WidgetId) {}
370
371    /// Populate the AccessKit node for this item. Default: role
372    /// [`Role::GraphicsObject`] plus the [`SceneItem::label`] as the
373    /// AT name if set. The per-item `.access_*` builder chain layers
374    /// overrides on top of whatever this method writes.
375    fn accessibility(&self, builder: &mut AccessNodeBuilder, _ctx: &SceneItemA11yContext) {
376        builder.set_role(Role::GraphicsObject);
377        if let Some(label) = self.label() {
378            builder.set_name(label);
379        }
380    }
381}