View menu: tree/palette toggle, and Back/Forward through opened sheets

Tree / Palette hides the left column so the wiresheet has the whole window.
ttk has no hide flag for a pane, so the column is dropped from the paned
window and re-inserted at the front, which is why the configured width is
re-applied afterwards.

Back and Forward walk the sheets opened this session, with two arrow buttons
in a new toolbar on the same commands. History entries hold the element whose
children are painted rather than a tree item id: rebuilding the tree hands out
fresh ids, while the elements themselves survive. Recording runs from the tree
selection handler, the one route every sheet opening takes, and is suppressed
while a walk drives that selection so it cannot rewrite its own trail. Opening
a different file retires the history; a delete or an undo drops only the
entries whose element has left the document, keeping the position on screen.

open_file builds the tree inline instead of going through
refresh_treeview_from_dom, so it never raised the tree-filled callback and the
history had no way to see a new file. It and reset_to_startup_state now fire
it, which also gets the tree column fitted on load rather than on the first
resize.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 21:25:03 +03:00
co-authored by Claude Opus 5
parent 5424d84156
commit 23a8bfbaa8
4 changed files with 275 additions and 2 deletions
+17
View File
@@ -82,10 +82,27 @@ Undoing back to the depth of the last save clears the dirty tint rather than lea
| `Ctrl`/`Cmd` + `Z` | Undo |
| `Ctrl`/`Cmd` + `Y` or `Ctrl`/`Cmd` + `Shift` + `Z` | Redo |
| `Del` / `Backspace` | Delete selected component |
| `Ctrl`/`Cmd` + `B` | Show or hide the tree/palette column |
| `Alt` + `Left` / `Alt` + `Right` | Back and forward through the sheets opened this session |
Both `Command-` and `Control-` are bound for each, so the same build works on macOS and
Windows; menu accelerators render as `Cmd` or `Ctrl` per platform.
### The View menu
**Tree / Palette** hides the left column so the wiresheet has the whole window, and puts
it back at its configured width. ttk has no hide flag for a pane, so the column is dropped
from the paned window and re-inserted at the front.
**Back** and **Forward** walk the sheets opened this session, mirrored by the two arrow
buttons in the toolbar. The history records the element whose children are painted -- the
same thing `app_state.current_sheet_parent` holds -- rather than a tree item id, because
rebuilding the tree hands out fresh ids while the elements themselves survive. Opening a
different file retires the history; a delete or an undo drops only the entries whose
element has left the document, keeping the position on screen. Walking the history drives
the tree selection, which is what paints the sheet, and is suppressed from recording so a
walk cannot rewrite the trail it is following.
### Testing without a window
`app_state`, `sax_dom`, `schema`, `undo` and `wiresheet` import with no display, so the
+1 -1
View File
@@ -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.028"
VERSION = "0.0.0.029"
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
DEFAULT_GRID_SIZE = 10
DEFAULT_ADD_TIMESTAMP = True
+251 -1
View File
@@ -31,6 +31,30 @@ PALETTE_REQUESTED_ROWS = 5
# ttk has no way to hold a pane at a floor once the room is simply not there.
MIN_WINDOW_HEIGHT = 300
# How many visited sheets Back and Forward can walk through. A sheet is one tree node's
# children, so a session touches few of them; the cap only stops an unbounded list.
HISTORY_LIMIT = 50
MENU_BACK_LABEL = "Back"
MENU_FORWARD_LABEL = "Forward"
# --- Sheet view history ---
# Each entry is the element whose children are painted, which is what
# app_state.current_sheet_parent holds; None is the file's root node. Elements are kept
# rather than tree item ids because a rebuild of the tree hands out fresh ids, while the
# elements themselves survive anything short of a delete or a new file.
sheet_history = []
history_index = -1 # position in sheet_history that is on screen
_suppress_history = False # set while Back or Forward drives the tree selection
_history_file_path = None # the file sheet_history was recorded against
# Filled in once the widgets exist. History can be recorded before then, so every
# function that touches them checks first.
btn_back = None
btn_forward = None
menu_view = None
show_left_column_var = None
def refresh_all_views():
"""Rebuilds tree and canvas from the DOM. Registered as undo/redo's view refresh."""
@@ -110,6 +134,7 @@ def on_tree_select(event):
# Remembered so undo/redo and tidy can repaint this same sheet from the DOM.
app_state.current_sheet_parent = element
record_sheet_visit(element)
if element is not None:
components_to_render = [child for child in element if child.tag == "comp"]
@@ -669,6 +694,197 @@ def apply_left_pane_width():
paned_window.sashpos(0, app_state.LEFT_PANE_WIDTH)
def apply_left_column_visibility():
"""Shows or hides the tree/palette column to match the View menu's checkbutton.
ttk has no hide flag for a pane: the child is dropped from the paned window and put
back at the front, which is why the configured width has to be re-applied afterwards.
"""
if show_left_column_var is None:
return
panes = paned_window.panes()
is_shown = str(left_pane) in [str(pane) for pane in panes]
if show_left_column_var.get():
if not is_shown:
paned_window.insert(0, left_pane, weight=0)
apply_left_pane_width()
elif is_shown:
paned_window.forget(left_pane)
def toggle_left_column(event=None):
"""Keyboard route to the same toggle. The menu flips the variable for itself."""
if show_left_column_var is not None:
show_left_column_var.set(not show_left_column_var.get())
apply_left_column_visibility()
return "break"
# --- Sheet view history ---
def _tree_item_for_element(element):
"""The tree item whose sheet is this element's children, or None if it is gone.
A history entry recorded before a delete, or before another file was opened, no
longer has a node to select; that is what None reports.
"""
tree = app_state.tree
if tree is None:
return None
if element is None:
# The file's root node, which parse never puts in the element map.
children = tree.get_children()
return children[0] if children else None
for item_id, mapped in app_state.tree_element_map.items():
if mapped is element:
return item_id
return None
def _prune_sheet_history():
"""Drops entries whose sheet no longer exists, keeping the position on screen."""
global history_index
kept = []
new_index = -1
for position, element in enumerate(sheet_history):
if _tree_item_for_element(element) is None:
continue
kept.append(element)
if position <= history_index:
new_index = len(kept) - 1
sheet_history[:] = kept
history_index = min(new_index, len(kept) - 1)
def update_history_controls():
"""Greys out Back and Forward when there is nothing to walk to in that direction."""
can_back = history_index > 0
can_forward = 0 <= history_index < len(sheet_history) - 1
for button, enabled in ((btn_back, can_back), (btn_forward, can_forward)):
if button is not None:
button.config(state="normal" if enabled else "disabled")
if menu_view is not None:
menu_view.entryconfig(
MENU_BACK_LABEL, state="normal" if can_back else "disabled"
)
menu_view.entryconfig(
MENU_FORWARD_LABEL, state="normal" if can_forward else "disabled"
)
def reset_sheet_history():
"""Starts the history over, for a newly opened file."""
global history_index
sheet_history.clear()
history_index = -1
update_history_controls()
def record_sheet_visit(element):
"""Adds a freshly opened sheet to the history, dropping anything ahead of it.
Called from the tree selection handler, so it sees every way a sheet is opened.
Back and Forward suppress it: walking the history must not rewrite it.
"""
global history_index, _history_file_path
if _suppress_history:
return
# Entries name elements of one document, so a different file starts a fresh history
# whether or not the tree rebuild that came with it was announced.
if app_state.current_file_path != _history_file_path:
_history_file_path = app_state.current_file_path
sheet_history.clear()
history_index = -1
if 0 <= history_index < len(sheet_history) and sheet_history[history_index] is element:
return
del sheet_history[history_index + 1:]
sheet_history.append(element)
if len(sheet_history) > HISTORY_LIMIT:
del sheet_history[0]
history_index = len(sheet_history) - 1
update_history_controls()
def _show_sheet_history_entry(position):
"""Opens the sheet the history holds at this position, without recording the move."""
global history_index, _suppress_history
element = sheet_history[position]
item_id = _tree_item_for_element(element)
if item_id is None:
return
tree = app_state.tree
history_index = position
was_selected = tree.selection() == (item_id,)
_suppress_history = True
try:
# Selecting the node is what paints the sheet, via on_tree_select.
tree.selection_set(item_id)
tree.focus(item_id)
tree.see(item_id)
finally:
_suppress_history = False
if was_selected:
# ttk fires no selection event when the node is already the selected one, so
# the repaint this entry is asking for has to be made here instead.
app_state.current_sheet_parent = element
wiresheet.render_current_sheet()
update_history_controls()
def go_back(event=None):
"""View menu and toolbar: step to the previously opened sheet."""
_prune_sheet_history()
if history_index > 0:
_show_sheet_history_entry(history_index - 1)
else:
update_history_controls()
return "break"
def go_forward(event=None):
"""View menu and toolbar: step back towards the most recently opened sheet."""
_prune_sheet_history()
if 0 <= history_index < len(sheet_history) - 1:
_show_sheet_history_entry(history_index + 1)
else:
update_history_controls()
return "break"
def on_tree_filled():
"""Runs after every rebuild of the navigation tree.
A rebuild is either a new file, which retires the history, or a repaint after an
undo or a delete, which can leave entries pointing at elements that are no longer
in the document.
"""
global _history_file_path
fit_tree_column(app_state.tree)
if app_state.current_file_path != _history_file_path:
_history_file_path = app_state.current_file_path
reset_sheet_history()
else:
_prune_sheet_history()
update_history_controls()
# --- Dialogs ---
def open_preferences_dialog():
@@ -957,6 +1173,7 @@ SHORTCUTS = [
("s", do_save),
("z", do_undo),
("y", do_redo),
("b", toggle_left_column),
]
for key, handler in SHORTCUTS:
window.bind_all(f"<Command-{key}>", handler)
@@ -970,6 +1187,12 @@ window.bind_all("<Control-Shift-Z>", do_redo)
window.bind_all("<Delete>", delete_selected_component)
window.bind_all("<BackSpace>", delete_selected_component)
# Sheet history: the browser chords, in both the Alt and the mac bracket spelling.
window.bind_all("<Alt-Left>", go_back)
window.bind_all("<Alt-Right>", go_forward)
window.bind_all("<Command-bracketleft>", go_back)
window.bind_all("<Command-bracketright>", go_forward)
accel_mod = "Cmd" if sys.platform == "darwin" else "Ctrl"
# --- Native Application Menu Configurations ---
@@ -992,6 +1215,23 @@ menu_edit.add_separator()
menu_edit.add_command(label="Tidy Layout", command=do_tidy_layout)
menu_bar.add_cascade(label="Edit", menu=menu_edit)
menu_view = tk.Menu(menu_bar, tearoff=0)
show_left_column_var = tk.BooleanVar(value=True)
menu_view.add_checkbutton(
label="Tree / Palette",
variable=show_left_column_var,
command=apply_left_column_visibility,
accelerator=f"{accel_mod}+B",
)
menu_view.add_separator()
menu_view.add_command(
label=MENU_BACK_LABEL, command=go_back, accelerator="Alt+Left", state="disabled"
)
menu_view.add_command(
label=MENU_FORWARD_LABEL, command=go_forward, accelerator="Alt+Right", state="disabled"
)
menu_bar.add_cascade(label="View", menu=menu_view)
menu_kits = tk.Menu(menu_bar, tearoff=0)
menu_kits.add_command(
label="Manage Kits...",
@@ -1015,6 +1255,16 @@ status_label = ttk.Label(window, text="", anchor="w", foreground=app_state.STATU
status_label.pack(side=tk.BOTTOM, fill=tk.X, padx=8, pady=(0, 4))
app_state.status_label = status_label
# Packed ahead of the paned window for the same reason the status bar is.
toolbar = ttk.Frame(window)
toolbar.pack(side=tk.TOP, fill=tk.X, padx=8, pady=(4, 0))
btn_back = ttk.Button(toolbar, text="◀", width=3, command=go_back, state="disabled")
btn_back.pack(side=tk.LEFT)
btn_forward = ttk.Button(toolbar, text="▶", width=3, command=go_forward, state="disabled")
btn_forward.pack(side=tk.LEFT, padx=(3, 0))
paned_window = ttk.PanedWindow(window, orient=tk.HORIZONTAL)
paned_window.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
@@ -1260,7 +1510,7 @@ canvas.bind("<ButtonPress-2>", on_canvas_right_click)
paned_window.add(frame_editor, weight=3)
undo.set_refresh_callback(refresh_all_views)
sax_file.set_tree_filled_callback(lambda: fit_tree_column(tree))
sax_file.set_tree_filled_callback(on_tree_filled)
refresh_palette_kits()
apply_left_pane_width()
+6
View File
@@ -106,6 +106,9 @@ def reset_to_startup_state():
for item in app_state.tree.get_children():
app_state.tree.delete(item)
if _tree_filled is not None:
_tree_filled()
if app_state.canvas is not None:
app_state.canvas.delete("all")
wiresheet.draw_grid()
@@ -161,6 +164,9 @@ def open_file():
else:
parse_xml_to_tree(tree, root_node, app_state.xml_root_element)
if _tree_filled is not None:
_tree_filled()
except schema.MissingManifestError as missing:
messagebox.showerror(
"Kit manifest missing",