Right-click a component on the wiresheet or in the tree and Properties takes
over the editor pane, listing every slot the manifest declares rather than the
handful a box paints. Config slots that are not @readonly are white and
editable; everything else is grey.
Rows are built from the slot's facets, read against SedonaDoc/niagara.html:
@trueText/@falseText, @range, @precision, @unit, and @min/@max, which bound
what Save accepts. Three facets it had wrong:
- null is a value, not an absence. control::WriteFloat pairs 'in' with a
setNull action and the sample app stores val="null" on four of them, so
null now shows, parses back, and reads as @nullText where declared.
- @min/@max on a Buf count characters. TcpDevice.ipAddr is a Buf(17) with
@max 16; a longer string is refused rather than overrunning the device.
- an int's implicit range is noise. [-2147483648 - 2147483647] said no more
than "a whole number", so only byte and short print a range they did not
declare.
meta gets a row of its own: the '>>' opens its user groups as checkboxes and
its position as x and y, since typing 822411269 into a field sets neither.
Save writes only the rows that moved, as one undoable SlotValueCommand, and
refuses the lot if any row fails its range check. An edited field paints the
view in the canvas's own dirty tint, and every way of leaving asks before
discarding.
The file name on the tree's root row goes red while anything is unwritten,
counting both a dirty document and unsaved fields. Two things this needed:
the nav tree runs on its own style, because a themed Treeview's selected
foreground beats a row tag's; and the tag is added to or removed from the row
rather than recoloured, because reconfiguring a tag does not repaint rows that
are already drawn.
Also fixes a latent hazard the panel exposed: Del and Backspace are bound
application-wide, so they now stand down while a text field has focus rather
than deleting the component behind it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
251 lines
7.5 KiB
Python
251 lines
7.5 KiB
Python
"""Undo/redo as a stack of reversible commands.
|
|
|
|
Commands own the DOM mutation in both directions. 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 palette
|
|
import schema
|
|
from sax_dom import remove_prop, restore_children, set_prop_value, update_meta_property
|
|
|
|
_undo_stack = []
|
|
_redo_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 or redo."""
|
|
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. Any redo history becomes unreachable."""
|
|
_undo_stack.append(command)
|
|
_redo_stack.clear()
|
|
|
|
|
|
def can_undo():
|
|
return bool(_undo_stack)
|
|
|
|
|
|
def can_redo():
|
|
return bool(_redo_stack)
|
|
|
|
|
|
def undo():
|
|
if not _undo_stack:
|
|
return None
|
|
command = _undo_stack.pop()
|
|
command.undo()
|
|
_redo_stack.append(command)
|
|
_refresh()
|
|
return command.label
|
|
|
|
|
|
def redo():
|
|
if not _redo_stack:
|
|
return None
|
|
command = _redo_stack.pop()
|
|
command.redo()
|
|
_undo_stack.append(command)
|
|
_refresh()
|
|
return command.label
|
|
|
|
|
|
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()
|
|
_redo_stack.clear()
|
|
_saved_depth = 0
|
|
|
|
|
|
def reload_schema_registry():
|
|
"""Recompiles the slot registry after the <schema> block changed.
|
|
|
|
A failure is swallowed: undo and redo only ever land 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)
|
|
|
|
|
|
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 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)
|