"""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 from dataclasses import dataclass import app_state import live_hooks import schema import undo import wire_style from sax_dom import (get_component_path, read_meta_property, remove_prop, set_prop_value, update_meta_property) LINK_EPS = 0.5 # tolerance so a wire touching its own box edge is not a collision # How far a wire keeps from the boxes it passes, in grid squares. A wire stays one # square clear of a box's top and bottom and two clear of its sides, and it never turns # closer than two squares to the side of a box -- including the turn just after leaving # a slot and the last one before arriving at one. Two wires running side by side, either # way, stand at least half a square apart. CLEAR_SIDE_GRIDS = 2 CLEAR_END_GRIDS = 1 CLEAR_WIRE_GRIDS = 0.5 # The tight spacing, used only for a wire the grid spacing above cannot place -- boxes # laid out closer together than it needs. A vertical run sits WIRE_HUG from the box it # leaves or enters and never nearer than WIRE_MIN_CLEAR; a horizontal one keeps # LINK_CLEAR from a box above or below; the last turn is ARRIVE_TURN_GRIDS back. Side by # side, vertical runs stand WIRE_LANE_GAP apart and horizontal ones WIRE_ROW_GAP. WIRE_HUG = 10 WIRE_MIN_CLEAR = 6 LINK_CLEAR = 5 ARRIVE_TURN_GRIDS = 1.5 WIRE_LANE_GAP = 6 WIRE_ROW_GAP = 3 # 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 @dataclass(frozen=True) class Clearance: """The spacing a route keeps from boxes, at 1:1. side nearest a vertical run may come to a box's left or right edge hug where a vertical run beside the box it leaves or enters prefers to sit end nearest a horizontal run may come to a box's top or bottom edge arrive how far in front of the target box the last turn is made lane nearest two different wires' vertical runs may stand side by side row nearest two different wires' horizontal runs may stand side by side """ side: float hug: float end: float arrive: float lane: float row: float def grid_clearance(): """The spacing every wire is routed with first, measured in grid squares.""" grid = app_state.GRID_SIZE side = CLEAR_SIDE_GRIDS * grid apart = CLEAR_WIRE_GRIDS * grid return Clearance(side=side, hug=side, end=CLEAR_END_GRIDS * grid, arrive=side, lane=apart, row=apart) def tight_clearance(): """The older, tighter spacing, kept for a wire that cannot be routed on the grid one. Boxes laid out before the grid rule may stand closer than it needs, and a wire that brushes a box is still far better than a link reduced to knobs. """ return Clearance(side=WIRE_MIN_CLEAR, hug=WIRE_HUG, end=LINK_CLEAR, arrive=max(WIRE_HUG, ARRIVE_TURN_GRIDS * app_state.GRID_SIZE), lane=WIRE_LANE_GAP, row=WIRE_ROW_GAP) 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 SHEET_MARGIN_GRIDS = 3 # grid squares of grey kept past anything that overflows the sheet 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 offsheet_input_comps(components): """The components with a link arriving from off this sheet, drawn as a knob on their left edge.""" root = app_state.xml_root_element links_elem = root.find("links") if root is not None else None if links_elem is None: return set() comp_by_path = {get_component_path(comp, root): comp for comp in components} found = set() 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 dst = comp_by_path.get(to_ref.rsplit(".", 1)[0]) if dst is not None and from_ref.rsplit(".", 1)[0] not in comp_by_path: found.add(dst) return found def box_left_limits(tags, margin, step): """Left-most x each box on the open sheet may be moved to, keyed by canvas tag. The margin, or one step further in for a box with an off-sheet input, so its knob stays on the sheet rather than out in the grey margin. """ knobbed = offsheet_input_comps(list(app_state.canvas_comp_map.values())) return {tag: margin + step if app_state.canvas_comp_map.get(tag) in knobbed else margin for tag in tags} 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(): """Paints the sheet and grids it; the canvas around it is the off-sheet tint. Drawn at the sheet's zoom, so a box still lands on a line at every scale. """ canvas = app_state.canvas canvas.delete("grid") # The sheet starts past the margin strip no box may enter, which stays grey too. x1 = y1 = layout_margin(app_state.meta_scale) * app_state.zoom x2, y2 = (coord * app_state.zoom for coord in sheet_extent()) canvas.create_rectangle(x1, y1, x2, y2, outline="#c8c8c8", tags=("grid", "sheet_bg")) app_state.paint_sheet_tint() # white or pink by saved state, grey or pink around it step = app_state.GRID_SIZE * app_state.zoom if step >= MIN_GRID_STEP: draw_grid_lines(canvas, step, x1, y1, x2, y2) canvas.tag_lower("grid") def sheet_extent(): """1:1 (width, height) of the sheet: the furthest position meta can store a box at. Only a box's top-left corner has to fit; a box reaching past the edge is drawn over the grey, and keeping it inside is left to the author. """ side = META_COORD_MAX * (app_state.meta_scale or app_state.GRID_SIZE) return side, side def draw_grid_lines(canvas, step, x1, y1, x2, y2): for index in range(math.ceil(x1 / step - 1e-6), math.floor(x2 / step + 1e-6) + 1): canvas.create_line(index * step, y1, index * step, y2, fill="#e0e0e0", tags="grid") for index in range(math.ceil(y1 / step - 1e-6), math.floor(y2 / step + 1e-6) + 1): canvas.create_line(x1, index * step, x2, index * step, fill="#e0e0e0", tags="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, clear): """True if no part of the path runs under a box or crowds one. Every run keeps `clear` from each box: `end` above and below, `side` left and right. Only the two stubs pinned to the slots -- `side` out of the source and `arrive` into the target -- are held merely out from under the boxes, since they have to reach their pins wherever the rows happen to be. A run that carries on level past its stub is still a run, so it may not graze a box edge on the way. """ points = wire_style.simplify(points) padded = [(x1 - clear.side, y1 - clear.end, x2 + clear.side, y2 + clear.end) for x1, y1, x2, y2 in boxes] last = len(points) - 2 for idx, ((x1, y1), (x2, y2)) in enumerate(zip(points, points[1:])): level = abs(y1 - y2) < LINK_EPS lo, hi = x1, x2 # the part of the run that must keep its distance if level and idx == 0: lo = x1 + math.copysign(min(clear.side, abs(x2 - x1)), x2 - x1) if level and idx == last: hi = x2 - math.copysign(min(clear.arrive, abs(x2 - lo)), x2 - lo) if idx in (0, last) and level: if segment_hits_boxes(x1, y1, x2, y2, boxes): return False if abs(hi - lo) > LINK_EPS and segment_hits_boxes(lo, y1, hi, y2, padded): return False elif idx in (0, last): if segment_hits_boxes(x1, y1, x2, y2, boxes): return False elif segment_hits_boxes(x1, y1, x2, y2, padded): 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, clear): """Vertical channel positions to try, best first. A run hugs the box it leaves or the box it enters, `clear.hug` away -- that is the rule that a vertical line stays close to 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] + clear.hug, dst_box[0] - clear.arrive] for bx1, _, bx2, _ in boxes: ordered.append(bx2 + clear.hug) ordered.append(bx1 - clear.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 same_signal(pins, other_pins): """True if two wires may lie exactly on top of each other. A wire's `pins` are the slots at its two ends. Wires leaving one output carry the same signal, and wires arriving at one input merge into it, so where two of them share a pin they may share a run too -- that is a branch or a merge, not a clash. """ return bool(pins and other_pins and pins & other_pins) def runs_clash(at, lo, hi, other_at, other_lo, other_hi, pins, other_pins, apart): """True if a run would sit too close beside, on top of, or end to end with another. `at` is the run's fixed coordinate (x for a vertical run, y for a horizontal one) and lo..hi its extent along the other axis. Two runs closer than `apart` side by side clash where they overlap, and so do two on one line that stop less than `apart` short of each other, which would read as one continuous wire. A wire never clashes with itself: its own runs meet at its bends. """ if pins is not None and pins == other_pins: return False if hi < other_lo - apart + LINK_EPS or lo > other_hi + apart - LINK_EPS: return False gap = abs(at - other_at) if gap >= apart - LINK_EPS: return False return not (gap < LINK_EPS and same_signal(pins, other_pins)) def lane_is_crowded(lane, run_lo, run_hi, claimed, pins, apart): """True if a vertical run at `lane` would crowd one already placed. Wires sharing a pin may share a trunk exactly -- that is a branch, and gets a junction dot. Running alongside it a few pixels off is refused for every wire alike. """ return any(runs_clash(lane, run_lo, run_hi, tx, t_lo, t_hi, pins, t_pins, apart) for tx, t_lo, t_hi, t_pins in claimed) def free_lane(cx, run_lo, run_hi, boxes, taken, low, high, clear, max_steps=8, pins=None, accept=None): """Nudges cx outwards in `clear.lane` steps until the run has a lane of its own. A lane is rejected while it crowds a box (closer than `clear.side`) or while it would run alongside an already placed run that overlaps it vertically -- two runs passing through the same gap have to stand `clear.lane` 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. `accept`, when given, can turn down a lane that is otherwise free, and the search carries on. """ for step in range(0, max_steps): for sign in ((0,) if step == 0 else (1, -1)): lane = cx + sign * step * clear.lane if not (low <= lane <= high): continue if vertical_run_clearance(lane, run_lo, run_hi, boxes) < clear.side - LINK_EPS: continue if lane_is_crowded(lane, run_lo, run_hi, taken, pins, clear.lane): continue if accept is None or accept(lane): return lane return None def crosses_wires(points, ledger, pins): """True if the path would cross a run another wire has already laid down. A crossing gets a hop, so it is allowed, but a lane that avoids one is worth looking for first. Wires sharing a pin are one signal and never count. """ points = wire_style.simplify(points) for (x1, y1), (x2, y2) in zip(points, points[1:]): if abs(x1 - x2) < LINK_EPS: # vertical: against other wires' rows lo, hi = min(y1, y2), max(y1, y2) for ry, rx1, rx2, row_pins in ledger["rows"]: if (row_pins != pins and not same_signal(pins, row_pins) and rx1 + LINK_EPS < x1 < rx2 - LINK_EPS and lo + LINK_EPS < ry < hi - LINK_EPS): return True else: # horizontal: against their lanes lo, hi = min(x1, x2), max(x1, x2) for cx, c_lo, c_hi, col_pins in ledger["cols"]: if (col_pins != pins and not same_signal(pins, col_pins) and lo + LINK_EPS < cx < hi - LINK_EPS and c_lo + LINK_EPS < y1 < c_hi - LINK_EPS): return True return False def hug_lane(edge_x, side, run_lo, run_hi, boxes, claimed, clear, max_offset=0, min_offset=0, pins=None): """Lane for a run pinned to one box edge, starting `clear.side` 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 `clear.hug`, out to `max_offset`. Returns None when nothing up to that limit clears both the boxes and the runs already placed. `min_offset` raises the floor, and the hug position with it, for a run that must stand further off. """ floor = int(math.ceil(max(clear.side, min_offset))) hug = max(int(math.ceil(clear.hug)), floor) offsets = list(range(hug, floor - 1, -1)) if max_offset > hug: offsets += list(range(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) < clear.side - LINK_EPS: continue if lane_is_crowded(lane, run_lo, run_hi, claimed, pins, clear.lane): continue return lane return None def row_is_stacked(y, x_lo, x_hi, rows, pins, apart): """True if a horizontal run here would hide under, or blur into, another wire's. A wire sharing a pin may lie exactly on top: 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(runs_clash(y, x_lo, x_hi, ry, rx1, rx2, pins, row_pins, apart) for ry, rx1, rx2, row_pins in rows) def free_rows(cy, x_lo, x_hi, rows, pins, apart, 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 `apart` 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 * apart if not row_is_stacked(row, x_lo, x_hi, rows, pins, apart): out.append(row) return out def route_link_path(src_box, src_y, dst_box, dst_y, boxes, taken=None, direct_only=False, pins=None, clear=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, pins), ...], "rows": [(y, x1, x2, pins), ...]}. Each new wire claims a vertical lane and a horizontal lane of its own against it; pass None to route in isolation. `pins` are the slots at the wire's two ends (see same_signal). The search relaxes in tiers: a wire that can only be drawn with its run standing off further than it would like is still far better than no wire at all. `clear` is the spacing kept from the boxes, grid_clearance() unless given. """ clear = clear or grid_clearance() start = (src_box[2], src_y) end = (dst_box[0], dst_y) # The first turn stands clear.side out from the source, the last clear.arrive in # front of the target, never closer. leave = (start[0] + clear.side, src_y) arrive = (end[0] - clear.arrive, 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.""" points = wire_style.simplify(points) return [ (y1, min(x1, x2), max(x1, x2), pins) for (x1, y1), (x2, y2) in zip(points, points[1:]) if abs(y1 - y2) < LINK_EPS and abs(x1 - x2) > LINK_EPS ] def stacked(candidate): return any(row_is_stacked(y, x1, x2, claimed_rows, pins, clear.row) for y, x1, x2, _ in rows_of(candidate)) def path_through(lane): return [start, leave, (lane, src_y), (lane, dst_y), arrive, end] def straight_through(max_steps, allow_stack=False, uncrossed=False, hug_only=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. With `uncrossed` only a lane that crosses no other wire will do; with `hug_only` only the lane beside its own source is tried (beside either end for a level wire), not ones beside the boxes in between. """ accept = None if uncrossed: accept = lambda lane: not crosses_wires(path_through(lane), ledger, pins) candidates = channel_candidates(src_box, dst_box, boxes, low, high, clear) if hug_only: # A bent wire hugs its source only: turning at the far end instead would # take the lane the wires below it need to turn outside it. hugs = (src_box[2] + clear.hug,) if abs(src_y - dst_y) < LINK_EPS: hugs += (dst_box[0] - clear.arrive,) candidates = [cx for cx in candidates if cx in hugs] for cx in candidates: lane = free_lane(cx, run_lo, run_hi, boxes, claimed, low, high, clear, max_steps, pins, accept) if lane is None: continue candidate = path_through(lane) if not path_is_clear(candidate, boxes, clear): continue if not allow_stack and stacked(candidate): continue return candidate, [(lane, run_lo, run_hi, pins)] 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` a 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 - clear.end) lanes.add(by2 + clear.end) # 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) - clear.end) lanes.add(max(bottoms) + clear.end) for bottom in bottoms: nearest_below = [t for t in tops if t > bottom + 2 * clear.end] 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, clear, max_offset, pins=pins) if out_x is None: continue # Checked against its own outward run too, but as another wire's: the two # ends of one wire must never fold back onto each other. 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), None)], clear, max_offset, min_offset=clear.arrive, pins=pins) 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, pins, clear.row) 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) < clear.side - LINK_EPS): continue if (vertical_run_clearance(in_x, in_lo, in_hi, boxes) < clear.side - LINK_EPS): continue candidate = [start, (out_x, src_y), (out_x, cy), (in_x, cy), (in_x, dst_y), end] if not path_is_clear(candidate, boxes, clear): continue # The runs out of and into the slots are pinned to their rows, so they # are checked here: the long run was already kept clear by free_rows. if not allow_stack and stacked(candidate): continue return candidate, [(out_x, out_lo, out_hi, pins), (in_x, in_lo, in_hi, pins)] 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. # Only an uncrossed one, too: a wire that would cross another waits for the # second pass, where it can look further for a lane that does not. # A wire whose hug lane is taken waits as well, rather than jumping to a lane far # across the gap whose long run would then shut out the wires routed after it. attempts = (lambda: straight_through(1, uncrossed=True, hug_only=True),) else: standoff = max(WIRE_STANDOFF_MAX, 3 * clear.side) attempts = ( lambda: straight_through(8, uncrossed=True), # own lane, crossing nothing lambda: straight_through(1), # hugging, own lane lambda: around_boxes(clear.hug), # hugging, routed around lambda: straight_through(8), # lane nudged out of the band lambda: around_boxes(standoff), # 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(standoff, 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(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 @dataclass class WireJob: """One link with both ends on this sheet, on its way to becoming a wire.""" tag: str src_box: tuple src_y: float dst_box: tuple dst_y: float owner: str # the source pin; its wires branch with a junction dot target: str # the target pin points: list = None # the routed path, or None while unrouted / unroutable @property def pins(self): """Both end slots, told apart by side since one slot can be either end.""" return frozenset((("out", self.owner), ("in", self.target))) def stub_rows(self, clear): """The two runs pinned to the slots, before the first turn and after the last. Whatever route the wire takes, these are where it has to be, so they are claimed for every wire before any is routed. Otherwise an early wire can lay its long run along a row a later wire's slot is obliged to leave by. """ return [(self.src_y, self.src_box[2], self.src_box[2] + clear.side, self.pins), (self.dst_y, self.dst_box[0] - clear.arrive, self.dst_box[0], self.pins)] def route(self, boxes, ledger, clear, direct_only=False): self.points = route_link_path(self.src_box, self.src_y, self.dst_box, self.dst_y, boxes, ledger, direct_only=direct_only, pins=self.pins, clear=clear) def knob_ends(self): """Where the two knobs of an unroutable link sit: (x1, y1, x2, y2).""" return (self.src_box[2] + KNOB_RADIUS, self.src_y, self.dst_box[0] - KNOB_RADIUS, self.dst_y) def collect_link_jobs(links_elem, path_to_tag, want_knob): """Turns the sheet's s into wire jobs, and off-sheet ones into knob requests.""" jobs = [] 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 not (src_tag or dst_tag): continue wire_tag = f"link_wire_{len(app_state.canvas_link_map)}" 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 app_state.canvas_link_map[wire_tag] = link jobs.append(WireJob(wire_tag, src_box, src_y, dst_box, dst_y, from_ref, to_ref)) continue # 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 app_state.canvas_link_map[wire_tag] = link want_knob(knob_x, slot_y, "off_sheet", wire_tag) return jobs def paint_wires(canvas, jobs): """Draws routed wires: rounded bends, hops over crossings, dots where a fan-out splits. Junction dots go down first so the wires sit on top of them; a click near a branch then still lands on a wire rather than on an untagged dot. """ for job in jobs: job.points = wire_style.simplify(job.points) by_owner = {} for job in jobs: by_owner.setdefault(job.owner, []).append(job.points) r = wire_style.JUNCTION_RADIUS for x, y in wire_style.fan_out_junctions(by_owner): canvas.create_oval(x - r, y - r, x + r, y + r, fill=LINK_COLOR, outline="", tags=("link", "link_junction")) hops = wire_style.find_hops([job.points for job in jobs]) for job, job_hops in zip(jobs, hops): flat = [c for point in wire_style.styled_points(job.points, job_hops) for c in point] canvas.create_line( *flat, fill=LINK_COLOR, width=1, joinstyle=tk.ROUND, arrow=tk.LAST, arrowshape=(8, 9, 3), tags=("link", job.tag) ) 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 app_state.selected_link_tags.clear() _selected_link_restore.clear() app_state.unroutable_link_count = 0 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 = { get_component_path(comp_element, app_state.xml_root_element): comp_tag for comp_element, comp_tag in app_state.canvas_tag_by_element.items() } if not path_to_tag: return # 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): entry = knobs.setdefault( (round(x, 1), round(y, 1)), {"on_sheet": False, "off_sheet": False, "tags": set()}, ) entry[kind] = True entry["tags"].add(wire_tag) jobs = collect_link_jobs(links_elem, path_to_tag, want_knob) boxes = [b for b in (get_box_bounds(t) for t in path_to_tag.values()) if b] # Links between nearer columns claim lanes first, and straight ones before bent ones. # Among bent wires leaving one column, the first routed hugs its source closest, so # climbing wires go top source first and falling ones bottom source first: each # later wire then turns outside the earlier ones instead of crossing them. def routing_order(job): bent = abs(job.dst_y - job.src_y) > LINK_EPS climbing = job.dst_y < job.src_y return (round(job.dst_box[0] - job.src_box[0]), bent, not climbing, job.src_y if climbing else -job.src_y) jobs.sort(key=routing_order) # What the wires placed so far have claimed, shared across the whole sheet. # Routed in two passes: a wire that can run straight has its horizontal runs 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. # Every wire is offered the full grid spacing before any wire settles for less: boxes # placed closer than the grid rule allows still get a wire, just a tighter one. roomy = grid_clearance() ledger = {"cols": [], "rows": [row for job in jobs for row in job.stub_rows(roomy)]} # A wire that cannot go direct defers the bent wires after it across the same gap # in the same direction too, so they still take their lanes in routing order. deferred = set() for job in jobs: group = (round(job.src_box[0]), round(job.dst_box[0]), job.dst_y < job.src_y) if group in deferred and abs(job.dst_y - job.src_y) > LINK_EPS: continue job.route(boxes, ledger, roomy, direct_only=True) if job.points is None: deferred.add(group) for clear in (roomy, tight_clearance()): for job in jobs: if job.points is None: job.route(boxes, ledger, clear) routed = [job for job in jobs if job.points is not None] paint_wires(canvas, routed) # No route stays out from under the boxes: rather than hide a wire beneath a # component, show the link as knobs on both slots -- the marker a link to another # sheet gets -- and remember the ends so selecting it can draw a dashed hint. for job in jobs: if job.points is None: x1, y1, x2, y2 = job.knob_ends() want_knob(x1, y1, "on_sheet", job.tag) want_knob(x2, y2, "on_sheet", job.tag) app_state.knob_link_ends[job.tag] = job.knob_ends() 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. # Knobs are raised back above: they sit just outside their own box edge, and while # 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 = len(jobs) - len(routed) # wire tag -> [(item, fill, outline, width)], captured when that link is selected _selected_link_restore = {} 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, add=False): """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. With `add` the link joins whatever links are already selected (Ctrl+click); otherwise it becomes the only one. """ canvas = app_state.canvas if not add: deselect_link() elif wire_tag in app_state.selected_link_tags: return if wire_tag and wire_tag in app_state.canvas_link_map: # A knob can stand for several links. One already selected through another link # is red by now, so its true colours are taken from that link's record. captured = {entry[0]: entry for entries in _selected_link_restore.values() for entry in entries} restore = _selected_link_restore.setdefault(wire_tag, []) for item in canvas.find_withtag(wire_tag): restore.append(captured.get(item) or ( item, canvas.itemcget(item, "fill"), canvas.itemcget(item, "outline") if canvas.type(item) != "line" else "", canvas.itemcget(item, "width"), )) _paint_link_selected(wire_tag) app_state.selected_link_tags.append(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", f"link_select_hint_{wire_tag}") ) canvas.tag_raise("link_select_hint") canvas.tag_raise("link_knob") def _paint_link_selected(wire_tag): """Paints one link's wire and knobs in the selection red.""" canvas = app_state.canvas for item in canvas.find_withtag(wire_tag): 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) def toggle_link(wire_tag): """Ctrl+click: adds a link to the selection, or takes it back out.""" if wire_tag in app_state.selected_link_tags: _restore_link(wire_tag) app_state.selected_link_tags.remove(wire_tag) app_state.selected_link_tag = (app_state.selected_link_tags[-1] if app_state.selected_link_tags else None) # A knob shared with a link still selected went back to its own colours above. for still_selected in app_state.selected_link_tags: _paint_link_selected(still_selected) if app_state.canvas.find_withtag("link_select_hint"): app_state.canvas.tag_raise("link_select_hint") app_state.canvas.tag_raise("link_knob") else: select_link(wire_tag, add=True) def _restore_link(wire_tag): """Puts one selected link back exactly as it was drawn.""" canvas = app_state.canvas canvas.delete(f"link_select_hint_{wire_tag}") for item, fill, outline, width in _selected_link_restore.pop(wire_tag, ()): 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 def deselect_link(): """Puts every selected link back exactly as it was drawn.""" canvas = app_state.canvas canvas.delete("link_select_hint") for wire_tag in app_state.selected_link_tags: _restore_link(wire_tag) app_state.selected_link_tags.clear() 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 # Repainting the same sheet (a refresh, an undo, a device update) keeps whatever of # the selection is still on it; another sheet starts with nothing selected. kept = [app_state.canvas_comp_map.get(t) for t in app_state.selected_canvas_tags if t != app_state.selected_canvas_tag] kept.append(app_state.canvas_comp_map.get(app_state.selected_canvas_tag)) app_state.selected_canvas_tag = None app_state.selected_canvas_tags.clear() canvas.delete("component") canvas.delete("link") canvas.delete("slot_hover") # link_drag's hover ring, stale once boxes repaint 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 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 _, _, 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 draw_links() apply_sheet_zoom(fonts) on_sheet = set(components_to_render) for element in kept: if element is not None and element in on_sheet: tag = app_state.canvas_tag_by_element[element] app_state.selected_canvas_tags.add(tag) app_state.selected_canvas_tag = tag for tag in app_state.selected_canvas_tags: canvas.itemconfig(f"{tag} && block_outline", outline="#1f4e79", width=3) def apply_sheet_zoom(fonts): """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) fit_sheet_region() def fit_sheet_region(): """Sets the scrollable area: the whole sheet, plus whatever hangs off its edges. The sheet itself never grows (see sheet_extent). A box near the right or bottom edge can still reach past it, or a wire loop past the top, so the scrollable area takes those in too, with SHEET_MARGIN_GRIDS of grey beyond them. """ canvas = app_state.canvas zoom = app_state.zoom margin = SHEET_MARGIN_GRIDS * app_state.GRID_SIZE width, height = sheet_extent() x1, y1, x2, y2 = 0, 0, width, height bounds = sheet_content_bounds() if bounds is not None: if bounds[0] < 0: x1 = bounds[0] - margin if bounds[1] < 0: y1 = bounds[1] - margin if bounds[2] > width: x2 = bounds[2] + margin if bounds[3] > height: y2 = bounds[3] + margin canvas.config(scrollregion=(x1 * zoom, y1 * zoom, x2 * zoom, y2 * zoom)) draw_grid() def scroll_view_to(x, y): """Scrolls so canvas point (x, y) sits at the view's top-left, as near as the sheet allows.""" canvas = app_state.canvas region = str(canvas.cget("scrollregion")).split() if len(region) != 4: return x1, y1, x2, y2 = (float(v) for v in region) canvas.xview_moveto(max(0.0, (x - x1) / ((x2 - x1) or 1.0))) canvas.yview_moveto(max(0.0, (y - y1) / ((y2 - y1) or 1.0))) 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() fit_sheet_region() # a box dragged over the edge brings the grey past it into view 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) fit_sheet_region() 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 content_width = bounds[2] - bounds[0] content_height = bounds[3] - bounds[1] 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 clear.side out from the source box and clear.arrive in front of the target box, and stand clear.lane apart, so n of them span clear.side + clear.arrive + (n - 1) * clear.lane. """ clear = grid_clearance() return max(TIDY_GAP, clear.side + clear.arrive + max(0, run_count - 1) * clear.lane) LAYOUT_SWEEPS = 4 # down-and-up passes plan_columns makes to straighten the wires def plan_columns(columns, column_keys, demand, scale, wires=()): """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. Column x positions come from widths and wire lanes alone. Heights are then settled to make the wires run as straight as they can: each box is offered the height that lines its slot up with the slot at the other end of its wires (the median, when it has several), and each column is re-sorted by those heights, which also untangles crossings. Boxes keep at least TIDY_GAP apart, but any gap above that is free -- a box drops as far as its wires want. Sweeps run left to right, lining boxes up with what feeds them, and right to left, lining them up with what they feed. `wires` are (src_column, dst_column, src_item, src_slot, dst_item, dst_slot), left to right only. One that skips columns stands in each column it crosses as a placeholder box one wire tall, so it is sorted and spaced like the boxes there. The router turns such a wire down beside its source, crossing the columns in between level with its target slot, so a box still in its way is pushed down below it. The last sweep runs right to left so those heights are final when that is checked. """ margin = snap_up(TIDY_MARGIN, scale) clear_end = grid_clearance().end count = len(column_keys) # Column x positions depend only on widths and wire lanes, so they come first. column_x = [] cursor_x = margin # the left-most column clears the canvas edge by the margin if count and any(item.get("knob_in") for item in columns[column_keys[0]]): # One step further in, so a knob on a first-column box's left edge stays on # the sheet instead of out in the margin. cursor_x = snap_up(margin + app_state.GRID_SIZE, scale) for index, grid_x in enumerate(column_keys): column_x.append(cursor_x) gap = column_gap_for(demand.get(index, 0)) cursor_x = snap_up(cursor_x + max(i["width"] for i in columns[grid_x]) + gap, scale) order = [list(columns[key]) for key in column_keys] # A wire skipping columns gets a placeholder in each column it crosses, as tall as a # wire, so it is sorted and spaced among that column's boxes like one of them. That # is what keeps a box from being placed on the wrong side of a wire it then has to # cross. The wire is modelled as a chain through its placeholders, so every link of # the chain joins neighbouring columns. segments = [] # (a, a_slot, b, b_slot), a one column left of b for src_col, dst_col, src, s_slot, dst, d_slot in wires: previous, previous_slot = src, s_slot for index in range(src_col + 1, dst_col): placeholder = {"wire": True, "height": 0, "slot_offsets": {}} order[index].append(placeholder) segments.append((previous, previous_slot, placeholder, None)) previous, previous_slot = placeholder, None segments.append((previous, previous_slot, dst, d_slot)) incoming = {id(item): [] for column in order for item in column} outgoing = {id(item): [] for column in order for item in column} for segment in segments: outgoing[id(segment[0])].append(segment) incoming[id(segment[2])].append(segment) def offset(item, slot): if item.get("wire"): return 0 return item["slot_offsets"].get(slot, item["height"] / 2) def gap(above, below): """Clear space between two neighbours in a column, boxes or wires.""" if above.get("wire") and below.get("wire"): return grid_clearance().row if above.get("wire") or below.get("wire"): return clear_end return TIDY_GAP top = {} for column in order: # starting point: packed from the top cursor_y = margin for item in column: top[id(item)] = cursor_y cursor_y = snap_up(cursor_y + item["height"] + TIDY_GAP, scale) def wanted_from_left(item): return [(top[id(a)] + offset(a, a_slot) - offset(item, b_slot), offset(item, b_slot)) for a, a_slot, _, b_slot in incoming[id(item)]] def wanted_from_right(item): return [(top[id(b)] + offset(b, b_slot) - offset(item, a_slot), offset(item, a_slot)) for _, a_slot, b, b_slot in outgoing[id(item)]] def place(index, primary, fallback): column = order[index] wanted = {} anchor = {} for item in column: if item.get("loose"): continue heights = sorted(primary(item) or fallback(item)) if heights: spot, slot_offset = heights[(len(heights) - 1) // 2] else: spot, slot_offset = top[id(item)], 0 wanted[id(item)] = spot anchor[id(item)] = spot + slot_offset # the wire height it lines up with # Sorted by the wire each box lines up with, so neighbours' wires do not cross; # unlinked boxes stay packed below everything else, in their own order. column.sort(key=lambda i: (bool(i.get("loose")), anchor.get(id(i), 0))) rows = sorted( top[id(dst)] + offset(dst, d_slot) for src_col, dst_col, _, _, dst, d_slot in wires if src_col < index < dst_col ) floor_y = margin above = None for item in column: if above is not None: floor_y = above_bottom + gap(above, item) if item.get("loose"): spot = snap_up(floor_y, scale) elif item.get("wire"): spot = max(floor_y, wanted[id(item)]) # never stored, so not snapped else: spot = max(snap_up(floor_y, scale), round(wanted[id(item)] / scale) * scale) if not item.get("wire"): # Step below every passing wire the box would still sit across, keeping # the clearance the router wants between a horizontal run and a box. moved = True while moved: moved = False for row in rows: if spot - clear_end < row < spot + item["height"] + clear_end: spot = snap_up(row + clear_end, scale) moved = True top[id(item)] = spot above, above_bottom = item, spot + item["height"] def lift(): """Shifts the whole sheet up until its top-most box sits on the margin again. Boxes only ever settle downwards, so without this the sweeps would carry the whole picture further down the sheet each time round. """ tops = [top[id(i)] for column in order for i in column if not i.get("loose") and not i.get("wire")] shift = (min(tops, default=margin) - margin) // scale * scale if shift > 0: for key in top: top[key] -= shift for _ in range(LAYOUT_SWEEPS): for index in range(count): place(index, wanted_from_left, wanted_from_right) lift() for index in reversed(range(count)): place(index, wanted_from_right, wanted_from_left) lift() placements = [] clamped = 0 for index, column in enumerate(order): for item in column: if item.get("wire"): continue step_x = min(META_COORD_MAX, round(column_x[index] / scale)) step_y = min(META_COORD_MAX, round(top[id(item)] / scale)) if step_x == META_COORD_MAX or step_y == META_COORD_MAX: clamped += 1 placements.append((item, step_x, step_y)) return placements, clamped def sheet_link_pairs(components): """(source, src_slot, target, dst_slot) for each link between two components here.""" root = app_state.xml_root_element pairs = [] if root is None: return pairs links_elem = root.find("links") if links_elem is None: return pairs comp_by_path = {get_component_path(comp, root): comp for comp in components} 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_path, src_slot = from_ref.rsplit(".", 1) dst_path, dst_slot = to_ref.rsplit(".", 1) src = comp_by_path.get(src_path) dst = comp_by_path.get(dst_path) if src is not None and dst is not None and src is not dst: pairs.append((src, src_slot, dst, dst_slot)) return pairs def flow_columns(items, pairs): """Lays linked boxes out as a left-to-right signal flow, in as few columns as it can. Returns a list of columns, each listed top to bottom. A box goes one column right of the furthest box feeding it, so the column count is the length of the longest chain and cannot be smaller. A box nothing feeds is then pulled right to sit just before the first box it feeds, which keeps its wires short. Feedback loops are cut where they run backwards relative to the boxes' current positions, so a loop keeps the direction the author drew it in. """ def position(item): return item["grid_x"], item["grid_y"], item["comp"].get("name", "") item_of = {item["comp"]: item for item in items} succs = {id(item): [] for item in items} for src, _, dst, _ in pairs: if item_of[dst] not in succs[id(item_of[src])]: succs[id(item_of[src])].append(item_of[dst]) for out in succs.values(): out.sort(key=position) # Depth-first from the left-most boxes; an edge back onto the current path is # feedback and is left out of the layering. forward = {id(item): [] for item in items} state = {} # id -> 1 on the current path, 2 finished for start in sorted(items, key=position): if id(start) in state: continue state[id(start)] = 1 stack = [(start, iter(succs[id(start)]))] while stack: node, pending = stack[-1] nxt = next(pending, None) if nxt is None: state[id(node)] = 2 stack.pop() elif state.get(id(nxt)) == 1: continue # feedback else: forward[id(node)].append(nxt) if id(nxt) not in state: state[id(nxt)] = 1 stack.append((nxt, iter(succs[id(nxt)]))) preds = {id(item): [] for item in items} for item in items: for nxt in forward[id(item)]: preds[id(nxt)].append(item) # Longest path from the sources, in topological order. layer = {} indegree = {id(item): len(preds[id(item)]) for item in items} ready = sorted((i for i in items if not indegree[id(i)]), key=position) order = [] while ready: node = ready.pop(0) order.append(node) layer[id(node)] = max((layer[id(p)] + 1 for p in preds[id(node)]), default=0) for nxt in forward[id(node)]: indegree[id(nxt)] -= 1 if not indegree[id(nxt)]: ready.append(nxt) for node in reversed(order): if not preds[id(node)] and forward[id(node)]: layer[id(node)] = min(layer[id(n)] for n in forward[id(node)]) - 1 # Into any one box, wires from further left must arrive at higher slots than wires # from nearer columns. A long wire crosses the columns in between level with its # slot, so a nearer box feeding a slot above it would have to cross it on the way. # Only boxes nothing feeds are free to move, so they are held back far enough left. feeders = {} # id(target) -> [(slot height, source)] for src, _, dst, d_slot in pairs: src_item, dst_item = item_of[src], item_of[dst] if dst_item in forward[id(src_item)]: feeders.setdefault(id(dst_item), []).append( (dst_item["slot_offsets"].get(d_slot, 0), src_item)) sources = [n for n in order if not preds[id(n)] and forward[id(n)]] changed = True while changed: changed = False for node in sources: limit = layer[id(node)] for target_id, fed in feeders.items(): own = [height for height, src in fed if src is node] for height, other in fed: if other is not node and own and height > min(own): limit = min(limit, layer[id(other)]) limit = max(0, limit) if limit < layer[id(node)]: layer[id(node)] = limit changed = True columns = [[] for _ in range(max(layer.values(), default=-1) + 1)] for item in sorted(items, key=lambda i: (i["grid_y"], i["grid_x"])): columns[layer[id(item)]].append(item) # Order each column after the boxes feeding it, so wires run as level as they can; # then once back right to left, settling the boxes nothing feeds beside the boxes # they feed. Ties keep the author's top-to-bottom order. row = {} def settle(column, neighbours): def key(item): near = [row[id(n)] for n in neighbours(item) if id(n) in row] return sum(near) / len(near) if near else row.get(id(item), float("inf")) column.sort(key=key) for index, item in enumerate(column): row[id(item)] = index for column in columns: settle(column, lambda i: preds[id(i)]) for column in reversed(columns): settle(column, lambda i: forward[id(i)] if not preds[id(i)] else ()) return columns def build_layout_items(components): """Column-grouped layout items for a sheet, measured rather than read off the canvas. Returns (columns, column_keys, demand, wires), each column listed top to bottom. Linked boxes are arranged by flow_columns. A box with no link to another box on this sheet -- unlinked, or linked only off-sheet -- goes to the bottom of the first column, alphabetically by name. 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() pairs = sheet_link_pairs(components) on_sheet = {comp for src, _, dst, _ in pairs for comp in (src, dst)} knobbed = offsheet_input_comps(components) linked = [] loose = [] 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, slot_rows = measure_box(comp, fonts, linked_slot_index)[:3] item = { "comp": comp, "meta": meta_val, "grid_x": grid_x, "grid_y": grid_y, "width": width, "height": height, "knob_in": comp in knobbed, # Where each slot's wire meets the box, as painted by draw_component_box. "slot_offsets": { raw_name: HEADER_HEIGHT + row * SLOT_ROW_HEIGHT + SLOT_ROW_HEIGHT // 2 for row, (raw_name, _, _) in enumerate(slot_rows) }, } (linked if comp in on_sheet else loose).append(item) columns = dict(enumerate(flow_columns(linked, pairs))) if loose: loose.sort(key=lambda i: (i["comp"].get("name", "").casefold(), i["comp"].get("name", ""))) for item in loose: item["loose"] = True # plan_columns keeps these packed at the bottom columns.setdefault(0, []).extend(loose) 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), forward_wires(columns, column_of_comp)) def forward_wires(columns, column_of_comp): """Every wire running left to right between two columns, for plan_columns. Each is (src_column, dst_column, src_item, src_slot, dst_item, dst_slot). Feedback wires, running right to left, are left out: they go over or under the boxes anyway. """ wires = [] root = app_state.xml_root_element links_elem = root.find("links") if root is not None else None if links_elem is None: return wires item_by_path = { get_component_path(item["comp"], root): item for column in columns.values() for item in column } 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_path, src_slot = from_ref.rsplit(".", 1) dst_path, dst_slot = to_ref.rsplit(".", 1) src_item = item_by_path.get(src_path) dst_item = item_by_path.get(dst_path) if src_item is None or dst_item is None: continue src_col = column_of_comp[src_item["comp"]] dst_col = column_of_comp[dst_item["comp"]] if dst_col > src_col: wires.append((src_col, dst_col, src_item, src_slot, dst_item, dst_slot)) return wires 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, wires = 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, wires) if not clamped: return grid_size * multiplier return grid_size * META_SCALE_MAX def tidy_layout(): """Re-flows the open sheet as a left-to-right signal flow with no two boxes overlapping. Columns follow the wiring (see flow_columns), using as few as the longest chain of links allows; spacing is rebuilt from the sizes actually painted. Boxes with no link to another box on the sheet are gathered at the bottom of the first column, A to Z. 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, wires = 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, wires) 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: # Each new position goes to the device, and a box the device refused goes back to # where the device still has it. Only the boxes that moved are undoable. failures = [] moved = [] for change in changes: element, old_val, _, _ = change 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) else: moved.append(change) if moved: undo.push(undo.LiveMetaMoveCommand(moved, label="tidy layout")) 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)