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
+105 -85
View File
@@ -577,15 +577,17 @@ def resolve_slot_anchor(comp_tag, slot_name):
return box, box[1] + offset
def draw_knob(x, y, on_sheet, off_sheet, extra_tags=()):
def draw_knob(canvas, x, y, on_sheet, off_sheet, extra_tags=()):
"""Marks a slot whose links cannot be shown as a visible wire.
Painted in the wire colour when the far end is on this sheet but unreachable, and
grey when it is on another wiresheet, so the two cases read differently at a
glance. A slot carrying both is split down the middle, wire colour on the left
and grey on the right.
The canvas is passed in rather than read off `app_state`: the Link View draws the
same grey marker on a canvas of its own, and a knob is a knob wherever it is shown.
"""
canvas = app_state.canvas
tags = ("link", "link_knob") + tuple(extra_tags)
box = (x - KNOB_RADIUS, y - KNOB_RADIUS, x + KNOB_RADIUS, y + KNOB_RADIUS)
@@ -737,7 +739,8 @@ def draw_links():
)
for (x, y), entry in knobs.items():
draw_knob(x, y, entry["on_sheet"], entry["off_sheet"], tuple(entry["tags"]))
draw_knob(canvas, x, y, entry["on_sheet"], entry["off_sheet"],
tuple(entry["tags"]))
# Wires sit behind the boxes so they never paint over a neighbouring component.
# Nothing should actually be back there now, since a wire that cannot avoid a box is
@@ -832,6 +835,96 @@ def deselect_link():
# --- Component painting ---
def draw_component_box(canvas, comp, x, y, fonts, linked_slot_index, tags,
outline_tags=()):
"""Paints one component box with its top-left corner at (x, y).
Returns (width, height, slot_offsets), where slot_offsets maps a slot name to the y
offset of its row's centre from the box top -- which is what a wire anchors to.
Every caller gets the same box. The wiresheet paints these at their meta positions
and the Link View arranges them around one component; a box that read differently
between the two would suggest the user was looking at something else. `tags` goes on
every item, so the whole box is moved and picked as one, and the sheet adds
`outline_tags` to the outline alone -- the item it strokes to show a selection.
"""
name_font, type_font, slot_font = fonts
(box_width, box_height, slot_data_rows,
full_display_name, display_type) = measure_box(comp, fonts, linked_slot_index)
# Filled in as the rows are drawn below; keyed by slot name for link anchoring.
slot_offsets = {}
create_round_rectangle(
canvas, x, y, x + box_width, y + box_height,
radius=5, fill="white", outline="#7f9db9", width=2,
tags=tuple(tags) + tuple(outline_tags)
)
# Draw Header bar
canvas.create_rectangle(
x + 1, y + 1, x + box_width - 1, y + HEADER_HEIGHT,
fill="#dce6f2", outline="", tags=tags
)
canvas.create_line(
x, y + HEADER_HEIGHT, x + box_width, y + HEADER_HEIGHT,
fill="#7f9db9", tags=tags
)
# Header textual labels, ellipsised to leave room for the indicator square
header_text_width = box_width - 26
canvas.create_text(
x + 5, y + 8, anchor="w",
text=fit_text(full_display_name, name_font, header_text_width),
font=name_font, fill="black", tags=tags
)
canvas.create_text(
x + 5, y + 19, anchor="w",
text=fit_text(display_type, type_font, header_text_width),
font=type_font, fill="#555555", tags=tags
)
# Quick access indicator icon background slot placeholder
canvas.create_rectangle(
x + box_width - 17, y + 6, x + box_width - 5, y + 18,
fill="#ffe699", outline="#b4c6e7", tags=tags
)
# Render dynamic slots rows
current_row_y = y + HEADER_HEIGHT
for idx, (raw_name, s_name, val_str) in enumerate(slot_data_rows):
row_bg = "#eaeaea" if idx % 2 == 0 else "#dfdfdf"
slot_offsets[raw_name] = (current_row_y + (SLOT_ROW_HEIGHT // 2)) - y
canvas.create_rectangle(
x + 1, current_row_y, x + box_width - 1, current_row_y + SLOT_ROW_HEIGHT,
fill=row_bg, outline="", tags=tags
)
# Value keeps its full text; the name yields space to it when the row is tight
value_width = slot_font.measure(val_str)
label_space = box_width - 16 - value_width
canvas.create_text(
x + 6, current_row_y + (SLOT_ROW_HEIGHT // 2), anchor="w",
text=fit_text(s_name, slot_font, label_space),
font=slot_font, fill="black", tags=tags
)
canvas.create_text(
x + box_width - 6, current_row_y + (SLOT_ROW_HEIGHT // 2), anchor="e",
text=val_str, font=slot_font, fill="#333333", tags=tags
)
current_row_y += SLOT_ROW_HEIGHT
canvas.create_line(
x, current_row_y, x + box_width, current_row_y,
fill="#b8b8b8", tags=tags
)
return box_width, box_height, slot_offsets
def render_components(components_to_render):
"""Handles parsing and visual painting of given component listings on canvas with slot attributes."""
canvas = app_state.canvas
@@ -857,10 +950,6 @@ def render_components(components_to_render):
max_reached_x = 1000
max_reached_y = 1000
header_height = HEADER_HEIGHT
slot_row_height = SLOT_ROW_HEIGHT
footer_height = FOOTER_HEIGHT
for comp in components_to_render:
meta_prop = comp.find("prop[@name='meta']")
meta_str = meta_prop.get("val") if meta_prop is not None else None
@@ -869,90 +958,21 @@ def render_components(components_to_render):
coord_x = raw_x * meta_scale
coord_y = raw_y * meta_scale
(box_width, box_height, slot_data_rows,
full_display_name, display_type) = measure_box(comp, fonts, linked_slot_index)
comp_tag = f"comp_group_{id(comp)}"
app_state.canvas_comp_map[comp_tag] = comp
app_state.canvas_tag_by_element[comp] = comp_tag
box_width, box_height, slot_offsets = draw_component_box(
canvas, comp, coord_x, coord_y, fonts, linked_slot_index,
("component", comp_tag), outline_tags=("block_outline",)
)
app_state.component_slot_offsets[comp_tag] = slot_offsets
if coord_x + box_width > max_reached_x:
max_reached_x = coord_x + box_width
if coord_y + box_height > max_reached_y:
max_reached_y = coord_y + box_height
comp_tag = f"comp_group_{id(comp)}"
app_state.canvas_comp_map[comp_tag] = comp
app_state.canvas_tag_by_element[comp] = comp_tag
# Filled in as rows are drawn below; keyed by slot name for link anchoring.
slot_offsets = {}
app_state.component_slot_offsets[comp_tag] = slot_offsets
create_round_rectangle(
canvas, coord_x, coord_y, coord_x + box_width, coord_y + box_height,
radius=5, fill="white", outline="#7f9db9", width=2,
tags=("component", comp_tag, "block_outline")
)
# Draw Header bar
canvas.create_rectangle(
coord_x + 1, coord_y + 1, coord_x + box_width - 1, coord_y + header_height,
fill="#dce6f2", outline="", tags=("component", comp_tag)
)
canvas.create_line(
coord_x, coord_y + header_height, coord_x + box_width, coord_y + header_height,
fill="#7f9db9", tags=("component", comp_tag)
)
# Header textual labels, ellipsised to leave room for the indicator square
header_text_width = box_width - 26
canvas.create_text(
coord_x + 5, coord_y + 8, anchor="w",
text=fit_text(full_display_name, name_font, header_text_width),
font=name_font, fill="black",
tags=("component", comp_tag)
)
canvas.create_text(
coord_x + 5, coord_y + 19, anchor="w",
text=fit_text(display_type, type_font, header_text_width),
font=type_font, fill="#555555",
tags=("component", comp_tag)
)
# Quick access indicator icon background slot placeholder
canvas.create_rectangle(
coord_x + box_width - 17, coord_y + 6, coord_x + box_width - 5, coord_y + 18,
fill="#ffe699", outline="#b4c6e7", tags=("component", comp_tag)
)
# Render dynamic slots rows
current_row_y = coord_y + header_height
for idx, (raw_name, s_name, val_str) in enumerate(slot_data_rows):
row_bg = "#eaeaea" if idx % 2 == 0 else "#dfdfdf"
slot_offsets[raw_name] = (current_row_y + (slot_row_height // 2)) - coord_y
canvas.create_rectangle(
coord_x + 1, current_row_y, coord_x + box_width - 1, current_row_y + slot_row_height,
fill=row_bg, outline="", tags=("component", comp_tag)
)
# Value keeps its full text; the name yields space to it when the row is tight
value_width = slot_font.measure(val_str)
label_space = box_width - 16 - value_width
canvas.create_text(
coord_x + 6, current_row_y + (slot_row_height // 2), anchor="w",
text=fit_text(s_name, slot_font, label_space),
font=slot_font, fill="black", tags=("component", comp_tag)
)
canvas.create_text(
coord_x + box_width - 6, current_row_y + (slot_row_height // 2), anchor="e",
text=val_str, font=slot_font, fill="#333333", tags=("component", comp_tag)
)
current_row_y += slot_row_height
canvas.create_line(
coord_x, current_row_y, coord_x + box_width, current_row_y,
fill="#b8b8b8", tags=("component", comp_tag)
)
draw_links()
apply_sheet_zoom(fonts, max_reached_x + 200, max_reached_y + 200)