From a1f8059c49c210be6affe2a2e21fa82311528820 Mon Sep 17 00:00:00 2001 From: Arda Aydin Date: Fri, 4 Sep 2026 23:26:46 +0300 Subject: [PATCH] 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 "", 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 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 --- README.md | 18 +++++++ app_state.py | 2 +- main.py | 146 ++++++++++++++++++++++++++++++++++++++++++++++++++- sax_dom.py | 28 ++++++++++ 4 files changed, 191 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9972c2f..8fa8dfe 100644 --- a/README.md +++ b/README.md @@ -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 ""** 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 ``'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 `` 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 diff --git a/app_state.py b/app_state.py index c1ccf23..a17d98a 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.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 diff --git a/main.py b/main.py index 188c5ea..2926319 100644 --- a/main.py +++ b/main.py @@ -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(): diff --git a/sax_dom.py b/sax_dom.py index bdf2d8e..4118050 100644 --- a/sax_dom.py +++ b/sax_dom.py @@ -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 's from/to reference is + turned back into the component it points at. Paths carry no entry for , 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 = []