From b63655462f52de8f9296fca435e4fe99b0269707 Mon Sep 17 00:00:00 2001 From: Arda Aydin Date: Tue, 6 Oct 2026 20:51:02 +0300 Subject: [PATCH] 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 --- README.md | 14 +++++++++--- app_state.py | 2 +- main.py | 18 ++-------------- sax_file.py | 11 +++++----- undo.py | 61 ++++++++++++++++++++++++++-------------------------- 5 files changed, 50 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index a9f06c0..890a357 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ not inside the window. Tk always hands it to the system menu bar. | `app_state.py` | Preferences, runtime state, widget handles, `mark_dirty`/`mark_clean` | no | | `sax_dom.py` | `meta` bit-packing codec, component path resolution | no | | `schema.py` | Kit manifest loading, slot inheritance, slot visibility rules | no | -| `undo.py` | Undo/redo stack of reversible DOM commands | no | +| `undo.py` | Undo stack of reversible commands (DOM, and the device in live mode) | no | | `wiresheet.py` | Grid, component boxes, link routing and painting | yes | | `properties.py` | The Properties view: every slot of one component, read and written | yes | | `link_view.py` | The Link View: every link touching one component, drawn around it | yes | @@ -74,7 +74,16 @@ invisible to every other module. Every mutation of "the document changed" goes t (which also removes the `` entry placing it may have added) and `DeleteComponentCommand` (which restores the removed `` entries at their original indices, reinserting in ascending order so the indices stay valid) and `SlotValueCommand` -(one Save from the Properties view, however many slots it changed). Nothing in +(one Save from the Properties view, however many slots it changed). There is no redo. + +On a device the same steps are undoable, and each undo is sent there: `undo.py` has +live subclasses for moves, slot edits and renames, and `sax_file` has +`LiveComponentCommand` and `LiveLinkCommand`. Deleting a component first reads its +whole subtree back from the device (config, children, links, place among its +siblings), so undo can recreate it; the device hands out new ids, which the DOM +elements take on. An undo that does not fully reach the device warns and clears the +history, and so does every view change, since opening a sheet rebuilds the DOM from the +device. Nothing in `undo.py` touches Tk; `main` registers a refresh callback at startup, which is what keeps `sax_file` and `wiresheet` free to push commands without an import cycle. @@ -84,7 +93,6 @@ Undoing back to the depth of the last save clears the dirty tint rather than lea |---|---| | `Ctrl`/`Cmd` + `S` | Save (direct overwrite) | | `Ctrl`/`Cmd` + `Z` | Undo | -| `Ctrl`/`Cmd` + `Y` or `Ctrl`/`Cmd` + `Shift` + `Z` | Redo | | `Del` / `Backspace` | Delete selected component | | `Ctrl`/`Cmd` + `B` | Show or hide the tree/palette column | | `Ctrl` + `W` | Tidy Layout: re-flow the open sheet | diff --git a/app_state.py b/app_state.py index 67e7a5b..65c510e 100644 --- a/app_state.py +++ b/app_state.py @@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it. import configparser import os -VERSION = "0.0.0.152" +VERSION = "0.0.0.153" PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties") DEFAULT_GRID_SIZE = 10 DEFAULT_ADD_TIMESTAMP = True diff --git a/main.py b/main.py index 3ea5408..d24acc7 100644 --- a/main.py +++ b/main.py @@ -186,7 +186,7 @@ def refresh_dirty_display(): def refresh_all_views(): - """Rebuilds tree and canvas from the DOM. Registered as undo/redo's view refresh.""" + """Rebuilds tree and canvas from the DOM. Registered as undo's view refresh.""" sax_file.refresh_treeview_from_dom(app_state.xml_root_element) wiresheet.render_current_sheet() @@ -559,14 +559,6 @@ def do_undo(event=None): return "break" -def do_redo(event=None): - label = undo.redo() - if label is None: - return "break" - app_state.window.title(f"{_base_title()} — redid {label}") - return "break" - - def _base_title(): if app_state.live_mode: return app_state.base_window_title_live() @@ -943,7 +935,7 @@ def on_tree_select(event): element = app_state.tree_element_map.get(selected_id) components_to_render = [] - # Remembered so undo/redo and tidy can repaint this same sheet from the DOM. + # Remembered so undo and tidy can repaint this same sheet from the DOM. app_state.current_sheet_parent = element record_visit(VIEW_SHEET, element) @@ -2754,7 +2746,6 @@ tree_root_context_menu = tk.Menu(window, tearoff=0) SHORTCUTS = [ ("s", do_save), ("z", do_undo), - ("y", do_redo), ("b", toggle_left_column), # Zoom, in every spelling the key can arrive as: with and without Shift, and on the # numeric keypad. @@ -2774,10 +2765,6 @@ for key, handler in SHORTCUTS: window.bind_all(f"", handler) window.bind_all(f"", handler) -# Redo also as Shift-Z, the usual mac chord. -window.bind_all("", do_redo) -window.bind_all("", do_redo) - # Delete: the mac Delete key reports as BackSpace, the Windows one as Delete. window.bind_all("", delete_selected_component) window.bind_all("", delete_selected_component) @@ -2817,7 +2804,6 @@ menu_bar.add_cascade(label="File", menu=menu_file) menu_edit = tk.Menu(menu_bar, tearoff=0) menu_edit.add_command(label="Undo", command=do_undo, accelerator=f"{accel_mod}+Z") -menu_edit.add_command(label="Redo", command=do_redo, accelerator=f"{accel_mod}+Y") menu_edit.add_separator() menu_edit.add_command(label="Delete Component", command=delete_selected_component, accelerator="Del") menu_bar.add_cascade(label="Edit", menu=menu_edit) diff --git a/sax_file.py b/sax_file.py index ac1ed20..f590c43 100644 --- a/sax_file.py +++ b/sax_file.py @@ -462,11 +462,12 @@ def rename_component(comp_element, new_name): for link, attr, _, new_ref in link_changes: link.set(attr, new_ref) - if not app_state.live_mode: - undo.push(undo.RenameComponentCommand( - comp_element, old_name, new_name, link_changes, - label=f"rename {old_name} to {new_name}" - )) + command_class = (undo.LiveRenameCommand if app_state.live_mode + else undo.RenameComponentCommand) + undo.push(command_class( + comp_element, old_name, new_name, link_changes, + label=f"rename {old_name} to {new_name}" + )) app_state.mark_dirty() refresh_treeview_from_dom(root) diff --git a/undo.py b/undo.py index a53271a..ce258ec 100644 --- a/undo.py +++ b/undo.py @@ -1,7 +1,8 @@ -"""Undo/redo as a stack of reversible commands. +"""Undo as a stack of reversible commands. There is no redo: an undone step is gone. -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 +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). """ @@ -13,13 +14,12 @@ 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.""" + """Registers how the view should be rebuilt after an undo.""" global _refresh_view _refresh_view = callback @@ -36,21 +36,20 @@ def _refresh(): def push(command): - """Records an already-applied command. Any redo history becomes unreachable.""" + """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) - _redo_stack.clear() def can_undo(): return bool(_undo_stack) -def can_redo(): - return bool(_redo_stack) - - class LiveUndoError(Exception): - """A live command's undo or redo did not fully reach the device. + """A live command's undo did not fully reach the device. Raised with what did not happen; live_hooks.last_error holds why. """ @@ -65,21 +64,6 @@ def undo(): except LiveUndoError as failure: _abandon_history("Undo", failure) return None - _redo_stack.append(command) - _refresh() - return command.label - - -def redo(): - if not _redo_stack: - return None - command = _redo_stack.pop() - try: - command.redo() - except LiveUndoError as failure: - _abandon_history("Redo", failure) - return None - _undo_stack.append(command) _refresh() return command.label @@ -87,8 +71,8 @@ def redo(): 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. + 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() @@ -105,14 +89,13 @@ 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 block changed. - A failure is swallowed: undo and redo only ever land on a state that resolved once + 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. """ @@ -360,3 +343,19 @@ class RenameComponentCommand: 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()