Skip to main content

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}