Right-click a component on the wiresheet or in the tree and Properties takes
over the editor pane, listing every slot the manifest declares rather than the
handful a box paints. Config slots that are not @readonly are white and
editable; everything else is grey.
Rows are built from the slot's facets, read against SedonaDoc/niagara.html:
@trueText/@falseText, @range, @precision, @unit, and @min/@max, which bound
what Save accepts. Three facets it had wrong:
- null is a value, not an absence. control::WriteFloat pairs 'in' with a
setNull action and the sample app stores val="null" on four of them, so
null now shows, parses back, and reads as @nullText where declared.
- @min/@max on a Buf count characters. TcpDevice.ipAddr is a Buf(17) with
@max 16; a longer string is refused rather than overrunning the device.
- an int's implicit range is noise. [-2147483648 - 2147483647] said no more
than "a whole number", so only byte and short print a range they did not
declare.
meta gets a row of its own: the '>>' opens its user groups as checkboxes and
its position as x and y, since typing 822411269 into a field sets neither.
Save writes only the rows that moved, as one undoable SlotValueCommand, and
refuses the lot if any row fails its range check. An edited field paints the
view in the canvas's own dirty tint, and every way of leaving asks before
discarding.
The file name on the tree's root row goes red while anything is unwritten,
counting both a dirty document and unsaved fields. Two things this needed:
the nav tree runs on its own style, because a themed Treeview's selected
foreground beats a row tag's; and the tag is added to or removed from the row
rather than recoloured, because reconfiguring a tag does not repaint rows that
are already drawn.
Also fixes a latent hazard the panel exposed: Del and Backspace are bound
application-wide, so they now stand down while a text field has focus rather
than deleting the component behind it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
182 lines
5.9 KiB
Python
182 lines
5.9 KiB
Python
"""Document-level helpers for a Sedona app: the packed 'meta' property and component paths.
|
|
|
|
Pure XML and bit work, deliberately free of Tk so the rules stay testable headless.
|
|
"""
|
|
|
|
import copy
|
|
import xml.etree.ElementTree as ET
|
|
|
|
|
|
def snapshot_children(element):
|
|
"""Copies an element's children and leading text, so it can be put back wholesale.
|
|
|
|
Used for the <schema> block, which is short enough that copying it whole is easier to
|
|
trust than tracking which attribute moved and which entry was inserted where.
|
|
"""
|
|
return [copy.deepcopy(child) for child in element], element.text
|
|
|
|
|
|
def restore_children(element, snapshot):
|
|
"""Puts back what snapshot_children captured, whitespace and all."""
|
|
children, text = snapshot
|
|
for child in list(element):
|
|
element.remove(child)
|
|
element.text = text
|
|
for child in children:
|
|
element.append(copy.deepcopy(child))
|
|
|
|
|
|
def set_prop_value(element, name, value):
|
|
"""Writes a component's <prop>, creating it in the block the other props sit in.
|
|
|
|
A .sax keeps every <prop> ahead of the child <comp> elements, and ET.SubElement
|
|
would append past them, so a new prop is inserted after the last existing one
|
|
instead. That is cosmetic to a parser and not to a human reading the diff.
|
|
"""
|
|
prop = element.find(f"prop[@name='{name}']")
|
|
if prop is not None:
|
|
prop.set("val", value)
|
|
return prop
|
|
|
|
prop = ET.Element("prop", name=name, val=value)
|
|
last_prop_index = -1
|
|
for index, child in enumerate(element):
|
|
if child.tag == "prop":
|
|
last_prop_index = index
|
|
element.insert(last_prop_index + 1, prop)
|
|
return prop
|
|
|
|
|
|
def remove_prop(element, name):
|
|
"""Drops a component's <prop>, for undoing a value that the file never carried."""
|
|
prop = element.find(f"prop[@name='{name}']")
|
|
if prop is not None:
|
|
element.remove(prop)
|
|
|
|
|
|
def read_meta_property(meta_str):
|
|
"""Parses 32-bit meta property integer fields via structured byte extraction rules."""
|
|
if meta_str is None or not meta_str:
|
|
return 0, 0, 0, True, False, False, False, 0
|
|
try:
|
|
if meta_str.startswith("0x") or meta_str.startswith("0X"):
|
|
meta_val = int(meta_str, 16)
|
|
else:
|
|
meta_val = int(meta_str)
|
|
|
|
raw_x = (meta_val >> 24) & 0xFF
|
|
raw_y = (meta_val >> 16) & 0xFF
|
|
reserved = (meta_val >> 8) & 0xFF
|
|
user_group_byte = meta_val & 0xFF
|
|
|
|
group1 = bool(user_group_byte & 0x01)
|
|
group2 = bool(user_group_byte & 0x02)
|
|
group3 = bool(user_group_byte & 0x04)
|
|
group4 = bool(user_group_byte & 0x08)
|
|
user_group_reserved = (user_group_byte >> 4) & 0x0F
|
|
|
|
return raw_x, raw_y, reserved, group1, group2, group3, group4, user_group_reserved
|
|
except Exception:
|
|
return 0, 0, 0, True, False, False, False, 0
|
|
|
|
|
|
def update_meta_property(element, meta_str, raw_x, raw_y):
|
|
"""Re-encodes coordinates back into meta, completely preserving existing lower and consecutive bytes."""
|
|
_, _, reserved, g1, g2, g3, g4, ug_res = read_meta_property(meta_str)
|
|
|
|
user_group_byte = 0
|
|
if g1: user_group_byte |= 0x01
|
|
if g2: user_group_byte |= 0x02
|
|
if g3: user_group_byte |= 0x04
|
|
if g4: user_group_byte |= 0x08
|
|
user_group_byte |= (ug_res & 0x0F) << 4
|
|
|
|
new_val = (
|
|
((raw_x & 0xFF) << 24) |
|
|
((raw_y & 0xFF) << 16) |
|
|
((reserved & 0xFF) << 8) |
|
|
(user_group_byte & 0xFF)
|
|
)
|
|
|
|
meta_prop = element.find("prop[@name='meta']")
|
|
if meta_prop is None:
|
|
meta_prop = ET.SubElement(element, "prop", name="meta")
|
|
|
|
meta_prop.set("val", str(new_val))
|
|
|
|
|
|
def update_meta_groups(element, meta_str, groups):
|
|
"""Rewrites only meta's four user-group bits, leaving the packed position alone.
|
|
|
|
The mirror of update_meta_property, which rewrites only x and y. Both exist because
|
|
meta is one integer carrying two unrelated things, and an editor that touches one of
|
|
them must not disturb the other.
|
|
"""
|
|
raw_x, raw_y, reserved, _, _, _, _, ug_res = read_meta_property(meta_str)
|
|
|
|
user_group_byte = 0
|
|
for bit, on in enumerate(groups[:4]):
|
|
if on:
|
|
user_group_byte |= (1 << bit)
|
|
user_group_byte |= (ug_res & 0x0F) << 4
|
|
|
|
new_val = (
|
|
((raw_x & 0xFF) << 24) |
|
|
((raw_y & 0xFF) << 16) |
|
|
((reserved & 0xFF) << 8) |
|
|
(user_group_byte & 0xFF)
|
|
)
|
|
|
|
meta_prop = element.find("prop[@name='meta']")
|
|
if meta_prop is None:
|
|
meta_prop = ET.SubElement(element, "prop", name="meta")
|
|
|
|
meta_prop.set("val", str(new_val))
|
|
|
|
|
|
def find_parent_element(root_elem, child_to_find):
|
|
"""Traverses the XML DOM tree recursively to find the parent element of a given element."""
|
|
for parent in root_elem.iter():
|
|
if child_to_find in list(parent):
|
|
return parent
|
|
return None
|
|
|
|
|
|
def find_component_by_path(root_elem, path):
|
|
"""The component an absolute path names, or None if the document has no such node.
|
|
|
|
The inverse of get_component_path, and the way a <link>'s from/to reference is
|
|
turned back into the component it points at. Paths carry no entry for <app>, so
|
|
resolution starts among its children.
|
|
"""
|
|
if root_elem is None or not path:
|
|
return None
|
|
|
|
names = [part for part in path.split("/") if part]
|
|
if not names:
|
|
return None
|
|
|
|
node = root_elem.find("app")
|
|
if node is None:
|
|
node = root_elem
|
|
|
|
for name in names:
|
|
node = next(
|
|
(child for child in node if child.tag == "comp" and child.get("name") == name),
|
|
None,
|
|
)
|
|
if node is None:
|
|
return None
|
|
return node
|
|
|
|
|
|
def get_component_path(element, root_elem):
|
|
"""Computes the absolute path of a component within the application tree hierarchy."""
|
|
path_parts = []
|
|
curr = element
|
|
while curr is not None and curr.tag == "comp":
|
|
path_parts.append(curr.get("name", ""))
|
|
curr = find_parent_element(root_elem, curr)
|
|
path_parts.reverse()
|
|
return "/" + "/".join(path_parts)
|