diff --git a/app_state.py b/app_state.py index 9c9c6ba..7ad9bfb 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.149" +VERSION = "0.0.0.150" 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/live_hooks.py b/live_hooks.py index fd168fc..1cc00be 100644 --- a/live_hooks.py +++ b/live_hooks.py @@ -10,7 +10,7 @@ import binascii from tkinter import messagebox import app_state -from sox import BUF_ID, type_id_for_slot +from sox import BUF_ID, CONFIG, STR_ID, type_id_for_slot # Why the last live_* call failed, in words the user can act on. Every failing call sets @@ -41,8 +41,8 @@ def _session(): def warn_failure(title, what, detail=None): """Tells the user, in a dialog and the status bar, that `what` did not reach the device. - `detail` defaults to last_error. Live mode has no undo, so the dialog is the only - record the user gets that the editor and the device may now disagree. + `detail` defaults to last_error. Nothing retries a write that failed, so the dialog + is the only record the user gets that the editor and the device may now disagree. """ reason = detail if detail is not None else last_error app_state.set_status(f"{what}: {reason}", warn=True) @@ -230,6 +230,190 @@ def live_invoke(element, slot_name, arg=None): return False +# ── Undo: read a subtree back, put it back ────────────────────── + +def read_subtree(element): + """Everything it takes to put `element` and all it holds back on the device. + + Read from the device, not the DOM: the DOM only holds what has been opened, and a + placeholder child carries neither config nor children of its own. Requests go one + component at a time, at the pace the device sets (see used_kit_names). + + Returns {"tree", "links", "siblings"}, or None with last_error set. A node is + {"id", "kit_id", "type_id", "type_name", "name", "values", "element", "children"}, + where "element" is the DOM element showing it, if any, so a new id can reach it. + A link end, and each of "siblings" (the parent's children, in order), is ("node", + node), ("elem", element) or ("id", comp_id): a component outside the subtree is + followed by its element when it has one, because its id changes if it is itself + deleted and put back. + """ + ds = _session() + if ds is None: + return None + comp_id = _comp_id(element) + if comp_id is None: + return _fail(f"'{element.get('name', '?')}' has no component id.") + + client = ds.sox_client + root = app_state.xml_root_element + elements_by_id = {} + if root is not None: + for elem in root.iter("comp"): + if elem.get("id") is not None: + elements_by_id.setdefault(elem.get("id"), elem) + + nodes_by_id = {} + + def read(comp): + if comp.type_name not in app_state.final_components_registry: + raise _SnapshotError(f"No kit manifest describes {comp.type_name}, so " + f"'{comp.name}' could not be put back as it was.") + children = ds.load_children(comp.id) + if len(children) != len(comp.children): + raise _SnapshotError(f"Not every child of '{comp.name}' could be read.") + client.update([comp], CONFIG) + values = dict(comp.values) + # The cache reads every Buf as a Str, which loses bytes of a binary one (a + # user's cred); those are read again as what they are. + for slot in app_state.final_components_registry[comp.type_name]: + if ("c" in slot["flags"] and slot["type"] != "void" + and _wire_type(slot["type"], slot["facets"].get("asStr") is True) == BUF_ID): + values[slot["name"]] = client.read_prop(comp.id, slot["slot_id"], BUF_ID) + node = { + "id": comp.id, + "kit_id": comp.kit_id, + "type_id": comp.type_id, + "type_name": comp.type_name, + "name": comp.name, + "values": values, + "element": elements_by_id.get(str(comp.id)), + "children": [], + } + nodes_by_id[comp.id] = node + node["children"] = [read(child) for child in children] + return node + + try: + top = client.load(comp_id) + if top is None: + return _fail(f"'{element.get('name', '?')}' could not be read from the device.") + tree = read(top) + + links = [] + seen = set() + for cid in list(nodes_by_id): + for lnk in client.read_links(cid): + key = (lnk.from_comp_id, lnk.from_slot_id, lnk.to_comp_id, lnk.to_slot_id) + if key in seen: + continue + seen.add(key) + links.append((_link_end(lnk.from_comp_id, nodes_by_id, elements_by_id), + lnk.from_slot_id, + _link_end(lnk.to_comp_id, nodes_by_id, elements_by_id), + lnk.to_slot_id)) + except _SnapshotError as e: + return _fail(str(e)) + except Exception as e: + return _fail(f"'{element.get('name', '?')}' could not be read from the device: {e}") + + parent = client.cache_get(top.parent) + siblings = [_link_end(cid, nodes_by_id, elements_by_id) + for cid in (parent.children if parent is not None else [])] + return {"tree": tree, "links": links, "siblings": siblings} + + +class _SnapshotError(Exception): + pass + + +def _link_end(comp_id, nodes_by_id, elements_by_id): + if comp_id in nodes_by_id: + return ("node", nodes_by_id[comp_id]) + elem = elements_by_id.get(str(comp_id)) + if elem is not None: + return ("elem", elem) + return ("id", comp_id) + + +def _link_end_id(end): + kind, ref = end + if kind == "node": + return ref["id"] + if kind == "elem": + return _comp_id(ref) + return ref + + +def recreate_subtree(parent_element, snapshot, build_config): + """Adds a subtree from read_subtree back under `parent_element`, with its links. + + The device hands out new ids. Each node, and the DOM element showing it, takes its + new id, so whatever refers to them later still finds them. The component goes back + to its old place among its siblings. + + `build_config(type_name, device_values=...)` makes an add command's config values. + Returns (top_created, complete); when not complete, last_error says what is missing. + """ + ds = _session() + if ds is None: + return False, False + parent_id = _comp_id(parent_element) + if parent_id is None: + _fail(f"The parent '{parent_element.get('name', '?')}' has no component id.") + return False, False + + client = ds.sox_client + tree = snapshot["tree"] + + def create(node, under_id): + config = build_config(node["type_name"], device_values=node["values"]) + comp = client.add(under_id, node["kit_id"], node["type_id"], node["name"], config) + node["id"] = comp.id + if node["element"] is not None: + node["element"].set("id", str(comp.id)) + + def create_children(node): + for child in node["children"]: + create(child, node["id"]) + create_children(child) + + try: + create(tree, parent_id) + except Exception as e: + _fail(f"The device rejected the add of '{tree['name']}': {e}") + return False, False + + try: + create_children(tree) + for from_end, from_slot, to_end, to_slot in snapshot["links"]: + client.link(_link_end_id(from_end), from_slot, _link_end_id(to_end), to_slot) + + # The add put it last; the device's order is the order execution runs in. + parent = client.cache_get(parent_id) + wanted = [_link_end_id(end) for end in snapshot["siblings"]] + if (parent is not None and set(wanted) == set(parent.children) + and wanted != parent.children): + client.reorder(parent_id, wanted) + complete = True + except Exception as e: + _fail(f"'{tree['name']}' is back on the device, but not all of it: {e}") + complete = False + + ds.after_add(tree["id"]) + return True, complete + + +def wire_value(type_id, raw): + """A value as the device sent it, in the form encode_value wants for `type_id`.""" + if type_id == BUF_ID: + return bytes(raw) if isinstance(raw, (bytes, bytearray)) else str(raw).encode("utf-8") + if type_id == STR_ID: + if isinstance(raw, (bytes, bytearray)): + return bytes(raw).decode("utf-8", errors="replace").rstrip("\0") + return str(raw) + return _coerce_value(type_id, str(raw)) + + # ── Helpers ───────────────────────────────────────────────────── def _comp_id(element): diff --git a/main.py b/main.py index 623b187..9ca4a08 100644 --- a/main.py +++ b/main.py @@ -531,27 +531,27 @@ def _clear_restoring_flag(): def do_undo(event=None): - if app_state.live_mode: - app_state.set_status("Undo is not available in live mode", warn=True) - return "break" label = undo.undo() if label is None: return "break" - app_state.window.title(f"{app_state.base_window_title()} — undid {label}") + app_state.window.title(f"{_base_title()} — undid {label}") return "break" def do_redo(event=None): - if app_state.live_mode: - app_state.set_status("Redo is not available in live mode", warn=True) - return "break" label = undo.redo() if label is None: return "break" - app_state.window.title(f"{app_state.base_window_title()} — redid {label}") + 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() + return app_state.base_window_title() + + def do_save(event=None): if app_state.live_mode: do_download_to_sax() @@ -1771,11 +1771,12 @@ def on_canvas_release(event): "Move", f"{comp_element.get('name', 'The component')} was not " "moved on the device") return - if not app_state.live_mode: - undo.push(undo.MetaMoveCommand( - [(comp_element, meta_before, raw_x, raw_y)], - label=f"move {comp_element.get('name', 'component')}" - )) + command_class = (undo.LiveMetaMoveCommand if app_state.live_mode + else undo.MetaMoveCommand) + undo.push(command_class( + [(comp_element, meta_before, raw_x, raw_y)], + label=f"move {comp_element.get('name', 'component')}" + )) app_state.mark_dirty() app_state.drag_data["tag"] = None @@ -2029,6 +2030,12 @@ def record_visit(kind, element): """ global history_index, _history_file_path + # On a device, undo only reaches back as far as the view that is open. Opening a + # sheet reads the DOM afresh from the device, so steps taken before it name elements + # that are no longer there; Back and Forward are view changes too. + if app_state.live_mode: + undo.reset() + if _suppress_history: return @@ -2655,6 +2662,8 @@ def _live_tree_expand(comp_id, tree_item_id): def _ui(): global _restoring_selection app_state.xml_root_element = sax_root + # A fresh DOM: undo steps name elements of the one it replaces. + undo.reset() sax_file.refresh_treeview_from_dom(sax_root) # Re-open and re-select the node without triggering a wiresheet # re-render — the sheet is already showing the right content and diff --git a/ox_page_view.py b/ox_page_view.py index 1c3ea46..4754826 100644 --- a/ox_page_view.py +++ b/ox_page_view.py @@ -554,13 +554,19 @@ class OxPageViewPanel: # Live: the device first, and the page only shows what the device accepted. live_failures = [] - for element, name, _before, after in changes: + applied = [] + for change in changes: + element, name, _before, after = change if app_state.live_mode and not live_hooks.live_set_prop_value(element, name, after): live_failures.append(f"{name}: {live_hooks.last_error}") continue set_prop_value(element, name, after) + applied.append(change) - if not app_state.live_mode: + if app_state.live_mode: + if applied: + undo.push(undo.LiveSlotValueCommand(applied)) + else: undo.push(undo.SlotValueCommand(changes)) app_state.mark_dirty() diff --git a/properties.py b/properties.py index 5309fe1..3e0398f 100644 --- a/properties.py +++ b/properties.py @@ -1170,7 +1170,9 @@ class PropertiesPanel: # In live mode the device is written first and a value only lands in the document # once the device took it, so the panel never shows what the device does not hold. live_failures = [] - for element, prop_name, before, after in changes: + applied = [] + for change in changes: + element, prop_name, before, after = change if app_state.live_mode and not live_hooks.live_set_prop_value( element, prop_name, after): live_failures.append(f"{prop_name}: {live_hooks.last_error}") @@ -1182,9 +1184,14 @@ class PropertiesPanel: continue if prop_name != "meta": set_prop_value(element, prop_name, after) - written = len(changes) - len(live_failures) + applied.append(change) + written = len(applied) - if not app_state.live_mode: + if app_state.live_mode: + # Only what the device took; undo writes it back there. + if applied: + undo.push(undo.LiveSlotValueCommand(applied)) + else: undo.push(undo.SlotValueCommand(list(changes))) app_state.mark_dirty() diff --git a/sax_file.py b/sax_file.py index 297e208..ac1ed20 100644 --- a/sax_file.py +++ b/sax_file.py @@ -905,13 +905,15 @@ def reconcile_schema_kits_before_save(): return True -def build_config_values(full_type, overrides=None): +def build_config_values(full_type, overrides=None, device_values=None): """Builds SOX config_values list from manifest defaults for an add command. `overrides` maps slot name to the document-form value to send instead of the default. + `device_values` maps slot name to a value as the device sent it, for putting back a + component exactly as it was; it wins over both. Raises ValueError when a value does not fit its slot's type. """ - from live_hooks import _coerce_value, _wire_type + from live_hooks import _coerce_value, _wire_type, wire_value slots = app_state.final_components_registry.get(full_type, []) config_values = [] @@ -920,12 +922,15 @@ def build_config_values(full_type, overrides=None): continue if slot["type"] == "void": continue + tid = _wire_type(slot["type"], slot["facets"].get("asStr") is True) + if device_values and slot["name"] in device_values: + config_values.append((tid, wire_value(tid, device_values[slot["name"]]))) + continue val = (overrides or {}).get(slot["name"], slot["default"]) if val is None: val = properties.type_zero(slot["type"]) if val is None: continue - tid = _wire_type(slot["type"], slot["facets"].get("asStr") is True) config_values.append((tid, _coerce_value(tid, val))) return config_values @@ -1015,7 +1020,12 @@ def add_component(kit_name, type_name, grid_x, grid_y): position_failed = not live_hooks.live_set_prop_value( element, "meta", meta_prop.get("val", "0")) - if not app_state.live_mode: + if app_state.live_mode: + undo.push(LiveComponentCommand( + parent, index, element, present=True, + label=f"add {comp_name}" + )) + else: undo.push(undo.AddComponentCommand( parent, index, element, label=f"add {comp_name}" @@ -1058,11 +1068,7 @@ def remove_link(link_element): to_ref = link_element.get("to", "") if app_state.live_mode: - from_comp_path, from_slot_name = from_ref.rsplit(".", 1) if "." in from_ref else ("", from_ref) - to_comp_path, to_slot_name = to_ref.rsplit(".", 1) if "." in to_ref else ("", to_ref) - from_elem = find_component_by_path(app_state.xml_root_element, from_comp_path) - to_elem = find_component_by_path(app_state.xml_root_element, to_comp_path) - if not live_hooks.live_delete_link(from_elem, from_slot_name, to_elem, to_slot_name): + if not live_hooks.live_delete_link(*link_ends(from_ref, to_ref)): live_hooks.warn_failure("Delete Link", f"The link {from_ref} → {to_ref} was not removed from the device") return @@ -1075,7 +1081,12 @@ def remove_link(link_element): from_slot = from_ref.rsplit(".", 1)[-1] if "." in from_ref else from_ref to_slot = to_ref.rsplit(".", 1)[-1] if "." in to_ref else to_ref - if not app_state.live_mode: + if app_state.live_mode: + undo.push(LiveLinkCommand( + links_elem, index, link_element, present=False, + label=f"delete link {from_slot}→{to_slot}" + )) + else: undo.push(undo.DeleteLinkCommand( links_elem, index, link_element, label=f"delete link {from_slot}→{to_slot}" @@ -1096,11 +1107,7 @@ def add_link(from_path_slot, to_path_slot): links_elem = ET.SubElement(app_state.xml_root_element, "links") if app_state.live_mode: - from_comp_path, from_slot_name = from_path_slot.rsplit(".", 1) if "." in from_path_slot else ("", from_path_slot) - to_comp_path, to_slot_name = to_path_slot.rsplit(".", 1) if "." in to_path_slot else ("", to_path_slot) - from_elem = find_component_by_path(app_state.xml_root_element, from_comp_path) - to_elem = find_component_by_path(app_state.xml_root_element, to_comp_path) - if not live_hooks.live_add_link(from_elem, from_slot_name, to_elem, to_slot_name): + if not live_hooks.live_add_link(*link_ends(from_path_slot, to_path_slot)): live_hooks.warn_failure("Add Link", f"The link {from_path_slot} → {to_path_slot} was not made on the device") return @@ -1111,7 +1118,12 @@ def add_link(from_path_slot, to_path_slot): from_slot = from_path_slot.rsplit(".", 1)[-1] if "." in from_path_slot else from_path_slot to_slot = to_path_slot.rsplit(".", 1)[-1] if "." in to_path_slot else to_path_slot - if not app_state.live_mode: + if app_state.live_mode: + undo.push(LiveLinkCommand( + links_elem, list(links_elem).index(link_elem), link_elem, present=True, + label=f"add link {from_slot}→{to_slot}" + )) + else: undo.push(undo.AddLinkCommand( links_elem, link_elem, label=f"add link {from_slot}→{to_slot}" @@ -1137,37 +1149,21 @@ def execute_cascading_deletion(comp_element): if not confirm: return False + snapshot = None if app_state.live_mode: + # Read before it goes: once deleted, the device has nothing left to read back. + snapshot = live_hooks.read_subtree(comp_element) + unreadable = live_hooks.last_error if snapshot is None else "" if not live_hooks.live_delete_component(comp_element): live_hooks.warn_failure("Delete Component", f"{comp_name} was not deleted from the device") return False - comp_path = get_component_path(comp_element, app_state.xml_root_element) - all_elements_to_prune = [comp_element] + descendant_comps for elem in all_elements_to_prune: if elem in app_state.active_components_list: app_state.active_components_list.remove(elem) - # Positions are recorded before anything is removed, so the delete can be reversed. - link_records = [] - if app_state.xml_root_element is not None: - links_elem = app_state.xml_root_element.find("links") - if links_elem is not None: - for index, link in enumerate(list(links_elem)): - if link.tag != "link": - continue - l_from = link.get("from", "") - l_to = link.get("to", "") - - from_match = l_from.startswith(comp_path + ".") or l_from.startswith(comp_path + "/") - to_match = l_to.startswith(comp_path + ".") or l_to.startswith(comp_path + "/") - - if from_match or to_match: - link_records.append((links_elem, index, link)) - - for links_parent, _, link in link_records: - links_parent.remove(link) + link_records = detach_links_touching(comp_element) xml_parent = None comp_index = 0 @@ -1177,7 +1173,18 @@ def execute_cascading_deletion(comp_element): comp_index = list(xml_parent).index(comp_element) xml_parent.remove(comp_element) - if xml_parent is not None and not app_state.live_mode: + if xml_parent is not None and app_state.live_mode: + if snapshot is not None: + undo.push(LiveComponentCommand( + xml_parent, comp_index, comp_element, present=False, + snapshot=snapshot, link_records=link_records, + label=f"delete {comp_name}" + )) + else: + app_state.set_status( + f"{comp_name} was deleted from the device, but this delete cannot be " + f"undone: {unreadable}", warn=True) + elif xml_parent is not None: undo.push(undo.DeleteComponentCommand( xml_parent, comp_index, comp_element, link_records, label=f"delete {comp_name}" @@ -1195,3 +1202,156 @@ def execute_cascading_deletion(comp_element): refresh_treeview_from_dom(app_state.xml_root_element) wiresheet.render_components(app_state.active_components_list) return True + + +def link_ends(from_ref, to_ref): + """(from_elem, from_slot, to_elem, to_slot) for a link's "path.slot" ends.""" + from_path, from_slot = from_ref.rsplit(".", 1) if "." in from_ref else ("", from_ref) + to_path, to_slot = to_ref.rsplit(".", 1) if "." in to_ref else ("", to_ref) + root = app_state.xml_root_element + return (find_component_by_path(root, from_path), from_slot, + find_component_by_path(root, to_path), to_slot) + + +def detach_links_touching(comp_element): + """Takes every into or out of `comp_element`'s subtree out of the DOM. + + Returns (links_parent, index, link) records. Positions are recorded before anything + is removed, so putting them back in ascending order lands each on its old index. + """ + root = app_state.xml_root_element + if root is None: + return [] + links_elem = root.find("links") + if links_elem is None: + return [] + + comp_path = get_component_path(comp_element, root) + link_records = [] + for index, link in enumerate(list(links_elem)): + if link.tag != "link": + continue + l_from = link.get("from", "") + l_to = link.get("to", "") + from_match = l_from.startswith(comp_path + ".") or l_from.startswith(comp_path + "/") + to_match = l_to.startswith(comp_path + ".") or l_to.startswith(comp_path + "/") + if from_match or to_match: + link_records.append((links_elem, index, link)) + + for links_parent, _, link in link_records: + links_parent.remove(link) + return link_records + + +class LiveComponentCommand: + """A component added to or deleted from the device, undone and redone through Sox. + + Taking it away reads its whole subtree back first (config, children, links) and + then deletes it; putting it back adds all of that again. The device gives every + component a new id when it comes back, and the DOM elements take those ids on, so + later commands holding the same elements still reach the right components. + """ + + def __init__(self, parent, index, element, present, snapshot=None, link_records=(), + label="add"): + # present: whether the component is on the device now. snapshot: from + # live_hooks.read_subtree, taken when it was last taken away. + self.parent = parent + self.index = index + self.element = element + self.present = present + self.snapshot = snapshot + self.link_records = list(link_records) + self.label = label + + def undo(self): + self._flip() + + def redo(self): + self._flip() + + def _flip(self): + if self.present: + self._take_away() + else: + self._put_back() + + def _take_away(self): + name = self.element.get("name", "?") + snapshot = live_hooks.read_subtree(self.element) + if snapshot is None: + raise undo.LiveUndoError(f"{name} was not deleted from the device") + if not live_hooks.live_delete_component(self.element): + raise undo.LiveUndoError(f"{name} was not deleted from the device") + + self.snapshot = snapshot + self.present = False + self.link_records = detach_links_touching(self.element) + if self.element in list(self.parent): + self.index = list(self.parent).index(self.element) + self.parent.remove(self.element) + + def _put_back(self): + name = self.element.get("name", "?") + created, complete = live_hooks.recreate_subtree( + self.parent, self.snapshot, build_config_values) + if not created: + raise undo.LiveUndoError(f"{name} was not put back on the device") + + self.present = True + if self.element not in list(self.parent): + self.parent.insert(self.index, self.element) + for links_parent, index, link in sorted(self.link_records, key=lambda r: r[1]): + if link not in list(links_parent): + links_parent.insert(index, link) + + ds = app_state.device_session + if ds is not None: + ds.update_element_from_cache(live_hooks._comp_id(self.element), + app_state.xml_root_element) + if not complete: + raise undo.LiveUndoError(f"{name} was not put back on the device in full") + + +class LiveLinkCommand: + """A link made or removed on the device, undone and redone through Sox.""" + + def __init__(self, links_parent, index, link_element, present, label="add link"): + self.links_parent = links_parent + self.index = index + self.link_element = link_element + self.present = present + self.label = label + + def undo(self): + self._flip() + + def redo(self): + self._flip() + + def _flip(self): + if self.present: + self._unlink() + else: + self._link() + + def _describe(self): + return f"{self.link_element.get('from', '')} → {self.link_element.get('to', '')}" + + def _ends(self): + return link_ends(self.link_element.get("from", ""), self.link_element.get("to", "")) + + def _unlink(self): + if not live_hooks.live_delete_link(*self._ends()): + raise undo.LiveUndoError(f"The link {self._describe()} was not removed from the device") + self.present = False + if self.link_element in list(self.links_parent): + self.index = list(self.links_parent).index(self.link_element) + self.links_parent.remove(self.link_element) + + def _link(self): + if not live_hooks.live_add_link(*self._ends()): + raise undo.LiveUndoError(f"The link {self._describe()} was not made on the device") + self.present = True + if self.link_element not in list(self.links_parent): + self.links_parent.insert(self.index, self.link_element) diff --git a/undo.py b/undo.py index f6d188d..a53271a 100644 --- a/undo.py +++ b/undo.py @@ -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. diff --git a/user_service_view.py b/user_service_view.py index af6fb73..baa1ac7 100644 --- a/user_service_view.py +++ b/user_service_view.py @@ -485,12 +485,17 @@ class _UserDialog: if app_state.live_mode: failures = [] - for elem, prop, _old, new in changes: + applied = [] + for change in changes: + elem, prop, _old, new = change if live_hooks.live_set_prop_value(elem, prop, new): set_prop_value(elem, prop, new) + applied.append(change) app_state.mark_dirty() else: failures.append(f"{prop}: {live_hooks.last_error}") + if applied: + undo.push(undo.LiveSlotValueCommand(applied, label="edit user")) if failures: live_hooks.warn_failure( "Edit User", diff --git a/wiresheet.py b/wiresheet.py index fab6f61..98a6fe1 100644 --- a/wiresheet.py +++ b/wiresheet.py @@ -1348,10 +1348,12 @@ def tidy_layout(): command.redo() # apply it; the command is the single writer of meta if app_state.live_mode: - # No undo in live mode: each new position goes to the device, and a box the - # device refused goes back to where the device still has it. + # Each new position goes to the device, and a box the device refused goes back to + # where the device still has it. Only the boxes that moved are undoable. failures = [] - for element, old_val, _, _ in changes: + moved = [] + for change in changes: + element, old_val, _, _ = change new_val = element.find("prop[@name='meta']").get("val", "0") if not live_hooks.live_set_prop_value(element, "meta", new_val): failures.append(f"{element.get('name', '?')}: {live_hooks.last_error}") @@ -1359,6 +1361,10 @@ def tidy_layout(): remove_prop(element, "meta") else: set_prop_value(element, "meta", old_val) + else: + moved.append(change) + if moved: + undo.push(undo.LiveMetaMoveCommand(moved, label="tidy layout")) render_current_sheet() if failures: live_hooks.warn_failure(