Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

TxnId

The reversible-mutation seam: what the scene tells a data layer so that layer can reverse an edit.

What this is not

There is no stack here, no history, no command list and no undo(). Undo belongs to the data layer, and the framework's job stops at handing that layer a record complete enough to invert. Everything below is mechanism; every policy question — what counts as one undo step, whether a cancelled gesture clears the redo stack, how long history is kept — is answered on the other side of the seam.

"Complete enough to invert" is a claim with teeth, so it is worth stating exactly: every SceneEdit::Change in a record carries both sides of what it replaced, and a SceneEdit::Removed carries the removed entry itself. Nothing else is in there. Two notifications the scene emits are therefore deliberately not recorded, and ItemChange::is_edit is the test that says which:

not recordedwhy
ItemChange::VisibilityChangeda 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
ItemChange::HandlersChanged with no replacedScene::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.

Both still ride the notification channel, because a view or a cache does need to hear about them. The distinction is between telling and describing.

Two channels, because the types force it

Scene::item_change_signal carries a SceneChange — an ItemChange wrapped in an envelope that says which transaction it belongs to, whose change it was, whether it counts as history, and whether it is a per-frame artefact. That channel is cheap, synchronous and Clone, and the view reconciles from it.

It cannot carry ownership. Signal<T> snapshots its value before fanning out, so T: Clone, and a removed entry holds a Box<dyn SceneItem> or a Box<dyn Widget> — neither clonable. So a removal's contents travel a second, owning channel: the SceneTransactionRecord delivered to the edit sink. See crate::salvage for why that matters and what it carries.

A transaction is a scope, and the scope is the write scope

Every SceneModel mutator already runs inside a write scope (that is how the change fan-out escapes the RefCell borrow), and SceneWriteGuard opens one around a whole block. A transaction is the same boundary: one scope, one TxnId. So a subtree remove is one transaction with N edits without anything being wrapped by hand, and a block of edits under a write guard is one transaction because it is one scope.

SceneTransaction sits above that: it holds the scope open across several mutator calls and stamps them with a chosen ChangeSource/HistoryMode. Nesting joins — an inner transaction adds no boundary and the outer stamp wins — so an observer that opens its own transaction inside a framework-opened gesture cannot split that gesture in two.

Where the sink runs, and why its own writes are journaled

The sink is invoked with no borrow on the scene, which is what lets it read the scene and write it back. A sink that writes is not a corner case — validation, clamping and mirroring all do it — and if those writes were invisible the record the app holds would say old → new while the scene sat at something else, and a redo would replay the wrong value. So a sink write opens its own transaction like any other, its record queues behind the one being delivered, and the same delivery loop hands it over on a later round. The sink is never re-entered while it is running; it is also never taken out of its slot, so a panicking sink does not vanish.

Builder methods at a glance

as_u64, is_none

API reference

📖 Full rustdoc API for this module

pub struct TxnId

Process-unique id for one transaction.

Rides on every SceneChange emitted inside it, so a consumer watching the cheap notification channel can group a fan-out that arrived as N separate notifications — and match it to the owning SceneTransactionRecord when one is delivered. Ids count up from 1; TxnId(0) is the "no transaction yet" sentinel the change signal is seeded with and no scene mutation can ever carry.

#![allow(unused)]
fn main() {
pub struct TxnId(u64);
}

Methods

pub fn as_u64(self) -> u64

Raw value, for a consumer keying its own map on a transaction.

pub fn is_none(self) -> bool

Whether this is the sentinel the change and transaction signals hold before anything has been committed. Never true for a transaction the scene actually opened.

pub enum ChangeSource

Whose change this is.

Ambient on the open transaction rather than a parameter on every mutator: widening two dozen public signatures to thread it would be a worse API and would still miss the framework's own gesture code, which is the one caller an app cannot reach.

The framework opens ChangeSource::User transactions around exactly the places it writes the model on the user's behalf — the item-drag commit, the selection-transform commit and the Alt+arrow nudge. Without that, an app could not tell a finished drag from a programmatic move: both arrive as a bare LocalPosChanged.

#[non_exhaustive]: this crate has out-of-tree consumers.

