Files
sedona_editor/wiresheet.py
T
ArdaandClaude Opus 5.5 a2d4be6ea8 Make every live edit reach the device, and warn when it doesn't
In live mode the device is now written first and the document only takes
what the device accepted. Every live_hooks failure records a reason, and
warn_failure() shows it in a dialog and the status bar instead of failing
silently.

- Move, properties, OX page editor: rejected values are rolled back and listed
- Add/remove link: no longer edits the DOM alone when an end can't be resolved
- Add/delete component: report why the device refused
- Tidy Layout: sends each new position to the device, reverts refusals,
  marks the document dirty, and pushes no undo step in live mode
- User Manager: add (one Sox add carrying cred/perm/prov), edit and delete
  now go to the device
- Binary Buf slots (e.g. User.cred) are sent as Buf bytes, not as Str text
- Bump version to 0.0.0.136

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:16:00 +03:00

1531 lines
62 KiB
Python

"""Canvas painting: the grid, component boxes, and the routed link wires."""
import math
import os
import sys
import tkinter as tk
import tkinter.font as tkfont
import app_state
import live_hooks
import schema
import undo
from sax_dom import (get_component_path, read_meta_property, remove_prop, set_prop_value,
update_meta_property)
LINK_STUB = 8 # straight run leaving a slot before the wire is allowed to turn
LINK_CLEAR = 5 # 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 = 10
WIRE_MIN_CLEAR = 6
WIRE_LANE_GAP = 6
# 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 = 3
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 = 20 # minimum clear space kept between two boxes
TIDY_MARGIN = 30 # minimum clear space kept from the top and left canvas edges
# The scales the sheet can be painted at. A ladder rather than a free multiplier, so
# stepping out and back in lands on 1.0 exactly instead of on 0.9999, and so the labels
# are the round percentages people expect. Zoom to Fit is the one thing that lands
# between them, and the ladder's ends are the range it is allowed to reach.
ZOOM_LEVELS = (0.25, 0.33, 0.5, 0.67, 0.8, 1.0, 1.25, 1.5, 2.0, 3.0)
# Breathing room left around a sheet that has just been fitted, in painted pixels, so
# the outermost box is not flush against the canvas edge.
FIT_PADDING = 20
# Below this a grid line is closer to its neighbour than the line is wide, which reads
# as a grey wash rather than a grid, so the grid is left off instead.
MIN_GRID_STEP = 3
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
# Name, type and slot text, at 1:1. Zoom scales the fonts from these, which is why the
# sizes are named here rather than written into sheet_fonts alone.
FONT_SIZES = (9, 7, 8)
def sheet_fonts():
"""The three fonts a component box is drawn with. Box sizes are measured from these."""
name_size, type_size, slot_size = FONT_SIZES
return (
tkfont.Font(family="Segoe UI", size=name_size, weight="bold"),
tkfont.Font(family="Segoe UI", size=type_size),
tkfont.Font(family="Segoe UI", size=slot_size),
)
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.
Drawn at the sheet's zoom, so a box still lands on a line at every scale. The area
covered is the same patch of sheet whatever the zoom, which is why the span is
scaled along with the step.
"""
canvas = app_state.canvas
canvas.delete("grid")
step = app_state.GRID_SIZE * app_state.zoom
if step < MIN_GRID_STEP:
return
span = 3000 * app_state.zoom
for index in range(int(span // step) + 1):
offset = index * step
canvas.create_line(offset, 0, offset, span, fill="#e0e0e0", tags="grid")
canvas.create_line(0, offset, span, offset, 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.
"""
candidates = channel_candidates(src_box, dst_box, boxes, low, high)
for cx in candidates:
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.
attempt_names = ("straight(1)",)
attempts = (lambda: straight_through(1),)
else:
attempt_names = (
"straight(1)", "around(hug)", "straight(8)", "around(standoff)",
"straight(1,stack)", "straight(8,stack)", "around(standoff,stack)",
)
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 name, attempt in zip(attempt_names, 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(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.
"""
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. It stays centred
# even on a half-and-half knob: pushed into the wire-coloured half it would fill
# that half almost completely and leave no colour to read.
canvas.create_oval(
x - KNOB_CORE_RADIUS, y - KNOB_CORE_RADIUS,
x + 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)
# Sort by horizontal gap distance so shorter, simpler links claim lanes first.
# Ties are broken by vertical span, keeping the cheapest wires at the front.
jobs.sort(key=lambda j: (abs(j[3][0] - j[1][2]), abs(j[4] - j[2])))
# 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(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
# 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 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
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
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
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
draw_links()
apply_sheet_zoom(fonts, max_reached_x + 200, max_reached_y + 200)
def apply_sheet_zoom(fonts, extent_x, extent_y):
"""Scales the finished sheet to app_state.zoom and sizes the canvas to match.
Painting and routing both work at 1:1, because that is where every metric here is
tuned -- box header and row heights, wire stubs, lane gaps, knob radii -- so zoom is
applied to the finished picture rather than threaded through each of them. Text
scales with it because the three fonts the boxes were drawn with are resized after
they were measured with, which is what keeps a zoomed box in exactly the proportions
of a 1:1 one.
"""
canvas = app_state.canvas
zoom = app_state.zoom
if zoom != 1.0:
canvas.scale("component", 0, 0, zoom, zoom)
canvas.scale("link", 0, 0, zoom, zoom)
for font, base_size in zip(fonts, FONT_SIZES):
font.configure(size=max(1, round(base_size * zoom)))
scale_knob_ends(zoom)
canvas.config(scrollregion=(0, 0, extent_x * zoom, extent_y * zoom))
draw_grid()
def scale_knob_ends(zoom):
"""Brings the cached knob-to-knob ends onto the painted scale.
They are recorded while the wires are routed, which happens at 1:1, and are read
back at click time to draw the dashed line across a selected pair of knobs.
"""
for wire_tag, ends in app_state.knob_link_ends.items():
app_state.knob_link_ends[wire_tag] = tuple(value * zoom for value in ends)
def redraw_links():
"""Re-routes the wires of a sheet that is already painted, at whatever zoom it is at.
The router reads box positions straight off the canvas and measures its clearances
in 1:1 pixels, so the boxes are put back to that scale for the duration and the
whole picture, new wires included, is scaled again afterwards. Callers that have
just moved a box -- a drag, a drop -- want this rather than draw_links.
"""
zoom = app_state.zoom
if zoom == 1.0:
draw_links()
return
canvas = app_state.canvas
canvas.scale("component", 0, 0, 1.0 / zoom, 1.0 / zoom)
draw_links()
canvas.scale("component", 0, 0, zoom, zoom)
canvas.scale("link", 0, 0, zoom, zoom)
scale_knob_ends(zoom)
def zoom_step(direction):
"""The next zoom level in (+1) or out (-1); the current one at either end of the ladder.
The scale in force need not be one of the ladder's -- Zoom to Fit lands wherever the
sheet happens to fit -- so stepping in takes the first level above it and stepping
out the first below.
"""
current = app_state.zoom
if direction > 0:
above = [level for level in ZOOM_LEVELS if level > current + 1e-9]
return above[0] if above else ZOOM_LEVELS[-1]
below = [level for level in ZOOM_LEVELS if level < current - 1e-9]
return below[-1] if below else ZOOM_LEVELS[0]
def sheet_content_bounds():
"""The 1:1 rectangle the painted sheet occupies, or None if nothing is painted.
Wires are measured along with the boxes: one that loops back can reach further left
or higher than any box does, and a fit that cut it off would be hiding wiring.
Measured off item coordinates rather than canvas.bbox(), which is inflated by the
outline stroke. A stroke is the same two pixels at every zoom, so a bbox divided
back to 1:1 reports a slightly smaller sheet the further in it is zoomed -- enough
that fitting an already fitted sheet would creep instead of standing still.
"""
canvas = app_state.canvas
if canvas is None:
return None
xs, ys = [], []
for comp_tag in app_state.canvas_comp_map:
bounds = get_box_bounds(comp_tag)
if bounds:
xs.extend((bounds[0], bounds[2]))
ys.extend((bounds[1], bounds[3]))
for item in canvas.find_withtag("link"):
coords = canvas.coords(item)
xs.extend(coords[0::2])
ys.extend(coords[1::2])
if not xs or not ys:
return None
zoom = app_state.zoom
return (min(xs) / zoom, min(ys) / zoom, max(xs) / zoom, max(ys) / zoom)
def fit_zoom_level():
"""The scale at which the whole painted sheet fits the visible canvas, or None.
Not snapped to the ladder: a sheet fits at whatever scale it fits at, and set_zoom
holds that between the ladder's ends. Stepping in or out from there carries on up
and down the ladder as usual.
"""
canvas = app_state.canvas
bounds = sheet_content_bounds()
if bounds is None:
return None
# A canvas cannot scroll left of or above its origin, so anything out there cannot
# be brought into view however far the sheet is zoomed out. The fit is measured from
# the origin in that case rather than from the content's own corner.
left = max(0.0, bounds[0])
top = max(0.0, bounds[1])
content_width = bounds[2] - left
content_height = bounds[3] - top
view_width = canvas.winfo_width() - 2 * FIT_PADDING
view_height = canvas.winfo_height() - 2 * FIT_PADDING
if min(content_width, content_height, view_width, view_height) <= 0:
return None
return min(view_width / content_width, view_height / content_height)
def set_zoom(level):
"""Repaints the open sheet at this scale. Returns the level actually applied."""
level = min(max(level, ZOOM_LEVELS[0]), ZOOM_LEVELS[-1])
if level != app_state.zoom:
app_state.zoom = level
if app_state.xml_root_element is None:
# Nothing painted yet, but the grid is already on screen.
draw_grid()
else:
render_current_sheet()
return app_state.zoom
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); in live
mode moved_count is None when the device refused every move, already reported.
"""
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
if app_state.live_mode:
# No undo in live mode: each new position goes to the device, and a box the
# device refused goes back to where the device still has it.
failures = []
for element, old_val, _, _ in changes:
new_val = element.find("prop[@name='meta']").get("val", "0")
if not live_hooks.live_set_prop_value(element, "meta", new_val):
failures.append(f"{element.get('name', '?')}: {live_hooks.last_error}")
if old_val is None:
remove_prop(element, "meta")
else:
set_prop_value(element, "meta", old_val)
render_current_sheet()
if failures:
live_hooks.warn_failure(
"Tidy Layout",
f"{len(failures)} of {len(changes)} component(s) could not be moved on the "
"device and were left where they were",
"\n".join(f" • {failure}" for failure in failures),
)
if len(failures) == len(changes):
return None, clamped # already reported; nothing moved
app_state.mark_dirty()
return len(changes) - len(failures), clamped
undo.push(command)
app_state.mark_dirty()
render_current_sheet()
return len(changes), clamped
# ---------------------------------------------------------------------------
# Print (PNG export via screen capture)
# ---------------------------------------------------------------------------
_PRINT_PAD = 20 # breathing room around content in the saved image
def print_sheet(event=None):
"""Saves the wiresheet as a PNG image sized to its content and opens it.
The canvas is temporarily scrolled and resized so that every component and wire
is visible, then captured with PIL.ImageGrab. The grid is hidden during the
capture. The image size adapts automatically: a sparse sheet produces a small
image, a crowded one a larger image. When the content at the current zoom is
larger than the screen, the zoom is temporarily reduced so the full sheet fits.
"""
canvas = app_state.canvas
if canvas is None:
return
bounds = sheet_content_bounds()
if bounds is None:
app_state.set_status("Nothing to print.")
return
from PIL import ImageGrab
from tkinter import filedialog
# Build a default file name from the file name and current sheet path.
title_parts = []
if app_state.current_file_path:
title_parts.append(os.path.splitext(os.path.basename(app_state.current_file_path))[0])
if app_state.current_sheet_parent is not None:
path = get_component_path(app_state.current_sheet_parent,
app_state.xml_root_element)
if path:
title_parts.append(path.replace("/", "_"))
default_name = ("_".join(title_parts) if title_parts else "wiresheet") + ".png"
png_path = filedialog.asksaveasfilename(
parent=app_state.window,
title="Save Wiresheet as PNG",
defaultextension=".png",
initialfile=default_name,
filetypes=[("PNG Image", "*.png"), ("All Files", "*.*")],
)
if not png_path:
return
pad = _PRINT_PAD
# Work out how large the content is at 1:1 and decide a print zoom that keeps
# the whole sheet within the available screen area.
content_w = bounds[2] - bounds[0] + 2 * pad
content_h = bounds[3] - bounds[1] + 2 * pad
screen_w = canvas.winfo_screenwidth() - 100
screen_h = canvas.winfo_screenheight() - 100
max_zoom = min(screen_w / content_w, screen_h / content_h, app_state.zoom)
# Don't zoom below the minimum ladder level.
print_zoom = max(max_zoom, ZOOM_LEVELS[0])
# The original zoom is restored after capture.
original_zoom = app_state.zoom
zoom_changed = abs(print_zoom - original_zoom) > 1e-9
# Content rectangle at the print zoom (canvas pixel coordinates).
x1 = max(0.0, bounds[0] * print_zoom - pad)
y1 = max(0.0, bounds[1] * print_zoom - pad)
x2 = bounds[2] * print_zoom + pad
y2 = bounds[3] * print_zoom + pad
capture_w = int(math.ceil(x2 - x1))
capture_h = int(math.ceil(y2 - y1))
# Remember the canvas's current geometry so it can be restored afterwards.
old_scrollregion = canvas.cget("scrollregion")
old_width = canvas.winfo_width()
old_height = canvas.winfo_height()
old_xview = canvas.xview()
old_yview = canvas.yview()
try:
# Re-render at the print zoom if needed.
if zoom_changed:
app_state.zoom = print_zoom
render_current_sheet()
# Hide the grid.
grid_items = canvas.find_withtag("grid")
for item in grid_items:
canvas.itemconfigure(item, state="hidden")
# Expand the canvas widget and scroll region so the full content area is
# rendered on-screen in one shot.
canvas.config(
width=capture_w,
height=capture_h,
scrollregion=(x1, y1, x2, y2),
)
canvas.xview_moveto(0.0)
canvas.yview_moveto(0.0)
# Force Tk to process the resize and repaint before the grab.
canvas.update_idletasks()
canvas.update()
# Grab the canvas area from the screen.
cx = canvas.winfo_rootx()
cy = canvas.winfo_rooty()
grab_w = min(capture_w, canvas.winfo_width())
grab_h = min(capture_h, canvas.winfo_height())
image = ImageGrab.grab(bbox=(cx, cy, cx + grab_w, cy + grab_h))
image.save(png_path)
# Restore canvas geometry.
canvas.config(
width=old_width,
height=old_height,
scrollregion=old_scrollregion,
)
canvas.xview_moveto(old_xview[0])
canvas.yview_moveto(old_yview[0])
# Restore the grid.
for item in grid_items:
canvas.itemconfigure(item, state="normal")
# Restore the original zoom if it was changed.
if zoom_changed:
app_state.zoom = original_zoom
render_current_sheet()
else:
canvas.update_idletasks()
# Open the saved image with the system viewer.
if sys.platform == "win32":
os.startfile(png_path)
else:
import subprocess
opener = "open" if sys.platform == "darwin" else "xdg-open"
subprocess.Popen([opener, png_path])
app_state.set_status(f"Wiresheet saved: {png_path}")
except Exception as exc:
# Restore everything on error.
try:
canvas.config(
width=old_width,
height=old_height,
scrollregion=old_scrollregion,
)
canvas.xview_moveto(old_xview[0])
canvas.yview_moveto(old_yview[0])
for item in canvas.find_withtag("grid"):
canvas.itemconfigure(item, state="normal")
if zoom_changed:
app_state.zoom = original_zoom
render_current_sheet()
else:
canvas.update_idletasks()
except Exception:
pass
app_state.set_status(f"Print failed: {exc}", warn=True)