From 23a8bfbaa8012bcafddb365aa06e86276fc97ff3 Mon Sep 17 00:00:00 2001 From: Arda Aydin Date: Fri, 4 Sep 2026 21:25:03 +0300 Subject: [PATCH] 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 --- README.md | 17 ++++ app_state.py | 2 +- main.py | 252 ++++++++++++++++++++++++++++++++++++++++++++++++++- sax_file.py | 6 ++ 4 files changed, 275 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 74ac8be..04467e2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app_state.py b/app_state.py index c61b362..fc02231 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.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 diff --git a/main.py b/main.py index e8f69f0..4457335 100644 --- a/main.py +++ b/main.py @@ -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"", handler) @@ -970,6 +1187,12 @@ window.bind_all("", do_redo) window.bind_all("", delete_selected_component) window.bind_all("", delete_selected_component) +# Sheet history: the browser chords, in both the Alt and the mac bracket spelling. +window.bind_all("", go_back) +window.bind_all("", go_forward) +window.bind_all("", go_back) +window.bind_all("", 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("", 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() diff --git a/sax_file.py b/sax_file.py index 7749e8f..97b3093 100644 --- a/sax_file.py +++ b/sax_file.py @@ -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",