#![allow(unused)]
fn main() {
pub enum ChangeSource { /* variants */ }
}

Variants

  • User — A direct user interaction — a drag, a nudge, a transform handle.
  • Programmatic — The app moved the scene itself: a document load, a layout pass, a data re-source, an import, an animation tick. The default, because a mutation nobody claimed is not a user's.
  • Remote — Applied on behalf of a peer — collaboration, replay, a data-layer redo. A consumer re-broadcasting or recording history is expected to skip these; the framework does not interpret it.

pub enum HistoryMode

What a history above the scene should do with this transaction.

Three-valued, matching what the editors that have solved this converged on (tldraw's history: 'record' | 'record-preserveRedoStack' | 'ignore'). The framework never interprets it. It carries it faithfully and nothing else — which is the whole point: deciding what counts as an undo step is the data layer's job, and the scene's job is to make the decision expressible.

#[non_exhaustive]: this crate has out-of-tree consumers.

#![allow(unused)]
fn main() {
pub enum HistoryMode { /* variants */ }
}

Variants

  • Record — A document edit: push an entry, clear redo. The default.
  • RecordPreserveRedo — Push an entry but leave the redo stack alone. For a change that is undoable without invalidating a redo branch.
  • Ignore — Do not push. Views still reconcile; history does not move.

pub enum TxnOutcome

How a transaction finished.

#[non_exhaustive]: this crate has out-of-tree consumers.

#![allow(unused)]
fn main() {
pub enum TxnOutcome { /* variants */ }
}

Variants

  • Committed — The normal ending: the guard dropped, or the write scope closed.
  • AbandonedSceneTransaction::abandon — a cancelled interaction. The scene is not rolled back. The framework owns no inverse-application routine and will not grow one, because applying an inverse is the first 80 % of an undo stack. The record is delivered so the consumer can revert from the old values it was handed, without pushing anything a redo could replay.

pub struct SceneChange

One ItemChange plus the context the notification channel needs in order to be usable as a change feed rather than just a repaint trigger.

This is what Scene::item_change_signal carries.

#[non_exhaustive]: consumers read it, the scene constructs it.

#![allow(unused)]
fn main() {
pub struct SceneChange { /* fields */ }
}

Methods

pub fn id(&self) -> ItemId

The item this change is about.

pub enum Salvage

The ownership an ItemChange cannot carry, attached to the removal that produced it.

#[non_exhaustive]: this crate has out-of-tree consumers.

#![allow(unused)]
fn main() {
pub enum Salvage { /* variants */ }
}

Variants

  • Owned — The framework owns the removed entry and is handing it over. Feed it back to Scene::restore to undo the removal.
  • TakenByCaller — The caller used Scene::take and already holds the salvage, so the record carries only the fact of the removal.

pub enum SceneEdit

One edit inside a SceneTransactionRecord, in application order.

Invert a transaction by walking edits in reverse and applying each old; a Removed inverts to Scene::restore.

Only a removal needs an owning variant. Every other mutator either carries its own old in the ItemChange or hands the replaced value straight back to its caller — Scene::replace_item returns the box it swapped out, so the app that called it already owns what it would need to put back.

A Change here always satisfies ItemChange::is_edit: the scene's derived notifications never reach a record. See the module header for the two and why.

#[non_exhaustive]: this crate has out-of-tree consumers.

#![allow(unused)]
fn main() {
pub enum SceneEdit { /* variants */ }
}

Variants

  • Change — Any non-removal change, verbatim from the notification channel.
  • Removed — A removal, with what it destroyed. Descendants come before the named root, the order remove announces in.

pub struct SceneTransactionRecord

One committed transaction, delivered to the edit sink with the scene unborrowed.

Owning — it carries the boxes a removal would otherwise drop — and therefore cannot travel through a Signal. That is the whole reason the seam has two channels; see the module docs.

#[non_exhaustive]: this crate has out-of-tree consumers.

#![allow(unused)]
fn main() {
pub struct SceneTransactionRecord { /* fields */ }
}

Methods

pub fn is_empty(&self) -> bool

Whether this transaction changed nothing. An empty record is never delivered to the sink — a mutator that early-returned on an unchanged value has nothing to reverse.