Link View: every link of a component, drawn around it

Right-clicking a component now offers Link View alongside Properties, from
the wiresheet and from the navigation tree alike. It takes over the editor
pane the same way and answers one question -- what is wired to this
component, and where does it go.

The chosen component sits in the middle wearing the stroke a selected box
wears on the sheet, with what links into it on the left and what it links
out to on the right, so every arrow points the way the signal travels.
Pale yellow ground and no grid: nothing here can be positioned, because
the layout is derived from the links every time it is drawn and no part of
it is stored in the file. Read-only throughout -- no drag, no add, no
delete -- so a component's wiring can be read without the risk of
rearranging it.

Every line is grey, since this is not a wiresheet and a wire-blue line
would invite the reader to treat it as one. A link on the same sheet is
solid; one that crosses is dashed, keeps the grey knob that sheet paints
on the slot, and has the far component drawn in with its sheet named
beneath it. A reference the document cannot resolve gets a placeholder
naming the path, because a link the view silently dropped would be the one
thing a reader could not find out from here.

Clicking a wire or a component follows the link: it opens the sheet of the
end that is not the component the view is about, with the link selected in
red. A link that stays on one sheet therefore goes back to that sheet
where both ends are drawn, and one that crosses opens the other sheet --
the end the view could only show as a knob.

Supporting changes:

- draw_component_box is factored out of render_components, so the sheet
  and this view paint a component with the same code and a box cannot read
  differently between them. draw_knob takes its canvas for the same
  reason.
- The editor pane's hand-over is now shared: _take_editor_pane,
  _give_editor_pane_back and close_editor_panel replace the
  Properties-only versions, so "you have not saved that" is still asked
  once, in one place, whichever panel has the pane.
- Fixes the jump to a link's other sheet, which arrived with the link not
  selected. An after_idle callback is not late enough -- ttk dispatches
  its own selection event later than one of those, so the repaint wiped
  the selection. The link is left in _pending_link_selection for
  on_tree_select to honour once it has painted.
- Each gap is sized to hold a lane per wire crossing it, and lanes are
  handed out nearest-target-first, so a fan of a dozen links nests instead
  of crowding a dozen verticals into a 30px band.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 18:54:44 +03:00
