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}