diff --git a/app_state.py b/app_state.py index 356ed14..eff25b4 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.135" +VERSION = "0.0.0.136" 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 f318658..fd168fc 100644 --- a/live_hooks.py +++ b/live_hooks.py @@ -5,8 +5,17 @@ Checks app_state.live_mode and routes mutations through DeviceSession/SoxClient. In offline mode, falls through to the original DOM-only code paths. """ +import base64 +import binascii +from tkinter import messagebox + import app_state -from sox import type_id_for_slot +from sox import BUF_ID, type_id_for_slot + + +# Why the last live_* call failed, in words the user can act on. Every failing call sets +# it, so a caller can put it in front of the user with warn_failure(). +last_error = "" def is_live(): @@ -15,30 +24,59 @@ def is_live(): and app_state.device_session.is_connected) +def _fail(reason): + global last_error + last_error = reason + return None + + +def _session(): + """The connected DeviceSession, or None with last_error saying why not.""" + if not is_live(): + _fail("The device is not connected.") + return None + return app_state.device_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. + """ + reason = detail if detail is not None else last_error + app_state.set_status(f"{what}: {reason}", warn=True) + messagebox.showwarning(title, f"{what}.\n\n{reason}", parent=app_state.window) + + # ── Property Write ────────────────────────────────────────────── def live_set_prop_value(element, slot_name, value_str): """In live mode, sends a Sox write to the device. Returns True on success.""" - if not is_live(): + ds = _session() + if ds is None: return False - - ds = app_state.device_session comp_id = _comp_id(element) if comp_id is None: + _fail(f"'{element.get('name', '?')}' has no component id.") return False slot_info = _find_slot(ds, comp_id, slot_name) if slot_info is None: return False - type_id = type_id_for_slot(slot_info["type_name"]) - coerced = _coerce_value(type_id, value_str) + type_id = slot_info["type_id"] + try: + coerced = _coerce_value(type_id, value_str) + except (TypeError, ValueError, binascii.Error) as e: + _fail(f"'{value_str}' is not a valid value for {slot_name}: {e}") + return False try: ds.sox_client.write_prop(comp_id, slot_info["id"], type_id, coerced) return True except Exception as e: - app_state.set_status(f"Write failed: {e}", warn=True) + _fail(f"The device rejected the write to {slot_name}: {e}") return False @@ -46,43 +84,39 @@ def live_set_prop_value(element, slot_name, value_str): def live_add_component(parent_element, kit_name, type_name, comp_name, config_values=None): """In live mode, sends a Sox add to the device. Returns new comp_id or None.""" - if not is_live(): + ds = _session() + if ds is None: return None - - ds = app_state.device_session parent_id = _comp_id(parent_element) if parent_id is None: - return None + return _fail(f"The parent '{parent_element.get('name', '?')}' has no component id.") kit_id = _kit_id(ds, kit_name) + if kit_id is None: + return _fail(f"Kit '{kit_name}' is not installed on the device.") type_id_val = _type_id(ds, kit_id, type_name) - if kit_id is None or type_id_val is None: - return None - - sox_config = [] - if config_values: - for tid, val in config_values: - sox_config.append((tid, val)) + if type_id_val is None: + return _fail(f"The device's '{kit_name}' kit has no type '{type_name}'.") try: - new_comp = ds.sox_client.add(parent_id, kit_id, type_id_val, comp_name, sox_config) + new_comp = ds.sox_client.add(parent_id, kit_id, type_id_val, comp_name, + list(config_values or [])) ds.after_add(new_comp.id) return new_comp.id except Exception as e: - app_state.set_status(f"Add failed: {e}", warn=True) - return None + return _fail(f"The device rejected the add: {e}") # ── Delete Component ──────────────────────────────────────────── def live_delete_component(element): """In live mode, sends a Sox delete to the device. Returns True on success.""" - if not is_live(): + ds = _session() + if ds is None: return False - - ds = app_state.device_session comp_id = _comp_id(element) if comp_id is None: + _fail(f"'{element.get('name', '?')}' has no component id.") return False descendant_ids = [] @@ -97,7 +131,7 @@ def live_delete_component(element): ds.after_delete(comp_id, descendant_ids) return True except Exception as e: - app_state.set_status(f"Delete failed: {e}", warn=True) + _fail(f"The device rejected the delete: {e}") return False @@ -105,69 +139,70 @@ def live_delete_component(element): def live_rename_component(element, new_name): """In live mode, sends a Sox rename to the device. Returns True on success.""" - if not is_live(): + ds = _session() + if ds is None: return False - - ds = app_state.device_session comp_id = _comp_id(element) if comp_id is None: + _fail(f"'{element.get('name', '?')}' has no component id.") return False try: ds.sox_client.rename(comp_id, new_name) return True except Exception as e: - app_state.set_status(f"Rename failed: {e}", warn=True) + _fail(f"The device rejected the rename: {e}") return False # ── Link / Unlink ─────────────────────────────────────────────── -def live_add_link(from_element, from_slot_name, to_element, to_slot_name): - """In live mode, sends a Sox link command. Returns True on success.""" - if not is_live(): - return False - - ds = app_state.device_session +def _link_ends(ds, from_element, from_slot_name, to_element, to_slot_name): + """(from_id, from_slot_id, to_id, to_slot_id), or None with last_error set.""" + if from_element is None or to_element is None: + return _fail("One end of the link is not a component in the loaded tree.") from_id = _comp_id(from_element) to_id = _comp_id(to_element) if from_id is None or to_id is None: - return False - + return _fail("One end of the link has no component id.") from_slot = _find_slot(ds, from_id, from_slot_name) + if from_slot is None: + return None to_slot = _find_slot(ds, to_id, to_slot_name) - if from_slot is None or to_slot is None: - return False + if to_slot is None: + return None + return from_id, from_slot["id"], to_id, to_slot["id"] + +def live_add_link(from_element, from_slot_name, to_element, to_slot_name): + """In live mode, sends a Sox link command. Returns True on success.""" + ds = _session() + if ds is None: + return False + ends = _link_ends(ds, from_element, from_slot_name, to_element, to_slot_name) + if ends is None: + return False try: - ds.sox_client.link(from_id, from_slot["id"], to_id, to_slot["id"]) + ds.sox_client.link(*ends) return True except Exception as e: - app_state.set_status(f"Link failed: {e}", warn=True) + _fail(f"The device rejected the link: {e}") return False def live_delete_link(from_element, from_slot_name, to_element, to_slot_name): """In live mode, sends a Sox unlink command. Returns True on success.""" - if not is_live(): + ds = _session() + if ds is None: return False - - ds = app_state.device_session - from_id = _comp_id(from_element) - to_id = _comp_id(to_element) - if from_id is None or to_id is None: + ends = _link_ends(ds, from_element, from_slot_name, to_element, to_slot_name) + if ends is None: return False - - from_slot = _find_slot(ds, from_id, from_slot_name) - to_slot = _find_slot(ds, to_id, to_slot_name) - if from_slot is None or to_slot is None: - return False - try: - ds.sox_client.unlink(from_id, from_slot["id"], to_id, to_slot["id"]) + ds.sox_client.unlink(*ends) return True except Exception as e: - app_state.set_status(f"Unlink failed: {e}", warn=True) + _fail(f"The device rejected the unlink: {e}") return False @@ -175,12 +210,12 @@ def live_delete_link(from_element, from_slot_name, to_element, to_slot_name): def live_invoke(element, slot_name, arg=None): """In live mode, sends a Sox invoke. Returns True on success.""" - if not is_live(): + ds = _session() + if ds is None: return False - - ds = app_state.device_session comp_id = _comp_id(element) if comp_id is None: + _fail(f"'{element.get('name', '?')}' has no component id.") return False slot_info = _find_slot(ds, comp_id, slot_name) @@ -191,7 +226,7 @@ def live_invoke(element, slot_name, arg=None): ds.sox_client.invoke(comp_id, slot_info["id"]) return True except Exception as e: - app_state.set_status(f"Invoke failed: {e}", warn=True) + _fail(f"The device rejected the invoke of {slot_name}: {e}") return False @@ -210,22 +245,38 @@ def _comp_id(element): def _find_slot(ds, comp_id, slot_name): + """{name, id, type_id} for a slot of a cached component, or None with last_error set. + + A Buf goes over the wire as a Buf only when it is binary; an asStr Buf travels as a + Str, which is how the rest of the editor already reads and writes it. + """ comp = ds.sox_client.cache_get(comp_id) if comp is None: - return None + return _fail(f"Component {comp_id} is not loaded from the device.") type_name = ds.sox_client.type_name_map.get((comp.kit_id, comp.type_id)) if type_name and type_name in app_state.final_components_registry: for s in app_state.final_components_registry[type_name]: if s["name"] == slot_name: + slot_type = s.get("type") or "int" + as_str = (s.get("facets") or {}).get("asStr") is True return {"name": s["name"], "id": s["slot_id"], - "type_name": s.get("type", "int")} + "type_id": _wire_type(slot_type, as_str)} + return _fail(f"{type_name} has no slot '{slot_name}'.") slots = ds.sox_client.slot_registry.get((comp.kit_id, comp.type_id)) if slots is None: - return None + return _fail(f"No kit manifest describes {type_name or 'this component'}'s slots, " + "so the slot cannot be addressed on the device.") for s in slots: if s["name"] == slot_name: - return s - return None + return {"name": s["name"], "id": s["id"], + "type_id": _wire_type(s.get("type_name") or "int", s.get("is_as_str"))} + return _fail(f"{type_name or 'This component'} has no slot '{slot_name}'.") + + +def _wire_type(slot_type, as_str): + if slot_type in ("sys::Buf", "Buf", "buf") and not as_str: + return BUF_ID + return type_id_for_slot(slot_type) def _kit_id(ds, kit_name): @@ -246,17 +297,24 @@ def _type_id(ds, kit_id, type_name): def _coerce_value(type_id, value_str): + """The Python value encode_value wants for `value_str` in document form. + + Raises ValueError (or binascii.Error) when the text does not fit the type. + """ from sox import BOOL_ID, BYTE_ID, SHORT_ID, INT_ID, LONG_ID, FLOAT_ID, DOUBLE_ID, STR_ID if type_id == BOOL_ID: return value_str.lower() in ("true", "1", "yes") - elif type_id in (BYTE_ID, SHORT_ID, INT_ID): - return int(value_str) - elif type_id == LONG_ID: - return int(value_str) - elif type_id == FLOAT_ID: - return float(value_str) - elif type_id == DOUBLE_ID: + elif type_id in (BYTE_ID, SHORT_ID, INT_ID, LONG_ID): + try: + return int(value_str) + except ValueError: + return int(value_str, 0) # meta and flags can be written as 0x... + elif type_id in (FLOAT_ID, DOUBLE_ID): return float(value_str) elif type_id == STR_ID: return str(value_str) + elif type_id == BUF_ID: + if isinstance(value_str, (bytes, bytearray)): + return bytes(value_str) + return base64.b64decode(value_str or "", validate=True) # the .sax form return value_str diff --git a/main.py b/main.py index ff7e07b..06161c7 100644 --- a/main.py +++ b/main.py @@ -22,7 +22,7 @@ import ox_page_view import user_service_view import wiresheet from sax_dom import (find_component_by_path, find_parent_element, get_component_path, - read_meta_property, update_meta_property) + read_meta_property, remove_prop, set_prop_value, update_meta_property) # Tree column measurements, in pixels. The padding covers the expander arrow and the # cell's own inset, which sit ahead of the text ttk lets us measure. @@ -620,6 +620,8 @@ def do_tidy_layout(event=None): return "break" moved, clamped = wiresheet.tidy_layout() + if moved is None: + return "break" multiplier = app_state.meta_scale // app_state.GRID_SIZE if moved == 0: @@ -1543,11 +1545,21 @@ def on_canvas_release(event): if orig_x != raw_x or orig_y != raw_y: meta_before = app_state.drag_data.get("meta_before", meta_str) update_meta_property(comp_element, meta_str, raw_x, raw_y) - if live_hooks.is_live(): + if app_state.live_mode: new_meta = comp_element.find("prop[@name='meta']") - if new_meta is not None: - live_hooks.live_set_prop_value( - comp_element, "meta", new_meta.get("val", "0")) + if new_meta is None or not live_hooks.live_set_prop_value( + comp_element, "meta", new_meta.get("val", "0")): + # Back where the device still has it, so the sheet tells the truth. + if meta_str is None: + remove_prop(comp_element, "meta") + else: + set_prop_value(comp_element, "meta", meta_str) + app_state.drag_data["tag"] = None + wiresheet.render_current_sheet() + live_hooks.warn_failure( + "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)], diff --git a/ox_page_view.py b/ox_page_view.py index 4d07f44..1c3ea46 100644 --- a/ox_page_view.py +++ b/ox_page_view.py @@ -552,11 +552,13 @@ class OxPageViewPanel: if not changes: return False + # Live: the device first, and the page only shows what the device accepted. live_failures = [] for element, name, _before, after in changes: + 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) - if live_hooks.is_live() and not live_hooks.live_set_prop_value(element, name, after): - live_failures.append(name) if not app_state.live_mode: undo.push(undo.SlotValueCommand(changes)) @@ -566,8 +568,13 @@ class OxPageViewPanel: who = comp.get("name", "?") if live_failures: - app_state.set_status( - f"{who}: written locally but failed on device: {', '.join(live_failures)}") + live_hooks.warn_failure( + "Page Editor", + f"{len(live_failures)} of {len(changes)} change(s) to {who} did not reach " + "the device and were not applied", + "\n".join(f" • {failure}" for failure in live_failures), + ) + return len(live_failures) < len(changes) else: app_state.set_status( f"{who}: {', '.join(name for _, name, _, _ in changes)} changed.") diff --git a/properties.py b/properties.py index 81ea09d..5309fe1 100644 --- a/properties.py +++ b/properties.py @@ -35,7 +35,7 @@ import app_state import live_hooks import schema import undo -from sax_dom import (get_component_path, read_meta_property, set_prop_value, +from sax_dom import (get_component_path, read_meta_property, remove_prop, set_prop_value, update_meta_groups, update_meta_property) # Sedona slot types, grouped by how a value of that type is parsed and bounded. @@ -1167,13 +1167,22 @@ class PropertiesPanel: self.status_var.set("No changes to save.") return + # 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: + for element, prop_name, before, after in changes: + 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}") + if prop_name == "meta": # applied above, ahead of the write + if before is None: + remove_prop(element, "meta") + else: + set_prop_value(element, "meta", before) + continue if prop_name != "meta": set_prop_value(element, prop_name, after) - if live_hooks.is_live(): - if not live_hooks.live_set_prop_value(element, prop_name, after): - live_failures.append(prop_name) + written = len(changes) - len(live_failures) if not app_state.live_mode: undo.push(undo.SlotValueCommand(list(changes))) @@ -1183,11 +1192,20 @@ class PropertiesPanel: if self.on_change is not None: self.on_change() - count = len(changes) + count = written + name = self.comp.get('name', '?') if live_failures: self.status_var.set( - f"Written locally but failed on device: {', '.join(live_failures)}" + f"{written} written, {len(live_failures)} rejected by the device — " + "those still show the device's value." ) + live_hooks.warn_failure( + "Properties", + f"{len(live_failures)} of {len(changes)} change(s) to {name} did not reach " + "the device and were not applied", + "\n".join(f" • {failure}" for failure in live_failures), + ) + return elif app_state.live_mode: self.status_var.set( f"{count} slot{'' if count == 1 else 's'} written to device." diff --git a/sax_file.py b/sax_file.py index 04766b7..cf78e38 100644 --- a/sax_file.py +++ b/sax_file.py @@ -760,10 +760,13 @@ def reconcile_schema_kits_before_save(): return True -def _build_config_values(full_type): - """Builds SOX config_values list from manifest defaults for an add command.""" - from sox import type_id_for_slot - from live_hooks import _coerce_value +def build_config_values(full_type, overrides=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. + Raises ValueError when a value does not fit its slot's type. + """ + from live_hooks import _coerce_value, _wire_type slots = app_state.final_components_registry.get(full_type, []) config_values = [] @@ -772,12 +775,12 @@ def _build_config_values(full_type): continue if slot["type"] == "void": continue - val = slot["default"] + val = (overrides or {}).get(slot["name"], slot["default"]) if val is None: val = properties.type_zero(slot["type"]) if val is None: continue - tid = type_id_for_slot(slot["type"]) + tid = _wire_type(slot["type"], slot["facets"].get("asStr") is True) config_values.append((tid, _coerce_value(tid, val))) return config_values @@ -811,12 +814,18 @@ def add_component(kit_name, type_name, grid_x, grid_y): comp_name = unique_child_name(parent, type_name) - if live_hooks.is_live(): + if app_state.live_mode: full_type = f"{kit_name}::{type_name}" - config_values = _build_config_values(full_type) + try: + config_values = build_config_values(full_type) + except (ValueError, TypeError) as e: + live_hooks.warn_failure("Add Component", f"{comp_name} was not added to the device", + f"A default value in the kit manifest does not fit its slot: {e}") + return None new_id = live_hooks.live_add_component( parent, kit_name, type_name, comp_name, config_values or None) if new_id is None: + live_hooks.warn_failure("Add Component", f"{comp_name} was not added to the device") return None comp_id = new_id else: @@ -847,17 +856,19 @@ def add_component(kit_name, type_name, grid_x, grid_y): index = len(list(parent)) parent.append(element) - if live_hooks.is_live(): + if app_state.live_mode: ds = app_state.device_session if ds is not None: ds.update_element_from_cache(comp_id, app_state.xml_root_element) update_meta_property(element, None, grid_x, grid_y) - if live_hooks.is_live(): + position_failed = False + if app_state.live_mode: meta_prop = element.find("prop[@name='meta']") if meta_prop is not None: - live_hooks.live_set_prop_value(element, "meta", meta_prop.get("val", "0")) + position_failed = not live_hooks.live_set_prop_value( + element, "meta", meta_prop.get("val", "0")) if not app_state.live_mode: undo.push(undo.AddComponentCommand( @@ -869,7 +880,13 @@ def add_component(kit_name, type_name, grid_x, grid_y): refresh_treeview_from_dom(app_state.xml_root_element) wiresheet.render_current_sheet() - if app_state.live_mode: + if position_failed: + # The component exists on the device, so it stays; only where it sits is unsaved. + live_hooks.warn_failure( + "Add Component", + f"{comp_name} was added to the device, but its position on the sheet was not " + "written, so the device still has it at its default position") + elif app_state.live_mode: app_state.set_status(f"{comp_name} ({kit_name}::{type_name}) added to device.") elif kit_name in app_state.pending_schema_kits: app_state.set_status( @@ -895,14 +912,15 @@ def remove_link(link_element): from_ref = link_element.get("from", "") to_ref = link_element.get("to", "") - if live_hooks.is_live(): + 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 from_elem is not None and to_elem is not None: - if not live_hooks.live_delete_link(from_elem, from_slot_name, to_elem, to_slot_name): - return + if not live_hooks.live_delete_link(from_elem, from_slot_name, to_elem, to_slot_name): + live_hooks.warn_failure("Delete Link", + f"The link {from_ref} → {to_ref} was not removed from the device") + return try: index = list(links_elem).index(link_element) @@ -932,14 +950,15 @@ def add_link(from_path_slot, to_path_slot): if links_elem is None: links_elem = ET.SubElement(app_state.xml_root_element, "links") - if live_hooks.is_live(): + 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 from_elem is not None and to_elem is not None: - if not live_hooks.live_add_link(from_elem, from_slot_name, to_elem, to_slot_name): - return + if not live_hooks.live_add_link(from_elem, from_slot_name, to_elem, to_slot_name): + live_hooks.warn_failure("Add Link", + f"The link {from_path_slot} → {to_path_slot} was not made on the device") + return link_elem = ET.SubElement(links_elem, "link") link_elem.set("from", from_path_slot) @@ -973,8 +992,9 @@ def execute_cascading_deletion(comp_element): if not confirm: return False - if live_hooks.is_live(): + if app_state.live_mode: 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) diff --git a/user_service_view.py b/user_service_view.py index 06167f6..af6fb73 100644 --- a/user_service_view.py +++ b/user_service_view.py @@ -17,6 +17,8 @@ from tkinter import font as tkfont, messagebox, ttk import xml.etree.ElementTree as ET import app_state +import live_hooks +import sax_file import undo from sax_dom import set_prop_value @@ -425,9 +427,32 @@ class _UserDialog: def _apply_add(self, username, password): elem = ET.Element("comp", name=username, type="sys::User") cred = _hash_credentials(username, password) - ET.SubElement(elem, "prop", name="cred", val=cred) - ET.SubElement(elem, "prop", name="perm", val=str(self._perm_val)) - ET.SubElement(elem, "prop", name="prov", val=str(self._prov_val)) + values = {"cred": cred, "perm": str(self._perm_val), "prov": str(self._prov_val)} + + if app_state.live_mode: + # One Sox add carrying every config value, so the user never exists on the + # device without its password and rights. + try: + config = sax_file.build_config_values("sys::User", values) + except (ValueError, TypeError) as e: + live_hooks.warn_failure("Add User", f"User '{username}' was not added to the device", + f"A value does not fit its slot: {e}") + return + new_id = live_hooks.live_add_component( + self._service_comp, "sys", "User", username, config) + if new_id is None: + live_hooks.warn_failure("Add User", f"User '{username}' was not added to the device") + return + elem.set("id", str(new_id)) + + for name, val in values.items(): + ET.SubElement(elem, "prop", name=name, val=val) + + if app_state.live_mode: + self._service_comp.append(elem) + app_state.mark_dirty() + app_state.set_status(f"User '{username}' added to device.") + return index = len(list(self._service_comp)) cmd = undo.AddComponentCommand(self._service_comp, index, elem, label="add user") @@ -458,6 +483,25 @@ class _UserDialog: if not changes: return + if app_state.live_mode: + failures = [] + for elem, prop, _old, new in changes: + if live_hooks.live_set_prop_value(elem, prop, new): + set_prop_value(elem, prop, new) + app_state.mark_dirty() + else: + failures.append(f"{prop}: {live_hooks.last_error}") + if failures: + live_hooks.warn_failure( + "Edit User", + f"{len(failures)} of {len(changes)} change(s) to user '{username}' did " + "not reach the device and were not applied", + "\n".join(f" • {failure}" for failure in failures), + ) + else: + app_state.set_status(f"User '{username}' updated on device.") + return + cmd = undo.SlotValueCommand(changes, label="edit user") cmd.redo() undo.push(cmd) @@ -593,6 +637,16 @@ class UserServicePanel: parent=self.frame.winfo_toplevel()): return + if app_state.live_mode: + if not live_hooks.live_delete_component(user_elem): + live_hooks.warn_failure("Delete User", f"User '{name}' was not deleted from the device") + return + self.comp.remove(user_elem) + app_state.mark_dirty() + app_state.set_status(f"User '{name}' deleted from device.") + self._after_change() + return + index = list(self.comp).index(user_elem) cmd = undo.AddComponentCommand(self.comp, index, user_elem, label="delete user") cmd.undo() diff --git a/wiresheet.py b/wiresheet.py index bd27dda..75bb82b 100644 --- a/wiresheet.py +++ b/wiresheet.py @@ -7,9 +7,11 @@ import tkinter as tk import tkinter.font as tkfont import app_state +import live_hooks import schema import undo -from sax_dom import get_component_path, read_meta_property, update_meta_property +from sax_dom import (get_component_path, read_meta_property, remove_prop, set_prop_value, + update_meta_property) LINK_STUB = 8 # straight run leaving a slot before the wire is allowed to turn @@ -1303,7 +1305,8 @@ def tidy_layout(): the first column and row clear the canvas edge by at least TIDY_MARGIN, and neighbours are left at least TIDY_GAP apart, widened where wires need lanes. A sheet too wide for meta's 0..255 range is laid out at a coarser multiple of the grid rather - than being squashed against the limit. Returns (moved_count, clamped_count). + than being squashed against the limit. Returns (moved_count, clamped_count); in live + mode moved_count is None when the device refused every move, already reported. """ if not app_state.canvas_comp_map: return 0, 0 @@ -1327,6 +1330,32 @@ def tidy_layout(): command = undo.MetaMoveCommand(changes, label="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. + failures = [] + for element, old_val, _, _ in changes: + 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}") + if old_val is None: + remove_prop(element, "meta") + else: + set_prop_value(element, "meta", old_val) + render_current_sheet() + if failures: + live_hooks.warn_failure( + "Tidy Layout", + f"{len(failures)} of {len(changes)} component(s) could not be moved on the " + "device and were left where they were", + "\n".join(f" • {failure}" for failure in failures), + ) + if len(failures) == len(changes): + return None, clamped # already reported; nothing moved + app_state.mark_dirty() + return len(changes) - len(failures), clamped + undo.push(command) app_state.mark_dirty() render_current_sheet()