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
+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()