Properties view: every slot of a component, edited in the editor pane

Right-click a component on the wiresheet or in the tree and Properties takes
over the editor pane, listing every slot the manifest declares rather than the
handful a box paints. Config slots that are not @readonly are white and
editable; everything else is grey.

Rows are built from the slot's facets, read against SedonaDoc/niagara.html:
@trueText/@falseText, @range, @precision, @unit, and @min/@max, which bound
what Save accepts. Three facets it had wrong:

  - null is a value, not an absence. control::WriteFloat pairs 'in' with a
    setNull action and the sample app stores val="null" on four of them, so
    null now shows, parses back, and reads as @nullText where declared.
  - @min/@max on a Buf count characters. TcpDevice.ipAddr is a Buf(17) with
    @max 16; a longer string is refused rather than overrunning the device.
  - an int's implicit range is noise. [-2147483648 - 2147483647] said no more
    than "a whole number", so only byte and short print a range they did not
    declare.

meta gets a row of its own: the '>>' opens its user groups as checkboxes and
its position as x and y, since typing 822411269 into a field sets neither.

Save writes only the rows that moved, as one undoable SlotValueCommand, and
refuses the lot if any row fails its range check. An edited field paints the
view in the canvas's own dirty tint, and every way of leaving asks before
discarding.

The file name on the tree's root row goes red while anything is unwritten,
counting both a dirty document and unsaved fields. Two things this needed:
the nav tree runs on its own style, because a themed Treeview's selected
foreground beats a row tag's; and the tag is added to or removed from the row
rather than recoloured, because reconfiguring a tag does not repaint rows that
are already drawn.

