Skip to content

Frames, fields and views

A manager owns one transaction key, a set of roots and one history cursor. A frame records the roots and committed managed graph visible at that step. Field histories are sparse: unchanged values can be shared with earlier frames.

Choose what history owns

from yidl_undoable import UndoRedoManager, field, local_store, managed, undoable

@undoable
class Note:
    text: str = managed(default="original")
    label: str = field(default="draft")
    cache: dict = local_store(default_factory=dict)

history = UndoRedoManager()
note = Note(transaction_manager=history)
history.initialize(note)
history.begin()
note.text = "pending"
note.label = "reviewed"
note.cache["width"] = 80
history.rollback()
assert note.text == "original"
assert note.label == "reviewed" and note.cache == {"width": 80}

Only text is versioned. Unmarked annotations also use ordinary field storage. const prevents reference replacement, but does not freeze mutable contents. Lazy and local storage are shared across frames. See the field table before deciding what belongs to your logical model.

Live views follow the cursor

saved_current = note.current
with history.begin():
    note.text = "new"
    assert note.text == note.default.text == note.working.text == "new"
    assert note.current.text == "original"
assert saved_current.text == "new"
assert history.undo() and saved_current.text == "original"
View During editing Outside editing
Ordinary handle / .default Pending managed values if touched Selected committed values
.current Committed values, managed setters reject Selected committed values
.working Pending values; may thaw on first read Committed values

These are live handles, not snapshots. Keeping .current does not preserve old values. Snapshots pin committed managed data instead.

A new edit replaces redo

assert history.can_redo()
with history.begin():
    note.text = "another direction"
assert not history.can_redo()
assert history.frame_count == 2

cursor counts positions in the surviving history. frame_id is a separately allocated increasing ID; after branching it need not equal the cursor. Every successful outer commit creates a frame, including a no-op edit. There is currently no retention limit or disk-backed history.