Files
sedona_editor/undo.py
T
ArdaandClaude Opus 5.5 b63655462f Remove redo; undo rename on a device
Redo is gone in both file and device mode: the Edit menu entry, Ctrl/Cmd+Y
and Ctrl/Cmd+Shift+Z, and the redo stack. An undone step is dropped.
Undoing past the last save now leaves the saved depth unreachable, so a
new edit cannot be mistaken for the saved state.

Renames on a device are undoable: LiveRenameCommand renames back on the
device first, and warns and clears history if the device refuses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:51:02 +03:00

362 lines
12 KiB
Python

"""Undo as a stack of reversible commands. There is no redo: an undone step is gone.
Commands own the DOM mutation in both directions -- redo() is how a command is first
applied, undo() takes it back. In live mode they also carry it to the device, and raise
LiveUndoError when it does not get there. Nothing here touches Tk: the view is
refreshed through a callback that main registers at startup, which also keeps the
import graph free of cycles (sax_file and wiresheet both push commands here).
"""
import app_state
import live_hooks
import palette
import schema
from sax_dom import remove_prop, restore_children, set_prop_value, update_meta_property
_undo_stack = []
_saved_depth = 0 # len(_undo_stack) as of the last save
_refresh_view = None
def set_refresh_callback(callback):
"""Registers how the view should be rebuilt after an undo."""
global _refresh_view
_refresh_view = callback
def _refresh():
if _refresh_view is not None:
_refresh_view()
# Undoing back to the last saved state should clear the dirty marker, not keep it lit.
if len(_undo_stack) == _saved_depth:
app_state.mark_clean()
else:
app_state.mark_dirty()
def push(command):
"""Records an already-applied command."""
global _saved_depth
# Undone past the save, the saved state can no longer be reached by undoing.
if len(_undo_stack) < _saved_depth:
_saved_depth = -1
_undo_stack.append(command)
def can_undo():
return bool(_undo_stack)
class LiveUndoError(Exception):
"""A live command's undo did not fully reach the device.
Raised with what did not happen; live_hooks.last_error holds why.
"""
def undo():
if not _undo_stack:
return None
command = _undo_stack.pop()
try:
command.undo()
except LiveUndoError as failure:
_abandon_history("Undo", failure)
return None
_refresh()
return command.label
def _abandon_history(title, failure):
"""After a step that did not reach the device, the history no longer matches it.
The device may hold part of the step, so the steps below it cannot be trusted to land
where they say. History is dropped rather than replayed against a state it never saw.
"""
reset()
_refresh()
live_hooks.warn_failure(title, f"{failure}. Undo history was cleared")
def mark_saved():
"""Pins the current stack depth as the on-disk state."""
global _saved_depth
_saved_depth = len(_undo_stack)
def reset():
"""Drops all history, for when a different file is loaded."""
global _saved_depth
_undo_stack.clear()
_saved_depth = 0
def reload_schema_registry():
"""Recompiles the slot registry after the <schema> block changed.
A failure is swallowed: undo only ever lands on a state that resolved once
already, so a missing manifest here means Sedona home moved underneath the session,
which the next action reports in its own words.
"""
palette.invalidate()
try:
schema.load_schema_kits_and_manifests()
except schema.MissingManifestError:
pass
class SchemaBlockCommand:
"""A change to the file's <schema> block, held as a before/after snapshot.
Kit entries decide which manifest describes which type, so the slot registry is
recompiled in both directions — a repinned kit can change a component's slots.
"""
def __init__(self, schema_element, before, after, label="update kits"):
# before/after: snapshots from sax_dom.snapshot_children
self.schema_element = schema_element
self.before = before
self.after = after
self.label = label
def undo(self):
restore_children(self.schema_element, self.before)
reload_schema_registry()
def redo(self):
restore_children(self.schema_element, self.after)
reload_schema_registry()
class MetaMoveCommand:
"""One or many components changing position, i.e. their packed meta x/y."""
def __init__(self, changes, label="move"):
# changes: list of (element, old_meta_val, new_x, new_y)
self.changes = changes
self.label = label
def undo(self):
for element, old_val, _, _ in self.changes:
meta_prop = element.find("prop[@name='meta']")
if meta_prop is not None and old_val is not None:
meta_prop.set("val", old_val)
def redo(self):
for element, old_val, new_x, new_y in self.changes:
update_meta_property(element, old_val, new_x, new_y)
def _meta_val(element):
meta_prop = element.find("prop[@name='meta']")
return meta_prop.get("val") if meta_prop is not None else None
class LiveMetaMoveCommand(MetaMoveCommand):
"""A move on the device: each new position is written there after the DOM takes it.
A box the device refuses goes back to where the device still has it.
"""
def undo(self):
before = [(element, _meta_val(element)) for element, *_ in self.changes]
super().undo()
self._send(before)
def redo(self):
before = [(element, _meta_val(element)) for element, *_ in self.changes]
super().redo()
self._send(before)
@staticmethod
def _send(before):
failures = []
for element, old_val in before:
new_val = _meta_val(element)
if new_val is None or new_val == old_val:
continue
if not live_hooks.live_set_prop_value(element, "meta", new_val):
failures.append(element.get("name", "?"))
if old_val is None:
remove_prop(element, "meta")
else:
set_prop_value(element, "meta", old_val)
if failures:
raise LiveUndoError(f"{', '.join(failures)} could not be moved on the device")
class SlotValueCommand:
"""One Save from the Properties dialog: every slot it changed, as one undoable step.
A change is recorded as the <prop>'s value before and after, with None standing for
"the file had no such prop". Undoing back to None removes the prop again rather than
writing the default out, so a component that only ever carried the props the file
shipped with still carries exactly those.
"""
def __init__(self, changes, label="edit properties"):
# changes: list of (element, prop_name, old_val_or_None, new_val_or_None)
self.changes = changes
self.label = label
def _apply(self, value_index):
for change in self.changes:
element, prop_name = change[0], change[1]
value = change[value_index]
if value is None:
remove_prop(element, prop_name)
else:
set_prop_value(element, prop_name, value)
def undo(self):
self._apply(2)
def redo(self):
self._apply(3)
class LiveSlotValueCommand(SlotValueCommand):
"""Slot edits on the device: each value is written there first, and only a value the
device took lands in the DOM, so the views never show what the device does not hold.
A DOM read from the device carries every slot, so a None side does not come up; one
that did would have nothing to send, and only the DOM would change.
"""
def _apply(self, value_index):
failures = []
for change in self.changes:
element, prop_name = change[0], change[1]
value = change[value_index]
if value is None:
remove_prop(element, prop_name)
continue
if not live_hooks.live_set_prop_value(element, prop_name, value):
failures.append(f"{element.get('name', '?')}.{prop_name}")
continue
set_prop_value(element, prop_name, value)
if failures:
raise LiveUndoError(f"{', '.join(failures)} could not be written to the device")
class AddComponentCommand:
"""Placing a component.
Nothing here touches <schema>. The kit a placement needs is declared when the file is
saved, from the component types actually in the DOM, so undoing a placement takes its
kit out of that reckoning without this command having to unwind anything.
"""
def __init__(self, parent, index, element, label="add"):
self.parent = parent
self.index = index
self.element = element
self.label = label
def undo(self):
if self.element in list(self.parent):
self.parent.remove(self.element)
def redo(self):
if self.element not in list(self.parent):
self.parent.insert(self.index, self.element)
class AddLinkCommand:
"""Adding a single link to the <links> section."""
def __init__(self, links_parent, link_element, label="add link"):
self.links_parent = links_parent
self.link_element = link_element
self.label = label
def undo(self):
if self.link_element in list(self.links_parent):
self.links_parent.remove(self.link_element)
def redo(self):
if self.link_element not in list(self.links_parent):
self.links_parent.append(self.link_element)
class DeleteLinkCommand:
"""Removing a single link from the <links> section."""
def __init__(self, links_parent, index, link_element, label="delete link"):
self.links_parent = links_parent
self.index = index
self.link_element = link_element
self.label = label
def undo(self):
if self.link_element not in list(self.links_parent):
self.links_parent.insert(self.index, self.link_element)
def redo(self):
if self.link_element in list(self.links_parent):
self.links_parent.remove(self.link_element)
class DeleteComponentCommand:
"""A cascading delete: the component subtree plus every link that referenced it."""
def __init__(self, parent, index, element, link_records, label="delete"):
# link_records: list of (links_parent, index, link_element)
self.parent = parent
self.index = index
self.element = element
self.link_records = link_records
self.label = label
def undo(self):
self.parent.insert(self.index, self.element)
# Ascending order so each link lands back on its original index.
for links_parent, index, link in sorted(self.link_records, key=lambda r: r[1]):
links_parent.insert(index, link)
def redo(self):
for links_parent, _, link in self.link_records:
if link in list(links_parent):
links_parent.remove(link)
if self.element in list(self.parent):
self.parent.remove(self.element)
class RenameComponentCommand:
"""A rename, plus every link whose path ran through the component's old name."""
def __init__(self, element, old_name, new_name, link_changes, label="rename"):
# link_changes: list of (link_element, attr, old_value, new_value)
self.element = element
self.old_name = old_name
self.new_name = new_name
self.link_changes = link_changes
self.label = label
def undo(self):
self.element.set("name", self.old_name)
for link, attr, old_value, _ in self.link_changes:
link.set(attr, old_value)
def redo(self):
self.element.set("name", self.new_name)
for link, attr, _, new_value in self.link_changes:
link.set(attr, new_value)
class LiveRenameCommand(RenameComponentCommand):
"""A rename on the device: the old name goes back there before the DOM takes it."""
def undo(self):
if not live_hooks.live_rename_component(self.element, self.old_name):
raise LiveUndoError(f"{self.new_name} was not renamed back to {self.old_name} "
"on the device")
super().undo()
def redo(self):
if not live_hooks.live_rename_component(self.element, self.new_name):
raise LiveUndoError(f"{self.old_name} was not renamed to {self.new_name} "
"on the device")
super().redo()