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>
329 lines
12 KiB
Python
329 lines
12 KiB
Python
"""Kit manifest loading, slot inheritance, and which slots are worth showing."""
|
|
|
|
import os
|
|
import xml.etree.ElementTree as ET
|
|
|
|
import app_state
|
|
|
|
_dependency_cache = {} # manifest path -> tuple of kit names it depends on
|
|
|
|
|
|
class MissingManifestError(Exception):
|
|
"""A kit the file needs has no manifest under the Sedona home folder.
|
|
|
|
Raised rather than handled here, so the caller decides what to abandon: opening a
|
|
file abandons the whole load, placing a palette component only abandons that
|
|
component. Carrying the kit names lets either one name them in its own wording.
|
|
"""
|
|
|
|
def __init__(self, kit_names, unresolved_type=None):
|
|
self.kit_names = sorted(set(kit_names))
|
|
self.unresolved_type = unresolved_type
|
|
super().__init__(", ".join(self.kit_names))
|
|
|
|
|
|
def describe_missing_manifests(error):
|
|
"""The body of the dialog both callers show, pointing at the folder to check."""
|
|
kit_list = "\n".join(f" • {name}" for name in error.kit_names)
|
|
home = app_state.SEDONA_HOME or "(not set)"
|
|
|
|
lines = ["No manifest was found for:", "", kit_list, "", f"Sedona home folder:\n {home}", ""]
|
|
|
|
if error.unresolved_type:
|
|
lines.append(
|
|
f"Type '{error.unresolved_type}' inherits from a kit that is not in the "
|
|
"file's schema block, so its slots cannot be resolved."
|
|
)
|
|
lines.append("")
|
|
|
|
lines.append(
|
|
"Check that the Sedona home folder is the right one and that it holds "
|
|
"manifests/<kit>/<kit>-<checksum>.xml for each kit listed above. "
|
|
"Preferences → Sedona home folder points it somewhere else."
|
|
)
|
|
return "\n".join(lines)
|
|
|
|
|
|
def read_kit_dependencies(kit_name, checksum=None):
|
|
"""The kits a manifest declares its kit was built against, by name only.
|
|
|
|
A manifest lists them as <depend on="sys 1+"/>; the version bound is dropped here,
|
|
since the schema block pins a build by checksum and has nowhere to record a range.
|
|
|
|
An unreadable or absent manifest yields no dependencies rather than an error: the
|
|
callers use this to widen a list, and a kit whose manifest is missing is already
|
|
reported by whatever needed its types.
|
|
"""
|
|
manifest_path = find_manifest_path(kit_name, checksum)
|
|
if not manifest_path:
|
|
return ()
|
|
if manifest_path in _dependency_cache:
|
|
return _dependency_cache[manifest_path]
|
|
|
|
dependencies = []
|
|
try:
|
|
root = ET.parse(manifest_path).getroot()
|
|
depends_element = root.find("depends")
|
|
if depends_element is not None:
|
|
for depend in depends_element.findall("depend"):
|
|
on = (depend.get("on") or "").strip()
|
|
if on:
|
|
dependencies.append(on.split()[0])
|
|
except Exception as error:
|
|
print(f"Cannot read dependencies of {manifest_path}: {error}")
|
|
|
|
_dependency_cache[manifest_path] = tuple(dependencies)
|
|
return _dependency_cache[manifest_path]
|
|
|
|
|
|
def read_summary_facet(slot_element):
|
|
"""Reads a slot's '@summary' facet. Absent means summary, matching manifest convention."""
|
|
facets = slot_element.find("facets")
|
|
if facets is None:
|
|
return True
|
|
for bool_facet in facets.findall("bool"):
|
|
if bool_facet.get("name") == "summary":
|
|
return (bool_facet.get("val", "true").strip().lower() != "false")
|
|
return True
|
|
|
|
|
|
def read_all_facets(slot_element):
|
|
"""Every facet on a slot, as {name: value} with each one typed by its own tag.
|
|
|
|
The manifest writes facets as <str name="unit" val="celsius"/>, <int name="max"
|
|
val="1000"/> and so on, so the tag is the type. The Properties dialog needs the lot —
|
|
unit, precision, min, max, trueText/falseText, range — and there are few enough of
|
|
them per slot that carrying the whole dict costs less than a lookup per facet.
|
|
"""
|
|
facets = {}
|
|
facet_element = slot_element.find("facets")
|
|
if facet_element is None:
|
|
return facets
|
|
|
|
for child in facet_element:
|
|
name = child.get("name")
|
|
if not name:
|
|
continue
|
|
raw = child.get("val", "")
|
|
if child.tag == "bool":
|
|
facets[name] = (raw.strip().lower() == "true")
|
|
elif child.tag == "int":
|
|
try:
|
|
facets[name] = int(raw)
|
|
except ValueError:
|
|
facets[name] = raw
|
|
elif child.tag in ("float", "double"):
|
|
try:
|
|
facets[name] = float(raw)
|
|
except ValueError:
|
|
facets[name] = raw
|
|
else:
|
|
facets[name] = raw
|
|
return facets
|
|
|
|
|
|
def read_readonly_facet(slot_element):
|
|
"""Reads a slot's '@readonly' facet. Absent means writable."""
|
|
facets = slot_element.find("facets")
|
|
if facets is None:
|
|
return False
|
|
for bool_facet in facets.findall("bool"):
|
|
if bool_facet.get("name") == "readonly":
|
|
return (bool_facet.get("val", "false").strip().lower() == "true")
|
|
return False
|
|
|
|
|
|
def get_slots_with_inheritance(type_name, kit_schemas):
|
|
"""Recursively resolves the slots for a given type by traversing its base chain."""
|
|
if type_name not in kit_schemas:
|
|
kit_prefix = type_name.split("::")[0] if "::" in type_name else type_name
|
|
raise MissingManifestError([kit_prefix], unresolved_type=type_name)
|
|
|
|
type_element = kit_schemas[type_name]
|
|
base_type = type_element.get("base")
|
|
|
|
resolved_slots = []
|
|
if base_type:
|
|
resolved_slots = get_slots_with_inheritance(base_type, kit_schemas)
|
|
|
|
current_slots = []
|
|
for slot in type_element.findall("slot"):
|
|
current_slots.append({
|
|
"name": slot.get("name"),
|
|
"type": slot.get("type"),
|
|
"default": slot.get("default"), # absent means "the type's own zero"
|
|
"flags": slot.get("flags", ""),
|
|
"readonly": read_readonly_facet(slot),
|
|
"summary": read_summary_facet(slot),
|
|
"facets": read_all_facets(slot),
|
|
"declared_id": int(slot.get("id"))
|
|
})
|
|
|
|
current_slots.sort(key=lambda s: s["declared_id"])
|
|
resolved_slots.extend(current_slots)
|
|
return resolved_slots
|
|
|
|
|
|
def compile_schema_registry(manifest_paths):
|
|
"""Loads and maps types across multiple kit files, ensuring all dependencies exist."""
|
|
kit_schemas = {}
|
|
|
|
for path in manifest_paths:
|
|
try:
|
|
tree = ET.parse(path)
|
|
root = tree.getroot()
|
|
kit_name = root.get("name")
|
|
|
|
for type_tag in root.findall("type"):
|
|
type_name = f"{kit_name}::{type_tag.get('name')}"
|
|
kit_schemas[type_name] = type_tag
|
|
except Exception as e:
|
|
print(f"Error parsing manifest {path}: {e}")
|
|
|
|
app_state.final_components_registry.clear()
|
|
for type_name in kit_schemas.keys():
|
|
flat_slots = get_slots_with_inheritance(type_name, kit_schemas)
|
|
|
|
resolved_type_structure = []
|
|
for sequence_id, slot_data in enumerate(flat_slots):
|
|
resolved_type_structure.append({
|
|
"slot_id": sequence_id,
|
|
"name": slot_data["name"],
|
|
"type": slot_data["type"],
|
|
"default": slot_data["default"],
|
|
"flags": slot_data["flags"],
|
|
"readonly": slot_data["readonly"],
|
|
"summary": slot_data["summary"],
|
|
"facets": slot_data["facets"]
|
|
})
|
|
app_state.final_components_registry[type_name] = resolved_type_structure
|
|
|
|
|
|
def find_manifest_path(kit_name, checksum):
|
|
"""The manifest file describing a kit, or None when Sedona home holds none.
|
|
|
|
A declared checksum is honoured when its file exists; otherwise the last manifest by
|
|
filename is taken, the tolerance that lets a file with no checksums still open.
|
|
"""
|
|
manifest_dir = os.path.join(app_state.SEDONA_HOME, "manifests", kit_name)
|
|
|
|
if checksum:
|
|
target_file = os.path.join(manifest_dir, f"{kit_name}-{checksum}.xml")
|
|
if os.path.exists(target_file):
|
|
return target_file
|
|
|
|
if os.path.exists(manifest_dir):
|
|
files = [
|
|
f for f in os.listdir(manifest_dir)
|
|
if f.startswith(f"{kit_name}-") and f.endswith(".xml")
|
|
]
|
|
if files:
|
|
files.sort()
|
|
return os.path.join(manifest_dir, files[-1])
|
|
|
|
return None
|
|
|
|
|
|
def load_schema_kits_and_manifests():
|
|
"""Loads the manifests for the file's <schema> kits, plus any kit still only pending.
|
|
|
|
A component placed from the palette records its kit in ``pending_schema_kits`` rather
|
|
than writing to <schema> straight away — that reconciliation happens on save. Its
|
|
types still have to resolve now, or the box could not be painted, so pending kits are
|
|
compiled into the registry alongside the declared ones.
|
|
"""
|
|
app_state.active_schema_kits.clear()
|
|
if app_state.xml_root_element is None:
|
|
return
|
|
|
|
schema_elem = app_state.xml_root_element.find("schema")
|
|
if schema_elem is None:
|
|
return
|
|
|
|
for kit in schema_elem.findall("kit"):
|
|
name = kit.get("name")
|
|
if name:
|
|
app_state.active_schema_kits[name] = kit.get("checksum")
|
|
|
|
kits_to_load = dict(app_state.active_schema_kits)
|
|
for name, checksum in app_state.pending_schema_kits.items():
|
|
kits_to_load.setdefault(name, checksum)
|
|
|
|
manifest_paths = []
|
|
missing_kits = []
|
|
for name, checksum in kits_to_load.items():
|
|
manifest_path = find_manifest_path(name, checksum)
|
|
if manifest_path:
|
|
manifest_paths.append(manifest_path)
|
|
else:
|
|
# Every missing kit is collected before reporting, so one dialog names them
|
|
# all rather than the user fixing them one reopen at a time.
|
|
missing_kits.append(name)
|
|
|
|
if missing_kits:
|
|
raise MissingManifestError(missing_kits)
|
|
|
|
compile_schema_registry(manifest_paths)
|
|
|
|
|
|
def get_all_slots_for_type(type_str):
|
|
"""Returns every slot for a type as (name, type_string, flags, readonly) tuples."""
|
|
if type_str not in app_state.final_components_registry:
|
|
return []
|
|
return [
|
|
(slot["name"], slot["type"], slot["flags"], slot["readonly"])
|
|
for slot in app_state.final_components_registry[type_str]
|
|
if slot["name"]
|
|
]
|
|
|
|
|
|
def get_full_slots_for_type(type_str):
|
|
"""Every slot of a type, whole, in manifest order — nothing filtered out.
|
|
|
|
get_slots_for_type hides most of them, which is right for a wiresheet box and wrong
|
|
for the Properties dialog: a config slot is only editable somewhere, and this is
|
|
that somewhere. Copies are handed out so a caller cannot edit the registry in place.
|
|
"""
|
|
return [dict(slot) for slot in app_state.final_components_registry.get(type_str, [])
|
|
if slot["name"]]
|
|
|
|
|
|
def get_slots_for_type(type_str, linked_slots=()):
|
|
"""
|
|
Returns the slots worth painting for a given type, prefixed with double-digit
|
|
index tags taken from the manifest so hidden rows leave gaps rather than renumber.
|
|
|
|
Visibility, in order of precedence:
|
|
- a linked slot is always shown, so its wire has a row to anchor to
|
|
- '@summary false' hides the slot, even when it is runtime
|
|
- runtime slots (no 'c' flag) are shown; they hold the live signal path
|
|
- config slots that nothing links to are hidden as clutter
|
|
- the 'o' (operator) flag hides a slot outright
|
|
"""
|
|
if type_str not in app_state.final_components_registry:
|
|
return []
|
|
|
|
slots_list = []
|
|
for slot_data in app_state.final_components_registry[type_str]:
|
|
slot_name = slot_data["name"]
|
|
if not slot_name:
|
|
continue
|
|
if "o" in slot_data["flags"]:
|
|
continue
|
|
|
|
is_linked = slot_name in linked_slots
|
|
if not is_linked:
|
|
if not slot_data.get("summary", True):
|
|
continue
|
|
if "c" in slot_data["flags"]:
|
|
continue
|
|
|
|
# Format to double digits (e.g., 00, 01, 02)
|
|
padded_id = f"{slot_data['slot_id']:02d}"
|
|
display_label = f"{padded_id}. {slot_name}"
|
|
|
|
# Keep structural name unchanged for XML lookup inside comp, but pass display label
|
|
slots_list.append((slot_name, display_label))
|
|
|
|
return slots_list
|