Files
sedona_editor/wiresheet.py
T
ArdaandClaude Opus 5 e2cd13b9a0 Wiresheet: grid-snapped layout, visible wire routing, knob legend
Layout rules for tidy and for dragging:
- Boxes snap to whole meta steps live while dragged, not only on release.
- The left-most column and top row clear the canvas edge by at least 30px,
  and neighbours by at least 30px, each rounded up to the next step.
- Box widths round up to the grid so a right edge lands on a grid line.
- Column gaps widen only where wires need lanes, so most stay at exactly 30px.
- A sheet too wide for meta's 0..255 range is laid out at 2, then 3, then 4
  grid squares per step. The multiplier is derived from the sheet's contents,
  so painting and tidying agree on it without recording anything in the file.
  The grid size in Preferences is untouched.

Wire routing:
- No wire may pass under a component. A link with no clear route is shown as
  knobs on both slots instead of a line hidden behind a box.
- Vertical runs hug a box within 15px, never closer than 10px, and two runs
  sharing a gap stand 10px apart.
- Horizontal runs of different links stay 5px apart. Runs from one output pin
  may share, since a fan-out is one signal.
- Straight routes claim their rows first, as their segments are pinned to
  their slots and cannot move later.

Knobs:
- Wire colour with a small white centre when the far end is on this sheet,
  grey when it is on another, half and half when a slot carries both.
- Selectable and deletable like a wire, and raised above the boxes so a click
  reaches them.
- Selecting a same-sheet pair draws a red dashed line between the two knobs.

Also:
- get_box_bounds returns the rectangle actually drawn rather than canvas.bbox,
  which was inflated by the outline stroke and by selection.
- Preferences offers to round the box width to a multiple of the grid.
- The tidy coordinate-limit warning said to lower the grid size; raising it is
  what helps, since the reach is 255 * grid size.
- Tidy Layout moves from its own menu into Edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 19:05:58 +03:00

1129 lines
47 KiB
Python