co-authored by Claude Opus 5
parent eb5ad92015
commit 56749872b4
5 changed files with 1095 additions and 165 deletions
+184 -53
View File
@@ -6,6 +6,7 @@ import tkinter as tk
from tkinter import filedialog, font as tkfont, messagebox, ttk
import app_state
import link_view
import manage_kits
import palette
import properties
@@ -62,17 +63,25 @@ zoom_label = None
menu_view = None
show_left_column_var = None
# The Properties view takes the editor pane over from the canvas rather than floating
# above it, so at most one of them is on screen and this is which. None means the sheet.
# The Properties and Link views each take the editor pane over from the canvas rather
# than floating above it, so at most one of the three is on screen and these two say
# which. Both None means the sheet is showing.
properties_panel = None
link_panel = None
# The sheet to put the tree back on when a sheet change is refused because of unsaved
# edits, and the flag that stops the restoring selection from being read as a fresh
# choice. The element is held rather than the tree item id, for the same reason the
# history holds elements: a rebuild of the tree hands out fresh ids.
properties_return_sheet = None
panel_return_sheet = None
_restoring_selection = False
# The link a jump has asked to be picked out, held until the sheet it is on has been
# painted. Selecting a tree node paints its sheet, and painting a sheet clears whatever
# link was selected, so the pick cannot be made at the point the node is selected --
# see go_to_sheet_with_link.
_pending_link_selection = None
def anything_unwritten():
"""Whether there is a change no one has written yet, in the document or in a field.
@@ -113,10 +122,18 @@ def refresh_all_views():
sax_file.refresh_treeview_from_dom(app_state.xml_root_element)
wiresheet.render_current_sheet()
# An undo or a cascading delete can take the component the Properties view is showing
# out of the document underneath it. Nothing it could display then would be true.
# An undo or a cascading delete can take the component a panel is showing out of the
# document underneath it. Nothing it could display then would be true.
if properties_panel is not None and not _component_in_document(properties_panel.comp):
close_properties(force=True)
close_editor_panel(force=True)
# The Link View reads the document and writes nothing, so a change elsewhere is
# simply redrawn rather than being a reason to take the view down.
if link_panel is not None:
if _component_in_document(link_panel.comp):
link_panel.draw()
else:
close_editor_panel(force=True)
def _component_in_document(comp):
@@ -125,40 +142,63 @@ def _component_in_document(comp):
def delete_component(comp):
"""Deletes a component, taking its Properties view down with it if one is showing.
"""Deletes a component, taking a view of it down with it if one is showing.
Every delete in the UI comes through here. sax_file does its own view refresh rather
than going through refresh_all_views, so the panel would otherwise be left describing
a component the document no longer has.
"""
showing = properties_panel is not None and properties_panel.comp is comp
panel = properties_panel or link_panel
showing = panel is not None and panel.comp is comp
if sax_file.execute_cascading_deletion(comp) and showing:
close_properties(force=True)
close_editor_panel(force=True)
def show_properties(comp):
"""Puts one component's slots in the editor pane, in place of the wiresheet.
def editor_panel_showing():
"""Whether something other than the wiresheet has the editor pane."""
return properties_panel is not None or link_panel is not None
def _take_editor_pane():
"""Hides the sheet so a panel can have the pane.
The canvas is only unpacked, never destroyed, so the sheet keeps its items and its
scroll position and comes back exactly as it was. It is still painted while it is
hidden -- a Save here repaints it -- which costs nothing and means there is no
hidden -- a Properties Save repaints it -- which costs nothing and means there is no
catching up to do when it returns.
"""
global properties_panel, properties_return_sheet
if comp is None:
return
# Right-clicking a second component is a way of leaving the first, so it asks about
# unsaved edits exactly as Close does, and a refusal leaves the first one showing.
if not close_properties():
return
properties_return_sheet = app_state.current_sheet_parent
global panel_return_sheet
panel_return_sheet = app_state.current_sheet_parent
canvas_vscroll.pack_forget()
canvas_hscroll.pack_forget()
app_state.canvas.pack_forget()
def _give_editor_pane_back():
"""Puts the sheet back, in the order the pane was built in.
The scrollbars are repacked before the canvas because pack honours the order it was
given: packed after it, they would take their space from the middle of the pane.
"""
canvas_vscroll.pack(side=tk.RIGHT, fill=tk.Y)
canvas_hscroll.pack(side=tk.BOTTOM, fill=tk.X)
app_state.canvas.pack(fill=tk.BOTH, expand=True)
app_state.clear_status()
def show_properties(comp):
"""Puts one component's slots in the editor pane, in place of the wiresheet."""
global properties_panel
if comp is None:
return
# Right-clicking a second component is a way of leaving the first, so it asks about
# unsaved edits exactly as Close does, and a refusal leaves the first one showing.
if not close_editor_panel():
return
_take_editor_pane()
properties_panel = properties.build_properties_panel(
frame_editor, comp, on_change=refresh_all_views, on_close=dismiss_properties,
on_dirty_change=refresh_dirty_display,
@@ -183,20 +223,60 @@ def dismiss_properties(restore_canvas=True):
refresh_dirty_display()
if restore_canvas:
# Repacked in the order the pane was built in, so the scrollbars keep their edges.
canvas_vscroll.pack(side=tk.RIGHT, fill=tk.Y)
canvas_hscroll.pack(side=tk.BOTTOM, fill=tk.X)
app_state.canvas.pack(fill=tk.BOTH, expand=True)
app_state.clear_status()
_give_editor_pane_back()
def close_properties(force=False):
"""Asks about unsaved edits, then takes the view down. True if the pane is now free.
def show_link_view(comp):
"""Puts one component's links in the editor pane, in place of the wiresheet.
Every way of leaving the Properties view goes through this, so "you have not saved
that" is asked once, in one place, whether the user pressed Close, picked another
sheet, walked the history or opened a different component.
The same trade the Properties view makes, and for the same reason: the sheet and a
picture of one component's wiring are two answers to the same question, so only one
of them should be on screen at a time.
"""
global link_panel
if comp is None:
return
if not close_editor_panel():
return
_take_editor_pane()
link_panel = link_view.build_link_view_panel(
frame_editor, comp, on_open_link=open_link_from_link_view,
on_close=dismiss_link_view,
)
link_panel.frame.pack(fill=tk.BOTH, expand=True)
app_state.set_status(f"Link View: {comp.get('name', '?')}")
def dismiss_link_view(restore_canvas=True):
"""Takes the Link View down and gives the pane back.
Nothing is ever asked here: the view has changed nothing, so there is nothing to
lose by leaving it.
"""
global link_panel
if link_panel is None:
return
link_panel.destroy()
link_panel = None
if restore_canvas:
_give_editor_pane_back()
def close_editor_panel(force=False):
"""Frees the editor pane, asking about unsaved edits first. True if it is now free.
Every way of leaving a panel goes through this, so "you have not saved that" is
asked once, in one place, whether the user pressed Close, picked another sheet,
walked the history or opened a different component. Only the Properties view has
anything to ask about; the Link View simply stands down.
"""
if link_panel is not None:
dismiss_link_view()
return True
if properties_panel is None:
return True
if force:
@@ -208,7 +288,7 @@ def close_properties(force=False):
def _restore_tree_selection():
"""Puts the tree back on the sheet that is actually showing, after a refused change."""
global _restoring_selection
item_id = _tree_item_for_element(properties_return_sheet)
item_id = _tree_item_for_element(panel_return_sheet)
if item_id is None:
return
@@ -378,13 +458,22 @@ def on_canvas_wheel_zoom(event):
def on_tree_select(event):
"""Triggered when a branch node is clicked in the Navigation view."""
global _pending_link_selection
# Read and cleared first thing, so a link a jump asked for is only ever picked out
# by the very selection that jump made. A selection that turns out not to paint a
# sheet -- one being put back after a refusal, one of nothing at all -- drops the
# request rather than leaving it to be applied to some later sheet.
pending_link = _pending_link_selection
_pending_link_selection = None
if _restoring_selection:
return
# Choosing a node means "show me that sheet", so the Properties view stands down
# Choosing a node means "show me that sheet", so whatever has the pane stands down
# rather than leaving the selection looking like it did nothing. Refusing to leave
# has to put the tree back on the sheet that is still showing.
if not close_properties():
if not close_editor_panel():
_restore_tree_selection()
return
@@ -412,6 +501,10 @@ def on_tree_select(event):
wiresheet.render_components(components_to_render)
# The sheet is painted now, so a link the jump asked for has wires to be found among.
if pending_link is not None:
select_link_on_open_sheet(pending_link)
def delete_selected_link():
"""Removes the currently selected link from the DOM and repaints."""
@@ -516,26 +609,63 @@ def go_to_link_sheet():
app_state.set_status("This link has no other sheet to go to.", warn=True)
return
sheet_element, _ = destination
go_to_sheet_with_link(destination[0], link_elem)
def go_to_sheet_with_link(sheet_element, link_elem):
"""Opens a sheet and picks out one link on it.
Shared by the link popup's jump and the Link View, so following a link lands the
same way whichever of them was used to follow it.
"""
global _pending_link_selection
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.",
f"'{link_view.sheet_display_name(sheet_element)}' is not in the "
"navigation tree.",
warn=True,
)
return
# Selecting the node is what paints the sheet, and painting a sheet clears whatever
# link was selected -- so the link is left as a request for the selection handler to
# honour once it has painted, rather than picked out here. An idle callback is not
# good enough: ttk dispatches its own selection event later than one of those, so a
# link selected from one is wiped by the repaint that follows it.
_pending_link_selection = link_elem
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.
# This is also what records the visit, so Back returns to the sheet the link was
# followed from.
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 open_link_from_link_view(link_elem):
"""Link View: leave for the wiresheet this link leads to, with the link selected.
Where it leads is the sheet of the end that is not the component the view is about:
a link that stays on one sheet goes back to that sheet, where both ends are drawn,
and one that crosses to another sheet opens that other sheet -- which is the end the
view could only show as a knob. A reference the document cannot resolve has no sheet
of its own, so it falls back to the one the component itself is on.
"""
focus = link_panel.comp if link_panel is not None else None
root = app_state.xml_root_element
if focus is None or root is None:
return
peer = next((component for component in link_endpoint_components(link_elem)
if component is not None and component is not focus), focus)
sheet_element = find_parent_element(root, peer)
# The sheet needs the pane back before it can be scrolled to the link: a hidden
# canvas has no width to centre anything in.
dismiss_link_view()
go_to_sheet_with_link(sheet_element, link_elem)
def select_link_on_open_sheet(link_elem):
@@ -553,7 +683,7 @@ def delete_selected_component(event=None):
# Del is bound application-wide, so it also reaches a Properties field being typed in
# and every other window. A keystroke aimed at a text field must not delete a
# component, and neither must one pressed while the sheet is not even on screen.
if properties_panel is not None:
if editor_panel_showing():
return
focused = app_state.window.focus_get()
@@ -863,6 +993,10 @@ def fill_component_popup(menu, comp):
label="Properties",
command=lambda: show_properties(comp),
)
menu.add_command(
label="Link View",
command=lambda: show_link_view(comp),
)
menu.add_separator()
menu.add_command(
label="Delete Component",
@@ -1260,7 +1394,7 @@ def _show_sheet_history_entry(position):
def go_back(event=None):
"""View menu and toolbar: step to the previously opened sheet."""
if not close_properties():
if not close_editor_panel():
return "break"
_prune_sheet_history()
if history_index > 0:
@@ -1272,7 +1406,7 @@ def go_back(event=None):
def go_forward(event=None):
"""View menu and toolbar: step back towards the most recently opened sheet."""
if not close_properties():
if not close_editor_panel():
return "break"
_prune_sheet_history()
if 0 <= history_index < len(sheet_history) - 1:
@@ -1293,8 +1427,9 @@ def on_tree_filled():
# A different file has no such component, and a rebuild after a delete may have taken
# it. Neither is a moment to ask about edits that now have nowhere to go.
if properties_panel is not None and not _component_in_document(properties_panel.comp):
close_properties(force=True)
panel = properties_panel or link_panel
if panel is not None and not _component_in_document(panel.comp):
close_editor_panel(force=True)
# The root row is new after every rebuild, so its colour has to be reapplied.
refresh_dirty_display()
@@ -1580,11 +1715,7 @@ def _rebuild_link_context_menu():
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")
sheet_name = link_view.sheet_display_name(destination[0])
link_context_menu.add_separator()
link_context_menu.add_command(