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 = []