Properties: a hidden-slots toggle, and bools filled by their state

@hidden is now filtered by default. niagara.html says hidden slots "are
designed to be invisible to the user" and that interfaces should rarely
display them; every one under Sedona home is a void action a kit uses
internally, such as HdwAI.checkType. A "Hidden slots (n)" checkbox in the
footer brings them back and the answer sticks for the session. The checkbox
only appears on a type that has some, and the rows are always built -- the
toggle grid_removes and re-grids them, so an emptied grid row collapses rather
than leaving a gap.

A bool now says which state it is in by the colour of its field rather than by
a dot beside it that had to be learnt: green true, red false, blue null, with
the words left black so they read as easily as every other value. Dropping the
dot also lets a bool's field line up with the numbers above and below it.

Two things that needed changing to get there:

  - a dropdown is offered only where there is a choice to make. A read-only
    bool or enum gets a plain field, since a disabled dropdown is an arrow
    that does nothing -- and a read-only bool is still filled, because a
    runtime bool has a state too.
  - the dropdown is a tk.OptionMenu, not a ttk.Combobox. On the Windows themes
    a Combobox draws its field with a native element and ignores
    fieldbackground outright, so a bool could never be filled with its colour.

An OptionMenu reports through its variable rather than an event, which means
loading a row looks exactly like choosing one -- so the load is flagged and the
unsaved-state tint left alone until every row is in, or the view would open
already claiming edits nobody made.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-06 00:09:51 +03:00
co-authored by Claude Opus 5
parent 2db230dec1
commit eee48eb2bb
3 changed files with 230 additions and 66 deletions
+196 -61
View File
@@ -5,6 +5,11 @@ table are the same piece of screen and only one of them is ever the thing being
`main` is what hides the canvas and puts this in its place; this module knows only the
frame it was handed.
The one slot it does filter is `@hidden`, and only by default: niagara.html says user
interfaces should rarely display hidden slots, and every one under Sedona home is a `void`
action a kit uses internally. A checkbox in the footer brings them back, and the choice
sticks for the session.
The wiresheet box hides most of a type's slots — `HvacControl` paints 8 of its 47 — which
is right for a diagram and wrong for the one place a value is meant to be changed. This
view is that place, so it filters nothing: every slot the manifest declares gets a row,
@@ -89,9 +94,21 @@ ROW_BG = "#ffffff"
HEADER_BG = "#f2f2f2"
TYPE_COLOUR = "#555555"
PATH_COLOUR = "#7a7a7a"
DOT_TRUE = "#3fa34d"
DOT_FALSE = "#d9534f"
DOT_NULL = "#4a90d9"
# A bool says which state it is in by the colour of its field. Filled rather than
# lettered: a block of colour carries down a long list of slots where coloured text does
# not, and the words stay black so they read as easily as every other value.
BOOL_BACKGROUNDS = {
"true": "#c8e6c9",
"false": "#ffcdd2",
"null": "#bbdefb",
}
def bool_state(raw):
"""Which of the three a value is in: 'true', 'false' or 'null'."""
if is_null(raw):
return "null"
return "true" if str(raw).strip().lower() in ("true", "1") else "false"
FIELD_WIDTH = 18 # characters; the entry and the combobox share it so fields line up
ROW_HEIGHT_HINT = 22
@@ -100,6 +117,10 @@ ROW_HEIGHT_HINT = 22
# models null as NaN for a float and a sentinel for the integer types, and a config slot
# can genuinely hold it: control::WriteFloat pairs an 'in' property with a 'setNull'
# action, and the sample app carries <prop name="in" val="null"/> on four of them.
# Whether hidden slots are listed. Held for the session rather than per panel, so the
# answer does not have to be given again for every component looked at.
show_hidden_slots = False
NULL_VALUE = "null"
NULL_TEXT = "null"
@@ -221,6 +242,16 @@ def effective_value(comp, slot):
return type_zero(slot["type"])
def is_hidden(slot):
"""The '@hidden' facet: a slot a kit keeps for itself.
niagara.html puts it plainly -- hidden slots "are designed to be invisible to the
user", and interfaces should rarely display them. Every one under Sedona home is a
void action such as `HdwAI.checkType`.
"""
return slot["facets"].get("hidden") is True
def is_editable(slot):
"""Config and not read-only. The rule the whole view's greying comes from."""
return "c" in slot["flags"] and not slot["readonly"]
@@ -470,6 +501,10 @@ class PropertiesPanel:
# different thing from whether it has been.
self._tint_targets = []
self.dirty = False
# Set while rows are being filled from the document. An OptionMenu reports through
# its variable, so loading a value looks exactly like choosing one; without this
# the view would open already claiming unsaved edits.
self._loading = False
comp_type = comp.get("type", "sys::Component")
self.slots = schema.get_full_slots_for_type(comp_type)
@@ -481,8 +516,12 @@ class PropertiesPanel:
# sibling nothing at all.
self._build_header(comp_type)
self._build_footer()
self._loading = True
self._build_rows_area()
self._loading = False
self._apply_hidden_filter()
self._load_path()
self._update_tint()
if not self.slots:
self.status_var.set(
@@ -505,7 +544,7 @@ class PropertiesPanel:
for row in self.rows:
if not row["editable"]:
continue
if (row["widget"].get() or "").strip() != row["loaded_text"].strip():
if (self.row_text(row) or "").strip() != row["loaded_text"].strip():
return True
if self.meta_row is not None:
@@ -518,6 +557,8 @@ class PropertiesPanel:
def _update_tint(self):
"""Repaints the view in the same colour the canvas wears when the file is dirty."""
if self._loading:
return
was_dirty = self.dirty
self.dirty = self.has_unsaved_changes()
for widget, normal_background in self._tint_targets:
@@ -532,15 +573,42 @@ class PropertiesPanel:
if was_dirty != self.dirty and self.on_dirty_change is not None:
self.on_dirty_change()
def _watch_for_edits(self, widget, kind):
"""Makes a field repaint the view as soon as its value moves."""
def _watch_for_edits(self, row, widget, kind):
"""Makes a field repaint the view as soon as its value moves.
`kind` is the widget's, not the slot's: an editable bool is a dropdown, a
read-only one is a plain field, and the two report a change differently.
"""
if kind == "text":
widget.bind("<KeyRelease>", lambda _event: self._update_tint(), add="+")
widget.bind("<<Paste>>", lambda _event: self._update_tint(), add="+")
widget.bind("<FocusOut>", lambda _event: self._update_tint(), add="+")
return
# An OptionMenu reports through its variable rather than an event, which also
# catches a value set from anywhere else.
row["var"].trace_add("write", lambda *_args: self._on_choice_changed(row))
def _on_choice_changed(self, row):
# A bool's fill is its value, so picking the other one has to recolour the field
# straight away rather than waiting for the next load.
if row["kind"] == "bool":
self._recolour_bool(row)
self._update_tint()
def _recolour_bool(self, row):
"""Fills a bool row's field by which of the three states it now reads as."""
slot = row["slot"]
text = (self.row_text(row) or "").strip()
false_text, true_text = bool_texts(slot)
if text == true_text:
state = "true"
elif text == false_text:
state = "false"
else:
widget.bind("<<ComboboxSelected>>",
lambda _event: self._update_tint(), add="+")
state = "null"
fill = BOOL_BACKGROUNDS[state]
row["widget"].configure(bg=fill, activebackground=fill)
# -- construction --
@@ -639,62 +707,97 @@ class PropertiesPanel:
dot.grid(row=grid_row, column=0, padx=(6, 2))
return dot
def _apply_hidden_filter(self):
"""Takes hidden rows out of the grid, or puts them back.
grid_remove keeps each widget's options, so putting a row back needs no
arithmetic, and an emptied grid row collapses to nothing rather than leaving a
gap where the slot was.
"""
for row in self.rows:
if not is_hidden(row["slot"]):
continue
for widget in row["widgets"]:
if show_hidden_slots:
widget.grid()
else:
widget.grid_remove()
self.scroll_canvas.configure(scrollregion=self.scroll_canvas.bbox("all"))
def _build_slot_row(self, grid_row, slot):
self._add_bullet(grid_row)
bullet = self._add_bullet(grid_row)
self._tinted(tk.Label(self.rows_frame, text=self._row_label(slot), anchor="w",
bg=ROW_BG, fg=LABEL_COLOUR),
ROW_BG).grid(row=grid_row, column=1, sticky="w", padx=(2, 8))
name_label = self._tinted(tk.Label(self.rows_frame, text=self._row_label(slot),
anchor="w", bg=ROW_BG, fg=LABEL_COLOUR),
ROW_BG)
name_label.grid(row=grid_row, column=1, sticky="w", padx=(2, 8))
self._tinted(tk.Label(self.rows_frame, text=qualified_type(slot["type"]),
anchor="w", bg=ROW_BG, fg=TYPE_COLOUR),
ROW_BG).grid(row=grid_row, column=2, sticky="w", padx=(0, 10))
type_label = self._tinted(tk.Label(self.rows_frame,
text=qualified_type(slot["type"]),
anchor="w", bg=ROW_BG, fg=TYPE_COLOUR), ROW_BG)
type_label.grid(row=grid_row, column=2, sticky="w", padx=(0, 10))
field_frame = self._tinted(tk.Frame(self.rows_frame, bg=ROW_BG), ROW_BG)
field_frame.grid(row=grid_row, column=3, sticky="w", padx=(0, 4))
editable = is_editable(slot)
row = {"slot": slot, "editable": editable, "kind": None, "widget": None,
"state_dot": None, "loaded_text": ""}
"loaded_text": "",
"widgets": [bullet, name_label, type_label, field_frame]}
names = enum_range(slot)
if slot["type"] == "bool":
row["kind"] = "bool"
state_dot = self._tinted(tk.Canvas(field_frame, width=14, height=16,
highlightthickness=0, bg=ROW_BG), ROW_BG)
state_dot.grid(row=0, column=0)
row["state_dot"] = state_dot
false_text, true_text = bool_texts(slot)
choices = [false_text, true_text]
# '@allowNull false' is the manifest saying this bool is only ever one of the
# two, so null is offered on every other bool and withheld here.
if allows_null(slot):
choices.append(null_text(slot))
widget = ttk.Combobox(field_frame, values=choices,
width=FIELD_WIDTH - 3, state="readonly")
widget.grid(row=0, column=1)
elif names and slot["type"] in INTEGER_TYPES:
row["kind"] = "enum"
widget = ttk.Combobox(field_frame, values=list(names),
width=FIELD_WIDTH - 1, state="readonly")
widget.grid(row=0, column=1)
else:
row["kind"] = "text"
# A dropdown is offered only where there is a choice to make: a disabled one on a
# runtime slot is an arrow that does nothing.
row["is_choice"] = editable and row["kind"] in ("bool", "enum")
if row["is_choice"]:
if row["kind"] == "bool":
false_text, true_text = bool_texts(slot)
choices = [false_text, true_text]
# '@allowNull false' is the manifest saying this bool is only ever one of
# the two, so null is offered on every other bool and withheld here.
if allows_null(slot):
choices.append(null_text(slot))
else:
choices = list(names)
# An OptionMenu rather than a ttk.Combobox, for one reason: the themed
# Combobox draws its field with a native element and ignores fieldbackground
# outright, so a bool could never be filled with its colour. This is a plain
# Tk widget and takes the colour it is given.
row["var"] = tk.StringVar()
widget = tk.OptionMenu(field_frame, row["var"], *choices)
widget.configure(width=FIELD_WIDTH - 5, anchor="w", relief="solid", bd=1,
highlightthickness=0, padx=4, pady=0,
bg=EDITABLE_FIELD_BG, activebackground=EDITABLE_FIELD_BG)
widget["menu"].configure(bg=EDITABLE_FIELD_BG)
else:
row["var"] = None
widget = tk.Entry(field_frame, width=FIELD_WIDTH, relief="solid", bd=1,
highlightthickness=0, justify="left",
readonlybackground=READONLY_FIELD_BG)
widget.configure(bg=EDITABLE_FIELD_BG if editable else READONLY_FIELD_BG)
widget.grid(row=0, column=1)
widget.grid(row=0, column=1)
row["widget"] = widget
if editable:
self._watch_for_edits(widget, row["kind"])
self._watch_for_edits(row, widget,
"choice" if row["is_choice"] else "text")
suffix = " ".join(part for part in (unit_symbol(slot["facets"].get("unit")),
range_text(slot)) if part)
self._tinted(tk.Label(self.rows_frame, text=suffix, anchor="w", bg=ROW_BG,
fg="#555555"),
ROW_BG).grid(row=grid_row, column=4, sticky="w", padx=(6, 6))
suffix_label = self._tinted(tk.Label(self.rows_frame, text=suffix, anchor="w",
bg=ROW_BG, fg="#555555"), ROW_BG)
suffix_label.grid(row=grid_row, column=4, sticky="w", padx=(6, 6))
row["widgets"].append(suffix_label)
self.rows.append(row)
self._load_row(row)
@@ -745,6 +848,19 @@ class PropertiesPanel:
# the tint has to put back.
self._tinted(footer, footer.cget("bg"))
# Offered only where there is something to reveal, so most components carry no
# control for a thing they do not have.
hidden_count = sum(1 for slot in self.slots if is_hidden(slot))
if hidden_count:
self.show_hidden_var = tk.BooleanVar(value=show_hidden_slots)
box = tk.Checkbutton(
footer, variable=self.show_hidden_var,
text=f"Hidden slots ({hidden_count})",
command=self._on_show_hidden_toggled,
)
self._tinted(box, box.cget("bg"))
box.pack(side=tk.LEFT, padx=(0, 12))
self.status_var = tk.StringVar(value="")
status = tk.Label(footer, textvariable=self.status_var, anchor="w", fg="#333333")
self._tinted(status, status.cget("bg"))
@@ -757,11 +873,34 @@ class PropertiesPanel:
ttk.Button(footer, text="Refresh", width=9,
command=self.refresh).pack(side=tk.RIGHT)
def _on_show_hidden_toggled(self):
"""The checkbox: remember the answer for the session, then re-filter the rows."""
global show_hidden_slots
show_hidden_slots = bool(self.show_hidden_var.get())
self._apply_hidden_filter()
# -- loading --
def _set_state_dot(self, canvas_widget, colour):
canvas_widget.delete("all")
canvas_widget.create_oval(3, 4, 12, 13, fill=colour, outline="#666666")
def row_text(self, row):
"""What a row currently reads, whichever kind of field it wears."""
if row["var"] is not None:
return row["var"].get()
return row["widget"].get()
def _set_row_text(self, row, text):
if row["var"] is not None:
row["var"].set(text)
return
widget = row["widget"]
widget.configure(state="normal")
widget.delete(0, tk.END)
widget.insert(0, text)
def _field_background(self, row, raw):
"""The fill a row's field takes: a bool's state, or its editability."""
if row["kind"] == "bool":
return BOOL_BACKGROUNDS[bool_state(raw)]
return EDITABLE_FIELD_BG if row["editable"] else READONLY_FIELD_BG
def _load_row(self, row):
"""Puts the DOM's current value into one row's widget and remembers it as loaded."""
@@ -770,28 +909,21 @@ class PropertiesPanel:
text = display_text(slot, raw)
widget = row["widget"]
if row["kind"] == "text":
widget.configure(state="normal")
widget.delete(0, tk.END)
widget.insert(0, text)
if row["editable"]:
widget.configure(state="normal", fg=LABEL_COLOUR, bg=EDITABLE_FIELD_BG)
else:
widget.configure(state="readonly", fg=READONLY_TEXT)
else:
widget.configure(state="readonly")
widget.set(text)
if not row["editable"]:
widget.configure(state="disabled")
# Set before the fill, so a StringVar trace sees the value it is colouring for.
self._set_row_text(row, text)
fill = self._field_background(row, raw)
if row["state_dot"] is not None:
if is_null(raw):
colour = DOT_NULL
elif str(raw).strip().lower() in ("true", "1"):
colour = DOT_TRUE
if row["is_choice"]:
widget.configure(bg=fill, activebackground=fill)
widget["menu"].configure(bg=EDITABLE_FIELD_BG)
else:
# A bool keeps its fill whether or not it can be changed: the colour is what
# says which state the slot is in, and a runtime bool has a state too.
widget.configure(fg=LABEL_COLOUR if row["editable"] else READONLY_TEXT)
if row["editable"]:
widget.configure(state="normal", bg=fill)
else:
colour = DOT_FALSE
self._set_state_dot(row["state_dot"], colour)
widget.configure(state="readonly", readonlybackground=fill)
row["loaded_text"] = text
@@ -902,8 +1034,11 @@ class PropertiesPanel:
def refresh(self):
"""Re-reads every value from the DOM, discarding edits that were not saved."""
self._loading = True
for row in self.rows:
self._load_row(row)
self._loading = False
if self.meta_row is not None:
self._load_meta_row()
self._load_path()
@@ -932,7 +1067,7 @@ class PropertiesPanel:
if not row["editable"]:
continue
slot = row["slot"]
current = (row["widget"].get() or "").strip()
current = (self.row_text(row) or "").strip()
if current == row["loaded_text"].strip():
continue