Live undo/redo: add/delete component and link, move, property edits
Undo and redo now work against a connected device; each step is sent there through Sox. Deleting a component first reads its whole subtree back (config, unloaded children, links, place among siblings) so undo can recreate it; recreated components take new device ids, which the DOM elements pick up so later steps still reach them. Binary Buf config (a user's cred) is read with read_prop, since the cache decodes every Buf as a Str. Moves, tidy layout, and Properties / page editor / user edits record only what the device accepted. A step that does not fully reach the device warns and clears the history. History is also cleared on every view change in live mode, as opening a sheet rebuilds the DOM from the device. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
"""Undo/redo as a stack of reversible commands.
|
||||
|
||||
Commands own the DOM mutation in both directions. Nothing here touches Tk: the view is
|
||||
Commands own the DOM mutation in both directions; 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
|
||||
@@ -47,11 +49,22 @@ def can_redo():
|
||||
return bool(_redo_stack)
|
||||
|
||||
|
||||
class LiveUndoError(Exception):
|
||||
"""A live command's undo or redo 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()
|
||||
command.undo()
|
||||
try:
|
||||
command.undo()
|
||||
except LiveUndoError as failure:
|
||||
_abandon_history("Undo", failure)
|
||||
return None
|
||||
_redo_stack.append(command)
|
||||
_refresh()
|
||||
return command.label
|
||||
@@ -61,12 +74,27 @@ def redo():
|
||||
if not _redo_stack:
|
||||
return None
|
||||
command = _redo_stack.pop()
|
||||
command.redo()
|
||||
try:
|
||||
command.redo()
|
||||
except LiveUndoError as failure:
|
||||
_abandon_history("Redo", failure)
|
||||
return None
|
||||
_undo_stack.append(command)
|
||||
_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 neither direction can be trusted to land
|
||||
where it says. 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
|
||||
@@ -137,6 +165,44 @@ class MetaMoveCommand:
|
||||
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.
|
||||
|
||||
@@ -167,6 +233,30 @@ class SlotValueCommand:
|
||||
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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user