Also fixes a latent hazard the panel exposed: Del and Backspace are bound
application-wide, so they now stand down while a text field has focus rather
than deleting the component behind it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 23:38:03 +03:00
co-authored by Claude Opus 5
parent 5fde6473a0
commit 2db230dec1
8 changed files with 1675 additions and 15 deletions
+215 -2
View File
@@ -8,6 +8,7 @@ from tkinter import filedialog, font as tkfont, messagebox, ttk
import app_state
import manage_kits
import palette
import properties
import sax_file
import schema
import undo
@@ -36,6 +37,10 @@ MIN_WINDOW_HEIGHT = 300
# children, so a session touches few of them; the cap only stops an unbounded list.
HISTORY_LIMIT = 50
# Tk class names of the widgets that own the keyboard while they have focus. Del and
# Backspace mean "edit this text" in each of them, never "delete the selected component".
TEXT_ENTRY_CLASSES = ("Entry", "TEntry", "TCombobox", "Text", "Spinbox", "TSpinbox")
MENU_BACK_LABEL = "Back"
MENU_FORWARD_LABEL = "Forward"
@@ -57,12 +62,167 @@ zoom_label = None
menu_view = None
show_left_column_var = None
# The Properties view takes the editor pane over from the canvas rather than floating
# above it, so at most one of them is on screen and this is which. None means the sheet.
properties_panel = None
# The sheet to put the tree back on when a sheet change is refused because of unsaved
# edits, and the flag that stops the restoring selection from being read as a fresh
# choice. The element is held rather than the tree item id, for the same reason the
# history holds elements: a rebuild of the tree hands out fresh ids.
properties_return_sheet = None
_restoring_selection = False
def anything_unwritten():
"""Whether there is a change no one has written yet, in the document or in a field.
The Properties view counts before its Save: a typed value is as unwritten as a
dragged box, and a file name that only went red on the second of those would be
telling half the story.
"""
if app_state.is_dirty:
return True
return properties_panel is not None and properties_panel.dirty
def refresh_dirty_display():
"""Puts the file name on the tree's root row in red, or back to its usual colour.
The tag is added to or taken off the row rather than having its colour reconfigured.
Reconfiguring a tag does not reliably repaint rows already on screen, so the name
only turned red on the paths that happened to rebuild the tree -- a Properties Save
did, dragging a box did not.
"""
tree = app_state.tree
if tree is None:
return
rows = tree.get_children()
if not rows:
return
root_row = rows[0]
wanted = (app_state.DIRTY_NAME_TAG,) if anything_unwritten() else ()
if tuple(tree.item(root_row, "tags")) != wanted:
tree.item(root_row, tags=wanted)
def refresh_all_views():
"""Rebuilds tree and canvas from the DOM. Registered as undo/redo's view refresh."""
sax_file.refresh_treeview_from_dom(app_state.xml_root_element)
wiresheet.render_current_sheet()
# An undo or a cascading delete can take the component the Properties view is showing
# out of the document underneath it. Nothing it could display then would be true.
if properties_panel is not None and not _component_in_document(properties_panel.comp):
close_properties(force=True)
def _component_in_document(comp):
root = app_state.xml_root_element
return root is not None and any(element is comp for element in root.iter())
def delete_component(comp):
"""Deletes a component, taking its Properties view down with it if one is showing.
Every delete in the UI comes through here. sax_file does its own view refresh rather
than going through refresh_all_views, so the panel would otherwise be left describing
a component the document no longer has.
"""
showing = properties_panel is not None and properties_panel.comp is comp
if sax_file.execute_cascading_deletion(comp) and showing:
close_properties(force=True)
def show_properties(comp):
"""Puts one component's slots in the editor pane, in place of the wiresheet.
The canvas is only unpacked, never destroyed, so the sheet keeps its items and its
scroll position and comes back exactly as it was. It is still painted while it is
hidden -- a Save here repaints it -- which costs nothing and means there is no
catching up to do when it returns.
"""
global properties_panel, properties_return_sheet
if comp is None:
return
# Right-clicking a second component is a way of leaving the first, so it asks about
# unsaved edits exactly as Close does, and a refusal leaves the first one showing.
if not close_properties():
return
properties_return_sheet = app_state.current_sheet_parent
canvas_vscroll.pack_forget()
canvas_hscroll.pack_forget()
app_state.canvas.pack_forget()
properties_panel = properties.build_properties_panel(
frame_editor, comp, on_change=refresh_all_views, on_close=dismiss_properties,
on_dirty_change=refresh_dirty_display,
)
properties_panel.frame.pack(fill=tk.BOTH, expand=True)
app_state.set_status(f"Properties: {comp.get('name', '?')}")
def dismiss_properties(restore_canvas=True):
"""Takes the Properties view down and gives the pane back, asking nothing.
The panel's own Close is what asks; by the time it calls this the question has been
put and answered. Anything that has made the view moot -- the component deleted, the
file closed -- comes straight here too.
"""
global properties_panel
if properties_panel is None:
return
properties_panel.destroy()
properties_panel = None
refresh_dirty_display()
if restore_canvas:
# Repacked in the order the pane was built in, so the scrollbars keep their edges.
canvas_vscroll.pack(side=tk.RIGHT, fill=tk.Y)
canvas_hscroll.pack(side=tk.BOTTOM, fill=tk.X)
app_state.canvas.pack(fill=tk.BOTH, expand=True)
app_state.clear_status()
def close_properties(force=False):
"""Asks about unsaved edits, then takes the view down. True if the pane is now free.
Every way of leaving the Properties view goes through this, so "you have not saved
that" is asked once, in one place, whether the user pressed Close, picked another
sheet, walked the history or opened a different component.
"""
if properties_panel is None:
return True
if force:
dismiss_properties()
return True
return bool(properties_panel.close())
def _restore_tree_selection():
"""Puts the tree back on the sheet that is actually showing, after a refused change."""
global _restoring_selection
item_id = _tree_item_for_element(properties_return_sheet)
if item_id is None:
return
_restoring_selection = True
app_state.tree.selection_set(item_id)
# ttk queues its selection event rather than dispatching it, so the flag has to
# outlive this call or the restoring selection is read as a fresh choice.
app_state.window.after_idle(_clear_restoring_flag)
def _clear_restoring_flag():
global _restoring_selection
_restoring_selection = False
def do_undo(event=None):
label = undo.undo()
@@ -218,6 +378,16 @@ def on_canvas_wheel_zoom(event):
def on_tree_select(event):
"""Triggered when a branch node is clicked in the Navigation view."""
if _restoring_selection:
return
# Choosing a node means "show me that sheet", so the Properties view stands down
# rather than leaving the selection looking like it did nothing. Refusing to leave
# has to put the tree back on the sheet that is still showing.
if not close_properties():
_restore_tree_selection()
return
tree = app_state.tree
selected_items = tree.selection()
if not selected_items:
@@ -380,6 +550,19 @@ def select_link_on_open_sheet(link_elem):
def delete_selected_component(event=None):
"""Deletes the highlighted canvas component (or selected link) and all its descendants recursively."""
# Del is bound application-wide, so it also reaches a Properties field being typed in
# and every other window. A keystroke aimed at a text field must not delete a
# component, and neither must one pressed while the sheet is not even on screen.
if properties_panel is not None:
return
focused = app_state.window.focus_get()
if focused is not None:
if focused.winfo_toplevel() is not app_state.window:
return
if focused.winfo_class() in TEXT_ENTRY_CLASSES:
return
if app_state.selected_link_tag:
delete_selected_link()
return
@@ -389,7 +572,7 @@ def delete_selected_component(event=None):
comp_element = app_state.canvas_comp_map.get(app_state.selected_canvas_tag)
if comp_element is not None:
sax_file.execute_cascading_deletion(comp_element)
delete_component(comp_element)
def on_tree_right_click(event):
@@ -676,9 +859,14 @@ def fill_component_popup(menu, comp):
that was actually clicked.
"""
menu.delete(0, tk.END)
menu.add_command(
label="Properties",
command=lambda: show_properties(comp),
)
menu.add_separator()
menu.add_command(
label="Delete Component",
command=lambda: sax_file.execute_cascading_deletion(comp),
command=lambda: delete_component(comp),
)
menu.add_separator()
menu.add_command(label="Link Mark", command=lambda: set_link_mark(comp))
@@ -1072,6 +1260,8 @@ def _show_sheet_history_entry(position):
def go_back(event=None):
"""View menu and toolbar: step to the previously opened sheet."""
if not close_properties():
return "break"
_prune_sheet_history()
if history_index > 0:
_show_sheet_history_entry(history_index - 1)
@@ -1082,6 +1272,8 @@ def go_back(event=None):
def go_forward(event=None):
"""View menu and toolbar: step back towards the most recently opened sheet."""
if not close_properties():
return "break"
_prune_sheet_history()
if 0 <= history_index < len(sheet_history) - 1:
_show_sheet_history_entry(history_index + 1)
@@ -1099,6 +1291,13 @@ def on_tree_filled():
"""
global _history_file_path
# A different file has no such component, and a rebuild after a delete may have taken
# it. Neither is a moment to ask about edits that now have nowhere to go.
if properties_panel is not None and not _component_in_document(properties_panel.comp):
close_properties(force=True)
# The root row is new after every rebuild, so its colour has to be reapplied.
refresh_dirty_display()
fit_tree_column(app_state.tree)
if app_state.current_file_path != _history_file_path:
@@ -1536,12 +1735,25 @@ left_pane = ttk.PanedWindow(paned_window, orient=tk.VERTICAL)
frame_tree = ttk.Frame(left_pane)
_nav_style = ttk.Style()
_nav_style.map(
app_state.NAV_TREE_STYLE,
# No ("selected", ...) entry: without one the row keeps whatever foreground its tag
# gives it, which is the whole point.
foreground=[("disabled", "SystemGrayText")],
background=[("disabled", "SystemButtonFace"),
("selected", app_state.NAV_SELECTION_BG)],
)
tree = ttk.Treeview(
frame_tree,
show="tree",
selectmode="browse",
height=TREE_REQUESTED_ROWS,
style=app_state.NAV_TREE_STYLE,
)
# Configured once, for the life of the tree. Only the row's tags change after this.
tree.tag_configure(app_state.DIRTY_NAME_TAG, foreground=app_state.DIRTY_NAME_COLOUR)
app_state.tree = tree
attach_tree_scrollbars(frame_tree, tree)
tree.bind("<<TreeviewSelect>>", on_tree_select)
@@ -1777,6 +1989,7 @@ canvas.bind("<Control-Button-5>", do_zoom_out)
paned_window.add(frame_editor, weight=3)
app_state.dirty_display_callback = refresh_dirty_display
undo.set_refresh_callback(refresh_all_views)
sax_file.set_tree_filled_callback(on_tree_filled)
refresh_palette_kits()