Compare commits
3
Commits
f15fc42363
...
f22d4852c9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f22d4852c9 | ||
|
|
088fe69964 | ||
|
|
a2d4be6ea8 |
+1
-1
@@ -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.138"
|
||||
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
|
||||
DEFAULT_GRID_SIZE = 10
|
||||
DEFAULT_ADD_TIMESTAMP = True
|
||||
|
||||
+38
-1
@@ -176,6 +176,41 @@ class DeviceSession:
|
||||
for cid in parent.children
|
||||
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):
|
||||
"""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")
|
||||
if self.sox_client and 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",
|
||||
name=part.name, checksum=str(part.checksum))
|
||||
name=part.name, checksum=f"{part.checksum & 0xFFFFFFFF:08x}")
|
||||
|
||||
app_elem = ET.SubElement(sax_root, "app")
|
||||
if root_comp is not None:
|
||||
|
||||
+129
-71
@@ -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
|
||||
|
||||
@@ -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:
|
||||
@@ -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)
|
||||
|
||||
|
||||
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):
|
||||
"""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),
|
||||
)
|
||||
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(
|
||||
label="Delete Component",
|
||||
command=lambda: delete_component(comp),
|
||||
@@ -1543,11 +1616,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)],
|
||||
|
||||
+9
-4
@@ -82,6 +82,11 @@ def column_label(version, 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):
|
||||
"""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
|
||||
for version, checksum in builds:
|
||||
if checksum == current_checksum:
|
||||
if same_checksum(checksum, current_checksum):
|
||||
installed_version = version
|
||||
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
|
||||
|
||||
for version, checksum in builds:
|
||||
if is_declared and checksum == current_checksum:
|
||||
if is_declared and same_checksum(checksum, current_checksum):
|
||||
continue
|
||||
|
||||
label = build_label(version, checksum)
|
||||
@@ -176,7 +181,7 @@ def collect_rows():
|
||||
installed_text = NO_CHECKSUM
|
||||
else:
|
||||
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({
|
||||
@@ -512,7 +517,7 @@ class KitsPanel:
|
||||
if "section" in row or not row["declared"] or not row["latest"]:
|
||||
continue
|
||||
latest_checksum = row["latest"][1]
|
||||
if latest_checksum != row["checksum"]:
|
||||
if not same_checksum(latest_checksum, row["checksum"]):
|
||||
selections.append((row["kit"], latest_checksum))
|
||||
|
||||
if selections:
|
||||
|
||||
+11
-4
@@ -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.")
|
||||
|
||||
+25
-7
@@ -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."
|
||||
|
||||
+139
-21
@@ -344,6 +344,92 @@ def save_file_as():
|
||||
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():
|
||||
"""Lowest unused id, matching how this app's ids are dense with no gaps."""
|
||||
used = set()
|
||||
@@ -415,6 +501,18 @@ def used_kit_names():
|
||||
if app_state.xml_root_element is None:
|
||||
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"):
|
||||
type_str = comp.get("type") or ""
|
||||
if "::" in type_str:
|
||||
@@ -760,10 +858,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 +873,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 +912,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 +954,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 +978,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 +1010,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 +1048,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 +1090,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)
|
||||
|
||||
@@ -248,3 +248,25 @@ class DeleteComponentCommand:
|
||||
links_parent.remove(link)
|
||||
if self.element in list(self.parent):
|
||||
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
@@ -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()
|
||||
|
||||
+31
-2
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user