The picture was right and unreadable: eleven links round one component
drew twelve wire crossings, because each column was stacked in the order
the file happens to list its links and the lanes were handed out by how
far a wire had to travel.
Both columns are now stacked in the order of the chosen component's own
slots -- what feeds its first slot at the top, what feeds its fifth below
that. Nothing here is read from meta, so the view was always free to put
the peers where they read best, and this is the order that turns the set
of wires on one side into a ladder: both ends run the same way down the
page, so no wire has any reason to cross another.
That leaves which lane each wire turns in. For a ladder there is an order
that cannot produce a crossing, and read off the slots rather than off the
direction the line is drawn in, it is the same on both sides -- two fans,
each nesting outwards from the component's own row:
reaching a peer slot above its focus slot -> turns nearer the focus
the higher up the focus it lands
reaching a peer slot below its focus slot -> turns nearer the focus
the lower down the focus it lands
Two wires of one fan, the outer going round the inner, pass above and
below its vertical but never through it; and a wire of one fan can never
share a stretch of the page with a wire of the other, so the two fans
reuse each other's lanes and the gap stays as narrow as the picture
allows. Where several wires leave the same slot no order of focus heights
separates them, so the longest branch turns first and the rest carry on
along the shared run beside the pin -- a wiresheet's own fan-out. Where
two slots already line up the wire is one straight line and is settled
before anything has to be fitted past it; the layout is rounded to whole
pixels so that a pair that ought to line up does.
The reasoning is not trusted on its own: every wire is checked against the
ones already routed (routes_meet) and takes the next lane out if it
clashes. One thing genuinely breaks the ladder -- a peer whose own slots
run counter to the focus's, linking its row 4 to row 3 and its row 3 to
row 4. Ends inverted like that have to cross wherever they are drawn,
since neither box's slot order is this view's to change.
Measured over 461 linked components in four real files, 1113 wires: no
wire passes through a box at all, and ten crossings remain in six
components, every one of them an inverted pair of that kind. Before this
the same four files drew 45.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
881 lines
41 KiB
Python
881 lines
41 KiB
Python
"""The Link View: every link touching one component, drawn around it.
|
|
|
|
Like the Properties view it takes the editor pane over from the canvas rather than
|
|
floating above it, so the sheet and this picture are the same piece of screen and only
|
|
one of them is ever the thing being looked at. `main` is what hides the canvas and puts
|
|
this in its place; this module knows only the frame it was handed.
|
|
|
|
It answers one question -- "what is wired to this component, and where does it go?" -- and
|
|
nothing else. Nothing here writes to the document: there is no drag, no add, no delete,
|
|
so a component's wiring can be read without the risk of rearranging it. The one action is
|
|
following a link, which hands back to the wiresheet.
|
|
|
|
What it draws:
|
|
|
|
the chosen component in the middle
|
|
a component linking into it on the left, feeding its slot
|
|
a component it links out to on the right, fed from its slot
|
|
a link on the same wiresheet a solid grey line
|
|
a link to another wiresheet a dashed grey line, and the grey knob the sheet
|
|
itself shows on that slot, with the far sheet named
|
|
|
|
The pale yellow ground is the one thing that says at a glance that this is not a
|
|
wiresheet. There is no grid, because nothing here can be positioned: the layout is
|
|
derived from the links every time it is drawn and no part of it is stored in the file.
|
|
|
|
Boxes come from `wiresheet.draw_component_box`, so a component reads exactly as it does
|
|
on its own sheet -- same header, same slot rows, same values -- and the wire lands on the
|
|
same slot row a wire would land on there.
|
|
"""
|
|
|
|
import os
|
|
import tkinter as tk
|
|
from tkinter import font as tkfont, ttk
|
|
|
|
import app_state
|
|
import wiresheet
|
|
from sax_dom import find_component_by_path, find_parent_element, get_component_path
|
|
|
|
# The ground. Pale yellow rather than the sheet's white so a glance at the pane says
|
|
# which of the two views is on screen, without a caption having to say it.
|
|
SHEET_BG = "#fbf6d9"
|
|
|
|
# Links are grey here whatever they are: this view is not a wiresheet, and the blue of a
|
|
# real wire would invite the reader to treat it as one. Only the pointer's own hover
|
|
# darkens a line, which is how it says a line can be clicked.
|
|
WIRE_COLOUR = "#8a8a8a"
|
|
WIRE_HOVER_COLOUR = "#3c3c3c"
|
|
WIRE_WIDTH = 1
|
|
WIRE_HOVER_WIDTH = 2
|
|
OFF_SHEET_DASH = (5, 3) # a link whose far end is on another wiresheet
|
|
|
|
# The Properties view's header, so the two views wear the same band.
|
|
HEADER_BG = "#f2f2f2"
|
|
PATH_COLOUR = "#7a7a7a"
|
|
|
|
LABEL_COLOUR = "#6b6b6b" # the "on <sheet>" note under an off-sheet component
|
|
MISSING_COLOUR = "#b06060" # a link naming a component this document does not have
|
|
|
|
# The chosen component wears the same stroke the wiresheet puts on a selected box, so
|
|
# "this is the one you picked" is said the same way in both views. Its own outline is
|
|
# restyled rather than a ring being drawn around it: a ring would sit exactly where
|
|
# every wire meets the box and would nick each arrowhead.
|
|
FOCUS_OUTLINE = "#1f4e79"
|
|
FOCUS_OUTLINE_WIDTH = 3
|
|
|
|
# Layout, in pixels.
|
|
MARGIN = 40
|
|
COLUMN_GAP = 110 # the least room a column of wires is given
|
|
ROW_GAP = 26
|
|
STUB = 14 # straight run leaving a slot before the wire may turn
|
|
LANE_GAP = 9 # between the vertical runs of two wires in one gap
|
|
SELF_LOOP_DROP = 18 # how far below its box a component's link to itself runs
|
|
LABEL_GAP = 6 # between a box's bottom and the note under it
|
|
|
|
EPSILON = 0.5 # two coordinates this close are the same coordinate
|
|
|
|
|
|
def column_gap(wire_count):
|
|
"""How wide the gap beside a column has to be to hold its wires.
|
|
|
|
A wire that cannot run straight turns in a vertical run of its own, and two runs on
|
|
the same line are one line as far as a reader is concerned -- so the gap is sized to
|
|
the number of wires rather than fixed, and a component with a dozen links gets a
|
|
wider gap instead of a dozen wires in a 30px band. COLUMN_GAP is the floor, for the
|
|
few links that would otherwise be drawn closer together than the boxes they join.
|
|
"""
|
|
return max(COLUMN_GAP, 2 * STUB + LANE_GAP * (wire_count + 1))
|
|
|
|
|
|
def spans_overlap(first, second):
|
|
"""Whether two 1-D spans share more than a single point."""
|
|
low = max(min(first), min(second))
|
|
high = min(max(first), max(second))
|
|
return high - low > EPSILON
|
|
|
|
|
|
def strictly_within(value, span):
|
|
"""Whether a coordinate is inside a span rather than at either end of it."""
|
|
return min(span) + EPSILON < value < max(span) - EPSILON
|
|
|
|
|
|
def segments_meet(first, second):
|
|
"""Whether two axis-aligned segments make a mark a reader would read as a tangle.
|
|
|
|
Two kinds count. A vertical run crossing a horizontal one is a crossing, and two
|
|
vertical runs sharing a line over the same stretch are one line where there should
|
|
be two.
|
|
|
|
Two *horizontal* runs sharing a line deliberately do not count: every wire leaving
|
|
one output slot leaves it at the same height, so a fan-out shares the run beside its
|
|
pin. That is how a wiresheet draws a fan-out too, and forcing those apart would move
|
|
a wire off the slot it belongs to.
|
|
"""
|
|
(a1, a2), (b1, b2) = first, second
|
|
a_vertical = abs(a1[0] - a2[0]) < EPSILON
|
|
b_vertical = abs(b1[0] - b2[0]) < EPSILON
|
|
|
|
if a_vertical and b_vertical:
|
|
return (abs(a1[0] - b1[0]) < EPSILON
|
|
and spans_overlap((a1[1], a2[1]), (b1[1], b2[1])))
|
|
if not a_vertical and not b_vertical:
|
|
return False
|
|
|
|
(v1, v2), (h1, h2) = (first, second) if a_vertical else (second, first)
|
|
return (strictly_within(v1[0], (h1[0], h2[0]))
|
|
and strictly_within(h1[1], (v1[1], v2[1])))
|
|
|
|
|
|
def routes_meet(first, second):
|
|
"""Whether two wires, each a list of corner points, meet anywhere along their way."""
|
|
first_segments = list(zip(first, first[1:]))
|
|
second_segments = list(zip(second, second[1:]))
|
|
return any(segments_meet(a, b) for a in first_segments for b in second_segments)
|
|
|
|
|
|
def sheet_display_name(sheet_element):
|
|
"""What to call the wiresheet an element sits on.
|
|
|
|
A folder is named by itself; the app element has no name of its own and is the row
|
|
the navigation tree shows under the file's name, so it is named after the file.
|
|
"""
|
|
if sheet_element is not None:
|
|
name = sheet_element.get("name")
|
|
if name:
|
|
return name
|
|
return os.path.basename(app_state.current_file_path or "app")
|
|
|
|
|
|
def collect_links(comp):
|
|
"""Every link with an end on `comp`, in the order the file lists them.
|
|
|
|
Each entry is a dict describing one `<link>` from this component's point of view:
|
|
|
|
side 'in' the peer feeds a slot of comp, so it belongs on the left
|
|
'out' comp feeds a slot of the peer, so it belongs on the right
|
|
'self' both ends are comp
|
|
comp_slot the slot on comp; for a self-link, the slot fed
|
|
peer_slot the slot on the peer; for a self-link, the slot read
|
|
peer the peer component, or None when the reference names one the
|
|
document does not have
|
|
peer_path the path the reference actually names, which is all a broken one has
|
|
same_sheet whether the peer is a child of the same parent, which is what makes
|
|
a link drawable as a wire on one sheet
|
|
|
|
Pure DOM work, so the reading of a document's links is testable without a window.
|
|
"""
|
|
root = app_state.xml_root_element
|
|
if root is None or comp is None:
|
|
return []
|
|
|
|
links_elem = root.find("links")
|
|
if links_elem is None:
|
|
return []
|
|
|
|
comp_path = get_component_path(comp, root)
|
|
here = find_parent_element(root, comp)
|
|
entries = []
|
|
|
|
for link in links_elem.findall("link"):
|
|
from_ref = link.get("from", "")
|
|
to_ref = link.get("to", "")
|
|
if "." not in from_ref or "." not in to_ref:
|
|
continue
|
|
|
|
from_path, from_slot = from_ref.rsplit(".", 1)
|
|
to_path, to_slot = to_ref.rsplit(".", 1)
|
|
|
|
# A link is read from this component's side: the end that is not comp is the
|
|
# peer, and which end comp is on is what puts the peer left or right.
|
|
if from_path == comp_path and to_path == comp_path:
|
|
side, comp_slot, peer_slot, peer_path = "self", to_slot, from_slot, comp_path
|
|
elif to_path == comp_path:
|
|
side, comp_slot, peer_slot, peer_path = "in", to_slot, from_slot, from_path
|
|
elif from_path == comp_path:
|
|
side, comp_slot, peer_slot, peer_path = "out", from_slot, to_slot, to_path
|
|
else:
|
|
continue
|
|
|
|
peer = comp if side == "self" else find_component_by_path(root, peer_path)
|
|
peer_sheet = find_parent_element(root, peer) if peer is not None else None
|
|
|
|
entries.append({
|
|
"link": link,
|
|
"side": side,
|
|
"comp_slot": comp_slot,
|
|
"peer_slot": peer_slot,
|
|
"peer": peer,
|
|
"peer_path": peer_path,
|
|
"peer_sheet": peer_sheet,
|
|
"same_sheet": peer is not None and peer_sheet is here,
|
|
})
|
|
|
|
return entries
|
|
|
|
|
|
def group_by_peer(entries):
|
|
"""Collapses the entries of one side onto one box per peer.
|
|
|
|
A component wired twice into the same slot block is one box with two wires, exactly
|
|
as it would be on a sheet -- drawing it twice would say there are two of it.
|
|
Grouped by the path rather than the element, so a broken reference still groups with
|
|
itself instead of collapsing every unresolved link into one box.
|
|
"""
|
|
groups = []
|
|
index = {}
|
|
for entry in entries:
|
|
key = entry["peer_path"]
|
|
if key not in index:
|
|
index[key] = {"peer": entry["peer"], "peer_path": key,
|
|
"peer_sheet": entry["peer_sheet"],
|
|
"same_sheet": entry["same_sheet"], "entries": []}
|
|
groups.append(index[key])
|
|
index[key]["entries"].append(entry)
|
|
return groups
|
|
|
|
|
|
def _slot_order_key(group, focus_row_of):
|
|
"""Where a peer belongs in its column: the first focus slot its links land on.
|
|
|
|
A peer wired to several of the chosen component's slots is placed by the topmost of
|
|
them, and the path breaks a tie so that two peers on one slot keep a stable order
|
|
from one draw to the next.
|
|
"""
|
|
unknown = len(focus_row_of)
|
|
rows = [focus_row_of.get(entry["comp_slot"], unknown) for entry in group["entries"]]
|
|
return (min(rows), group["peer_path"])
|
|
|
|
|
|
class LinkViewPanel:
|
|
"""One component's links, drawn around it on a canvas of this view's own.
|
|
|
|
Built into a frame the caller supplies and does nothing with it afterwards: packing
|
|
that frame where the canvas was, and putting the canvas back on Close, is `main`'s
|
|
business. Nothing here reaches for the wiresheet canvas or the window.
|
|
|
|
The picture is drawn once, from the document, and never edited -- so there is no
|
|
dirty state, nothing to save, and Close asks nothing. `on_open_link` is how following
|
|
a link gets back to the wiresheet; this module does not know how that is done.
|
|
"""
|
|
|
|
def __init__(self, parent, comp, on_open_link=None, on_close=None):
|
|
self.comp = comp
|
|
self.on_open_link = on_open_link
|
|
self.on_close = on_close
|
|
|
|
# Nothing here can be edited, so there is never anything unwritten. `main` reads
|
|
# this the way it reads the Properties view's, which is what lets one caller
|
|
# treat either panel as "the thing in the editor pane".
|
|
self.dirty = False
|
|
|
|
# Read by draw() rather than here, so a redraw after an undo or a delete shows
|
|
# the links the document has now rather than the ones it had when this opened.
|
|
self.entries = []
|
|
|
|
# tag -> the link that tag stands for, and what a hover has to put back. Rebuilt
|
|
# by every draw, since a repaint hands out fresh tags.
|
|
self.link_by_tag = {}
|
|
self.hover_restore = {}
|
|
|
|
# Numbers the wires of one draw, so every line has a tag of its own even when
|
|
# two of them stand for links between the same pair of components.
|
|
self._wire_seq = 0
|
|
|
|
self.frame = tk.Frame(parent)
|
|
|
|
# The footer is packed first and against the bottom: pack hands out leftover
|
|
# space in order, so an expanding area packed first leaves a later sibling none.
|
|
self._build_header()
|
|
self._build_footer()
|
|
self._build_canvas_area()
|
|
self.draw()
|
|
|
|
# -- construction --
|
|
|
|
def _build_header(self):
|
|
header = tk.Frame(self.frame, padx=8, pady=6, bg=HEADER_BG)
|
|
header.pack(fill=tk.X)
|
|
|
|
name_font = tkfont.Font(family="Segoe UI", size=9, weight="bold")
|
|
plain_font = tkfont.Font(family="Segoe UI", size=9)
|
|
|
|
tk.Label(header, text=self.comp.get("name", "?"), font=name_font,
|
|
bg=HEADER_BG).pack(side=tk.LEFT)
|
|
tk.Label(header, text=f"({self.comp.get('type', 'sys::Component')})",
|
|
font=plain_font, fg="#555555", bg=HEADER_BG).pack(side=tk.LEFT,
|
|
padx=(8, 0))
|
|
|
|
# The path is what a <link> names this component by, so it is the one thing on
|
|
# screen that says which of four identically named boxes this picture is about.
|
|
root = app_state.xml_root_element
|
|
path = get_component_path(self.comp, root) if root is not None else ""
|
|
tk.Label(header, text=path, font=plain_font, fg=PATH_COLOUR,
|
|
bg=HEADER_BG).pack(side=tk.LEFT, padx=(16, 0))
|
|
|
|
# Filled in by draw(), which is what counts the links.
|
|
self.count_var = tk.StringVar(value="")
|
|
tk.Label(header, textvariable=self.count_var, font=plain_font, fg="#555555",
|
|
bg=HEADER_BG).pack(side=tk.RIGHT)
|
|
|
|
def _build_canvas_area(self):
|
|
area = tk.Frame(self.frame)
|
|
area.pack(fill=tk.BOTH, expand=True)
|
|
area.grid_rowconfigure(0, weight=1)
|
|
area.grid_columnconfigure(0, weight=1)
|
|
|
|
self.canvas = tk.Canvas(area, bg=SHEET_BG, highlightthickness=0)
|
|
self.canvas.grid(row=0, column=0, sticky="nsew")
|
|
|
|
self.vertical_bar = ttk.Scrollbar(area, orient="vertical",
|
|
command=self.canvas.yview)
|
|
self.vertical_bar.grid(row=0, column=1, sticky="ns")
|
|
|
|
self.horizontal_bar = ttk.Scrollbar(area, orient="horizontal",
|
|
command=self.canvas.xview)
|
|
self.horizontal_bar.grid(row=1, column=0, sticky="ew")
|
|
|
|
# Each bar is shown only while it has somewhere to scroll, so a pane wide enough
|
|
# for the picture carries neither.
|
|
self.canvas.configure(
|
|
yscrollcommand=lambda first, last: self._sync_bar(
|
|
self.vertical_bar, first, last),
|
|
xscrollcommand=lambda first, last: self._sync_bar(
|
|
self.horizontal_bar, first, last),
|
|
)
|
|
|
|
# Bound on this frame, not bind_all: the wiresheet canvas has wheel bindings of
|
|
# its own, and an application-wide one here would scroll this from there.
|
|
self.frame.bind("<MouseWheel>", self._on_wheel)
|
|
self.frame.bind("<Shift-MouseWheel>", self._on_shift_wheel)
|
|
|
|
def _build_footer(self):
|
|
footer = tk.Frame(self.frame, padx=8, pady=6)
|
|
footer.pack(side=tk.BOTTOM, fill=tk.X)
|
|
|
|
# A legend, because the difference between the two line styles is the whole
|
|
# point of the picture and is not self-evident from one line.
|
|
legend = tk.Canvas(footer, width=210, height=16, highlightthickness=0,
|
|
bg=footer.cget("bg"))
|
|
legend.pack(side=tk.LEFT, padx=(0, 10))
|
|
legend.create_line(2, 8, 26, 8, fill=WIRE_COLOUR, width=WIRE_WIDTH)
|
|
legend.create_text(30, 8, anchor="w", text="this sheet", fill=LABEL_COLOUR,
|
|
font=("Segoe UI", 8))
|
|
legend.create_line(96, 8, 120, 8, fill=WIRE_COLOUR, width=WIRE_WIDTH,
|
|
dash=OFF_SHEET_DASH)
|
|
legend.create_text(124, 8, anchor="w", text="another sheet", fill=LABEL_COLOUR,
|
|
font=("Segoe UI", 8))
|
|
|
|
self.status_var = tk.StringVar(value="")
|
|
tk.Label(footer, textvariable=self.status_var, anchor="w",
|
|
fg="#333333").pack(side=tk.LEFT, fill=tk.X, expand=True)
|
|
|
|
ttk.Button(footer, text="Close", width=9,
|
|
command=self.close).pack(side=tk.RIGHT)
|
|
|
|
def _sync_bar(self, bar, first, last):
|
|
"""Moves a scrollbar's thumb, and hides the bar when it spans the whole."""
|
|
bar.set(first, last)
|
|
if float(first) <= 0.0 and float(last) >= 1.0:
|
|
bar.grid_remove()
|
|
else:
|
|
bar.grid()
|
|
|
|
def _on_wheel(self, event):
|
|
if self.canvas.winfo_exists():
|
|
self.canvas.yview_scroll(-1 * (event.delta // 120), "units")
|
|
|
|
def _on_shift_wheel(self, event):
|
|
if self.canvas.winfo_exists():
|
|
self.canvas.xview_scroll(-1 * (event.delta // 120), "units")
|
|
|
|
# -- drawing --
|
|
|
|
def draw(self):
|
|
"""Lays the picture out from the links and paints it.
|
|
|
|
Positions are worked out here rather than read from the file: `meta` carries a
|
|
component's place on its own wiresheet, which says nothing about where it
|
|
belongs relative to this one. Both columns are measured before anything is
|
|
drawn, because the middle box's position depends on the taller of them.
|
|
"""
|
|
canvas = self.canvas
|
|
canvas.delete("all")
|
|
self.link_by_tag.clear()
|
|
self.hover_restore.clear()
|
|
self._wire_seq = 0
|
|
|
|
self.entries = collect_links(self.comp)
|
|
count = len(self.entries)
|
|
self.count_var.set(f"{count} link{'' if count == 1 else 's'}")
|
|
|
|
fonts = wiresheet.sheet_fonts()
|
|
# The same index the sheet paints with, so a linked slot has a row here too and
|
|
# a wire has the same row to land on.
|
|
linked_slots = wiresheet.build_linked_slot_index()
|
|
|
|
incoming = group_by_peer([e for e in self.entries if e["side"] == "in"])
|
|
outgoing = group_by_peer([e for e in self.entries if e["side"] == "out"])
|
|
self_links = [e for e in self.entries if e["side"] == "self"]
|
|
|
|
focus_width, focus_height, focus_rows = wiresheet.measure_box(
|
|
self.comp, fonts, linked_slots)[:3]
|
|
focus_size = (focus_width, focus_height)
|
|
|
|
# Which row of the chosen component each of its slots is on. Peers are put in
|
|
# that order, top to bottom, so the wires form a ladder instead of a weave: a
|
|
# component feeding the first slot sits above one feeding the fifth, and neither
|
|
# has any reason to cross the other on the way in. Read from the measurement
|
|
# rather than from the drawn box, because the order has to be settled before
|
|
# there is anything drawn to read it off.
|
|
focus_row_of = {raw: index for index, (raw, _label, _value)
|
|
in enumerate(focus_rows)}
|
|
incoming.sort(key=lambda group: _slot_order_key(group, focus_row_of))
|
|
outgoing.sort(key=lambda group: _slot_order_key(group, focus_row_of))
|
|
|
|
left_sizes = [self._group_size(group, fonts, linked_slots) for group in incoming]
|
|
right_sizes = [self._group_size(group, fonts, linked_slots) for group in outgoing]
|
|
|
|
left_width = max([size[0] for size in left_sizes], default=0)
|
|
right_width = max([size[0] for size in right_sizes], default=0)
|
|
left_height = self._column_height(left_sizes)
|
|
right_height = self._column_height(right_sizes)
|
|
|
|
content_height = max(left_height, right_height, focus_size[1])
|
|
|
|
# A link a component makes to itself loops out through both gaps, so it is
|
|
# counted in each of them.
|
|
left_gap = column_gap(sum(1 for e in self.entries if e["side"] != "out"))
|
|
right_gap = column_gap(sum(1 for e in self.entries if e["side"] != "in"))
|
|
|
|
focus_x = MARGIN + (left_width + left_gap if incoming else 0)
|
|
right_x = focus_x + focus_size[0] + right_gap
|
|
|
|
# Rounded to whole pixels. Every height and slot offset here is an integer, so
|
|
# rounding the one division in the layout is what lets two slots that ought to
|
|
# line up line up exactly -- and an exactly aligned pair is a wire drawn as one
|
|
# straight line rather than one with a 1px jog in it.
|
|
focus_y = MARGIN + round((content_height - focus_size[1]) / 2)
|
|
|
|
focus_box = self._draw_box(self.comp, focus_x, focus_y, fonts, linked_slots,
|
|
("lv_focus",), outline_tags=("lv_focus_outline",))
|
|
canvas.itemconfig("lv_focus_outline", outline=FOCUS_OUTLINE,
|
|
width=FOCUS_OUTLINE_WIDTH)
|
|
|
|
# Every box is placed before any wire is drawn, so a box can never be laid over
|
|
# a wire that was already there.
|
|
jobs = {"in": [], "out": []}
|
|
columns = ((incoming, left_sizes, left_height, left_width, MARGIN, "in"),
|
|
(outgoing, right_sizes, right_height, right_width, right_x, "out"))
|
|
for column, sizes, column_height, column_width, column_x, side in columns:
|
|
y = MARGIN + round((content_height - column_height) / 2)
|
|
for group, (width, height) in zip(column, sizes):
|
|
# Boxes are right-aligned on the left and left-aligned on the right, so
|
|
# every wire crosses the same gap however wide its own box turned out.
|
|
x = column_x + (column_width - width) if side == "in" else column_x
|
|
box = self._draw_group_box(group, x, y, fonts, linked_slots)
|
|
for entry in group["entries"]:
|
|
jobs[side].append(self._wire_ends(entry, side, focus_box, box))
|
|
y += height + ROW_GAP
|
|
|
|
# Each side is routed as a set, not wire by wire: which lane a wire may turn in
|
|
# depends on where the others turn, so they cannot be settled one at a time.
|
|
for side, side_jobs in jobs.items():
|
|
self._route_side(side_jobs, focus_box, side)
|
|
for job in side_jobs:
|
|
self._draw_wire(job)
|
|
|
|
# A self-link crosses both gaps, so it takes the lane beyond every wire that has
|
|
# a peer to reach, on each side.
|
|
for offset, entry in enumerate(self_links):
|
|
self._draw_self_wire(entry, focus_box,
|
|
len(jobs["in"]) + offset,
|
|
len(jobs["out"]) + offset)
|
|
|
|
# Wires sit behind the boxes, as they do on a wiresheet, so a run that has to
|
|
# pass a component -- a link a component makes to itself, at the far end of its
|
|
# loop -- can never be mistaken for something drawn on top of that box. Knobs
|
|
# are not lowered with them: a knob marks a slot and belongs on top of it.
|
|
self.canvas.tag_lower("lv_wire")
|
|
|
|
if not self.entries:
|
|
canvas.create_text(
|
|
focus_x + focus_size[0] / 2, focus_y + focus_size[1] + 24,
|
|
text="Nothing is linked to this component.",
|
|
fill=LABEL_COLOUR, font=("Segoe UI", 9)
|
|
)
|
|
self.status_var.set("No links touch this component.")
|
|
else:
|
|
# An unresolved reference is not on another wiresheet -- it is on no sheet at
|
|
# all -- so the two are counted apart rather than read as the same thing.
|
|
off_sheet = sum(1 for e in self.entries
|
|
if e["peer"] is not None and not e["same_sheet"])
|
|
broken = sum(1 for e in self.entries if e["peer"] is None)
|
|
note = f"{count} link{'' if count == 1 else 's'}"
|
|
if off_sheet:
|
|
note += f", {off_sheet} on another wiresheet"
|
|
if broken:
|
|
note += f", {broken} naming a component this document does not have"
|
|
self.status_var.set(f"{note}. Click a link or a component to go to it.")
|
|
|
|
self._fit_scrollregion()
|
|
|
|
def _group_size(self, group, fonts, linked_slots):
|
|
"""How much room one peer box takes, note included."""
|
|
if group["peer"] is None:
|
|
return self._missing_box_size(group["peer_path"])
|
|
width, height = wiresheet.measure_box(group["peer"], fonts, linked_slots)[:2]
|
|
if not group["same_sheet"]:
|
|
# The "on <sheet>" note sits under the box and is part of what the row
|
|
# has to clear, or the next box would be drawn over it.
|
|
height += LABEL_GAP + 14
|
|
return width, height
|
|
|
|
def _missing_box_size(self, peer_path):
|
|
note_font = tkfont.Font(family="Segoe UI", size=8)
|
|
return max(120, note_font.measure(peer_path) + 20), 34
|
|
|
|
def _column_height(self, sizes):
|
|
if not sizes:
|
|
return 0
|
|
return sum(size[1] for size in sizes) + ROW_GAP * (len(sizes) - 1)
|
|
|
|
def _draw_box(self, comp, x, y, fonts, linked_slots, tags, outline_tags=()):
|
|
"""Paints a component box and returns what a wire needs to reach its slots."""
|
|
width, height, slot_offsets = wiresheet.draw_component_box(
|
|
self.canvas, comp, x, y, fonts, linked_slots, tags, outline_tags
|
|
)
|
|
return {"x": x, "y": y, "width": width, "height": height,
|
|
"slots": slot_offsets}
|
|
|
|
def _draw_group_box(self, group, x, y, fonts, linked_slots):
|
|
"""One peer's box, its sheet named under it when that sheet is not this one.
|
|
|
|
The box carries the group's own tag so clicking it follows the first of its
|
|
links, which is the same thing clicking that wire does. A reference the document
|
|
cannot resolve gets a placeholder instead: there is no component to draw, and
|
|
drawing nothing would lose the link entirely.
|
|
"""
|
|
tag = f"lv_peer_{id(group)}"
|
|
entries = group["entries"]
|
|
|
|
if group["peer"] is None:
|
|
width, height = self._missing_box_size(group["peer_path"])
|
|
self.canvas.create_rectangle(
|
|
x, y, x + width, y + height, fill="#f4eccd", outline=MISSING_COLOUR,
|
|
dash=OFF_SHEET_DASH, tags=(tag,)
|
|
)
|
|
self.canvas.create_text(
|
|
x + width / 2, y + 12, text=group["peer_path"], fill=MISSING_COLOUR,
|
|
font=("Segoe UI", 8), tags=(tag,)
|
|
)
|
|
self.canvas.create_text(
|
|
x + width / 2, y + 24, text="not in this document",
|
|
fill=MISSING_COLOUR, font=("Segoe UI", 8), tags=(tag,)
|
|
)
|
|
box = {"x": x, "y": y, "width": width, "height": height, "slots": {}}
|
|
else:
|
|
box = self._draw_box(group["peer"], x, y, fonts, linked_slots, (tag,))
|
|
if not group["same_sheet"]:
|
|
self.canvas.create_text(
|
|
x + box["width"] / 2, y + box["height"] + LABEL_GAP,
|
|
anchor="n", text=f"on {sheet_display_name(group['peer_sheet'])}",
|
|
fill=LABEL_COLOUR, font=("Segoe UI", 8), tags=(tag,)
|
|
)
|
|
|
|
# A box stands for its links, so clicking it follows one. Registered against the
|
|
# first, and the status line says so when there is more than one to choose from.
|
|
self._make_followable(tag, entries[0]["link"], len(entries))
|
|
return box
|
|
|
|
def _slot_y(self, box, slot_name):
|
|
"""Where a wire meets a box: its slot's row, or the box's middle if it has none."""
|
|
offset = box["slots"].get(slot_name)
|
|
if offset is None:
|
|
return box["y"] + box["height"] / 2
|
|
return box["y"] + offset
|
|
|
|
def _lane_x(self, focus_box, side, rank):
|
|
"""Where a wire's vertical run stands, measured out from the chosen component.
|
|
|
|
Out from the focus rather than in from the peer, because every wire on one side
|
|
leaves the same box edge while the boxes it reaches are all different heights:
|
|
anchoring to the shared edge is what lets the lanes nest. `column_gap` sized the
|
|
gap to hold one lane per wire, so a rank inside that count lands inside the gap.
|
|
"""
|
|
if side == "in":
|
|
return focus_box["x"] - STUB - (rank + 1) * LANE_GAP
|
|
return focus_box["x"] + focus_box["width"] + STUB + (rank + 1) * LANE_GAP
|
|
|
|
def _wire_ends(self, entry, side, focus_box, peer_box):
|
|
"""Where one link's wire has to start and finish, before it is routed.
|
|
|
|
Drawn peer-to-focus on the left and focus-to-peer on the right, so every arrow
|
|
points the way the signal travels and the picture reads left to right the way a
|
|
wiresheet does. A link whose far end is on another sheet keeps the grey knob its
|
|
own sheet paints on that slot, so the slot looks the same here as it does there,
|
|
and the wire stands off far enough to leave that marker its own space.
|
|
"""
|
|
focus_y = self._slot_y(focus_box, entry["comp_slot"])
|
|
peer_y = self._slot_y(peer_box, entry["peer_slot"])
|
|
off_sheet = not entry["same_sheet"]
|
|
standoff = 2 * wiresheet.KNOB_RADIUS if off_sheet else 0
|
|
|
|
if side == "in":
|
|
start = (peer_box["x"] + peer_box["width"], peer_y)
|
|
end = (focus_box["x"] - standoff, focus_y)
|
|
knob_x = focus_box["x"] - wiresheet.KNOB_RADIUS
|
|
else:
|
|
start = (focus_box["x"] + focus_box["width"] + standoff, focus_y)
|
|
end = (peer_box["x"], peer_y)
|
|
knob_x = focus_box["x"] + focus_box["width"] + wiresheet.KNOB_RADIUS
|
|
|
|
# The two heights are kept apart from the two ends: which end is the focus's
|
|
# swaps between the sides, and the lane rule is written in terms of the slots
|
|
# rather than the direction the line happens to be drawn in.
|
|
return {"entry": entry, "side": side, "start": start, "end": end,
|
|
"focus_y": focus_y, "peer_y": peer_y,
|
|
"knob": (knob_x, focus_y) if off_sheet else None,
|
|
"off_sheet": off_sheet, "route": None}
|
|
|
|
def _route_side(self, side_jobs, focus_box, side):
|
|
"""Chooses a lane for every wire on one side so that no two of them meet.
|
|
|
|
The peers are already stacked in the order of the focus slots their links land
|
|
on, so both ends of the set run the same way down the page -- a ladder, not a
|
|
weave. For a ladder there is a lane order that cannot produce a crossing, and it
|
|
is two fans rather than one order. Reading it off the slots, not off the
|
|
direction the line is drawn in, it is the same on both sides:
|
|
|
|
a wire reaching a peer slot *above* its focus slot turns nearer the focus
|
|
the higher up the focus it lands
|
|
|
|
a wire reaching a peer slot *below* its focus slot turns nearer the focus
|
|
the lower down the focus it lands
|
|
|
|
So each fan nests outwards from the focus's own row. Take two wires of the upper
|
|
fan, the higher one nested inside the lower: the lower one's run into the focus
|
|
passes below where the higher one's vertical ends, and its run out to its peer
|
|
passes above where that vertical begins, so it goes round it and never through
|
|
it. The lower fan is the same argument upside down. And a wire of one fan can
|
|
never share a stretch of the page with a wire of the other -- ordering the
|
|
ladder is what rules that out -- so the two fans reuse each other's lanes and
|
|
the gap stays as narrow as the picture allows.
|
|
|
|
Each fan is walked from the lane against the focus outwards, and every wire
|
|
still has to pass the collision check before it takes a lane. The argument above
|
|
rests on the ladder being ordered, and a peer whose own slots run counter to the
|
|
focus's is not quite a ladder; the check is what makes that case come out tidy
|
|
anyway rather than trusting the reasoning to cover it.
|
|
"""
|
|
straight, above, below = [], [], []
|
|
for job in side_jobs:
|
|
offset = job["peer_y"] - job["focus_y"]
|
|
if abs(offset) < EPSILON:
|
|
straight.append(job)
|
|
elif offset < 0:
|
|
above.append(job)
|
|
else:
|
|
below.append(job)
|
|
|
|
# Within a fan, the focus slot each wire lands on is the order that matters --
|
|
# except where several wires leave the *same* slot, which no order of focus
|
|
# heights can separate. That is a fan-out, and there the longest branch has to
|
|
# turn first, against the focus: the shorter ones then carry on along the shared
|
|
# run beside the pin and drop away outside it, which is how a wiresheet draws a
|
|
# fan-out and the only order that keeps a branch from crossing its neighbour.
|
|
def fan_order(job, direction):
|
|
return (direction * job["focus_y"],
|
|
-abs(job["peer_y"] - job["focus_y"]))
|
|
|
|
above.sort(key=lambda job: fan_order(job, 1))
|
|
below.sort(key=lambda job: fan_order(job, -1))
|
|
|
|
routed = []
|
|
for job in straight:
|
|
# The slots already line up: one straight line, no vertical run to place.
|
|
# This is what ordering the peers buys, and it is settled first because such
|
|
# a wire crosses the whole gap and everything else has to be fitted past it.
|
|
job["route"] = [job["start"], job["end"]]
|
|
routed.append(job)
|
|
|
|
for job in above + below:
|
|
for rank in range(len(side_jobs)):
|
|
candidate = self._route(job["start"], job["end"],
|
|
self._lane_x(focus_box, side, rank))
|
|
if not any(routes_meet(candidate, other["route"])
|
|
for other in routed):
|
|
job["route"] = candidate
|
|
break
|
|
else:
|
|
# No lane in the gap is clear. Rather than leave the link off the
|
|
# picture it is drawn in the outermost lane and the crossing is
|
|
# accepted -- a crossed wire says more than a missing one.
|
|
job["route"] = self._route(
|
|
job["start"], job["end"],
|
|
self._lane_x(focus_box, side, len(side_jobs) - 1)
|
|
)
|
|
routed.append(job)
|
|
|
|
def _draw_wire(self, job):
|
|
"""Paints one routed wire, and the knob of a link that leaves the sheet."""
|
|
entry = job["entry"]
|
|
self._wire_seq += 1
|
|
tag = f"lv_link_{id(entry['link'])}_{self._wire_seq}"
|
|
self._draw_route(job["route"], tag, job["off_sheet"])
|
|
|
|
if job["knob"] is not None:
|
|
# The knob belongs to the slot, not to the wire, so it is drawn outside the
|
|
# wire's tag: hovering the line must not recolour the slot's marker.
|
|
wiresheet.draw_knob(self.canvas, job["knob"][0], job["knob"][1], False, True)
|
|
|
|
self._make_followable(tag, entry["link"], 1)
|
|
|
|
def _route(self, start, end, lane_x):
|
|
"""A wire's corners: out of one slot, across the gap, into the other.
|
|
|
|
Three segments rather than a diagonal, because that is how the wiresheet draws
|
|
one and a picture of the same links should not change shape between the two.
|
|
Two slots already at the same height need no turn at all.
|
|
"""
|
|
(sx, sy), (ex, ey) = start, end
|
|
if abs(sy - ey) < EPSILON:
|
|
return [(sx, sy), (ex, ey)]
|
|
return [(sx, sy), (lane_x, sy), (lane_x, ey), (ex, ey)]
|
|
|
|
def _draw_self_wire(self, entry, focus_box, in_rank, out_rank):
|
|
"""A component linked to itself: out of one of its slots and back into another.
|
|
|
|
It has no peer to stand opposite, so the wire loops under the box, using the
|
|
next lane out on each side so it clears every wire that has a peer. Rare, and
|
|
drawn rather than dropped because a link the view did not show would be the one
|
|
thing a reader could not find out from here.
|
|
"""
|
|
out_y = self._slot_y(focus_box, entry["peer_slot"])
|
|
in_y = self._slot_y(focus_box, entry["comp_slot"])
|
|
right_lane = self._lane_x(focus_box, "out", out_rank)
|
|
left_lane = self._lane_x(focus_box, "in", in_rank)
|
|
below = (focus_box["y"] + focus_box["height"] + SELF_LOOP_DROP
|
|
+ out_rank * LANE_GAP)
|
|
|
|
points = [(focus_box["x"] + focus_box["width"], out_y), (right_lane, out_y),
|
|
(right_lane, below), (left_lane, below),
|
|
(left_lane, in_y), (focus_box["x"], in_y)]
|
|
|
|
self._wire_seq += 1
|
|
tag = f"lv_link_{id(entry['link'])}_{self._wire_seq}"
|
|
self._draw_route(points, tag, not entry["same_sheet"])
|
|
self._make_followable(tag, entry["link"], 1)
|
|
|
|
def _draw_route(self, points, tag, off_sheet):
|
|
flat = [coord for point in points for coord in point]
|
|
self.canvas.create_line(
|
|
*flat, fill=WIRE_COLOUR, width=WIRE_WIDTH, arrow=tk.LAST,
|
|
arrowshape=(8, 9, 3), dash=OFF_SHEET_DASH if off_sheet else None,
|
|
tags=("lv_wire", tag)
|
|
)
|
|
|
|
# -- following a link --
|
|
|
|
def _make_followable(self, tag, link_elem, link_count):
|
|
"""Makes everything under one tag say where it goes, and go there when clicked.
|
|
|
|
Bound per tag rather than per item, so a box's dozen items behave as the one
|
|
thing they draw.
|
|
"""
|
|
self.link_by_tag[tag] = (link_elem, link_count)
|
|
self.canvas.tag_bind(tag, "<Enter>", lambda _e, t=tag: self._on_enter(t))
|
|
self.canvas.tag_bind(tag, "<Leave>", lambda _e, t=tag: self._on_leave(t))
|
|
self.canvas.tag_bind(tag, "<Button-1>", lambda _e, t=tag: self._on_click(t))
|
|
|
|
def _on_enter(self, tag):
|
|
link_elem, link_count = self.link_by_tag[tag]
|
|
self.canvas.configure(cursor="hand2")
|
|
|
|
# Only the wire is recoloured; a box under the pointer keeps its own colours, so
|
|
# the component still reads as the component it is.
|
|
for item in self.canvas.find_withtag(tag):
|
|
if self.canvas.type(item) != "line" or "lv_wire" not in self.canvas.gettags(item):
|
|
continue
|
|
self.hover_restore[item] = (self.canvas.itemcget(item, "fill"),
|
|
self.canvas.itemcget(item, "width"))
|
|
self.canvas.itemconfig(item, fill=WIRE_HOVER_COLOUR, width=WIRE_HOVER_WIDTH)
|
|
|
|
message = f"{link_elem.get('from', '?')} → {link_elem.get('to', '?')}"
|
|
if link_count > 1:
|
|
message += f" (first of {link_count} links to this component)"
|
|
self.status_var.set(message)
|
|
|
|
def _on_leave(self, tag):
|
|
self.canvas.configure(cursor="")
|
|
for item in list(self.hover_restore):
|
|
fill, width = self.hover_restore.pop(item)
|
|
try:
|
|
self.canvas.itemconfig(item, fill=fill, width=width)
|
|
except tk.TclError:
|
|
pass # the item went away with a redraw
|
|
self.status_var.set("")
|
|
|
|
def _on_click(self, tag):
|
|
link_elem = self.link_by_tag[tag][0]
|
|
if self.on_open_link is not None:
|
|
self.on_open_link(link_elem)
|
|
|
|
# -- leaving --
|
|
|
|
def _fit_scrollregion(self):
|
|
"""Sizes the canvas to the picture and puts the chosen component in view.
|
|
|
|
The region is the drawn extent with a margin, rather than a fixed span: this
|
|
picture is only ever as big as the links make it.
|
|
"""
|
|
bounds = self.canvas.bbox("all")
|
|
if bounds is None:
|
|
self.canvas.configure(scrollregion=(0, 0, 1, 1))
|
|
return
|
|
|
|
region = (min(0, bounds[0] - MARGIN), min(0, bounds[1] - MARGIN),
|
|
bounds[2] + MARGIN, bounds[3] + MARGIN)
|
|
self.canvas.configure(scrollregion=region)
|
|
# A picture wider or taller than the pane opens on the middle box, which is the
|
|
# one thing the view is about.
|
|
self.canvas.after_idle(self._centre_on_focus)
|
|
|
|
def _centre_on_focus(self):
|
|
if not self.canvas.winfo_exists():
|
|
return
|
|
box = self.canvas.bbox("lv_focus")
|
|
region = str(self.canvas.cget("scrollregion")).split()
|
|
if box is None or len(region) != 4:
|
|
return
|
|
|
|
left, top, right, bottom = (float(value) for value in region)
|
|
span_x = (right - left) or 1.0
|
|
span_y = (bottom - top) or 1.0
|
|
middle_x = (box[0] + box[2]) / 2 - self.canvas.winfo_width() / 2
|
|
middle_y = (box[1] + box[3]) / 2 - self.canvas.winfo_height() / 2
|
|
self.canvas.xview_moveto(max(0.0, (middle_x - left) / span_x))
|
|
self.canvas.yview_moveto(max(0.0, (middle_y - top) / span_y))
|
|
|
|
def close(self):
|
|
"""Hands the pane back. Nothing to ask: this view has never changed anything."""
|
|
if self.on_close is not None:
|
|
self.on_close()
|
|
return True
|
|
|
|
def destroy(self):
|
|
"""Tears the panel's widgets down, for a caller that is done with it."""
|
|
if self.frame.winfo_exists():
|
|
self.frame.destroy()
|
|
|
|
|
|
def build_link_view_panel(parent, comp, on_open_link=None, on_close=None):
|
|
"""A Link View for one component, built into `parent` but not yet placed.
|
|
|
|
`on_open_link` is handed the `<link>` element a click chose, and is what takes the
|
|
view down and opens the wiresheet it leads to; `on_close` is what the Close button
|
|
asks for. The caller packs `panel.frame` wherever it wants it.
|
|
"""
|
|
if comp is None or parent is None:
|
|
return None
|
|
return LinkViewPanel(parent, comp, on_open_link, on_close)
|