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}