"""Canvas painting: the grid, component boxes, and the routed link wires."""
import math
import tkinter as tk
import tkinter.font as tkfont
import app_state
import schema
import undo
from sax_dom import get_component_path, read_meta_property, update_meta_property
LINK_STUB = 12 # straight run leaving a slot before the wire is allowed to turn
LINK_CLEAR = 8 # gap kept between a horizontal wire lane and any box above or below
LINK_EPS = 0.5 # tolerance so a wire touching its own box edge is not a collision
# Vertical wire runs. A run hugs the box it leaves or enters instead of drifting down the
# middle of the gap, but may not crowd it: WIRE_HUG is where it sits, WIRE_MIN_CLEAR is
# the closest it is ever allowed to get, and two runs sharing one gap stand
# WIRE_LANE_GAP apart. A gap therefore has to be WIRE_HUG either side plus one
# WIRE_LANE_GAP per extra run, which is why TIDY_GAP below is exactly 2 * WIRE_HUG.
WIRE_HUG = 15
WIRE_MIN_CLEAR = 10
WIRE_LANE_GAP = 10
# No wire may pass under a component, and that outranks hugging: when the hug band is
# already taken, a run may stand off this far instead so the wire can still be drawn
# where everyone can see it. A link with no clear path at all is shown as a pair of
# knobs, the same way a link leaving the sheet is.
WIRE_STANDOFF_MAX = 60
# Two different wires running horizontally at the same height are indistinguishable, so
# a free horizontal lane is nudged until it clears any other wire's by this much. The
# short stubs joining a slot cannot move -- they have to meet their pin -- so a fan-out
# from one output pin still shares the run right beside the pin.
WIRE_ROW_GAP = 5
LINK_COLOR = "#2f6fba"
KNOB_OFFSHEET_COLOR = "#9aa0a6" # a link whose far end is on another wiresheet
KNOB_RADIUS = 4
KNOB_CORE_RADIUS = 1.5 # white centre marking a link that stays on this sheet
# Box metrics. Kept tight on purpose: stored meta positions were authored against
# compact boxes, with columns pitched about 100px apart and rows about 60px apart.
# Anything larger and boxes land on top of each other before a single one is moved.
HEADER_HEIGHT = 26
SLOT_ROW_HEIGHT = 14
FOOTER_HEIGHT = 2
MIN_BOX_WIDTH = 80 # ignored when the configured width is smaller
META_COORD_MAX = 255 # meta packs x and y into one byte each
# meta addresses at most META_COORD_MAX steps on each axis, so a sheet laid out one grid
# square per step can only be META_COORD_MAX * GRID_SIZE px wide. A sheet that needs more
# is laid out at a whole multiple of the grid instead -- 2 grid squares per step, then 3,
# then 4 -- which multiplies the reach without touching the user's grid size. The
# multiplier is derived from the sheet's own contents, so painting and tidying always
# agree on it without anything having to be recorded in the file.
META_SCALE_MAX = 12
TIDY_GAP = 30 # minimum clear space kept between two boxes
TIDY_MARGIN = 30 # minimum clear space kept from the top and left canvas edges
def snap_up(value, grid_size):
"""Smallest multiple of grid_size that is >= value; grid lines sit on multiples.
Used wherever a minimum clearance has to be honoured on a grid: 30px of margin on a
20px grid becomes 40px, because 30 is not a grid line and shrinking to 20 would
break the minimum.
"""
if grid_size <= 0:
return value
return int(math.ceil(value / grid_size - 1e-9)) * grid_size
def suggest_box_width_for_grid(width, grid_size):
"""Next grid line at or above `width`, so the width cap can sit on the grid.
The cap is applied after a box has been fitted to its text, so a cap that is not a
multiple of the grid puts the widest boxes off-grid. Steps one grid line down if
rounding up would breach MAX_ALLOWED_BOX_WIDTH; the allowed 60..400 width range
always holds a multiple of any allowed grid (2..200), so the result stays usable.
Returns `width` unchanged when it already lands on the grid.
"""
if grid_size <= 0:
return width
proposed = snap_up(width, grid_size)
if proposed > app_state.MAX_ALLOWED_BOX_WIDTH:
proposed -= grid_size
return proposed
def layout_margin(grid_size=None):
"""The top/left edge offset a box may not encroach on, snapped onto the grid."""
if grid_size is None:
grid_size = app_state.GRID_SIZE
return snap_up(TIDY_MARGIN, grid_size)
def short_type_name(comp_type):
"""'ontrolControl::HvacControl' -> 'HvacControl'. The kit prefix alone cost ~40px."""
return comp_type.split("::")[-1] if "::" in comp_type else comp_type
def fit_text(text, font, max_px):
"""Truncates with an ellipsis so a label can never widen or overflow its box."""
if max_px <= 0:
return ""
if font.measure(text) <= max_px:
return text
ellipsis = "…"
if font.measure(ellipsis) > max_px:
return ""
trimmed = text
while trimmed and font.measure(trimmed + ellipsis) > max_px:
trimmed = trimmed[:-1]
return (trimmed + ellipsis) if trimmed else ellipsis
def sheet_fonts():
"""The three fonts a component box is drawn with. Box sizes are measured from these."""
return (
tkfont.Font(family="Segoe UI", size=9, weight="bold"),
tkfont.Font(family="Segoe UI", size=7),
tkfont.Font(family="Segoe UI", size=8),
)
def measure_box(comp, fonts, linked_slot_index):
"""Works out a component's box without drawing it.
Returns (width, height, slot_rows, display_name, display_type). Shared by painting
and by the layout arithmetic, so the two can never disagree about how big a box is.
"""
name_font, type_font, slot_font = fonts
comp_name = comp.get("name", "Unknown")
comp_type = comp.get("type", "sys::Component")
comp_id = comp.get("id")
display_name = f"{comp_name} [id: {comp_id}]" if comp_id is not None else comp_name
if app_state.xml_root_element is not None:
comp_path = get_component_path(comp, app_state.xml_root_element)
else:
comp_path = ""
slots = schema.get_slots_for_type(comp_type, linked_slot_index.get(comp_path, ()))
slot_rows = []
for raw_name, display_label in slots:
prop_elem = comp.find(f"prop[@name='{raw_name}']")
val_str = prop_elem.get("val", "null") if prop_elem is not None else "null"
slot_rows.append((raw_name, display_label, val_str))
# Dynamically size width and height metrics, then clamp so a long slot name
# cannot push the box across its neighbour.
display_type = short_type_name(comp_type)
max_content_width = max(name_font.measure(display_name),
type_font.measure(display_type)) + 30
for _, display_label, val_str in slot_rows:
row_width = slot_font.measure(display_label) + slot_font.measure(val_str) + 24
if row_width > max_content_width:
max_content_width = row_width
# Widen to the next grid line so a box's right edge lands on the grid like its
# left edge does, which keeps the tidy column pitch grid-aligned too.
fitted_width = snap_up(max(MIN_BOX_WIDTH, max_content_width), app_state.GRID_SIZE)
width = min(app_state.BOX_WIDTH, fitted_width)
height = HEADER_HEIGHT + (len(slot_rows) * SLOT_ROW_HEIGHT) + FOOTER_HEIGHT
return width, height, slot_rows, display_name, display_type
def draw_grid():
"""Draws a grid onto the Canvas based on the scrollable region."""
canvas = app_state.canvas
canvas.delete("grid")
width = 3000
height = 3000
for x in range(0, width, app_state.GRID_SIZE):
canvas.create_line(x, 0, x, height, fill="#e0e0e0", tags="grid")
for y in range(0, height, app_state.GRID_SIZE):
canvas.create_line(0, y, width, y, fill="#e0e0e0", tags="grid")
canvas.tag_lower("grid")
def create_round_rectangle(canvas, x1, y1, x2, y2, radius=4, **kwargs):
"""Draws a visually smooth rounded rectangle using native canvas arcs and polygons."""
points = [
x1+radius, y1, x1+radius, y1, x2-radius, y1, x2-radius, y1, x2, y1,
x2, y1+radius, x2, y1+radius, x2, y2-radius, x2, y2-radius, x2, y2,
x2-radius, y2, x2-radius, y2, x1+radius, y2, x1+radius, y2, x1, y2,
x1, y2-radius, x1, y2-radius, x1, y1+radius, x1, y1+radius, x1, y1
]
return canvas.create_polygon(points, **kwargs, smooth=True)
# --- Link indexing and routing ---
def build_linked_slot_index():
"""Maps component path -> set of slot names touched by a link, either end."""
index = {}
if app_state.xml_root_element is None:
return index
links_elem = app_state.xml_root_element.find("links")
if links_elem is None:
return index
for link in links_elem.findall("link"):
for ref in (link.get("from", ""), link.get("to", "")):
if "." not in ref:
continue
comp_path, slot_name = ref.rsplit(".", 1)
index.setdefault(comp_path, set()).add(slot_name)
return index
def get_box_bounds(comp_tag):
"""The rectangle a component's box was actually drawn at, valid after drags.
Measured off the outline polygon rather than canvas.bbox(): bbox is inflated by the
outline stroke, and by more again while the box is selected and stroked thicker.
Those few pixels matter, because layout budgets its gaps against this width and the
router measures wire clearance against these edges. Falls back to bbox for anything
drawn without an outline.
"""
canvas = app_state.canvas
outline_item = canvas.find_withtag(f"{comp_tag} && block_outline")
if outline_item:
coords = canvas.coords(outline_item[0])
if len(coords) >= 4:
xs, ys = coords[0::2], coords[1::2]
return min(xs), min(ys), max(xs), max(ys)
box = canvas.bbox(comp_tag)
return box if box else None
def get_box_origin(comp_tag):
"""Exact (left, top) of a component's box, or None if it is not on the canvas."""
bounds = get_box_bounds(comp_tag)
return (bounds[0], bounds[1]) if bounds else None
def segment_hits_boxes(x1, y1, x2, y2, boxes):
"""True if an axis-aligned segment overlaps any box rectangle."""
if x1 > x2:
x1, x2 = x2, x1
if y1 > y2:
y1, y2 = y2, y1
for bx1, by1, bx2, by2 in boxes:
if x2 < bx1 + LINK_EPS or x1 > bx2 - LINK_EPS:
continue
if y2 < by1 + LINK_EPS or y1 > by2 - LINK_EPS:
continue
return True
return False
def path_is_clear(points, boxes):
for idx in range(len(points) - 1):
(x1, y1), (x2, y2) = points[idx], points[idx + 1]
if segment_hits_boxes(x1, y1, x2, y2, boxes):
return False
return True
def vertical_run_clearance(cx, y1, y2, boxes):
"""Smallest horizontal distance from the vertical run at cx to any box it passes.
Only boxes whose vertical extent overlaps the run can crowd it, so a run that slips
by above or below a box is not charged for it. Returns 0 when the run is inside a
box, and infinity when it passes nothing at all.
"""
if y1 > y2:
y1, y2 = y2, y1
nearest = float("inf")
for bx1, by1, bx2, by2 in boxes:
if y2 < by1 - LINK_EPS or y1 > by2 + LINK_EPS:
continue
if bx1 - LINK_EPS <= cx <= bx2 + LINK_EPS:
return 0.0
nearest = min(nearest, (bx1 - cx) if cx < bx1 else (cx - bx2))
return nearest
def channel_candidates(src_box, dst_box, boxes, low, high):
"""Vertical channel positions to try, best first.
A run hugs the box it leaves or the box it enters, WIRE_HUG away -- that is the rule
that a vertical line stays within WIRE_HUG of a component. The midpoint of the gap
is kept only as a last resort, for when every hug position is blocked.
"""
ordered = [src_box[2] + WIRE_HUG, dst_box[0] - WIRE_HUG]
for bx1, _, bx2, _ in boxes:
ordered.append(bx2 + WIRE_HUG)
ordered.append(bx1 - WIRE_HUG)
ordered.append((low + high) / 2)
seen, out = set(), []
for cx in ordered:
if low <= cx <= high and cx not in seen:
seen.add(cx)
out.append(cx)
return out
def free_lane(cx, run_lo, run_hi, boxes, taken, low, high, max_steps=8):
"""Nudges cx outwards in WIRE_LANE_GAP steps until the run has a lane of its own.
A lane is rejected while it crowds a box (closer than WIRE_MIN_CLEAR) or while it
would run alongside an already placed run that overlaps it vertically -- two runs
passing through the same gap have to stand WIRE_LANE_GAP apart. Returns None when
no lane inside the gap works. `max_steps` of 1 permits no nudging at all, which is
how the caller asks for the exact hug position and nothing else.
"""
for step in range(0, max_steps):
for sign in ((0,) if step == 0 else (1, -1)):
lane = cx + sign * step * WIRE_LANE_GAP
if not (low <= lane <= high):
continue
if vertical_run_clearance(lane, run_lo, run_hi, boxes) < WIRE_MIN_CLEAR - LINK_EPS:
continue
crowded = any(
abs(lane - tx) < WIRE_LANE_GAP - LINK_EPS
and not (run_hi < t_lo - LINK_EPS or run_lo > t_hi + LINK_EPS)
for tx, t_lo, t_hi in taken
)
if not crowded:
return lane
return None
def hug_lane(edge_x, side, run_lo, run_hi, boxes, claimed, max_offset=WIRE_HUG):
"""Lane for a run pinned to one box edge, starting WIRE_MIN_CLEAR out from it.
`side` is +1 for a run leaving a box's right edge, -1 for one entering its left
edge. Offsets inside the hug band are offered first, furthest out of the band
first so the run keeps clear of its own box; only when every one of those is taken
does it reach past WIRE_HUG, out to `max_offset`. Returns None when nothing up to
that limit clears both the boxes and the runs already placed.
"""
offsets = list(range(WIRE_HUG, WIRE_MIN_CLEAR - 1, -1))
if max_offset > WIRE_HUG:
offsets += list(range(WIRE_HUG + 1, int(max_offset) + 1))
for offset in offsets:
lane = edge_x + side * offset
if vertical_run_clearance(lane, run_lo, run_hi, boxes) < WIRE_MIN_CLEAR - LINK_EPS:
continue
if any(
abs(lane - tx) < WIRE_LANE_GAP - LINK_EPS
and not (run_hi < t_lo - LINK_EPS or run_lo > t_hi + LINK_EPS)
for tx, t_lo, t_hi in claimed
):
continue
return lane
return None
def row_is_stacked(y, x_lo, x_hi, rows, owner):
"""True if a horizontal run here would hide under another wire's.
Runs belonging to the same source pin are ignored: several wires leaving one output
are the same signal, and sharing the run out of the pin is how a fan-out should look.
"""
return any(
row_owner != owner
and abs(y - ry) < WIRE_ROW_GAP - LINK_EPS
and not (x_hi < rx1 - LINK_EPS or x_lo > rx2 + LINK_EPS)
for ry, rx1, rx2, row_owner in rows
)
def free_rows(cy, x_lo, x_hi, rows, owner, max_steps=8):
"""Heights near cy, closest first, where no other wire is already running.
A row is skipped while another wire's horizontal run sits within WIRE_ROW_GAP of
it and overlaps it along x -- that is the case where one line hides under another.
Every clear row is offered rather than only the nearest, because the nearest may
still turn out to cross a box once the whole path is assembled.
"""
out = []
for step in range(0, max_steps):
for sign in ((0,) if step == 0 else (1, -1)):
row = cy + sign * step * WIRE_ROW_GAP
if not row_is_stacked(row, x_lo, x_hi, rows, owner):
out.append(row)
return out
def route_link_path(src_box, src_y, dst_box, dst_y, boxes, taken=None,
direct_only=False, owner=None):
"""Routes an orthogonal wire from a source slot to a target slot around every box.
Returns None when no path can reach the target without passing under a component.
No wire may be drawn under a box, so the caller shows such a link as knobs instead,
exactly as it shows one whose far end lives on another sheet.
`taken` is the sheet's ledger of what other wires have already claimed:
{"cols": [(x, y1, y2), ...], "rows": [(y, x1, x2), ...]}. Each new wire claims a
vertical lane and a horizontal lane of its own against it; pass None to route in
isolation. The search relaxes in tiers: a wire that can only be drawn with its run
standing off further than WIRE_HUG is still far better than no wire at all.
"""
start = (src_box[2], src_y)
end = (dst_box[0], dst_y)
leave = (start[0] + LINK_STUB, src_y)
arrive = (end[0] - LINK_STUB, dst_y)
low, high = min(leave[0], arrive[0]), max(leave[0], arrive[0])
run_lo, run_hi = min(src_y, dst_y), max(src_y, dst_y)
ledger = taken if taken is not None else {"cols": [], "rows": []}
claimed = ledger["cols"]
claimed_rows = ledger["rows"]
def rows_of(points):
"""The horizontal runs a finished path lays down, for the ledger."""
return [
(y1, min(x1, x2), max(x1, x2), owner)
for (x1, y1), (x2, y2) in zip(points, points[1:])
if abs(y1 - y2) < LINK_EPS and abs(x1 - x2) > LINK_EPS
]
def straight_through(max_steps, allow_stack=False):
"""A single vertical channel in the gap between the two boxes.
The two horizontal runs are pinned to their slots, so unless `allow_stack`
they are refused when they would hide under another wire: the wire is better
off detouring, which puts it on a lane it can actually move.
"""
for cx in channel_candidates(src_box, dst_box, boxes, low, high):
lane = free_lane(cx, run_lo, run_hi, boxes, claimed, low, high, max_steps)
if lane is None:
continue
candidate = [start, leave, (lane, src_y), (lane, dst_y), arrive, end]
if not path_is_clear(candidate, boxes):
continue
if not allow_stack and any(
row_is_stacked(y, x1, x2, claimed_rows, owner)
for y, x1, x2, _ in rows_of(candidate)
):
continue
return candidate, [(lane, run_lo, run_hi)]
return None, None
def around_boxes(max_offset, allow_stack=False):
"""Over or under everything, hugging each end. Covers right-to-left feedback.
With `allow_stack` the long run may settle on a row another wire already uses,
which is a last resort: a wire sharing a row is still far better than a link
shown only as knobs.
"""
lanes = set()
for _, by1, _, by2 in boxes:
lanes.add(by1 - LINK_CLEAR)
lanes.add(by2 + LINK_CLEAR)
# Also offer the clear air above and below the whole sheet, and the middle of
# each horizontal band between one row of boxes and the next. A lane pinned to
# one box's edge is often still blocked by a taller neighbour, whereas the
# middle of a band usually crosses the sheet unobstructed.
tops = sorted({b[1] for b in boxes})
bottoms = sorted({b[3] for b in boxes})
lanes.add(min(tops) - LINK_CLEAR)
lanes.add(max(bottoms) + LINK_CLEAR)
for bottom in bottoms:
nearest_below = [t for t in tops if t > bottom + 2 * LINK_CLEAR]
if nearest_below:
lanes.add((bottom + min(nearest_below)) / 2)
midline = (src_y + dst_y) / 2
for base_cy in sorted(lanes, key=lambda c: abs(c - midline)):
out_x = hug_lane(src_box[2], 1, min(src_y, base_cy), max(src_y, base_cy),
boxes, claimed, max_offset)
if out_x is None:
continue
in_x = hug_lane(dst_box[0], -1, min(base_cy, dst_y), max(base_cy, dst_y),
boxes, claimed + [(out_x, min(src_y, base_cy),
max(src_y, base_cy))], max_offset)
if in_x is None:
continue
# The long run across the sheet is the one segment free to move, so step it
# off any other wire already crossing at that height. Each clear height is
# tried in turn, since a height clear of wires can still cross a box.
span_lo, span_hi = min(out_x, in_x), max(out_x, in_x)
options = free_rows(base_cy, span_lo, span_hi, claimed_rows, owner)
if allow_stack and base_cy not in options:
options.append(base_cy) # last resort: share the row rather than drop the wire
for cy in options:
out_lo, out_hi = min(src_y, cy), max(src_y, cy)
in_lo, in_hi = min(cy, dst_y), max(cy, dst_y)
# The lanes were picked against the unnudged height, and moving the
# row lengthens both vertical runs, so their clearance is checked
# again over the spans they actually cover.
if (vertical_run_clearance(out_x, out_lo, out_hi, boxes)
< WIRE_MIN_CLEAR - LINK_EPS):
continue
if (vertical_run_clearance(in_x, in_lo, in_hi, boxes)
< WIRE_MIN_CLEAR - LINK_EPS):
continue
candidate = [start, (out_x, src_y), (out_x, cy),
(in_x, cy), (in_x, dst_y), end]
if path_is_clear(candidate, boxes):
return candidate, [(out_x, out_lo, out_hi), (in_x, in_lo, in_hi)]
return None, None
# Tier by how much of the spacing guidance has to give way. Crossing a box is never
# on the menu, so the last tier is "no wire", handled by the caller.
if direct_only:
# First pass: only the straight route, whose horizontal runs are pinned to their
# slots and cannot be moved later. Letting these claim their rows first stops a
# detouring wire from parking its movable lane on top of one of them.
attempts = (lambda: straight_through(1),)
else:
attempts = (
lambda: straight_through(1), # hugging, own lane
lambda: around_boxes(WIRE_HUG), # hugging, routed around
lambda: straight_through(8), # lane nudged out of the band
lambda: around_boxes(WIRE_STANDOFF_MAX), # standing off, routed around
# Last resorts, in order. A wire sharing a row with another is still far
# better than a link reduced to knobs, so stacking is only reached once
# every route that keeps its own row has been ruled out.
lambda: straight_through(1, allow_stack=True),
lambda: straight_through(8, allow_stack=True),
lambda: around_boxes(WIRE_STANDOFF_MAX, allow_stack=True),
)
for attempt in attempts:
candidate, runs = attempt()
if candidate is not None:
if taken is not None:
taken["cols"].extend(runs)
taken["rows"].extend(rows_of(candidate))
return candidate
return None
def resolve_slot_anchor(comp_tag, slot_name):
"""Returns (box_bounds, y) for a slot row, centring on the box when the row is hidden."""
box = get_box_bounds(comp_tag)
if box is None:
return None, None
offset = app_state.component_slot_offsets.get(comp_tag, {}).get(slot_name)
if offset is None:
return box, (box[1] + box[3]) / 2
return box, box[1] + offset
def draw_knob(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.
"""
canvas = app_state.canvas
tags = ("link", "link_knob") + tuple(extra_tags)
box = (x - KNOB_RADIUS, y - KNOB_RADIUS, x + KNOB_RADIUS, y + KNOB_RADIUS)
if on_sheet and off_sheet:
# Half of each: wire colour on the left for the link that stays on this sheet,
# grey on the right for the one that leaves it.
canvas.create_arc(*box, start=90, extent=180, style=tk.PIESLICE,
fill=LINK_COLOR, outline="", tags=tags)
canvas.create_arc(*box, start=270, extent=180, style=tk.PIESLICE,
fill=KNOB_OFFSHEET_COLOR, outline="", tags=tags)
canvas.create_oval(*box, fill="", outline="white", width=1, tags=tags)
else:
canvas.create_oval(*box, fill=LINK_COLOR if on_sheet else KNOB_OFFSHEET_COLOR,
outline="white", width=1, tags=tags)
if on_sheet:
# A small white centre is what marks the far end as being on this sheet. It
# keeps its colour through selection, so the middle never fills in -- and on a
# half-and-half knob it sits in the wire-coloured half it belongs to.
cx = x - KNOB_RADIUS / 2 if off_sheet else x
canvas.create_oval(
cx - KNOB_CORE_RADIUS, y - KNOB_CORE_RADIUS,
cx + KNOB_CORE_RADIUS, y + KNOB_CORE_RADIUS,
fill="white", outline="", tags=tags + ("link_knob_core",)
)
LINK_SELECT_COLOR = "#e00000"
LINK_SELECT_WIDTH = 2
LINK_HINT_DASH = (5, 3) # dashes of the line shown between a selected pair of knobs
def draw_links():
"""Paints wires between slots on this sheet and knobs for links leaving it."""
canvas = app_state.canvas
canvas.delete("link")
app_state.canvas_link_map.clear()
app_state.knob_link_ends.clear()
app_state.selected_link_tag = None
if app_state.xml_root_element is None:
return
links_elem = app_state.xml_root_element.find("links")
if links_elem is None:
return
path_to_tag = {}
for comp_element, comp_tag in app_state.canvas_tag_by_element.items():
path_to_tag[get_component_path(comp_element, app_state.xml_root_element)] = comp_tag
if not path_to_tag:
return
boxes = [b for b in (get_box_bounds(t) for t in path_to_tag.values()) if b]
# What the wires drawn so far have claimed, shared across the whole sheet: vertical
# runs as (x, y1, y2) so two through one gap end up WIRE_LANE_GAP apart, and
# horizontal runs as (y, x1, x2) so no two cross the sheet at the same height.
claimed_runs = {"cols": [], "rows": []}
jobs = [] # [wire_tag, src_box, src_y, dst_box, dst_y, points, owner]
unroutable = 0
link_index = 0
# Knobs are gathered per slot rather than drawn as they come up, because a slot
# carrying both an off-sheet link and an unreachable one on this sheet is drawn as
# a single half-and-half marker.
knobs = {} # (x, y) -> {"on_sheet": bool, "off_sheet": bool, "tags": set}
def want_knob(x, y, kind, wire_tag=None):
entry = knobs.setdefault(
(round(x, 1), round(y, 1)),
{"on_sheet": False, "off_sheet": False, "tags": set()},
)
entry[kind] = True
if wire_tag:
entry["tags"].add(wire_tag)
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)
src_tag = path_to_tag.get(from_path)
dst_tag = path_to_tag.get(to_path)
if src_tag and dst_tag:
src_box, src_y = resolve_slot_anchor(src_tag, from_slot)
dst_box, dst_y = resolve_slot_anchor(dst_tag, to_slot)
if src_box is None or dst_box is None:
continue
wire_tag = f"link_wire_{link_index}"
link_index += 1
app_state.canvas_link_map[wire_tag] = link
jobs.append([wire_tag, src_box, src_y, dst_box, dst_y, None, from_ref])
elif src_tag or dst_tag:
# One end is on another sheet. The knob still stands for a real link, so
# it is tagged and registered like a wire and can be picked and deleted.
if src_tag:
box, slot_y = resolve_slot_anchor(src_tag, from_slot)
knob_x = box[2] + KNOB_RADIUS if box is not None else None
else:
box, slot_y = resolve_slot_anchor(dst_tag, to_slot)
knob_x = box[0] - KNOB_RADIUS if box is not None else None
if box is None:
continue
wire_tag = f"link_wire_{link_index}"
link_index += 1
app_state.canvas_link_map[wire_tag] = link
want_knob(knob_x, slot_y, "off_sheet", wire_tag)
# Route in two passes. A wire that can run straight has its horizontal segments
# pinned to its two slots, so it gets first claim on those rows; wires that have to
# detour then pick lanes that keep clear of them. Routing in one pass lets a detour
# settle on a row a later straight wire is obliged to use.
for job in jobs:
wire_tag, src_box, src_y, dst_box, dst_y, _, owner = job
job[5] = route_link_path(src_box, src_y, dst_box, dst_y, boxes,
claimed_runs, direct_only=True, owner=owner)
for job in jobs:
if job[5] is None:
wire_tag, src_box, src_y, dst_box, dst_y, _, owner = job
job[5] = route_link_path(src_box, src_y, dst_box, dst_y, boxes,
claimed_runs, owner=owner)
for wire_tag, src_box, src_y, dst_box, dst_y, points, _ in jobs:
if points is None:
# No route that stays out from under the boxes. Rather than hide a wire
# beneath a component, show the link as knobs on both slots -- the same
# marker a link to another sheet gets. Tagged so it stays selectable.
want_knob(src_box[2] + KNOB_RADIUS, src_y, "on_sheet", wire_tag)
want_knob(dst_box[0] - KNOB_RADIUS, dst_y, "on_sheet", wire_tag)
# Both ends are on this sheet, so selecting the link can show where it
# actually goes with a dashed line between the two knobs.
app_state.knob_link_ends[wire_tag] = (
src_box[2] + KNOB_RADIUS, src_y, dst_box[0] - KNOB_RADIUS, dst_y
)
unroutable += 1
continue
flat = [coord for point in points for coord in point]
canvas.create_line(
*flat, fill=LINK_COLOR, width=1,
arrow=tk.LAST, arrowshape=(8, 9, 3), tags=("link", wire_tag)
)
for (x, y), entry in knobs.items():
draw_knob(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
# drawn as knobs instead. Knobs themselves are raised back above: they sit just
# outside their own box edge, and while they are underneath it a click on one lands
# on the box instead of selecting the link.
if canvas.find_withtag("component"):
canvas.tag_lower("link", "component")
if canvas.find_withtag("link_knob"):
canvas.tag_raise("link_knob", "component")
app_state.unroutable_link_count = unroutable
_selected_link_restore = [] # [(item, fill, outline, width)] captured when a link is selected
def trim_to_knobs(x1, y1, x2, y2):
"""Pulls both ends of a knob-to-knob line back to the knob rims.
Drawn centre to centre the line would cross the hollow middle of each knob, which
is exactly the part meant to stay white.
"""
span = math.hypot(x2 - x1, y2 - y1)
if span <= 2 * KNOB_RADIUS:
return x1, y1, x2, y2
ux, uy = (x2 - x1) / span, (y2 - y1) / span
inset = KNOB_RADIUS + 1
return (x1 + ux * inset, y1 + uy * inset,
x2 - ux * inset, y2 - uy * inset)
def select_link(wire_tag):
"""Highlights a link in red, remembering what each of its items looked like.
The colours cannot simply be reset to LINK_COLOR afterwards: a link shown as knobs
may be grey, or half grey, and blanket-restoring would repaint it as an ordinary
on-sheet marker.
"""
canvas = app_state.canvas
deselect_link()
if wire_tag and wire_tag in app_state.canvas_link_map:
for item in canvas.find_withtag(wire_tag):
_selected_link_restore.append((
item,
canvas.itemcget(item, "fill"),
canvas.itemcget(item, "outline") if canvas.type(item) != "line" else "",
canvas.itemcget(item, "width"),
))
if canvas.type(item) == "line":
canvas.itemconfig(item, fill=LINK_SELECT_COLOR,
width=LINK_SELECT_WIDTH)
elif "link_knob_core" in canvas.gettags(item):
pass # the white centre stays white while selected
else:
canvas.itemconfig(item, fill=LINK_SELECT_COLOR)
canvas.tag_raise(wire_tag)
app_state.selected_link_tag = wire_tag
# A link with both ends on this sheet but no drawable route is two knobs with
# nothing between them, so while it is selected the connection is spelled out
# as a dashed line. Raised above the boxes, since it is a transient marker
# rather than wiring, and tagged "link" so the next repaint clears it.
ends = app_state.knob_link_ends.get(wire_tag)
if ends:
canvas.create_line(
*trim_to_knobs(*ends), fill=LINK_SELECT_COLOR,
width=LINK_SELECT_WIDTH, dash=LINK_HINT_DASH,
arrow=tk.LAST, arrowshape=(8, 9, 3),
tags=("link", "link_select_hint")
)
canvas.tag_raise("link_select_hint")
canvas.tag_raise("link_knob")
def deselect_link():
"""Puts the selected link back exactly as it was drawn."""
canvas = app_state.canvas
canvas.delete("link_select_hint")
if app_state.selected_link_tag:
for item, fill, outline, width in _selected_link_restore:
try:
if canvas.type(item) == "line":
canvas.itemconfig(item, fill=fill, width=width)
else:
canvas.itemconfig(item, fill=fill, outline=outline, width=width)
except tk.TclError:
pass # the item went away with a repaint
app_state.selected_link_tag = None
_selected_link_restore.clear()
# --- Component painting ---
def render_components(components_to_render):
"""Handles parsing and visual painting of given component listings on canvas with slot attributes."""
canvas = app_state.canvas
app_state.active_components_list = components_to_render
app_state.selected_canvas_tag = None
canvas.delete("component")
canvas.delete("link")
app_state.canvas_comp_map.clear()
app_state.canvas_tag_by_element.clear()
app_state.component_slot_offsets.clear()
fonts = name_font, type_font, slot_font = sheet_fonts()
linked_slot_index = build_linked_slot_index()
# Settle how many pixels one meta step is worth before anything is positioned. A
# sheet that overflows meta's range is painted at a coarser multiple of the grid,
# and tidy works the same figure out independently, so the two always agree.
meta_scale = sheet_meta_scale(components_to_render)
app_state.meta_scale = meta_scale
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
raw_x, raw_y = read_meta_property(meta_str)[:2]
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)
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)
)
canvas.config(scrollregion=(0, 0, max_reached_x + 200, max_reached_y + 200))
draw_links()
def render_current_sheet():
"""Repaints whichever sheet is open, re-reading children from the DOM.
Undo of a delete puts an element back, so the component list has to be rebuilt from
the document rather than reused from the previous render.
"""
if app_state.xml_root_element is None:
return
parent = app_state.current_sheet_parent
if parent is None:
app_element = app_state.xml_root_element.find("app")
if app_element is None:
app_element = app_state.xml_root_element
parent = app_element
render_components([child for child in parent if child.tag == "comp"])
def wire_demand_by_boundary(column_of_comp):
"""How many vertical wire runs each gap between neighbouring columns has to carry.
Keyed by the left column's index. A wire spanning several columns is charged only to
the gap holding its vertical run -- it crosses the others horizontally, which costs
no width -- and the router hugs the source box, so that gap is the one just right of
the source's column. A wire running right to left doubles back over or under the
boxes instead, which puts one stub beside each of its two ends.
"""
demand = {}
root = app_state.xml_root_element
if root is None:
return demand
links_elem = root.find("links")
if links_elem is None:
return demand
column_of_path = {
get_component_path(comp, root): column
for comp, column in column_of_comp.items()
}
for link in links_elem.findall("link"):
from_ref, to_ref = link.get("from", ""), link.get("to", "")
if "." not in from_ref or "." not in to_ref:
continue
src = column_of_path.get(from_ref.rsplit(".", 1)[0])
dst = column_of_path.get(to_ref.rsplit(".", 1)[0])
if src is None or dst is None:
continue
if src + 1 == dst:
boundaries = (src,) # neighbours: one run in the gap between
else:
# Anything further apart, and anything doubling back, has to travel over or
# under the boxes in between, which needs a stub beside each of its ends.
boundaries = (src, dst - 1)
for boundary in boundaries:
if boundary >= 0:
demand[boundary] = demand.get(boundary, 0) + 1
return demand
def column_gap_for(run_count):
"""Clear space a column gap needs to carry `run_count` vertical wire runs.
Lanes start WIRE_MIN_CLEAR in from either box edge and stand WIRE_LANE_GAP apart, so
n of them span 2 * WIRE_MIN_CLEAR + (n - 1) * WIRE_LANE_GAP. One or two runs already
fit inside the plain TIDY_GAP -- at 10 and 20 across a 30px gap, both still hugging
a box -- which is why the great majority of gaps stay at exactly TIDY_GAP.
"""
return max(TIDY_GAP, 2 * WIRE_MIN_CLEAR + max(0, run_count - 1) * WIRE_LANE_GAP)
def plan_columns(columns, column_keys, demand, scale):
"""Walks the columns at `scale` px per meta step, without touching the DOM.
Returns (placements, clamped) where a placement is (item, step_x, step_y). Boxes are
positioned in pixels exactly as they will be painted, then divided down into meta
steps, so a coarser scale reaches further across the sheet for the same 0..255 range.
"""
margin = snap_up(TIDY_MARGIN, scale)
placements = []
clamped = 0
cursor_x = margin # the left-most column clears the canvas edge by the margin
for index, grid_x in enumerate(column_keys):
column = sorted(columns[grid_x], key=lambda i: i["grid_y"])
cursor_y = margin
for item in column:
step_x = min(META_COORD_MAX, round(cursor_x / scale))
step_y = min(META_COORD_MAX, round(cursor_y / scale))
if step_x == META_COORD_MAX or step_y == META_COORD_MAX:
clamped += 1
placements.append((item, step_x, step_y))
# Clear the box just placed by at least TIDY_GAP, then step to the next
# whole meta step so the box still lands on a storable position.
cursor_y = snap_up(cursor_y + item["height"] + TIDY_GAP, scale)
gap = column_gap_for(demand.get(index, 0))
cursor_x = snap_up(cursor_x + max(i["width"] for i in column) + gap, scale)
return placements, clamped
def build_layout_items(components):
"""Column-grouped layout items for a sheet, measured rather than read off the canvas.
Returns (columns, column_keys, demand). Called both while painting -- to settle the
scale before anything is drawn -- and by tidy, so the two agree.
"""
fonts = sheet_fonts()
linked_slot_index = build_linked_slot_index()
columns = {}
for comp in components:
meta_prop = comp.find("prop[@name='meta']")
meta_val = meta_prop.get("val") if meta_prop is not None else None
grid_x, grid_y = read_meta_property(meta_val)[:2]
width, height = measure_box(comp, fonts, linked_slot_index)[:2]
columns.setdefault(grid_x, []).append({
"comp": comp, "meta": meta_val,
"grid_x": grid_x, "grid_y": grid_y,
"width": width, "height": height,
})
column_keys = sorted(columns)
column_of_comp = {
item["comp"]: index
for index, key in enumerate(column_keys)
for item in columns[key]
}
return columns, column_keys, wire_demand_by_boundary(column_of_comp)
def sheet_meta_scale(components):
"""Pixels per meta step for this sheet: GRID_SIZE times the smallest multiplier that fits.
Tries one grid square per step first, then two, three and so on, stopping as soon as
the whole sheet fits inside meta's 0..META_COORD_MAX range on both axes. Derived
purely from the sheet's contents, so every caller works out the same answer.
"""
grid_size = app_state.GRID_SIZE
if not components:
return grid_size
columns, column_keys, demand = build_layout_items(components)
if not column_keys:
return grid_size
for multiplier in range(1, META_SCALE_MAX + 1):
_, clamped = plan_columns(columns, column_keys, demand, grid_size * multiplier)
if not clamped:
return grid_size * multiplier
return grid_size * META_SCALE_MAX
def tidy_layout():
"""Re-flows the open sheet so no two boxes overlap, preserving the author's columns.
Boxes keep their column grouping and their top-to-bottom order; only the spacing is
rebuilt, from the sizes actually painted. Every placement lands on a whole meta step,
the first column and row clear the canvas edge by at least TIDY_MARGIN, and
neighbours are left at least TIDY_GAP apart, widened where wires need lanes. A sheet
too wide for meta's 0..255 range is laid out at a coarser multiple of the grid rather
than being squashed against the limit. Returns (moved_count, clamped_count).
"""
if not app_state.canvas_comp_map:
return 0, 0
components = list(app_state.canvas_comp_map.values())
columns, column_keys, demand = build_layout_items(components)
if not column_keys:
return 0, 0
scale = sheet_meta_scale(components)
placements, clamped = plan_columns(columns, column_keys, demand, scale)
changes = [
(item["comp"], item["meta"], step_x, step_y)
for item, step_x, step_y in placements
if (step_x, step_y) != (item["grid_x"], item["grid_y"])
]
if not changes:
return 0, clamped
command = undo.MetaMoveCommand(changes, label="tidy layout")
command.redo() # apply it; the command is the single writer of meta
undo.push(command)
app_state.mark_dirty()
render_current_sheet()
return len(changes), clamped