Skip to main content

teksilo_scene/
journal.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The **reversible-mutation seam**: what the scene tells a data layer so that
5//! layer can reverse an edit.
6//!
7//! # What this is not
8//!
9//! There is no stack here, no history, no command list and no `undo()`. Undo
10//! belongs to the data layer, and the framework's job stops at handing that
11//! layer a record complete enough to invert. Everything below is *mechanism*;
12//! every policy question — what counts as one undo step, whether a cancelled
13//! gesture clears the redo stack, how long history is kept — is answered on the
14//! other side of the seam.
15//!
16//! "Complete enough to invert" is a claim with teeth, so it is worth stating
17//! exactly: **every [`SceneEdit::Change`] in a record carries both sides of
18//! what it replaced**, and a [`SceneEdit::Removed`] carries the removed entry
19//! itself. Nothing else is in there. Two notifications the scene emits are
20//! therefore deliberately *not* recorded, and
21//! [`ItemChange::is_edit`](crate::ItemChange::is_edit) is the test that says
22//! which:
23//!
24//! | not recorded | why |
25//! | --- | --- |
26//! | [`ItemChange::VisibilityChanged`] | a convenience beside the `FlagsChanged` that describes the same mutation — recording both would make hiding a card two edits, and only through one of the two flag doors |
27//! | [`ItemChange::HandlersChanged`] with no `replaced` | `Scene::handlers_mut` hands out a `&mut` and is told nothing about what the caller does with it, so it can describe the change to no one. `Scene::set_item_handlers` knows both sides and is recorded. |
28//!
29//! Both still ride the notification channel, because a view or a cache does
30//! need to hear about them. The distinction is between *telling* and
31//! *describing*.
32//!
33//! # Two channels, because the types force it
34//!
35//! [`Scene::item_change_signal`](crate::Scene::item_change_signal) carries a
36//! [`SceneChange`] — an [`ItemChange`] wrapped in an envelope that says *which
37//! transaction* it belongs to, *whose* change it was, *whether it counts as
38//! history*, and *whether it is a per-frame artefact*. That channel is cheap,
39//! synchronous and `Clone`, and the view reconciles from it.
40//!
41//! It cannot carry ownership. `Signal<T>` snapshots its value before fanning
42//! out, so `T: Clone`, and a removed entry holds a `Box<dyn SceneItem>` or a
43//! `Box<dyn Widget>` — neither clonable. So a removal's *contents* travel a
44//! second, owning channel: the [`SceneTransactionRecord`] delivered to the edit
45//! sink. See [`crate::salvage`] for why that matters and what it carries.
46//!
47//! # A transaction is a scope, and the scope is the write scope
48//!
49//! Every `SceneModel` mutator already runs inside a write scope (that is how
50//! the change fan-out escapes the `RefCell` borrow), and
51//! [`SceneWriteGuard`](crate::SceneWriteGuard) opens one around a whole block.
52//! A transaction is the same boundary: one scope, one [`TxnId`]. So a subtree
53//! `remove` is one transaction with N edits without anything being wrapped by
54//! hand, and a block of edits under a write guard is one transaction because it
55//! is one scope.
56//!
57//! [`SceneTransaction`](crate::SceneTransaction) sits *above* that: it holds the scope open across
58//! several mutator calls and stamps them with a chosen
59//! [`ChangeSource`]/[`HistoryMode`]. Nesting **joins** — an inner transaction
60//! adds no boundary and the outer stamp wins — so an observer that opens its
61//! own transaction inside a framework-opened gesture cannot split that gesture
62//! in two.
63//!
64//! # Where the sink runs, and why its own writes are journaled
65//!
66//! The sink is invoked with **no borrow on the scene**, which is what lets it
67//! read the scene and write it back. A sink that writes is not a corner case —
68//! validation, clamping and mirroring all do it — and if those writes were
69//! invisible the record the app holds would say `old → new` while the scene sat
70//! at something else, and a redo would replay the wrong value. So a sink write
71//! opens its own transaction like any other, its record queues behind the one
72//! being delivered, and the same delivery loop hands it over on a later round.
73//! The sink is never re-entered while it is running; it is also never taken out
74//! of its slot, so a panicking sink does not vanish.
75
76use std::cell::{Cell, RefCell};
77use std::collections::VecDeque;
78use std::rc::Rc;
79
80use teksilo_core::signal::Signal;
81
82use crate::item::ItemId;
83use crate::salvage::RemovedItem;
84use crate::scene::{CascadeBudget, ItemChange};
85
86/// Process-unique id for one transaction.
87///
88/// Rides on every [`SceneChange`] emitted inside it, so a consumer watching the
89/// cheap notification channel can group a fan-out that arrived as N separate
90/// notifications — and match it to the owning [`SceneTransactionRecord`] when
91/// one is delivered.
92/// Ids count up from 1; `TxnId(0)` is the "no transaction yet" sentinel the
93/// change signal is seeded with and no scene mutation can ever carry.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
95pub struct TxnId(u64);
96
97impl TxnId {
98    /// Raw value, for a consumer keying its own map on a transaction.
99    pub fn as_u64(self) -> u64 {
100        self.0
101    }
102
103    /// Whether this is the sentinel the change and transaction signals hold
104    /// before anything has been committed. Never `true` for a transaction the
105    /// scene actually opened.
106    pub fn is_none(self) -> bool {
107        self.0 == 0
108    }
109}
110
111/// *Whose* change this is.
112///
113/// Ambient on the open transaction rather than a parameter on every mutator:
114/// widening two dozen public signatures to thread it would be a worse API and
115/// would still miss the framework's own gesture code, which is the one caller
116/// an app cannot reach.
117///
118/// The framework opens [`ChangeSource::User`] transactions around exactly the
119/// places it writes the model on the user's behalf — the item-drag commit, the
120/// selection-transform commit and the `Alt`+arrow nudge. Without that, an app
121/// could not tell a finished drag from a programmatic move: both arrive as a
122/// bare `LocalPosChanged`.
123///
124/// `#[non_exhaustive]`: this crate has out-of-tree consumers.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
126#[non_exhaustive]
127pub enum ChangeSource {
128    /// A direct user interaction — a drag, a nudge, a transform handle.
129    User,
130    /// The app moved the scene itself: a document load, a layout pass, a data
131    /// re-source, an import, an animation tick. The default, because a mutation
132    /// nobody claimed is not a user's.
133    #[default]
134    Programmatic,
135    /// Applied on behalf of a peer — collaboration, replay, a data-layer redo.
136    /// A consumer re-broadcasting or recording history is expected to skip
137    /// these; the framework does not interpret it.
138    Remote,
139}
140
141/// What a history *above* the scene should do with this transaction.
142///
143/// Three-valued, matching what the editors that have solved this converged on
144/// (tldraw's `history: 'record' | 'record-preserveRedoStack' | 'ignore'`). The
145/// framework never interprets it. It carries it faithfully and nothing else —
146/// which is the whole point: deciding what counts as an undo step is the data
147/// layer's job, and the scene's job is to make the decision expressible.
148///
149/// `#[non_exhaustive]`: this crate has out-of-tree consumers.
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
151#[non_exhaustive]
152pub enum HistoryMode {
153    /// A document edit: push an entry, clear redo. The default.
154    #[default]
155    Record,
156    /// Push an entry but leave the redo stack alone. For a change that is
157    /// undoable without invalidating a redo branch.
158    RecordPreserveRedo,
159    /// Do not push. Views still reconcile; history does not move.
160    Ignore,
161}
162
163/// How a transaction finished.
164///
165/// `#[non_exhaustive]`: this crate has out-of-tree consumers.
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
167#[non_exhaustive]
168pub enum TxnOutcome {
169    /// The normal ending: the guard dropped, or the write scope closed.
170    #[default]
171    Committed,
172    /// [`SceneTransaction::abandon`](crate::SceneTransaction::abandon) — a cancelled interaction.
173    ///
174    /// The scene is **not** rolled back. The framework owns no
175    /// inverse-application routine and will not grow one, because applying an
176    /// inverse is the first 80 % of an undo stack. The record is delivered so
177    /// the consumer can revert from the `old` values it was handed, without
178    /// pushing anything a redo could replay.
179    Abandoned,
180}
181
182/// One [`ItemChange`] plus the context the notification channel needs in order
183/// to be usable as a change feed rather than just a repaint trigger.
184///
185/// This is what
186/// [`Scene::item_change_signal`](crate::Scene::item_change_signal) carries.
187///
188/// `#[non_exhaustive]`: consumers read it, the scene constructs it.
189#[derive(Debug, Clone)]
190#[non_exhaustive]
191pub struct SceneChange {
192    /// The transaction this change belongs to. Every change emitted inside one
193    /// write scope — or one [`SceneTransaction`](crate::SceneTransaction) — shares it.
194    pub txn: TxnId,
195    /// Whose change it is. See [`ChangeSource`].
196    pub source: ChangeSource,
197    /// What a history above the scene should do with it. See [`HistoryMode`].
198    pub history: HistoryMode,
199    /// A change that must be **rendered** but must not be **recorded**.
200    ///
201    /// Set for per-frame model churn that is an artefact of animation rather
202    /// than an edit: the scene's own
203    /// [`refresh_dynamic_bounds`](crate::Scene::refresh_dynamic_bounds), which
204    /// re-reads every signal-driven AABB each build and so emits a
205    /// `LocalBoundsChanged` per frame for an animating item. An app driving its
206    /// own per-frame stream — an item whose `transform` follows a rotation
207    /// signal — marks it the same way with
208    /// [`SceneTransaction::ephemeral`](crate::SceneTransaction::ephemeral), so the knob is not one-sided across the
209    /// seam.
210    pub ephemeral: bool,
211    /// The change itself.
212    pub change: ItemChange,
213}
214
215impl SceneChange {
216    /// The item this change is about.
217    pub fn id(&self) -> ItemId {
218        self.change.id()
219    }
220
221    /// A default envelope around `change`, for a test that drives
222    /// `item_change_signal` directly rather than through a mutator.
223    ///
224    /// Test-only: a `SceneChange` a consumer receives is always stamped by the
225    /// transaction it came from, and a public constructor would let one be
226    /// forged with a transaction id no transaction ever had.
227    #[cfg(test)]
228    pub(crate) fn for_test(change: ItemChange) -> Self {
229        Self {
230            txn: TxnId::default(),
231            source: ChangeSource::default(),
232            history: HistoryMode::default(),
233            ephemeral: false,
234            change,
235        }
236    }
237}
238
239/// The ownership an [`ItemChange`] cannot carry, attached to the removal that
240/// produced it.
241///
242/// `#[non_exhaustive]`: this crate has out-of-tree consumers.
243#[derive(Debug)]
244#[non_exhaustive]
245pub enum Salvage {
246    /// The framework owns the removed entry and is handing it over. Feed it
247    /// back to [`Scene::restore`](crate::Scene::restore) to undo the removal.
248    Owned(Box<RemovedItem>),
249    /// The caller used [`Scene::take`](crate::Scene::take) and already holds
250    /// the salvage, so the record carries only the fact of the removal.
251    TakenByCaller,
252}
253
254/// One edit inside a [`SceneTransactionRecord`], in application order.
255///
256/// Invert a transaction by walking `edits` in **reverse** and applying each
257/// `old`; a `Removed` inverts to
258/// [`Scene::restore`](crate::Scene::restore).
259///
260/// Only a removal needs an owning variant. Every other mutator either carries
261/// its own `old` in the [`ItemChange`] or hands the replaced value straight
262/// back to its caller —
263/// [`Scene::replace_item`](crate::Scene::replace_item) returns the box it
264/// swapped out, so the app that called it already owns what it would need to
265/// put back.
266///
267/// A [`Change`](Self::Change) here always satisfies
268/// [`ItemChange::is_edit`](crate::ItemChange::is_edit): the scene's derived
269/// notifications never reach a record. See the module header for the three
270/// and why.
271///
272/// `#[non_exhaustive]`: this crate has out-of-tree consumers.
273#[derive(Debug)]
274#[non_exhaustive]
275pub enum SceneEdit {
276    /// Any non-removal change, verbatim from the notification channel.
277    Change(ItemChange),
278    /// A removal, with what it destroyed. Descendants come before the named
279    /// root, the order `remove` announces in.
280    Removed {
281        /// The removed item.
282        id: ItemId,
283        /// Its contents, or a note that the caller kept them.
284        salvage: Salvage,
285    },
286}
287
288/// One committed transaction, delivered to the edit sink with the scene
289/// unborrowed.
290///
291/// Owning — it carries the boxes a removal would otherwise drop — and therefore
292/// cannot travel through a `Signal`. That is the whole reason the seam has two
293/// channels; see the module docs.
294///
295/// `#[non_exhaustive]`: this crate has out-of-tree consumers.
296#[derive(Debug)]
297#[non_exhaustive]
298pub struct SceneTransactionRecord {
299    /// Matches the `txn` on every [`SceneChange`] this transaction emitted.
300    pub txn: TxnId,
301    /// Whose change it was.
302    pub source: ChangeSource,
303    /// What a history above the scene should do with it.
304    pub history: HistoryMode,
305    /// How it finished.
306    pub outcome: TxnOutcome,
307    /// `true` iff **every** edit in it was emitted while
308    /// [`SceneTransaction::ephemeral`](crate::SceneTransaction::ephemeral) (or the scene's own dynamic-bounds
309    /// refresh) was in effect. Never `true` for an empty record.
310    pub ephemeral: bool,
311    /// The edits, in application order.
312    pub edits: Vec<SceneEdit>,
313}
314
315impl SceneTransactionRecord {
316    /// Whether this transaction changed nothing. An empty record is never
317    /// delivered to the sink — a mutator that early-returned on an unchanged
318    /// value has nothing to reverse.
319    pub fn is_empty(&self) -> bool {
320        self.edits.is_empty()
321    }
322}
323
324/// The one transaction currently accumulating.
325struct OpenTxn {
326    id: TxnId,
327    source: ChangeSource,
328    history: HistoryMode,
329    outcome: TxnOutcome,
330    squash: bool,
331    /// AND of `ephemeral` over every edit recorded so far; meaningless (and
332    /// forced to `false`) when no edit was recorded.
333    all_ephemeral: bool,
334    edits: Vec<SceneEdit>,
335}
336
337/// RAII clear of [`EditJournal::delivering`], so a sink that panics cannot
338/// leave the journal permanently silent. Holds the `Rc` directly, so `Drop`
339/// never needs the scene's `RefCell`.
340struct DeliverGuard(Rc<EditJournal>);
341
342impl Drop for DeliverGuard {
343    fn drop(&mut self) {
344        self.0.delivering.set(false);
345    }
346}
347
348/// The scene's transaction state machine, its edit sink and the queue of
349/// records awaiting delivery.
350///
351/// Lives behind an `Rc` **beside** the `Scene` (like the change queue), so a
352/// delivery runs holding no borrow on the scene at all — which is what lets the
353/// sink read and write it.
354pub(crate) struct EditJournal {
355    next_id: Cell<u64>,
356    /// Open write scopes / transactions. The transaction commits when this
357    /// reaches zero.
358    depth: Cell<u32>,
359    /// Emissions made while this is non-zero are tagged `ephemeral`.
360    ephemeral_depth: Cell<u32>,
361    open: RefCell<Option<OpenTxn>>,
362    sink: RefCell<Option<Box<dyn FnMut(SceneTransactionRecord)>>>,
363    records: RefCell<VecDeque<SceneTransactionRecord>>,
364    delivering: Cell<bool>,
365    txn_signal: Signal<TxnId>,
366}
367
368impl std::fmt::Debug for EditJournal {
369    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
370        f.debug_struct("EditJournal")
371            .field("depth", &self.depth.get())
372            .field("queued_records", &self.records.borrow().len())
373            .field(
374                "has_sink",
375                &self.sink.try_borrow().map(|s| s.is_some()).ok(),
376            )
377            .field("delivering", &self.delivering.get())
378            .finish()
379    }
380}
381
382impl EditJournal {
383    pub(crate) fn new() -> Self {
384        Self {
385            next_id: Cell::new(1),
386            depth: Cell::new(0),
387            ephemeral_depth: Cell::new(0),
388            open: RefCell::new(None),
389            sink: RefCell::new(None),
390            records: RefCell::new(VecDeque::new()),
391            delivering: Cell::new(false),
392            txn_signal: Signal::new(TxnId(0)),
393        }
394    }
395
396    pub(crate) fn txn_signal(&self) -> Signal<TxnId> {
397        self.txn_signal.clone()
398    }
399
400    /// Whether a sink is installed — i.e. whether anything would read the
401    /// owning record. With none, removals drop their salvage exactly as they
402    /// always did, and no edit vector is ever built.
403    pub(crate) fn recording(&self) -> bool {
404        self.sink.try_borrow().map(|s| s.is_some()).unwrap_or(true)
405    }
406
407    /// Install the edit sink, returning the previous one.
408    pub(crate) fn set_sink(
409        &self,
410        sink: Option<Box<dyn FnMut(SceneTransactionRecord)>>,
411    ) -> Option<Box<dyn FnMut(SceneTransactionRecord)>> {
412        let mut slot = self.sink.try_borrow_mut().expect(
413            "SceneModel::set_edit_sink from inside the edit sink itself. The sink is \
414             held for the duration of its call; replace it from outside one.",
415        );
416        std::mem::replace(&mut *slot, sink)
417    }
418
419    /// Open a scope. The first one starts a transaction stamped
420    /// `source`/`history`; deeper ones join it and their stamp is ignored.
421    pub(crate) fn enter_scope(&self, source: ChangeSource, history: HistoryMode) {
422        let depth = self.depth.get();
423        self.depth.set(depth + 1);
424        if depth == 0 {
425            let id = TxnId(self.next_id.get());
426            self.next_id.set(self.next_id.get().wrapping_add(1));
427            *self.open.borrow_mut() = Some(OpenTxn {
428                id,
429                source,
430                history,
431                outcome: TxnOutcome::Committed,
432                squash: false,
433                all_ephemeral: true,
434                edits: Vec::new(),
435            });
436        }
437    }
438
439    /// Close a scope. At depth zero the transaction commits: its record is
440    /// built and queued, and the caller delivers it once the scene's borrow is
441    /// free.
442    pub(crate) fn exit_scope(&self) {
443        let depth = self.depth.get();
444        debug_assert!(depth > 0, "EditJournal scope underflow");
445        let depth = depth.saturating_sub(1);
446        self.depth.set(depth);
447        if depth > 0 {
448            return;
449        }
450        let Some(mut txn) = self.open.borrow_mut().take() else {
451            return;
452        };
453        if txn.edits.is_empty() {
454            // Nothing happened — a mutator that early-returned on an unchanged
455            // value. Delivering an empty record would make a snap-to-grid sink
456            // that writes only when it has something to correct loop forever
457            // against its own no-op.
458            return;
459        }
460        if txn.squash {
461            squash_edits(&mut txn.edits);
462        }
463        self.records.borrow_mut().push_back(SceneTransactionRecord {
464            txn: txn.id,
465            source: txn.source,
466            history: txn.history,
467            outcome: txn.outcome,
468            ephemeral: txn.all_ephemeral,
469            edits: txn.edits,
470        });
471    }
472
473    /// The stamp for a change emitted right now. Mints a standalone
474    /// transaction when nothing is open, which is the bare-`&mut Scene` door
475    /// (no write scope, so no scope to be the boundary).
476    pub(crate) fn stamp(&self) -> (TxnId, ChangeSource, HistoryMode, bool) {
477        let ephemeral = self.ephemeral_depth.get() > 0;
478        if let Some(open) = self.open.borrow().as_ref() {
479            return (open.id, open.source, open.history, ephemeral);
480        }
481        let id = TxnId(self.next_id.get());
482        self.next_id.set(self.next_id.get().wrapping_add(1));
483        (
484            id,
485            ChangeSource::default(),
486            HistoryMode::default(),
487            ephemeral,
488        )
489    }
490
491    /// Record one edit against the open transaction. Silently does nothing
492    /// when no sink is installed (nothing would read it) or when no
493    /// transaction is open (the bare-`&mut Scene` door with no scope: the
494    /// change is still announced, it is just not accumulated).
495    pub(crate) fn record(&self, edit: SceneEdit) {
496        if !self.recording() {
497            return;
498        }
499        if let Some(open) = self.open.borrow_mut().as_mut() {
500            open.all_ephemeral &= self.ephemeral_depth.get() > 0;
501            open.edits.push(edit);
502        }
503    }
504
505    /// Mark the open transaction cancelled. No-op with none open.
506    pub(crate) fn set_outcome(&self, outcome: TxnOutcome) {
507        if let Some(open) = self.open.borrow_mut().as_mut() {
508            open.outcome = outcome;
509        }
510    }
511
512    /// Ask the open transaction to coalesce adjacent same-subject continuous
513    /// edits when it commits.
514    pub(crate) fn request_squash(&self) {
515        if let Some(open) = self.open.borrow_mut().as_mut() {
516            open.squash = true;
517        }
518    }
519
520    pub(crate) fn enter_ephemeral(&self) {
521        self.ephemeral_depth.set(self.ephemeral_depth.get() + 1);
522    }
523
524    pub(crate) fn exit_ephemeral(&self) {
525        self.ephemeral_depth
526            .set(self.ephemeral_depth.get().saturating_sub(1));
527    }
528
529    /// How many scopes are open — a write scope, a `SceneWriteGuard`, or a
530    /// [`SceneTransaction`](crate::SceneTransaction) each raise it by one.
531    pub(crate) fn depth(&self) -> u32 {
532        self.depth.get()
533    }
534
535    /// Whether any record is waiting to be handed to the sink.
536    pub(crate) fn has_records(&self) -> bool {
537        !self.records.borrow().is_empty()
538    }
539
540    /// Hand every queued record to the sink, in commit order, until the queue
541    /// is empty.
542    ///
543    /// Holds no borrow on the scene, so the sink may read it and write it back.
544    /// A sink write commits its own transaction, which queues behind whatever
545    /// is left here and is delivered by this same loop — so the sink's own
546    /// edits are journaled rather than silently escaping the history it is
547    /// feeding.
548    ///
549    /// Re-entered from inside the sink (a sink write ends up here again), it
550    /// returns immediately and lets the outermost loop pick the new record up.
551    ///
552    /// # Termination
553    ///
554    /// The first round is the caller's own batch, finite by construction and
555    /// free. From the second on, every delivery is one the *sink* caused, and
556    /// is counted against `budget.total` — the same flat bound the change
557    /// fan-out uses, for the same reason: an iterative loop with no bound is a
558    /// frozen UI thread with no diagnostic. An empty record is never queued, so
559    /// a sink that only writes when it has a correction to make settles on its
560    /// own.
561    pub(crate) fn deliver(self: &Rc<Self>, budget: CascadeBudget) {
562        if self.delivering.get() {
563            return;
564        }
565        if self.records.borrow().is_empty() {
566            return;
567        }
568        self.delivering.set(true);
569        let _guard = DeliverGuard(Rc::clone(self));
570
571        let mut charged: u64 = 0;
572        let mut cascading = false;
573        loop {
574            let mut remaining = self.records.borrow().len();
575            if remaining == 0 {
576                break;
577            }
578            while remaining > 0 {
579                // Popped before delivery and never buffered locally: a sink
580                // that panics must cost exactly its own record, leaving the
581                // rest of the queue intact for the next flush.
582                let next = self.records.borrow_mut().pop_front();
583                let Some(record) = next else { break };
584                remaining -= 1;
585                if cascading {
586                    charged += 1;
587                    if charged > budget.total {
588                        runaway_sink(charged, budget.total, &record);
589                    }
590                }
591                let txn = record.txn;
592                // Borrowed, not taken: take-call-put-back loses the sink on a
593                // panic, and — worse — runs the sink's own writes with no sink
594                // installed, so a sink that corrects a value would leave the
595                // record the app holds disagreeing with the scene. Borrowing
596                // keeps it installed; `delivering` is what stops re-entry.
597                if let Ok(mut slot) = self.sink.try_borrow_mut()
598                    && let Some(sink) = slot.as_mut()
599                {
600                    sink(record);
601                }
602                // After the sink, still unborrowed. This is the door a
603                // per-gesture reaction (persistence, validation, a document
604                // dirty flag) belongs on — once per transaction, not once per
605                // intermediate write.
606                self.txn_signal.set(txn);
607            }
608            cascading = true;
609        }
610    }
611}
612
613/// RAII raise of the journal's ephemeral depth.
614///
615/// Used by the scene's own per-frame dynamic-bounds refresh and by
616/// [`SceneTransaction::ephemeral`](crate::SceneTransaction::ephemeral). A guard rather than a pair of calls so an
617/// early return — `refresh_dynamic_bounds` has several — cannot leave the whole
618/// scene permanently tagged "do not record".
619pub(crate) struct EphemeralScope(Rc<EditJournal>);
620
621impl EphemeralScope {
622    pub(crate) fn new(journal: &Rc<EditJournal>) -> Self {
623        journal.enter_ephemeral();
624        Self(Rc::clone(journal))
625    }
626}
627
628impl Drop for EphemeralScope {
629    fn drop(&mut self) {
630        self.0.exit_ephemeral();
631    }
632}
633
634/// RAII transaction scope for a mutator that emits many changes from one call.
635///
636/// [`Scene::take`](crate::Scene::take) is the only one, and it needs the guard
637/// rather than a matching pair of calls because it has several exits. Stamped
638/// with the defaults, so through a `SceneModel` — where a write scope is always
639/// already open — it nests and the outer stamp wins.
640pub(crate) struct RemovalScope(Rc<EditJournal>);
641
642impl RemovalScope {
643    pub(crate) fn new(journal: &Rc<EditJournal>) -> Self {
644        journal.enter_scope(ChangeSource::default(), HistoryMode::default());
645        Self(Rc::clone(journal))
646    }
647}
648
649impl Drop for RemovalScope {
650    fn drop(&mut self) {
651        self.0.exit_scope();
652    }
653}
654
655/// Panic for an edit sink that keeps producing transactions.
656#[cold]
657#[inline(never)]
658fn runaway_sink(delivered: u64, limit: u64, record: &SceneTransactionRecord) -> ! {
659    panic!(
660        "scene edit sink did not settle: the sink installed by \
661         `SceneModel::set_edit_sink` has produced {delivered} further transactions \
662         from one delivery without the queue emptying, past this scene's budget of \
663         {limit} (`CascadeBudget::total`). The last record was {record:?}. A sink is \
664         allowed to write the scene — that is what it is for — but its write must \
665         converge: write only when there is something to correct, and compare before \
666         you write. A transaction that changes nothing produces no record, so a \
667         converging sink stops on its own. If the cascade is genuinely this large and \
668         does terminate, raise `CascadeBudget::total` via \
669         `SceneModel::set_cascade_budget`."
670    )
671}
672
673/// Which continuous-value edits coalesce, keyed so two different fields of one
674/// item never collapse into each other.
675#[derive(PartialEq, Eq, Hash, Clone, Copy)]
676enum SquashKey {
677    LocalPos(ItemId),
678    LocalBounds(ItemId),
679    Transform(ItemId),
680    Opacity(ItemId),
681    Z(ItemId),
682    Placement(ItemId),
683}
684
685fn squash_key(change: &ItemChange) -> Option<SquashKey> {
686    Some(match *change {
687        ItemChange::LocalPosChanged { id, .. } => SquashKey::LocalPos(id),
688        ItemChange::LocalBoundsChanged { id, .. } => SquashKey::LocalBounds(id),
689        ItemChange::TransformChanged { id, .. } => SquashKey::Transform(id),
690        ItemChange::OpacityChanged { id, .. } => SquashKey::Opacity(id),
691        ItemChange::ZChanged { id, .. } => SquashKey::Z(id),
692        ItemChange::PlacementChanged { id, .. } => SquashKey::Placement(id),
693        _ => return None,
694    })
695}
696
697/// Fold a transaction's repeated writes to one continuous quantity into a
698/// single edit keeping the **first** `old` and the **last** `new`.
699///
700/// Only the six continuous quantities above, and only when the transaction
701/// asked for it ([`SceneTransaction::squash`](crate::SceneTransaction::squash)) — a coalesced record has lost
702/// the intermediate path, which a replay or presence consumer needs, and losing
703/// it silently by default would be exactly the kind of quiet data loss this
704/// seam exists to stop. Every other edit kind passes through untouched, in
705/// order.
706fn squash_edits(edits: &mut Vec<SceneEdit>) {
707    use std::collections::HashMap;
708    let mut first_seen: HashMap<SquashKey, usize> = HashMap::new();
709    let mut out: Vec<SceneEdit> = Vec::with_capacity(edits.len());
710    for edit in edits.drain(..) {
711        let SceneEdit::Change(change) = &edit else {
712            out.push(edit);
713            continue;
714        };
715        let Some(key) = squash_key(change) else {
716            out.push(edit);
717            continue;
718        };
719        if let Some(&at) = first_seen.get(&key) {
720            let SceneEdit::Change(kept) = &mut out[at] else {
721                unreachable!("only Change edits are indexed by squash_key")
722            };
723            let SceneEdit::Change(newer) = edit else {
724                unreachable!("matched as a Change above")
725            };
726            merge_new(kept, newer);
727        } else {
728            first_seen.insert(key, out.len());
729            out.push(edit);
730        }
731    }
732    *edits = out;
733}
734
735/// Overwrite `kept`'s `new` with `newer`'s, keeping `kept`'s `old`. Both are
736/// the same variant about the same item, guaranteed by the [`SquashKey`] match.
737fn merge_new(kept: &mut ItemChange, newer: ItemChange) {
738    match (kept, newer) {
739        (
740            ItemChange::LocalPosChanged { new: keep, .. },
741            ItemChange::LocalPosChanged { new: last, .. },
742        ) => *keep = last,
743        (
744            ItemChange::LocalBoundsChanged { new: keep, .. },
745            ItemChange::LocalBoundsChanged { new: last, .. },
746        ) => *keep = last,
747        (
748            ItemChange::TransformChanged { new: keep, .. },
749            ItemChange::TransformChanged { new: last, .. },
750        ) => *keep = last,
751        (
752            ItemChange::OpacityChanged { new: keep, .. },
753            ItemChange::OpacityChanged { new: last, .. },
754        ) => *keep = last,
755        (ItemChange::ZChanged { new: keep, .. }, ItemChange::ZChanged { new: last, .. }) => {
756            *keep = last
757        }
758        (
759            ItemChange::PlacementChanged { new: keep, .. },
760            ItemChange::PlacementChanged { new: last, .. },
761        ) => *keep = last,
762        _ => unreachable!("squash_key pairs identical variants about one item"),
763    }
764}
765
766#[cfg(test)]
767mod tests {
768    use super::*;
769    use crate::scene::Placement;
770    use teksilo_canvas::{Point, Transform2D};
771
772    fn moved(id: ItemId, from: f32, to: f32) -> SceneEdit {
773        SceneEdit::Change(ItemChange::LocalPosChanged {
774            id,
775            old: Point::new(from, 0.0),
776            new: Point::new(to, 0.0),
777        })
778    }
779
780    fn id(n: u64) -> ItemId {
781        // `ItemId::next` is crate-private and monotonic; minting two here keeps
782        // the test independent of how many ids other tests have consumed.
783        let _ = n;
784        ItemId::next()
785    }
786
787    #[test]
788    fn squash_keeps_the_first_old_and_the_last_new() {
789        let a = id(1);
790        let mut edits = vec![moved(a, 0.0, 1.0), moved(a, 1.0, 2.0), moved(a, 2.0, 7.0)];
791        squash_edits(&mut edits);
792        assert_eq!(edits.len(), 1);
793        let SceneEdit::Change(ItemChange::LocalPosChanged { old, new, .. }) = &edits[0] else {
794            panic!("expected one LocalPosChanged")
795        };
796        assert_eq!(*old, Point::new(0.0, 0.0));
797        assert_eq!(*new, Point::new(7.0, 0.0));
798    }
799
800    #[test]
801    fn squash_never_merges_two_items_or_two_fields() {
802        let a = id(1);
803        let b = id(2);
804        let mut edits = vec![
805            moved(a, 0.0, 1.0),
806            moved(b, 0.0, 5.0),
807            SceneEdit::Change(ItemChange::ZChanged {
808                id: a,
809                old: 0.0,
810                new: 1.0,
811            }),
812            moved(a, 1.0, 3.0),
813        ];
814        squash_edits(&mut edits);
815        // a's two moves fold; b's move and a's z stay distinct.
816        assert_eq!(edits.len(), 3);
817    }
818
819    #[test]
820    fn squash_leaves_discrete_edits_alone_and_in_order() {
821        let a = id(1);
822        let mut edits = vec![
823            SceneEdit::Change(ItemChange::Added { id: a }),
824            moved(a, 0.0, 1.0),
825            SceneEdit::Removed {
826                id: a,
827                salvage: Salvage::TakenByCaller,
828            },
829            moved(a, 1.0, 2.0),
830        ];
831        squash_edits(&mut edits);
832        // The two moves fold into the first; Added and Removed keep their slots
833        // in order, because collapsing structure would reorder the history.
834        assert_eq!(edits.len(), 3);
835        assert!(matches!(
836            edits[0],
837            SceneEdit::Change(ItemChange::Added { .. })
838        ));
839        assert!(matches!(
840            edits[1],
841            SceneEdit::Change(ItemChange::LocalPosChanged { .. })
842        ));
843        assert!(matches!(edits[2], SceneEdit::Removed { .. }));
844    }
845
846    #[test]
847    fn squash_folds_placement_writes() {
848        let a = id(1);
849        let p = |x: f32| Placement {
850            parent: None,
851            z: 0.0,
852            local_pos: Point::new(x, 0.0),
853            transform: Transform2D::identity(),
854        };
855        let mut edits = vec![
856            SceneEdit::Change(ItemChange::PlacementChanged {
857                id: a,
858                old: p(0.0),
859                new: p(1.0),
860            }),
861            SceneEdit::Change(ItemChange::PlacementChanged {
862                id: a,
863                old: p(1.0),
864                new: p(9.0),
865            }),
866        ];
867        squash_edits(&mut edits);
868        assert_eq!(edits.len(), 1);
869        let SceneEdit::Change(ItemChange::PlacementChanged { old, new, .. }) = &edits[0] else {
870            panic!("expected one PlacementChanged")
871        };
872        assert_eq!(old.local_pos.x, 0.0);
873        assert_eq!(new.local_pos.x, 9.0);
874    }
875}