teksilo_scene/salvage.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The **owning salvage** door: everything [`Scene::remove`](crate::Scene::remove)
5//! destroys, moved out instead of dropped.
6//!
7//! # Why a second channel exists at all
8//!
9//! [`ItemChange`](crate::ItemChange) travels through a `Signal`, and
10//! `Signal<T>` requires `T: Clone` (it snapshots the value before fanning out).
11//! A [`SceneItem`] is a `Box<dyn SceneItem>` with no `clone_item` and no
12//! downcast, and a heavyweight entry holds a `Box<dyn Widget>`. Neither can be
13//! cloned, so **the removed entry can never ride the notification channel** —
14//! not as a design preference but as a type fact. It has to be *moved*, which
15//! means a door that hands ownership over.
16//!
17//! That door is [`Scene::take`](crate::Scene::take) (the caller keeps the
18//! salvage) and the [`SceneTransactionRecord`](crate::SceneTransactionRecord)
19//! delivered to the edit sink (the framework hands it over). Both produce
20//! [`RemovedItem`]s; [`Scene::restore`](crate::Scene::restore) puts one back.
21//!
22//! # What a removal used to destroy
23//!
24//! `Scene::remove` drops, in this order: the entry (its item or widget box, its
25//! handlers, its geometry, its flags), then the item's slice of the logical
26//! accessibility tree — AT parent, AT relations, live-region status, landmark
27//! role, rotor categories — then its magnets. Only *after* all of that does it
28//! announce `ItemChange::Removed`. So an app that reconstructed the item from
29//! the event alone would restore the pixels and silently lose every one of
30//! those. On a crate whose differentiator is per-item accessibility, that is
31//! the single strongest reason this door exists.
32//!
33//! # The edges a naive salvage misses
34//!
35//! The logical AT tree is a *graph*, and a removal cuts edges on **both**
36//! sides:
37//!
38//! * the removed item's own AT parent (`removed → parent`), and
39//! * every **surviving** node that was AT-parented *under* the removed item
40//! (`survivor → removed`), which `remove` also drops — re-rooting those
41//! survivors at the view root.
42//!
43//! Recording only the first would make `restore` silently fail to re-adopt the
44//! survivors, i.e. fail at exactly the edge case the salvage is sold on. So
45//! [`ItemA11yDecorations`] carries both directions, and the same applies to
46//! relations, which `remove` retains on either endpoint.
47
48use teksilo_canvas::{Point, Rect, Transform2D};
49
50use crate::a11y::{A11yCategory, A11yNode, A11yRelation};
51use crate::flags::ItemFlags;
52use crate::item::{ItemId, SceneItem};
53use crate::magnet::{Magnet, MagnetId};
54use crate::scene::{SceneEntry, SceneEntryKind, SceneLayer, WidgetSource};
55
56/// The per-item slice of the **logical accessibility tree**, harvested by
57/// [`Scene::take`](crate::Scene::take) and re-attached by
58/// [`Scene::restore`](crate::Scene::restore).
59///
60/// Both directions of every edge, because a removal cuts both — see the module
61/// docs. A restore re-attaches only the edges whose *other* endpoint is still
62/// alive; an edge to something that has since been removed is dropped, because
63/// re-inserting it would name a node the AT walker cannot resolve.
64#[derive(Debug, Default)]
65#[non_exhaustive]
66pub struct ItemA11yDecorations {
67 /// The node this item was AT-parented under (`Scene::set_a11y_parent`).
68 pub parent: Option<A11yNode>,
69 /// Nodes that were AT-parented **under** this item and survived its
70 /// removal. `remove` re-roots them at the view root; a restore re-adopts
71 /// each one that still exists.
72 pub adopted: Vec<A11yNode>,
73 /// Every relation with this item at *either* endpoint, in declaration
74 /// order. A relation between two removed items is recorded once, on the
75 /// `from` endpoint's salvage.
76 pub relations: Vec<(A11yNode, A11yRelation, A11yNode)>,
77 /// Live-region politeness (`Scene::set_a11y_live`).
78 pub live: Option<accesskit::Live>,
79 /// Landmark role (`Scene::set_a11y_landmark`).
80 pub landmark: Option<accesskit::Role>,
81 /// Rotor / quick-nav categories (`Scene::set_a11y_categories`).
82 pub categories: Vec<A11yCategory>,
83}
84
85impl ItemA11yDecorations {
86 /// Whether this item carried any logical-AT decoration at all. A restore
87 /// of an undecorated item touches none of the scene's AT maps.
88 pub fn is_empty(&self) -> bool {
89 self.parent.is_none()
90 && self.adopted.is_empty()
91 && self.relations.is_empty()
92 && self.live.is_none()
93 && self.landmark.is_none()
94 && self.categories.is_empty()
95 }
96}
97
98/// One item lifted whole out of a [`Scene`](crate::Scene): its entry, its
99/// magnets and its logical-AT decorations, still owned and still carrying its
100/// original [`ItemId`].
101///
102/// Opaque on purpose. It wraps the crate-private `SceneEntry` **whole** rather
103/// than copying its fields out, so a future field added to the entry cannot be
104/// missed by [`Scene::restore`](crate::Scene::restore) — the same argument the
105/// single-construction-site rule makes for insertion.
106///
107/// # Ordering
108///
109/// [`Scene::take`](crate::Scene::take) returns a subtree deepest-first, the
110/// order `remove` already announces in.
111/// [`Scene::restore_all`](crate::Scene::restore_all) reverses it for you;
112/// restoring by hand means roots first, or a child hits
113/// [`RestoreError::MissingParent`].
114pub struct RemovedItem {
115 pub(crate) entry: SceneEntry,
116 /// Where the entry sat in the scene's **declaration order** — the order
117 /// the accessibility walk publishes siblings in, i.e. the order a screen
118 /// reader reads the scene in. A restore puts the entry back at this index
119 /// when it still exists, so an undone deletion does not silently move the
120 /// item to the end of the reading order.
121 pub(crate) entry_order: usize,
122 pub(crate) magnets: Vec<(MagnetId, Magnet)>,
123 pub(crate) a11y: ItemA11yDecorations,
124}
125
126impl std::fmt::Debug for RemovedItem {
127 /// Hand-written: `SceneEntry` holds a `Box<dyn SceneItem>` **or** a
128 /// `Box<dyn Widget>` plus an `Option<Box<SceneItemHandlerSet>>`, and
129 /// `Magnet` is `Clone` only — none of the three is `Debug`, so this cannot
130 /// be derived.
131 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132 f.debug_struct("RemovedItem")
133 .field("id", &self.entry.id)
134 .field("kind", &if self.is_widget() { "widget" } else { "item" })
135 .field("local_pos", &self.entry.local_pos)
136 .field("local_bounds", &self.entry.local_bounds)
137 .field("z", &self.entry.z)
138 .field("layer", &self.entry.layer)
139 .field("parent", &self.entry.parent)
140 .field("children", &self.entry.children)
141 .field("entry_order", &self.entry_order)
142 .field("magnets", &self.magnets.len())
143 .field("a11y", &self.a11y)
144 .finish()
145 }
146}
147
148impl RemovedItem {
149 /// The id this item had, and the id
150 /// [`Scene::restore`](crate::Scene::restore) puts it back at.
151 pub fn id(&self) -> ItemId {
152 self.entry.id
153 }
154
155 /// Position in the parent's coordinate frame.
156 pub fn local_pos(&self) -> Point {
157 self.entry.local_pos
158 }
159
160 /// AABB in local coordinates.
161 pub fn local_bounds(&self) -> Rect {
162 self.entry.local_bounds
163 }
164
165 /// Local→parent transform.
166 pub fn transform(&self) -> Transform2D {
167 self.entry.transform
168 }
169
170 /// Paint z-order.
171 pub fn z(&self) -> f32 {
172 self.entry.z
173 }
174
175 /// Lightweight paint band.
176 pub fn layer(&self) -> SceneLayer {
177 self.entry.layer
178 }
179
180 /// Logical parent at the moment of removal. A restore needs this item
181 /// present, or it fails with [`RestoreError::MissingParent`].
182 pub fn parent(&self) -> Option<ItemId> {
183 self.entry.parent
184 }
185
186 /// Forget the recorded parent, so this salvage restores at **root** level.
187 ///
188 /// The door [`RestoreError::MissingParent`] points at, and the one a
189 /// move *between* scenes needs: the parent a salvage names is still in the
190 /// scene it came from, so restoring a parented item into a second scene is
191 /// refused unless its parent went first — or unless the caller says, here,
192 /// that it should arrive unparented.
193 ///
194 /// Nothing else changes: geometry is untouched, so an item that was placed
195 /// in its parent's frame keeps those numbers and now reads them as scene
196 /// coordinates. Use
197 /// [`Scene::reparent_keeping_scene_pos`](crate::Scene::reparent_keeping_scene_pos)
198 /// after the restore when the item should not visibly move.
199 pub fn detach(&mut self) {
200 self.entry.parent = None;
201 }
202
203 /// Direct children at the moment of removal — all of them removed with it,
204 /// and all of them present in the same [`Scene::take`](crate::Scene::take)
205 /// result.
206 pub fn children(&self) -> &[ItemId] {
207 &self.entry.children
208 }
209
210 /// Behaviour flags.
211 pub fn flags(&self) -> ItemFlags {
212 self.entry.flags
213 }
214
215 /// Local opacity multiplier.
216 pub fn opacity(&self) -> f32 {
217 self.entry.opacity
218 }
219
220 /// Whether this was a heavyweight `Widget` entry rather than a lightweight
221 /// [`SceneItem`] one.
222 pub fn is_widget(&self) -> bool {
223 matches!(self.entry.kind, SceneEntryKind::Widget(_))
224 }
225
226 /// The lightweight item, if this was one. `None` for widget entries.
227 pub fn item(&self) -> Option<&dyn SceneItem> {
228 match &self.entry.kind {
229 SceneEntryKind::Item(item) => Some(&**item),
230 SceneEntryKind::Widget(_) => None,
231 }
232 }
233
234 /// Consume the salvage for its lightweight item box — cut-to-clipboard.
235 /// `None` for widget entries (and the salvage is dropped either way).
236 pub fn into_item(self) -> Option<Box<dyn SceneItem>> {
237 match self.entry.kind {
238 SceneEntryKind::Item(item) => Some(item),
239 SceneEntryKind::Widget(_) => None,
240 }
241 }
242
243 /// The type-erased payload of a multi-view (`Delegated`) heavyweight
244 /// entry, which is what lets a restored card be rebuilt by every view's
245 /// delegate. `None` for a lightweight item and for a single-view
246 /// (`Scene::add_widget`) entry.
247 pub fn payload(&self) -> Option<std::rc::Rc<dyn std::any::Any>> {
248 match &self.entry.kind {
249 SceneEntryKind::Widget(WidgetSource::Delegated { payload }) => Some(payload.clone()),
250 _ => None,
251 }
252 }
253
254 /// Whether a **single-view** (`Scene::add_widget`) entry still carries its
255 /// one `Box<dyn Widget>`.
256 ///
257 /// `true` for every lightweight item and for every multi-view
258 /// (`add_widget_item`) entry, because neither depends on a one-shot box.
259 ///
260 /// This is a **fact about the salvage, not a verdict on restorability**,
261 /// and the distinction matters: a `Once` widget already drained by a
262 /// mounted view restores perfectly well when the take and the restore
263 /// happen inside one build cycle (the view's orphan reap keys on the
264 /// scene's live heavyweight set, which the restore has already put the id
265 /// back into, so the arena instance is never destroyed). Refusing such a
266 /// restore would refuse one that works — and would refuse the whole entry,
267 /// geometry and accessibility included, over a widget instance.
268 ///
269 /// So [`Scene::restore`](crate::Scene::restore) always accepts, and this
270 /// reports what an app needs to decide for itself. When `false` **and** the
271 /// view has already reaped the instance, the restored entry is a
272 /// heavyweight slot with nothing to materialise — visible to no view. The
273 /// rule that avoids it entirely is the same one multi-view content already
274 /// follows: content that must survive an undo goes in through
275 /// [`SceneModel::add_widget_item`](crate::SceneModel::add_widget_item),
276 /// whose payload every view can rebuild from.
277 pub fn widget_instance_present(&self) -> bool {
278 match &self.entry.kind {
279 SceneEntryKind::Widget(WidgetSource::Once(slot)) => slot.is_some(),
280 _ => true,
281 }
282 }
283
284 /// The logical-AT decorations this item carried — both directions of every
285 /// edge. See [`ItemA11yDecorations`].
286 pub fn a11y(&self) -> &ItemA11yDecorations {
287 &self.a11y
288 }
289
290 /// How many magnets were attached to this item. Their ids are preserved by
291 /// a restore, so a consumer keying connections on
292 /// [`MagnetId`] keeps them.
293 pub fn magnet_count(&self) -> usize {
294 self.magnets.len()
295 }
296}
297
298/// Why [`Scene::restore`](crate::Scene::restore) could not put a salvage back.
299///
300/// `#[non_exhaustive]`: this crate has out-of-tree consumers and the restore
301/// path may learn to refuse something new.
302#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
303#[non_exhaustive]
304pub enum RestoreError {
305 /// An entry with this id is already in the scene. Restoring over it would
306 /// alias two items onto one identity.
307 ///
308 /// **Defensive.** [`Scene::take`](crate::Scene::take) retires the id, so a
309 /// second salvage for it cannot exist while the first is held, and the
310 /// restore that succeeds consumes the one that does — which makes this
311 /// unreachable through the public API today. It stays because it is the
312 /// only thing standing between a future door that breaks that invariant and
313 /// a corrupted `entry_index`, and refusing is better than aliasing.
314 #[error(
315 "cannot restore {0:?}: an item with that id is already in the scene. A salvage \
316 may only be restored once, and only while its id is free."
317 )]
318 IdAlreadyLive(ItemId),
319 /// The parent the item was removed from is not in the scene. Restore
320 /// roots-first — [`Scene::restore_all`](crate::Scene::restore_all) orders a
321 /// whole `take` result for you — or clear the recorded parent with
322 /// [`RemovedItem::detach`] before restoring, which is what a move into a
323 /// *different* scene normally wants.
324 ///
325 /// The salvage is consumed by the refused call, so a caller that may hit
326 /// this checks `parent()` (or calls `detach()`) **before** handing it over.
327 #[error(
328 "cannot restore {id:?}: its recorded parent {parent:?} is not in the scene. \
329 Restore roots before their children (Scene::restore_all does that for a \
330 whole Scene::take result)."
331 )]
332 MissingParent {
333 /// The item that could not be restored.
334 id: ItemId,
335 /// The parent it names, which is absent.
336 parent: ItemId,
337 },
338}
339
340/// A refused [`Scene::replace_item`](crate::Scene::replace_item): why, and the
341/// item it did not take.
342///
343/// The item comes back rather than being dropped, because a refused write must
344/// not eat the caller's value. A `Box<dyn SceneItem>` can be expensive to build
345/// and cannot be cloned, so a caller recovering from a precondition violation
346/// would otherwise have nothing left to insert instead — and the row it was
347/// reconciling would silently vanish.
348///
349/// `#[non_exhaustive]`: see [`RestoreError`]. The crate only ever hands this
350/// *out*, so there is nothing to construct — recover the item with
351/// `let ReplaceRejected { item, .. } = rejected;`.
352#[derive(Debug, thiserror::Error)]
353#[error("{error}")]
354#[non_exhaustive]
355pub struct ReplaceRejected {
356 /// Why the write was refused.
357 #[source]
358 pub error: ReplaceItemError,
359 /// The item that was not inserted, handed straight back.
360 pub item: Box<dyn SceneItem>,
361}
362
363/// Why [`Scene::replace_item`](crate::Scene::replace_item) could not swap an
364/// item box. Travels inside a [`ReplaceRejected`], which also carries the item.
365///
366/// `#[non_exhaustive]`: see [`RestoreError`].
367#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
368#[non_exhaustive]
369pub enum ReplaceItemError {
370 /// No entry with this id.
371 #[error("cannot replace {0:?}: no such item in the scene")]
372 UnknownItem(ItemId),
373 /// The entry is a heavyweight `Widget`, which holds no
374 /// [`SceneItem`] box. Replace its *data* with
375 /// [`SceneModel::set_payload`](crate::SceneModel::set_payload) instead.
376 #[error(
377 "cannot replace {0:?}: it is a heavyweight widget entry, not a lightweight \
378 item. Use SceneModel::set_payload to swap a Delegated entry's data."
379 )]
380 NotLightweight(ItemId),
381}