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:
+196
-61
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user