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
+33 -4
View File
@@ -5,7 +5,7 @@ component hierarchy as a navigation tree, paints the selected folder's children
wiresheet of draggable component boxes, draws the links between them, and writes box
positions back into each component's `meta` property on save.
Current version: **0.0.0.048** (see [Versioning](#versioning)).
Current version: **0.0.0.051** (see [Versioning](#versioning)).
## Requirements
@@ -151,7 +151,7 @@ than leaving the selection looking like it did nothing. So does deleting the com
is showing, from anywhere -- every delete in the UI goes through `delete_component`,
because `sax_file` does its own view refresh rather than going through `refresh_all_views`.
It lists **every** slot the manifest declares,
It lists every slot the manifest declares -- bar one kind, see **Hidden slots** below --
in manifest order, each row numbered with the same slot id the wiresheet box uses. The
box's visibility rules do not apply here: they are right for a diagram and wrong for the
one place a value is meant to be changed, so `HvacControl` shows all 47 rows rather than
@@ -177,10 +177,27 @@ A runtime slot holds a live value that no offline file carries, so nothing here
usefully write one; a `@readonly` config slot is the manifest saying the same about a
value that *is* persisted. Both read the same way on screen.
A **dropdown is offered only where there is a choice to make**. A read-only bool or enum
gets a plain field instead, since a disabled dropdown is an arrow that does nothing -- and
a read-only bool is still filled by its state, because a runtime bool has a state too.
The dropdown is a `tk.OptionMenu` rather than a `ttk.Combobox`, for one reason: 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. The plain Tk
widget takes the colour it is given. It reports a change through its variable rather than
an event, which also catches a value set from anywhere else -- and means loading a row
looks exactly like choosing one, so the load is flagged and the tint left alone until every
row is in.
Each row is drawn from the slot's facets, so it reads the way the kit author meant it to:
- `@trueText` / `@falseText` turn a bool into its own two words -- `Disabled` / `Enabled`
rather than false / true -- in a dropdown, with a red or green dot beside it.
rather than false / true -- and **the field is filled by the state**: green for true,
red for false, blue for null. It began as a coloured dot beside the field, which meant
learning what the dot stood for, then as coloured text; a block of colour carries down a
long list of slots where a letterform does not, and the words stay black so they read as
easily as every other value. Dropping the dot also let a bool's field line up with the
numbers above and below it.
- **Null is a value, not an absence.** Sedona models it 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
@@ -262,6 +279,19 @@ system selection.
`mark_clean` through a callback `main` registers -- the same shape `undo` uses for its view
refresh, and what keeps `app_state` free of any idea that a Properties view exists.
### Hidden slots
`@hidden` is the one facet the view filters on, and only 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 rather than being asked again for every component.
The rows are always built -- the checkbox `grid_remove`s and re-`grid`s them, which keeps
each widget's options so putting a row back needs no arithmetic, and an emptied grid row
collapses rather than leaving a gap. The checkbox itself only appears on a type that has
something to reveal, so most components carry no control for a thing they do not have.
### Unsaved edits in the Properties view
An edited field paints the whole view in **`app_state.DIRTY_TINT`** -- the same colour
@@ -431,7 +461,6 @@ Read against `SedonaDoc/niagara.html`, which is what tells Niagara -- and so thi
| Facet | Where it is used | Why not |
|---|---|---|
| `hidden` | 129 slots, every one a `void` action such as `HdwAI.checkType` | The doc says user interfaces should rarely display hidden slots, and the reference tool appears not to. This view shows every slot because that is what it is for; say the word and it will skip them. |
| `fieldWidth` | 3082 slots | A per-slot column count for the text field. Applying it would make the value column ragged; a table reads better with one width. |
| `radix`, `showSeparators`, `multiLine`, `fieldEditor` | unused by any kit under Sedona home | Nothing to render. |
+1 -1
View File
@@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it.
import configparser
import os
VERSION = "0.0.0.048"
VERSION = "0.0.0.051"
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
DEFAULT_GRID_SIZE = 10
DEFAULT_ADD_TIMESTAMP = True
+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