Link popup: go to the sheet the other end of the link is on

Right-clicking a wire or a knob now offers Go to Link Wire Sheet "<name>",
naming the sheet the far end is on -- the end a grey knob stands in for. A link
with both ends on the open sheet has nowhere to go, so the entry is left off
rather than greyed out.

find_component_by_path turns a link's from/to reference back into the component
it points at, the inverse of get_component_path, and the sheet is that
component's parent; a parent of <app> is the file's root node, which the popup
names after the file.

Arriving selects the same link and scrolls it to the middle of the view. It is
found by its element rather than by the tag it was picked on, since a repaint
hands out fresh wire tags, and it waits for that repaint from an after_idle
callback: ttk queues its selection event rather than dispatching it, so the
sheet is not painted when the tree node is selected -- and painting a sheet
clears the selected link, which was wiping the selection this feature had just
made. The jump is an ordinary sheet opening, so Back returns to where the link
was picked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 23:26:46 +03:00
co-authored by Claude Opus 5
parent ad8396ef7b
commit a1f8059c49
4 changed files with 191 additions and 3 deletions
+18
View File
@@ -128,6 +128,24 @@ element has left the document, keeping the position on screen. Walking the histo
the tree selection, which is what paints the sheet, and is suppressed from recording so a
walk cannot rewrite the trail it is following.
### Following a link to its other sheet
Right-clicking a wire or a knob offers **Go to Link Wire Sheet "<name>"** alongside Delete
Link, naming the sheet the far end of that link is on -- the end a grey knob stands in
for. The entry is left off a link with both ends on the open sheet, which has nowhere to
go. `find_component_by_path` in `sax_dom` turns a `<link>`'s from/to reference back into
the component it points at, the inverse of `get_component_path`, and the sheet is that
component's parent; a parent of `<app>` is the file's root node, which the popup names
after the file.
The jump selects the same link again once it arrives. It has to be found by its element
rather than by the tag it was picked on, because a repaint hands out fresh wire tags, and
it has to wait for that repaint: ttk queues its selection event rather than dispatching
it, so the sheet is not painted at the point the tree node is selected -- and painting a
sheet clears whatever link was selected. The selection is therefore made from an
`after_idle` callback, and the link is scrolled to the middle of the view. Arriving is an
ordinary sheet opening, so **Back** returns to the sheet the link was picked on.
### 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.034"
VERSION = "0.0.0.035"
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
DEFAULT_GRID_SIZE = 10
DEFAULT_ADD_TIMESTAMP = True
+144 -2
View File
@@ -12,7 +12,8 @@ import sax_file
import schema
import undo
import wiresheet
from sax_dom import get_component_path, read_meta_property, update_meta_property
from sax_dom import (find_component_by_path, find_parent_element, get_component_path,
read_meta_property, update_meta_property)
# Tree column measurements, in pixels. The padding covers the expander arrow and the
# cell's own inset, which sit ahead of the text ttk lets us measure.
@@ -262,6 +263,121 @@ def delete_selected_link():
sax_file.remove_link(link_elem)
def link_endpoint_components(link_elem):
"""The two components a link joins, resolved from its from/to references."""
root = app_state.xml_root_element
ends = []
for attribute in ("from", "to"):
reference = link_elem.get(attribute, "")
path = reference.rsplit(".", 1)[0] if "." in reference else ""
ends.append(find_component_by_path(root, path))
return ends
def link_target_sheet(link_elem):
"""Where a link leads: (sheet element, the component at that end), or None.
The far end is the one that is not on the open sheet, which is exactly the end a
knob stands in for. A link with both ends on this sheet has nowhere to go, and
neither has one whose references name a component this document does not have.
"""
root = app_state.xml_root_element
if root is None or link_elem is None:
return None
here = sax_file.current_sheet_container()
for component in link_endpoint_components(link_elem):
if component is None:
continue
sheet = find_parent_element(root, component)
if sheet is not None and sheet is not here:
return sheet, component
return None
def tree_item_for_sheet(sheet_element):
"""The navigation tree item that opens this sheet, or None if it has no node.
The app element is the file's root node, which the tree holds without an entry in
the element map, and which the history records as None for the same reason.
"""
root = app_state.xml_root_element
app_element = root.find("app") if root is not None else None
if sheet_element is app_element or sheet_element is root:
return _tree_item_for_element(None)
return _tree_item_for_element(sheet_element)
def scroll_canvas_to(tag):
"""Scrolls the sheet so the tagged items sit in the middle of the view."""
canvas = app_state.canvas
box = canvas.bbox(tag)
region = str(canvas.cget("scrollregion")).split()
if box is None or len(region) != 4:
return
span_x = float(region[2]) or 1.0
span_y = float(region[3]) or 1.0
middle_x = (box[0] + box[2]) / 2 - canvas.winfo_width() / 2
middle_y = (box[1] + box[3]) / 2 - canvas.winfo_height() / 2
canvas.xview_moveto(max(0.0, middle_x / span_x))
canvas.yview_moveto(max(0.0, middle_y / span_y))
def select_link_element(link_elem):
"""Selects whichever wire or knob now stands for this link, and scrolls it into view.
A repaint hands out fresh wire tags, so the link is found again by its element
rather than by the tag it had on the sheet it was picked on.
"""
for wire_tag, painted in app_state.canvas_link_map.items():
if painted is link_elem:
wiresheet.select_link(wire_tag)
scroll_canvas_to(wire_tag)
return True
return False
def go_to_link_sheet():
"""Link popup: open the sheet the far end of this link is on, with the link selected."""
link_elem = app_state.canvas_link_map.get(app_state.selected_link_tag)
destination = link_target_sheet(link_elem) if link_elem is not None else None
if destination is None:
app_state.set_status("This link has no other sheet to go to.", warn=True)
return
sheet_element, _ = destination
item_id = tree_item_for_sheet(sheet_element)
if item_id is None:
app_state.set_status(
f"'{sheet_element.get('name', 'That sheet')}' is not in the navigation tree.",
warn=True,
)
return
tree = app_state.tree
# Selecting the node is what paints the sheet, and what records the visit so Back
# returns to the sheet the link was picked on.
tree.selection_set(item_id)
tree.focus(item_id)
tree.see(item_id)
# ttk queues its selection event rather than dispatching it here, so the sheet is
# not painted yet -- and painting a sheet clears whatever link was selected. Picking
# the link out is therefore left until that has happened.
tree.after_idle(select_link_on_open_sheet, link_elem)
def select_link_on_open_sheet(link_elem):
"""Selects a link on the sheet that has just been painted, and says which it is."""
if select_link_element(link_elem):
app_state.set_status(
f"Link {link_elem.get('from', '?')} → {link_elem.get('to', '?')}"
)
else:
app_state.set_status("That link is not painted on this sheet.", warn=True)
def delete_selected_component(event=None):
"""Deletes the highlighted canvas component (or selected link) and all its descendants recursively."""
if app_state.selected_link_tag:
@@ -584,6 +700,7 @@ def on_canvas_right_click(event):
if wire_tag:
wiresheet.select_link(wire_tag)
_rebuild_link_context_menu()
link_context_menu.post(event.x_root, event.y_root)
return
@@ -1236,7 +1353,32 @@ window.minsize(1, MIN_WINDOW_HEIGHT)
# --- Context Popups Structures ---
canvas_context_menu = tk.Menu(window, tearoff=0)
link_context_menu = tk.Menu(window, tearoff=0)
link_context_menu.add_command(label="Delete Link", command=delete_selected_link)
def _rebuild_link_context_menu():
"""Rebuilds the link popup, naming the sheet the far end of the link is on.
A link with both ends on the open sheet is already fully in view, so the jump is
left off rather than greyed out.
"""
link_context_menu.delete(0, tk.END)
link_context_menu.add_command(label="Delete Link", command=delete_selected_link)
link_elem = app_state.canvas_link_map.get(app_state.selected_link_tag)
destination = link_target_sheet(link_elem) if link_elem is not None else None
if destination is None:
return
sheet_element = destination[0]
sheet_name = sheet_element.get("name")
if not sheet_name:
# The app element, which the tree shows under the file's own name.
sheet_name = os.path.basename(app_state.current_file_path or "app")
link_context_menu.add_separator()
link_context_menu.add_command(
label=f'Go to Link Wire Sheet "{sheet_name}"', command=go_to_link_sheet
)
def _rebuild_canvas_context_menu():
+28
View File
@@ -85,6 +85,34 @@ def find_parent_element(root_elem, child_to_find):
return None
def find_component_by_path(root_elem, path):
"""The component an absolute path names, or None if the document has no such node.
The inverse of get_component_path, and the way a <link>'s from/to reference is
turned back into the component it points at. Paths carry no entry for <app>, so
resolution starts among its children.
"""
if root_elem is None or not path:
return None
names = [part for part in path.split("/") if part]
if not names:
return None
node = root_elem.find("app")
if node is None:
node = root_elem
for name in names:
node = next(
(child for child in node if child.tag == "comp" and child.get("name") == name),
None,
)
if node is None:
return None
return node
def get_component_path(element, root_elem):
"""Computes the absolute path of a component within the application tree hierarchy."""
path_parts = []