Back and Forward walk the panels too, not just the sheets

Opening a Properties or Link view was invisible to the history, so Back
from one did two things at once: it took the panel down and stepped to the
sheet before the one it was opened over. There was no way back to a
picture you had just been reading.

A history entry is now a (kind, element) pair naming what the editor pane
held -- a sheet, a Properties view, or a Link View -- and restoring one
puts that back: a sheet entry selects its tree node, a panel entry
re-opens the panel. So opening a panel is a move like opening a sheet.
Back from a Properties view lands on whatever came before it and Forward
brings the panel back, and following a link out of the Link View now has a
way home: Back returns to the picture the link was followed from, and a
second Back to the sheet that was open before that.

The panel's own Close is deliberately not recorded. It means "leave this
view", and recording it would put a sheet entry after the panel's, so Back
would re-open the panel that had just been closed. Leaving the position
alone means Back steps to what came before the panel and Forward re-opens
it, which is what the arrows are for.

Both arrows still go through close_editor_panel first, so unsaved
Properties edits are asked about exactly as before and a refusal leaves
the view and the position where they were. Pruning now has two kinds of
entry to check -- a sheet needs a tree node, a panel needs its component
still in the document -- and collapses any pair it leaves naming the same
view, since a Back that visibly does nothing is worse than a shorter
trail.

