Compare commits
3
Commits
| 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 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
@@ -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:
|
||||||
|
|||||||
+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.
|
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"]
|
||||||
coerced = _coerce_value(type_id, value_str)
|
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:
|
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):
|
||||||
return int(value_str)
|
try:
|
||||||
elif type_id == LONG_ID:
|
return int(value_str)
|
||||||
return int(value_str)
|
except ValueError:
|
||||||
elif type_id == FLOAT_ID:
|
return int(value_str, 0) # meta and flags can be written as 0x...
|
||||||
return float(value_str)
|
elif type_id in (FLOAT_ID, DOUBLE_ID):
|
||||||
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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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."
|
||||||
|
|||||||
+139
-21
@@ -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,14 +1010,15 @@ 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",
|
||||||
return
|
f"The link {from_ref} → {to_ref} was not removed from the device")
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
index = list(links_elem).index(link_element)
|
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:
|
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",
|
||||||
return
|
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 = ET.SubElement(links_elem, "link")
|
||||||
link_elem.set("from", from_path_slot)
|
link_elem.set("from", from_path_slot)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user