Placing a palette component no longer edits <schema> straight away. The kit is noted as pending, its manifest is compiled so the box can be painted, and the status bar says the kit will be declared on save. The <schema> block is then reconciled when the file is written, in the one place that can report the whole kit list the file will carry, with each kit's version and checksum. What a kit's manifest depends on counts as used too, transitively, so placing a component can bring in more than one entry: hvac pulls in types, ontrolPion pulls in ontrolPlatform. New Kits > Manage Kits... dialog lists every kit in three groups -- needed by the app, declared but unused, and merely installed -- with the pinned build, the newest installed build, and an Action dropdown of every build a kit ships. The tick means the schema declares the kit: grey and fixed for kits the app needs, unticked to remove, ticked to add at the newest build. "Upgrade all kits to latest version" moves every declared kit; "Update kits" applies the ticks and the Action column, additions and removals included. Every schema change is applied as a batch, then verified: a missing manifest or a component type that stops resolving reverts the whole block and reports why, and a kit the app needs cannot be removed. Changes are one undo step, which recompiles the slot registry in both directions since a repin can change a component's slots. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
221 lines
6.5 KiB
Python
221 lines
6.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 restore_children, 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 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)
|