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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user