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