Files
sedona_editor/sax_dom.py
T
ArdaandClaude Opus 5 2db230dec1 Properties view: every slot of a component, edited in the editor pane
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>
2026-09-05 23:38:03 +03:00

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)