diff --git a/README.md b/README.md
index 261a84c..570a3f5 100644
--- a/README.md
+++ b/README.md
@@ -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. |
diff --git a/app_state.py b/app_state.py
index 8e3052a..8122ff2 100644
--- a/app_state.py
+++ b/app_state.py
@@ -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
diff --git a/properties.py b/properties.py
index 86190fb..58f3016 100644
--- a/properties.py
+++ b/properties.py
@@ -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 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("", lambda _event: self._update_tint(), add="+")
widget.bind("<>", lambda _event: self._update_tint(), add="+")
widget.bind("", 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("<>",
- 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