sheet_history and its functions are renamed for what they now hold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 19:39:45 +03:00
co-authored by Claude Opus 5
parent b119e02b9e
commit 1546c1c8e0
3 changed files with 160 additions and 72 deletions
+36 -10
View File
@@ -123,14 +123,36 @@ the boxes back to 1:1 for the routing pass and scales the result again, since th
reads box positions straight off the canvas. Canvas coordinates therefore carry the zoom, reads box positions straight off the canvas. Canvas coordinates therefore carry the zoom,
which is why the drag, release and drop handlers snap against `meta_scale * zoom`. which is why the drag, release and drop handlers snap against `meta_scale * zoom`.
**Back** and **Forward** walk the sheets opened this session, mirrored by the two arrow **Back** and **Forward** walk everything the **editor pane** has held this session,
buttons in the toolbar. The history records the element whose children are painted -- the mirrored by the two arrow buttons in the toolbar -- sheets and the two panels alike. Each
same thing `app_state.current_sheet_parent` holds -- rather than a tree item id, because entry is a `(kind, element)` pair naming one of the three:
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 | Entry | What Back or Forward puts back |
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 | `(VIEW_SHEET, element)` | selects that element's tree node, which is what paints its children |
walk cannot rewrite the trail it is following. | `(VIEW_PROPERTIES, comp)` | re-opens the Properties view of that component |
| `(VIEW_LINKS, comp)` | re-opens the Link View of that component |
So opening a panel is a move like opening a sheet: **Back** from a Properties view lands
on whatever the pane showed before it rather than skipping past to the sheet before that,
and **Forward** brings the panel back. Following a link out of the Link View is an
ordinary sheet opening, so Back from where it lands returns to the picture the link was
followed from, and a second Back to the sheet that was open before that.
The panel's own **Close** is not recorded. It means "leave this view", and recording it
would put a sheet entry after the panel's -- so Back would re-open the panel that had
just been closed. Leaving the position where it is instead means Back still steps to what
came before the panel, and Forward re-opens it, which is what the two arrows are for.
Entries hold elements rather than tree item ids, 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 the entries that can no longer be shown -- a sheet
with no node left, a component out of the document -- keeping the position on screen, and
collapses any pair that pruning has left naming the same view, since a Back that visibly
does nothing is worse than a shorter trail. Restoring an entry is suppressed from
recording, so a walk cannot rewrite the trail it is following, and both arrows go through
`close_editor_panel` first -- so unsaved Properties edits are still asked about, and a
refusal leaves both the view and the position where they were.
### The component popup ### The component popup
@@ -161,6 +183,9 @@ than leaving the selection looking like it did nothing. So does deleting the com
is showing, from anywhere -- every delete in the UI goes through `delete_component`, is showing, from anywhere -- every delete in the UI goes through `delete_component`,
because `sax_file` does its own view refresh rather than going through `refresh_all_views`. because `sax_file` does its own view refresh rather than going through `refresh_all_views`.
All three are recorded in one history, so **Back** and **Forward** walk them together --
see **The View menu** above.
### The Properties view ### The Properties view
**Properties**, from either component popup, is the one place a slot's value is meant to **Properties**, from either component popup, is the one place a slot's value is meant to
@@ -477,8 +502,9 @@ was wiped by the repaint that followed it. The link is therefore left in
`_pending_link_selection` for `on_tree_select` to honour once it has painted, and that `_pending_link_selection` for `on_tree_select` to honour once it has painted, and that
handler reads and clears the request first thing -- so a selection that turns out not to handler reads and clears the request first thing -- so a selection that turns out not to
paint a sheet drops it rather than leaving it to be applied to some later one. The link is paint a sheet drops it rather than leaving it to be applied to some later one. The link is
then scrolled to the middle of the view. Arriving is an ordinary sheet opening, so **Back** then scrolled to the middle of the view. Arriving is an ordinary sheet opening, so
returns to the sheet the link was followed from. **Back** returns to whatever the pane held before it -- the sheet the wire was
right-clicked on, or the Link View the link was clicked in.
### Testing without a window ### Testing without a window
+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 configparser
import os import os
VERSION = "0.0.0.056" VERSION = "0.0.0.057"
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
+122 -60
View File
@@ -34,10 +34,16 @@ 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 # How many visited views Back and Forward can walk through. A session touches few of
# children, so a session touches few of them; the cap only stops an unbounded list. # them; the cap only stops an unbounded list.
HISTORY_LIMIT = 50 HISTORY_LIMIT = 50
# What a history entry can be showing. The editor pane holds one of these three at a
# time, so the history that walks it has to be able to name each of them.
VIEW_SHEET = "sheet"
VIEW_PROPERTIES = "properties"
VIEW_LINKS = "links"
# Tk class names of the widgets that own the keyboard while they have focus. Del and # 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". # Backspace mean "edit this text" in each of them, never "delete the selected component".
TEXT_ENTRY_CLASSES = ("Entry", "TEntry", "TCombobox", "Text", "Spinbox", "TSpinbox") TEXT_ENTRY_CLASSES = ("Entry", "TEntry", "TCombobox", "Text", "Spinbox", "TSpinbox")
@@ -45,15 +51,26 @@ TEXT_ENTRY_CLASSES = ("Entry", "TEntry", "TCombobox", "Text", "Spinbox", "TSpinb
MENU_BACK_LABEL = "Back" MENU_BACK_LABEL = "Back"
MENU_FORWARD_LABEL = "Forward" MENU_FORWARD_LABEL = "Forward"
# --- Sheet view history --- # --- Editor pane history ---
# Each entry is the element whose children are painted, which is what # Each entry is (kind, element), naming what the pane was showing:
# 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 # (VIEW_SHEET, element) the sheet whose children are painted, which is what
# elements themselves survive anything short of a delete or a new file. # app_state.current_sheet_parent holds; None is the
sheet_history = [] # file's root node
history_index = -1 # position in sheet_history that is on screen # (VIEW_PROPERTIES, comp) the Properties view of that component
_suppress_history = False # set while Back or Forward drives the tree selection # (VIEW_LINKS, comp) the Link View of that component
_history_file_path = None # the file sheet_history was recorded against #
# Opening a Properties or Link view is a move like opening a sheet, so Back from one
# lands on what the pane showed before it rather than skipping past to the sheet before
# that, and Forward brings it back.
#
# 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.
view_history = []
history_index = -1 # position in view_history that is on screen
_suppress_history = False # set while Back or Forward restores an entry
_history_file_path = None # the file view_history was recorded against
# Filled in once the widgets exist. History can be recorded before then, so every # Filled in once the widgets exist. History can be recorded before then, so every
# function that touches them checks first. # function that touches them checks first.
@@ -204,6 +221,7 @@ def show_properties(comp):
on_dirty_change=refresh_dirty_display, on_dirty_change=refresh_dirty_display,
) )
properties_panel.frame.pack(fill=tk.BOTH, expand=True) properties_panel.frame.pack(fill=tk.BOTH, expand=True)
record_visit(VIEW_PROPERTIES, comp)
app_state.set_status(f"Properties: {comp.get('name', '?')}") app_state.set_status(f"Properties: {comp.get('name', '?')}")
@@ -246,6 +264,7 @@ def show_link_view(comp):
on_close=dismiss_link_view, on_close=dismiss_link_view,
) )
link_panel.frame.pack(fill=tk.BOTH, expand=True) link_panel.frame.pack(fill=tk.BOTH, expand=True)
record_visit(VIEW_LINKS, comp)
app_state.set_status(f"Link View: {comp.get('name', '?')}") app_state.set_status(f"Link View: {comp.get('name', '?')}")
@@ -488,7 +507,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) record_visit(VIEW_SHEET, 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"]
@@ -1289,27 +1308,50 @@ def _tree_item_for_element(element):
return None return None
def _prune_sheet_history(): def _entry_is_reachable(entry):
"""Drops entries whose sheet no longer exists, keeping the position on screen.""" """Whether a history entry still names something that could be shown.
A sheet entry needs a tree node to select, which is what paints it. A panel entry
needs only its component, since a panel takes the whole pane and is built straight
from the document.
"""
kind, element = entry
if kind == VIEW_SHEET:
return _tree_item_for_element(element) is not None
return _component_in_document(element)
def _prune_view_history():
"""Drops entries that can no longer be shown, keeping the position on screen."""
global history_index global history_index
kept = [] kept = []
new_index = -1 new_index = -1
for position, element in enumerate(sheet_history): for position, entry in enumerate(view_history):
if _tree_item_for_element(element) is None: if not _entry_is_reachable(entry):
continue continue
kept.append(element)
# Dropping an entry can leave the two either side of it naming the same view,
# and a Back that visibly does nothing is worse than a shorter trail. The
# position still lands on whichever of the two it was on, since they are the
# same view either way.
if kept and kept[-1][0] == entry[0] and kept[-1][1] is entry[1]:
if position <= history_index:
new_index = len(kept) - 1
continue
kept.append(entry)
if position <= history_index: if position <= history_index:
new_index = len(kept) - 1 new_index = len(kept) - 1
sheet_history[:] = kept view_history[:] = kept
history_index = min(new_index, len(kept) - 1) history_index = min(new_index, len(kept) - 1)
def update_history_controls(): def update_history_controls():
"""Greys out Back and Forward when there is nothing to walk to in that direction.""" """Greys out Back and Forward when there is nothing to walk to in that direction."""
can_back = history_index > 0 can_back = history_index > 0
can_forward = 0 <= history_index < len(sheet_history) - 1 can_forward = 0 <= history_index < len(view_history) - 1
for button, enabled in ((btn_back, can_back), (btn_forward, can_forward)): for button, enabled in ((btn_back, can_back), (btn_forward, can_forward)):
if button is not None: if button is not None:
@@ -1324,19 +1366,20 @@ def update_history_controls():
) )
def reset_sheet_history(): def reset_view_history():
"""Starts the history over, for a newly opened file.""" """Starts the history over, for a newly opened file."""
global history_index global history_index
sheet_history.clear() view_history.clear()
history_index = -1 history_index = -1
update_history_controls() update_history_controls()
def record_sheet_visit(element): def record_visit(kind, element):
"""Adds a freshly opened sheet to the history, dropping anything ahead of it. """Adds a freshly opened view to the history, dropping anything ahead of it.
Called from the tree selection handler, so it sees every way a sheet is opened. Every way of filling the editor pane comes through here: the tree selection handler
Back and Forward suppress it: walking the history must not rewrite it. for a sheet, and the two panels as they are put up. Back and Forward suppress it,
because walking the history must not rewrite the trail it is following.
""" """
global history_index, _history_file_path global history_index, _history_file_path
@@ -1347,70 +1390,89 @@ def record_sheet_visit(element):
# whether or not the tree rebuild that came with it was announced. # whether or not the tree rebuild that came with it was announced.
if app_state.current_file_path != _history_file_path: if app_state.current_file_path != _history_file_path:
_history_file_path = app_state.current_file_path _history_file_path = app_state.current_file_path
sheet_history.clear() view_history.clear()
history_index = -1 history_index = -1
if 0 <= history_index < len(sheet_history) and sheet_history[history_index] is element: # Opening what is already on screen is not a move. Compared by identity, since two
# sheets are the same sheet only when they are the same element.
if 0 <= history_index < len(view_history):
current_kind, current_element = view_history[history_index]
if current_kind == kind and current_element is element:
return return
del sheet_history[history_index + 1:] del view_history[history_index + 1:]
sheet_history.append(element) view_history.append((kind, element))
if len(sheet_history) > HISTORY_LIMIT: if len(view_history) > HISTORY_LIMIT:
del sheet_history[0] del view_history[0]
history_index = len(sheet_history) - 1 history_index = len(view_history) - 1
update_history_controls() update_history_controls()
def _show_sheet_history_entry(position): def _show_history_entry(position):
"""Opens the sheet the history holds at this position, without recording the move.""" """Puts back what the pane held at this position, without recording the move.
The pane is free by the time this runs: both ways in ask about unsaved edits and
take whatever panel is up back down first.
"""
global history_index, _suppress_history global history_index, _suppress_history
element = sheet_history[position] kind, element = view_history[position]
item_id = _tree_item_for_element(element) if not _entry_is_reachable((kind, element)):
if item_id is None:
return return
tree = app_state.tree
history_index = position
was_selected = tree.selection() == (item_id,)
_suppress_history = True _suppress_history = True
try: try:
# Selecting the node is what paints the sheet, via on_tree_select. if kind == VIEW_SHEET:
tree.selection_set(item_id) _show_sheet_entry(element)
tree.focus(item_id) elif kind == VIEW_PROPERTIES:
tree.see(item_id) show_properties(element)
else:
show_link_view(element)
finally: finally:
_suppress_history = False _suppress_history = False
if was_selected: history_index = position
# 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() update_history_controls()
def _show_sheet_entry(element):
"""Selects the tree node whose children are this sheet, which is what paints it."""
tree = app_state.tree
item_id = _tree_item_for_element(element)
was_selected = tree.selection() == (item_id,)
tree.selection_set(item_id)
tree.focus(item_id)
tree.see(item_id)
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. Leaving a
# panel lands here every time: closing it puts the tree's own sheet back on
# screen without the selection ever having moved.
app_state.current_sheet_parent = element
wiresheet.render_current_sheet()
def go_back(event=None): def go_back(event=None):
"""View menu and toolbar: step to the previously opened sheet.""" """View menu and toolbar: step to whatever the editor pane held before this."""
if not close_editor_panel(): if not close_editor_panel():
return "break" return "break"
_prune_sheet_history() _prune_view_history()
if history_index > 0: if history_index > 0:
_show_sheet_history_entry(history_index - 1) _show_history_entry(history_index - 1)
else: else:
update_history_controls() update_history_controls()
return "break" return "break"
def go_forward(event=None): def go_forward(event=None):
"""View menu and toolbar: step back towards the most recently opened sheet.""" """View menu and toolbar: step back towards the most recently opened view."""
if not close_editor_panel(): if not close_editor_panel():
return "break" return "break"
_prune_sheet_history() _prune_view_history()
if 0 <= history_index < len(sheet_history) - 1: if 0 <= history_index < len(view_history) - 1:
_show_sheet_history_entry(history_index + 1) _show_history_entry(history_index + 1)
else: else:
update_history_controls() update_history_controls()
return "break" return "break"
@@ -1437,9 +1499,9 @@ def on_tree_filled():
if app_state.current_file_path != _history_file_path: if app_state.current_file_path != _history_file_path:
_history_file_path = app_state.current_file_path _history_file_path = app_state.current_file_path
reset_sheet_history() reset_view_history()
else: else:
_prune_sheet_history() _prune_view_history()
update_history_controls() update_history_controls()