Compare commits

...
3 Commits
Author SHA1 Message Date
ArdaandClaude Opus 5.5 f22d4852c9 Fix Manage Kits for apps read from a device
The schema built from a Sox session spelled checksums as signed decimals,
so no kit matched a manifest: Installed showed raw checksums and Action
read "Keep at checksum ...". They are now 8 hex digits like a .sax file,
and checksums compare regardless of zero-padding.

A device session only loads folders as they are opened, so only sys
counted as used. Used kits now come from the whole component tree, read
one request at a time so a small controller is not flooded into dropping
the session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:10:53 +03:00
ArdaandClaude Opus 5.5 088fe69964 Add Rename to the component right-click menu
Rename is offered from both the canvas and the navigation tree. Names are
checked against Sedona's Component.checkName rules (1-7 ASCII chars, a
letter first, then letters, digits or '_') and must be unique among
siblings. In file mode link paths through the old name are rewritten and
the rename is one undo step; in live mode it goes to the device via Sox
and warns if the device rejects it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:22:49 +03:00
ArdaandClaude Opus 5.5 a2d4be6ea8 Make every live edit reach the device, and warn when it doesn't
In live mode the device is now written first and the document only takes
what the device accepted. Every live_hooks failure records a reason, and
warn_failure() shows it in a dialog and the status bar instead of failing
silently.

- Move, properties, OX page editor: rejected values are rolled back and listed
- Add/remove link: no longer edits the DOM alone when an end can't be resolved
- Add/delete component: report why the device refused
- Tidy Layout: sends each new position to the device, reverts refusals,
  marks the document dirty, and pushes no undo step in live mode
- User Manager: add (one Sox add carrying cred/perm/prov), edit and delete
  now go to the device
