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:
@@ -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` + `Z` | Undo |
|
||||||
| `Ctrl`/`Cmd` + `Y` or `Ctrl`/`Cmd` + `Shift` + `Z` | Redo |
|
| `Ctrl`/`Cmd` + `Y` or `Ctrl`/`Cmd` + `Shift` + `Z` | Redo |
|
||||||
| `Del` / `Backspace` | Delete selected component |
|
| `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
|
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.
|
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
|
### Testing without a window
|
||||||
|
|
||||||
`app_state`, `sax_dom`, `schema`, `undo` and `wiresheet` import with no display, so the
|
`app_state`, `sax_dom`, `schema`, `undo` and `wiresheet` import with no display, so the
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it.
|
|||||||
import configparser
|
import configparser
|
||||||
import os
|
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")
|
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
|
||||||
DEFAULT_GRID_SIZE = 10
|
DEFAULT_GRID_SIZE = 10
|
||||||
DEFAULT_ADD_TIMESTAMP = True
|
DEFAULT_ADD_TIMESTAMP = True
|
||||||
|
|||||||
@@ -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.
|
# ttk has no way to hold a pane at a floor once the room is simply not there.
|
||||||
MIN_WINDOW_HEIGHT = 300
|
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():
|
def refresh_all_views():
|
||||||
"""Rebuilds tree and canvas from the DOM. Registered as undo/redo's view refresh."""
|
"""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.
|
# Remembered so undo/redo and tidy can repaint this same sheet from the DOM.
|
||||||
app_state.current_sheet_parent = element
|
app_state.current_sheet_parent = element
|
||||||
|
record_sheet_visit(element)
|
||||||
|
|
||||||
if element is not None:
|
if element is not None:
|
||||||
components_to_render = [child for child in element if child.tag == "comp"]
|
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)
|
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 ---
|
# --- Dialogs ---
|
||||||
|
|
||||||
def open_preferences_dialog():
|
def open_preferences_dialog():
|
||||||
@@ -957,6 +1173,7 @@ SHORTCUTS = [
|
|||||||
("s", do_save),
|
("s", do_save),
|
||||||
("z", do_undo),
|
("z", do_undo),
|
||||||
("y", do_redo),
|
("y", do_redo),
|
||||||
|
("b", toggle_left_column),
|
||||||
]
|
]
|
||||||
for key, handler in SHORTCUTS:
|
for key, handler in SHORTCUTS:
|
||||||
window.bind_all(f"<Command-{key}>", handler)
|
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("<Delete>", delete_selected_component)
|
||||||
window.bind_all("<BackSpace>", 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"
|
accel_mod = "Cmd" if sys.platform == "darwin" else "Ctrl"
|
||||||
|
|
||||||
# --- Native Application Menu Configurations ---
|
# --- Native Application Menu Configurations ---
|
||||||
@@ -992,6 +1215,23 @@ menu_edit.add_separator()
|
|||||||
menu_edit.add_command(label="Tidy Layout", command=do_tidy_layout)
|
menu_edit.add_command(label="Tidy Layout", command=do_tidy_layout)
|
||||||
menu_bar.add_cascade(label="Edit", menu=menu_edit)
|
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 = tk.Menu(menu_bar, tearoff=0)
|
||||||
menu_kits.add_command(
|
menu_kits.add_command(
|
||||||
label="Manage Kits...",
|
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))
|
status_label.pack(side=tk.BOTTOM, fill=tk.X, padx=8, pady=(0, 4))
|
||||||
app_state.status_label = status_label
|
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 = ttk.PanedWindow(window, orient=tk.HORIZONTAL)
|
||||||
paned_window.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
|
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)
|
paned_window.add(frame_editor, weight=3)
|
||||||
|
|
||||||
undo.set_refresh_callback(refresh_all_views)
|
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()
|
refresh_palette_kits()
|
||||||
apply_left_pane_width()
|
apply_left_pane_width()
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,9 @@ def reset_to_startup_state():
|
|||||||
for item in app_state.tree.get_children():
|
for item in app_state.tree.get_children():
|
||||||
app_state.tree.delete(item)
|
app_state.tree.delete(item)
|
||||||
|
|
||||||
|
if _tree_filled is not None:
|
||||||
|
_tree_filled()
|
||||||
|
|
||||||
if app_state.canvas is not None:
|
if app_state.canvas is not None:
|
||||||
app_state.canvas.delete("all")
|
app_state.canvas.delete("all")
|
||||||
wiresheet.draw_grid()
|
wiresheet.draw_grid()
|
||||||
@@ -161,6 +164,9 @@ def open_file():
|
|||||||
else:
|
else:
|
||||||
parse_xml_to_tree(tree, root_node, app_state.xml_root_element)
|
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:
|
except schema.MissingManifestError as missing:
|
||||||
messagebox.showerror(
|
messagebox.showerror(
|
||||||
"Kit manifest missing",
|
"Kit manifest missing",
|
||||||
|
|||||||
Reference in New Issue
Block a user