- Binary Buf slots (e.g. User.cred) are sent as Buf bytes, not as Str text
- Bump version to 0.0.0.136

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:16:00 +03:00
11 changed files with 550 additions and 119 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it.
import configparser import configparser
import os import os
VERSION = "0.0.0.135" VERSION = "0.0.0.138"
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties") PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
DEFAULT_GRID_SIZE = 10 DEFAULT_GRID_SIZE = 10
DEFAULT_ADD_TIMESTAMP = True DEFAULT_ADD_TIMESTAMP = True
+38 -1
View File
@@ -176,6 +176,41 @@ class DeviceSession:
for cid in parent.children for cid in parent.children
if self.sox_client.cache_get(cid) is not None] if self.sox_client.cache_get(cid) is not None]
def used_kit_names(self):
"""Every kit a component on the device belongs to, loading the whole tree to see.
The DOM only holds what has been opened, so it cannot answer this on its own. Only
the tree is read — no config or runtime.
Requests go one at a time. A small controller handles a single folder's burst fine,
but a back-to-back stream of them fills its receive buffer; it stops acking, and
DASP closes the whole session ("unacked send timeout"). Waiting for each reply
before sending the next keeps it at the pace the device sets. Anything already
cached costs nothing, so only the first call is slow.
"""
client = self.sox_client
if client is None:
return set()
pending = [0]
loaded = 0
while pending:
comp_id = pending.pop()
comp = client.cache_get(comp_id)
if comp is None:
comp = client.load_ids([comp_id], checked=False)[0]
loaded += 1
if loaded % 20 == 0:
app_state.set_status(f"Reading components from the device… {loaded}")
if app_state.status_label is not None:
app_state.status_label.update_idletasks()
if comp is not None:
pending.extend(comp.children)
parts = client.schema_parts or []
return {parts[c.kit_id].name for c in client.cache
if c is not None and 0 <= c.kit_id < len(parts)}
def find_component_of_type(self, type_name, slot_name): def find_component_of_type(self, type_name, slot_name):
"""A component of `type_name` with `slot_name` read, loading where services live. """A component of `type_name` with `slot_name` read, loading where services live.
@@ -294,8 +329,10 @@ class DeviceSession:
schema_elem = ET.SubElement(sax_root, "schema") schema_elem = ET.SubElement(sax_root, "schema")
if self.sox_client and self.sox_client.schema_parts: if self.sox_client and self.sox_client.schema_parts:
for part in self.sox_client.schema_parts: for part in self.sox_client.schema_parts:
# Sox sends the checksum as a signed int; .sax files and manifest names
# spell it as 8 hex digits, and nothing matches unless this does too.
ET.SubElement(schema_elem, "kit", ET.SubElement(schema_elem, "kit",
name=part.name, checksum=str(part.checksum)) name=part.name, checksum=f"{part.checksum & 0xFFFFFFFF:08x}")
app_elem = ET.SubElement(sax_root, "app") app_elem = ET.SubElement(sax_root, "app")
if root_comp is not None: if root_comp is not None:
+127 -69
View File
@@ -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. In offline mode, falls through to the original DOM-only code paths.
""" """
import base64
import binascii
from tkinter import messagebox
import app_state 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(): def is_live():
@@ -15,30 +24,59 @@ def is_live():
and app_state.device_session.is_connected) 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 ────────────────────────────────────────────── # ── Property Write ──────────────────────────────────────────────
def live_set_prop_value(element, slot_name, value_str): def live_set_prop_value(element, slot_name, value_str):
"""In live mode, sends a Sox write to the device. Returns True on success.""" """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 return False
ds = app_state.device_session
comp_id = _comp_id(element) comp_id = _comp_id(element)
if comp_id is None: if comp_id is None:
_fail(f"'{element.get('name', '?')}' has no component id.")
return False return False
slot_info = _find_slot(ds, comp_id, slot_name) slot_info = _find_slot(ds, comp_id, slot_name)
if slot_info is None: if slot_info is None:
return False return False
type_id = type_id_for_slot(slot_info["type_name"]) type_id = slot_info["type_id"]
try:
coerced = _coerce_value(type_id, value_str) 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: try:
ds.sox_client.write_prop(comp_id, slot_info["id"], type_id, coerced) ds.sox_client.write_prop(comp_id, slot_info["id"], type_id, coerced)
return True return True
except Exception as e: 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 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): 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.""" """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 return None
ds = app_state.device_session
parent_id = _comp_id(parent_element) parent_id = _comp_id(parent_element)
if parent_id is None: 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) 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) type_id_val = _type_id(ds, kit_id, type_name)
if kit_id is None or type_id_val is None: if type_id_val is None:
return None return _fail(f"The device's '{kit_name}' kit has no type '{type_name}'.")
sox_config = []
if config_values:
for tid, val in config_values:
sox_config.append((tid, val))
try: 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) ds.after_add(new_comp.id)
return new_comp.id return new_comp.id
except Exception as e: except Exception as e:
app_state.set_status(f"Add failed: {e}", warn=True) return _fail(f"The device rejected the add: {e}")
return None
# ── Delete Component ──────────────────────────────────────────── # ── Delete Component ────────────────────────────────────────────
def live_delete_component(element): def live_delete_component(element):
"""In live mode, sends a Sox delete to the device. Returns True on success.""" """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 return False
ds = app_state.device_session
comp_id = _comp_id(element) comp_id = _comp_id(element)
if comp_id is None: if comp_id is None:
_fail(f"'{element.get('name', '?')}' has no component id.")
return False return False
descendant_ids = [] descendant_ids = []
@@ -97,7 +131,7 @@ def live_delete_component(element):
ds.after_delete(comp_id, descendant_ids) ds.after_delete(comp_id, descendant_ids)
return True return True
except Exception as e: except Exception as e:
app_state.set_status(f"Delete failed: {e}", warn=True) _fail(f"The device rejected the delete: {e}")
return False return False
@@ -105,69 +139,70 @@ def live_delete_component(element):
def live_rename_component(element, new_name): def live_rename_component(element, new_name):
"""In live mode, sends a Sox rename to the device. Returns True on success.""" """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 return False
ds = app_state.device_session
comp_id = _comp_id(element) comp_id = _comp_id(element)
if comp_id is None: if comp_id is None:
_fail(f"'{element.get('name', '?')}' has no component id.")
return False return False
try: try:
ds.sox_client.rename(comp_id, new_name) ds.sox_client.rename(comp_id, new_name)
return True return True
except Exception as e: except Exception as e:
app_state.set_status(f"Rename failed: {e}", warn=True) _fail(f"The device rejected the rename: {e}")
return False return False
# ── Link / Unlink ─────────────────────────────────────────────── # ── Link / Unlink ───────────────────────────────────────────────
def live_add_link(from_element, from_slot_name, to_element, to_slot_name): def _link_ends(ds, from_element, from_slot_name, to_element, to_slot_name):
"""In live mode, sends a Sox link command. Returns True on success.""" """(from_id, from_slot_id, to_id, to_slot_id), or None with last_error set."""
if not is_live(): if from_element is None or to_element is None:
return False return _fail("One end of the link is not a component in the loaded tree.")
ds = app_state.device_session
from_id = _comp_id(from_element) from_id = _comp_id(from_element)
to_id = _comp_id(to_element) to_id = _comp_id(to_element)
if from_id is None or to_id is None: 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) 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) to_slot = _find_slot(ds, to_id, to_slot_name)
if from_slot is None or to_slot is None: if to_slot is None:
return False 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: try:
ds.sox_client.link(from_id, from_slot["id"], to_id, to_slot["id"]) ds.sox_client.link(*ends)
return True return True
except Exception as e: except Exception as e:
app_state.set_status(f"Link failed: {e}", warn=True) _fail(f"The device rejected the link: {e}")
return False return False
def live_delete_link(from_element, from_slot_name, to_element, to_slot_name): 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.""" """In live mode, sends a Sox unlink command. Returns True on success."""
if not is_live(): ds = _session()
if ds is None:
return False return False
ends = _link_ends(ds, from_element, from_slot_name, to_element, to_slot_name)
ds = app_state.device_session if ends is None:
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 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: try:
ds.sox_client.unlink(from_id, from_slot["id"], to_id, to_slot["id"]) ds.sox_client.unlink(*ends)
return True return True
except Exception as e: except Exception as e:
app_state.set_status(f"Unlink failed: {e}", warn=True) _fail(f"The device rejected the unlink: {e}")
return False 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): def live_invoke(element, slot_name, arg=None):
"""In live mode, sends a Sox invoke. Returns True on success.""" """In live mode, sends a Sox invoke. Returns True on success."""
if not is_live(): ds = _session()
if ds is None:
return False return False
ds = app_state.device_session
comp_id = _comp_id(element) comp_id = _comp_id(element)
if comp_id is None: if comp_id is None:
_fail(f"'{element.get('name', '?')}' has no component id.")
return False return False
slot_info = _find_slot(ds, comp_id, slot_name) 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"]) ds.sox_client.invoke(comp_id, slot_info["id"])
return True return True
except Exception as e: 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 return False
@@ -210,22 +245,38 @@ def _comp_id(element):
def _find_slot(ds, comp_id, slot_name): 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) comp = ds.sox_client.cache_get(comp_id)
if comp is None: 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)) 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: if type_name and type_name in app_state.final_components_registry:
for s in app_state.final_components_registry[type_name]: for s in app_state.final_components_registry[type_name]:
if s["name"] == slot_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"], 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)) slots = ds.sox_client.slot_registry.get((comp.kit_id, comp.type_id))
if slots is None: 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: for s in slots:
if s["name"] == slot_name: if s["name"] == slot_name:
return s return {"name": s["name"], "id": s["id"],
return None "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): 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): 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 from sox import BOOL_ID, BYTE_ID, SHORT_ID, INT_ID, LONG_ID, FLOAT_ID, DOUBLE_ID, STR_ID
if type_id == BOOL_ID: if type_id == BOOL_ID:
return value_str.lower() in ("true", "1", "yes") return value_str.lower() in ("true", "1", "yes")
elif type_id in (BYTE_ID, SHORT_ID, INT_ID): elif type_id in (BYTE_ID, SHORT_ID, INT_ID, LONG_ID):
try:
return int(value_str) return int(value_str)
elif type_id == LONG_ID: except ValueError:
return int(value_str) return int(value_str, 0) # meta and flags can be written as 0x...
elif type_id == FLOAT_ID: elif type_id in (FLOAT_ID, DOUBLE_ID):
return float(value_str)
elif type_id == DOUBLE_ID:
return float(value_str) return float(value_str)
elif type_id == STR_ID: elif type_id == STR_ID:
return str(value_str) 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 return value_str
+88 -5
View File
@@ -22,7 +22,7 @@ import ox_page_view
import user_service_view import user_service_view
import wiresheet import wiresheet
from sax_dom import (find_component_by_path, find_parent_element, get_component_path, 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 # 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. # 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" return "break"
moved, clamped = wiresheet.tidy_layout() moved, clamped = wiresheet.tidy_layout()
if moved is None:
return "break"
multiplier = app_state.meta_scale // app_state.GRID_SIZE multiplier = app_state.meta_scale // app_state.GRID_SIZE
if moved == 0: if moved == 0:
@@ -1385,6 +1387,71 @@ def open_link_dialog(source_element, target_element):
ttk.Button(btn_frame, text="Cancel", width=10, command=dialog.destroy).pack(side=tk.LEFT, padx=5) ttk.Button(btn_frame, text="Cancel", width=10, command=dialog.destroy).pack(side=tk.LEFT, padx=5)
def open_rename_dialog(comp):
"""Asks for a new name, checking Sedona's naming rules as it is typed."""
window = app_state.window
root = app_state.xml_root_element
if root is None or comp.tag != "comp":
return
parent = find_parent_element(root, comp)
old_name = comp.get("name", "")
dialog = tk.Toplevel(window)
dialog.title("Rename Component")
dialog.resizable(False, False)
dialog.transient(window)
dialog.grab_set()
dialog.geometry(f"+{window.winfo_x() + 100}+{window.winfo_y() + 60}")
main_frame = tk.Frame(dialog, padx=10, pady=10)
main_frame.pack(fill=tk.BOTH, expand=True)
tk.Label(main_frame, text=f"New name for '{old_name}' [id: {comp.get('id', '?')}]:",
anchor="w").grid(row=0, column=0, sticky="w")
name_var = tk.StringVar(value=old_name)
entry = ttk.Entry(main_frame, textvariable=name_var, width=24)
entry.grid(row=1, column=0, sticky="we", pady=(4, 0))
tk.Label(main_frame, text="1-7 characters: a letter, then letters, digits or '_'.",
anchor="w", fg="#666666").grid(row=2, column=0, sticky="w", pady=(4, 0))
error_var = tk.StringVar(value="")
tk.Label(main_frame, textvariable=error_var, anchor="w", fg="#b00020",
wraplength=320, justify="left").grid(row=3, column=0, sticky="w")
btn_frame = tk.Frame(main_frame)
btn_frame.grid(row=4, column=0, pady=(10, 0))
ok_button = ttk.Button(btn_frame, text="OK", width=10)
ok_button.pack(side=tk.LEFT, padx=5)
ttk.Button(btn_frame, text="Cancel", width=10,
command=dialog.destroy).pack(side=tk.LEFT, padx=5)
def current_error():
name = name_var.get()
if name == old_name:
return None
return sax_file.check_component_name(name, parent, comp)
def on_change(*_):
err = current_error()
error_var.set(err or "")
ok_button.state(["disabled"] if err else ["!disabled"])
def on_ok(*_):
if current_error() is not None:
return
new_name = name_var.get()
dialog.destroy()
# An open panel may show the old name, so it is refreshed along with the sheet.
if new_name != old_name and sax_file.rename_component(comp, new_name):
refresh_all_views()
ok_button.configure(command=on_ok)
name_var.trace_add("write", on_change)
entry.bind("<Return>", on_ok)
dialog.bind("<Escape>", lambda _: dialog.destroy())
entry.focus_set()
entry.select_range(0, tk.END)
def fill_component_popup(menu, comp): def fill_component_popup(menu, comp):
"""Fills a popup with the actions that apply to one component. """Fills a popup with the actions that apply to one component.
@@ -1413,6 +1480,12 @@ def fill_component_popup(menu, comp):
command=lambda: show_user_service(comp), command=lambda: show_user_service(comp),
) )
menu.add_separator() menu.add_separator()
if comp.tag == "comp":
# The app itself is named by its appName property, not by a component name.
menu.add_command(
label="Rename...",
command=lambda: open_rename_dialog(comp),
)
menu.add_command( menu.add_command(
label="Delete Component", label="Delete Component",
command=lambda: delete_component(comp), command=lambda: delete_component(comp),
@@ -1543,11 +1616,21 @@ def on_canvas_release(event):
if orig_x != raw_x or orig_y != raw_y: if orig_x != raw_x or orig_y != raw_y:
meta_before = app_state.drag_data.get("meta_before", meta_str) meta_before = app_state.drag_data.get("meta_before", meta_str)
update_meta_property(comp_element, meta_str, raw_x, raw_y) 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']") new_meta = comp_element.find("prop[@name='meta']")
if new_meta is not None: if new_meta is None or not live_hooks.live_set_prop_value(
live_hooks.live_set_prop_value( comp_element, "meta", new_meta.get("val", "0")):
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: if not app_state.live_mode:
undo.push(undo.MetaMoveCommand( undo.push(undo.MetaMoveCommand(
[(comp_element, meta_before, raw_x, raw_y)], [(comp_element, meta_before, raw_x, raw_y)],
+9 -4
View File
@@ -82,6 +82,11 @@ def column_label(version, checksum):
return version if version else checksum return version if version else checksum
def same_checksum(a, b):
"""Whether two checksums name the same build, whatever their zero-padding."""
return bool(a) and bool(b) and a.lower().lstrip("0") == b.lower().lstrip("0")
def action_options(kit_name, current_checksum, is_declared, builds): def action_options(kit_name, current_checksum, is_declared, builds):
"""The Action dropdown for one kit: (label, checksum to apply) pairs, no-op first. """The Action dropdown for one kit: (label, checksum to apply) pairs, no-op first.
@@ -91,7 +96,7 @@ def action_options(kit_name, current_checksum, is_declared, builds):
""" """
installed_version = None installed_version = None
for version, checksum in builds: for version, checksum in builds:
if checksum == current_checksum: if same_checksum(checksum, current_checksum):
installed_version = version installed_version = version
break break
@@ -109,7 +114,7 @@ def action_options(kit_name, current_checksum, is_declared, builds):
installed_rank = palette.version_sort_key(installed_version) if installed_version else None installed_rank = palette.version_sort_key(installed_version) if installed_version else None
for version, checksum in builds: for version, checksum in builds:
if is_declared and checksum == current_checksum: if is_declared and same_checksum(checksum, current_checksum):
continue continue
label = build_label(version, checksum) label = build_label(version, checksum)
@@ -176,7 +181,7 @@ def collect_rows():
installed_text = NO_CHECKSUM installed_text = NO_CHECKSUM
else: else:
installed_text = column_label( installed_text = column_label(
next((v for v, c in builds if c == checksum), ""), checksum next((v for v, c in builds if same_checksum(c, checksum)), ""), checksum
) )
rows.append({ rows.append({
@@ -512,7 +517,7 @@ class KitsPanel:
if "section" in row or not row["declared"] or not row["latest"]: if "section" in row or not row["declared"] or not row["latest"]:
continue continue
latest_checksum = row["latest"][1] latest_checksum = row["latest"][1]
if latest_checksum != row["checksum"]: if not same_checksum(latest_checksum, row["checksum"]):
selections.append((row["kit"], latest_checksum)) selections.append((row["kit"], latest_checksum))
if selections: if selections:
+11 -4
View File
@@ -552,11 +552,13 @@ class OxPageViewPanel:
if not changes: if not changes:
return False return False
# Live: the device first, and the page only shows what the device accepted.
live_failures = [] live_failures = []
for element, name, _before, after in changes: 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) 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: if not app_state.live_mode:
undo.push(undo.SlotValueCommand(changes)) undo.push(undo.SlotValueCommand(changes))
@@ -566,8 +568,13 @@ class OxPageViewPanel:
who = comp.get("name", "?") who = comp.get("name", "?")
if live_failures: if live_failures:
app_state.set_status( live_hooks.warn_failure(
f"{who}: written locally but failed on device: {', '.join(live_failures)}") "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: else:
app_state.set_status( app_state.set_status(
f"{who}: {', '.join(name for _, name, _, _ in changes)} changed.") f"{who}: {', '.join(name for _, name, _, _ in changes)} changed.")
+25 -7
View File
@@ -35,7 +35,7 @@ import app_state
import live_hooks import live_hooks
import schema import schema
import undo 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) update_meta_groups, update_meta_property)
# Sedona slot types, grouped by how a value of that type is parsed and bounded. # 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.") self.status_var.set("No changes to save.")
return 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 = [] 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": if prop_name != "meta":
set_prop_value(element, prop_name, after) set_prop_value(element, prop_name, after)
if live_hooks.is_live(): written = len(changes) - len(live_failures)
if not live_hooks.live_set_prop_value(element, prop_name, after):
live_failures.append(prop_name)
if not app_state.live_mode: if not app_state.live_mode:
undo.push(undo.SlotValueCommand(list(changes))) undo.push(undo.SlotValueCommand(list(changes)))
@@ -1183,11 +1192,20 @@ class PropertiesPanel:
if self.on_change is not None: if self.on_change is not None:
self.on_change() self.on_change()
count = len(changes) count = written
name = self.comp.get('name', '?')
if live_failures: if live_failures:
self.status_var.set( 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: elif app_state.live_mode:
self.status_var.set( self.status_var.set(
f"{count} slot{'' if count == 1 else 's'} written to device." f"{count} slot{'' if count == 1 else 's'} written to device."
+135 -17
View File
@@ -344,6 +344,92 @@ def save_file_as():
MAX_NAME_LENGTH = 7 # every component name in a Sedona app observes this MAX_NAME_LENGTH = 7 # every component name in a Sedona app observes this
def check_component_name(name, parent=None, element=None):
"""Why `name` cannot name a component, or None if it can.
Mirrors Sedona's Component.checkName -- 1 to 7 ASCII characters, a letter first, then
letters, digits or '_' -- plus the uniqueness among siblings that OfflineApp enforces.
`element` is the component being renamed, which may keep its own name.
"""
if len(name) == 0:
return "The name is empty."
if len(name) > MAX_NAME_LENGTH:
return f"The name is longer than {MAX_NAME_LENGTH} characters."
for i, c in enumerate(name):
if "A" <= c <= "Z" or "a" <= c <= "z":
continue
if i == 0:
return "The name must start with a letter (A-Z or a-z)."
if "0" <= c <= "9" or c == "_":
continue
return f"'{c}' is not allowed; use only letters, digits and '_'."
if parent is not None:
for sibling in parent:
if sibling.tag == "comp" and sibling is not element and sibling.get("name") == name:
return f"'{name}' is already used by another component in the same parent."
return None
def rename_component(comp_element, new_name):
"""Renames a component and rewrites the link paths that ran through it.
Returns True when the rename happened. The name is validated here as well as in the
dialog, so nothing can put a name into the file that Sedona would refuse to load.
"""
root = app_state.xml_root_element
if root is None or comp_element.tag != "comp":
return False
old_name = comp_element.get("name", "")
if new_name == old_name:
return False
parent = find_parent_element(root, comp_element)
err = check_component_name(new_name, parent, comp_element)
if err is not None:
messagebox.showwarning("Rename Component", f"'{new_name}' is not a valid name.\n\n{err}")
return False
if app_state.live_mode:
if not live_hooks.live_rename_component(comp_element, new_name):
live_hooks.warn_failure("Rename Component",
f"{old_name} was not renamed on the device")
return False
old_path = get_component_path(comp_element, root)
new_path = old_path[:-len(old_name)] + new_name
# Links address components by path, so every one through the old name moves with it.
link_changes = []
links_elem = root.find("links")
if links_elem is not None:
for link in links_elem:
if link.tag != "link":
continue
for attr in ("from", "to"):
ref = link.get(attr, "")
if ref.startswith(old_path + ".") or ref.startswith(old_path + "/"):
link_changes.append((link, attr, ref, new_path + ref[len(old_path):]))
comp_element.set("name", 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}"
))
app_state.mark_dirty()
refresh_treeview_from_dom(root)
wiresheet.render_current_sheet()
where = " on the device" if app_state.live_mode else ""
app_state.set_status(f"{old_name} renamed to {new_name}{where}.")
return True
def allocate_component_id(): def allocate_component_id():
"""Lowest unused id, matching how this app's ids are dense with no gaps.""" """Lowest unused id, matching how this app's ids are dense with no gaps."""
used = set() used = set()
@@ -415,6 +501,18 @@ def used_kit_names():
if app_state.xml_root_element is None: if app_state.xml_root_element is None:
return names return names
# A device session's DOM only holds the folders opened so far.
session = app_state.device_session
if session is not None and session.is_connected:
try:
return session.used_kit_names()
except Exception as error:
messagebox.showwarning(
"Kits",
f"Could not read every component from the device:\n{error}\n\n"
"Kits shown as used come only from the folders opened so far.",
)
for comp in app_state.xml_root_element.iter("comp"): for comp in app_state.xml_root_element.iter("comp"):
type_str = comp.get("type") or "" type_str = comp.get("type") or ""
if "::" in type_str: if "::" in type_str:
@@ -760,10 +858,13 @@ def reconcile_schema_kits_before_save():
return True return True
def _build_config_values(full_type): def build_config_values(full_type, overrides=None):
"""Builds SOX config_values list from manifest defaults for an add command.""" """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 `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, []) slots = app_state.final_components_registry.get(full_type, [])
config_values = [] config_values = []
@@ -772,12 +873,12 @@ def _build_config_values(full_type):
continue continue
if slot["type"] == "void": if slot["type"] == "void":
continue continue
val = slot["default"] val = (overrides or {}).get(slot["name"], slot["default"])
if val is None: if val is None:
val = properties.type_zero(slot["type"]) val = properties.type_zero(slot["type"])
if val is None: if val is None:
continue 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))) config_values.append((tid, _coerce_value(tid, val)))
return config_values return config_values
@@ -811,12 +912,18 @@ def add_component(kit_name, type_name, grid_x, grid_y):
comp_name = unique_child_name(parent, type_name) 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}" 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( new_id = live_hooks.live_add_component(
parent, kit_name, type_name, comp_name, config_values or None) parent, kit_name, type_name, comp_name, config_values or None)
if new_id is None: if new_id is None:
live_hooks.warn_failure("Add Component", f"{comp_name} was not added to the device")
return None return None
comp_id = new_id comp_id = new_id
else: else:
@@ -847,17 +954,19 @@ def add_component(kit_name, type_name, grid_x, grid_y):
index = len(list(parent)) index = len(list(parent))
parent.append(element) parent.append(element)
if live_hooks.is_live(): if app_state.live_mode:
ds = app_state.device_session ds = app_state.device_session
if ds is not None: if ds is not None:
ds.update_element_from_cache(comp_id, app_state.xml_root_element) ds.update_element_from_cache(comp_id, app_state.xml_root_element)
update_meta_property(element, None, grid_x, grid_y) 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']") meta_prop = element.find("prop[@name='meta']")
if meta_prop is not None: 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: if not app_state.live_mode:
undo.push(undo.AddComponentCommand( undo.push(undo.AddComponentCommand(
@@ -869,7 +978,13 @@ def add_component(kit_name, type_name, grid_x, grid_y):
refresh_treeview_from_dom(app_state.xml_root_element) refresh_treeview_from_dom(app_state.xml_root_element)
wiresheet.render_current_sheet() 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.") app_state.set_status(f"{comp_name} ({kit_name}::{type_name}) added to device.")
elif kit_name in app_state.pending_schema_kits: elif kit_name in app_state.pending_schema_kits:
app_state.set_status( app_state.set_status(
@@ -895,13 +1010,14 @@ def remove_link(link_element):
from_ref = link_element.get("from", "") from_ref = link_element.get("from", "")
to_ref = link_element.get("to", "") 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) 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) 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) 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) 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): 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 return
try: try:
@@ -932,13 +1048,14 @@ def add_link(from_path_slot, to_path_slot):
if links_elem is None: if links_elem is None:
links_elem = ET.SubElement(app_state.xml_root_element, "links") 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) 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) 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) 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) 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): 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 return
link_elem = ET.SubElement(links_elem, "link") link_elem = ET.SubElement(links_elem, "link")
@@ -973,8 +1090,9 @@ def execute_cascading_deletion(comp_element):
if not confirm: if not confirm:
return False return False
if live_hooks.is_live(): if app_state.live_mode:
if not live_hooks.live_delete_component(comp_element): 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 return False
comp_path = get_component_path(comp_element, app_state.xml_root_element) comp_path = get_component_path(comp_element, app_state.xml_root_element)
+22
View File
@@ -248,3 +248,25 @@ class DeleteComponentCommand:
links_parent.remove(link) links_parent.remove(link)
if self.element in list(self.parent): if self.element in list(self.parent):
self.parent.remove(self.element) self.parent.remove(self.element)
class RenameComponentCommand:
"""A rename, plus every link whose path ran through the component's old name."""
def __init__(self, element, old_name, new_name, link_changes, label="rename"):
# link_changes: list of (link_element, attr, old_value, new_value)
self.element = element
self.old_name = old_name
self.new_name = new_name
self.link_changes = link_changes
self.label = label
def undo(self):
self.element.set("name", self.old_name)
for link, attr, old_value, _ in self.link_changes:
link.set(attr, old_value)
def redo(self):
self.element.set("name", self.new_name)
for link, attr, _, new_value in self.link_changes:
link.set(attr, new_value)
+57 -3
View File
@@ -17,6 +17,8 @@ from tkinter import font as tkfont, messagebox, ttk
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
import app_state import app_state
import live_hooks
import sax_file
import undo import undo
from sax_dom import set_prop_value from sax_dom import set_prop_value
@@ -425,9 +427,32 @@ class _UserDialog:
def _apply_add(self, username, password): def _apply_add(self, username, password):
elem = ET.Element("comp", name=username, type="sys::User") elem = ET.Element("comp", name=username, type="sys::User")
cred = _hash_credentials(username, password) cred = _hash_credentials(username, password)
ET.SubElement(elem, "prop", name="cred", val=cred) values = {"cred": cred, "perm": str(self._perm_val), "prov": str(self._prov_val)}
ET.SubElement(elem, "prop", name="perm", val=str(self._perm_val))
ET.SubElement(elem, "prop", name="prov", val=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)) index = len(list(self._service_comp))
cmd = undo.AddComponentCommand(self._service_comp, index, elem, label="add user") cmd = undo.AddComponentCommand(self._service_comp, index, elem, label="add user")
@@ -458,6 +483,25 @@ class _UserDialog:
if not changes: if not changes:
return 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 = undo.SlotValueCommand(changes, label="edit user")
cmd.redo() cmd.redo()
undo.push(cmd) undo.push(cmd)
@@ -593,6 +637,16 @@ class UserServicePanel:
parent=self.frame.winfo_toplevel()): parent=self.frame.winfo_toplevel()):
return 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) index = list(self.comp).index(user_elem)
cmd = undo.AddComponentCommand(self.comp, index, user_elem, label="delete user") cmd = undo.AddComponentCommand(self.comp, index, user_elem, label="delete user")
cmd.undo() cmd.undo()
+31 -2
View File
@@ -7,9 +7,11 @@ import tkinter as tk
import tkinter.font as tkfont import tkinter.font as tkfont
import app_state import app_state
import live_hooks
import schema import schema
import undo 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 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 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 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 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: if not app_state.canvas_comp_map:
return 0, 0 return 0, 0
@@ -1327,6 +1330,32 @@ def tidy_layout():
command = undo.MetaMoveCommand(changes, label="tidy layout") command = undo.MetaMoveCommand(changes, label="tidy layout")
command.redo() # apply it; the command is the single writer of meta 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) undo.push(command)
app_state.mark_dirty() app_state.mark_dirty()
render_current_sheet() render_current_sheet()