"""Canvas painting: the grid, component boxes, and the routed link wires.""" import math import tkinter as tk import tkinter.font as tkfont import app_state import schema import undo from sax_dom import get_component_path, read_meta_property, update_meta_property LINK_STUB = 12 # straight run leaving a slot before the wire is allowed to turn LINK_CLEAR = 8 # gap kept between a horizontal wire lane and any box above or below LINK_EPS = 0.5 # tolerance so a wire touching its own box edge is not a collision # Vertical wire runs. A run hugs the box it leaves or enters instead of drifting down the # middle of the gap, but may not crowd it: WIRE_HUG is where it sits, WIRE_MIN_CLEAR is # the closest it is ever allowed to get, and two runs sharing one gap stand # WIRE_LANE_GAP apart. A gap therefore has to be WIRE_HUG either side plus one # WIRE_LANE_GAP per extra run, which is why TIDY_GAP below is exactly 2 * WIRE_HUG. WIRE_HUG = 15 WIRE_MIN_CLEAR = 10 WIRE_LANE_GAP = 10 # No wire may pass under a component, and that outranks hugging: when the hug band is # already taken, a run may stand off this far instead so the wire can still be drawn # where everyone can see it. A link with no clear path at all is shown as a pair of # knobs, the same way a link leaving the sheet is. WIRE_STANDOFF_MAX = 60 # Two different wires running horizontally at the same height are indistinguishable, so # a free horizontal lane is nudged until it clears any other wire's by this much. The # short stubs joining a slot cannot move -- they have to meet their pin -- so a fan-out # from one output pin still shares the run right beside the pin. WIRE_ROW_GAP = 5 LINK_COLOR = "#2f6fba" KNOB_OFFSHEET_COLOR = "#9aa0a6" # a link whose far end is on another wiresheet KNOB_RADIUS = 4 KNOB_CORE_RADIUS = 1.5 # white centre marking a link that stays on this sheet # Box metrics. Kept tight on purpose: stored meta positions were authored against # compact boxes, with columns pitched about 100px apart and rows about 60px apart. # Anything larger and boxes land on top of each other before a single one is moved. HEADER_HEIGHT = 26 SLOT_ROW_HEIGHT = 14 FOOTER_HEIGHT = 2 MIN_BOX_WIDTH = 80 # ignored when the configured width is smaller META_COORD_MAX = 255 # meta packs x and y into one byte each # meta addresses at most META_COORD_MAX steps on each axis, so a sheet laid out one grid # square per step can only be META_COORD_MAX * GRID_SIZE px wide. A sheet that needs more # is laid out at a whole multiple of the grid instead -- 2 grid squares per step, then 3, # then 4 -- which multiplies the reach without touching the user's grid size. The # multiplier is derived from the sheet's own contents, so painting and tidying always # agree on it without anything having to be recorded in the file. META_SCALE_MAX = 12 TIDY_GAP = 30 # minimum clear space kept between two boxes TIDY_MARGIN = 30 # minimum clear space kept from the top and left canvas edges # 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. """ for cx in channel_candidates(src_box, dst_box, boxes, low, high): lane = free_lane(cx, run_lo, run_hi, boxes, claimed, low, high, max_steps) if lane is None: continue candidate = [start, leave, (lane, src_y), (lane, dst_y), arrive, end] if not path_is_clear(candidate, boxes): continue if not allow_stack and any( row_is_stacked(y, x1, x2, claimed_rows, owner) for y, x1, x2, _ in rows_of(candidate) ): continue return candidate, [(lane, run_lo, run_hi)] return None, None def around_boxes(max_offset, allow_stack=False): """Over or under everything, hugging each end. Covers right-to-left feedback. With `allow_stack` the long run may settle on a row another wire already uses, which is a last resort: a wire sharing a row is still far better than a link shown only as knobs. """ lanes = set() for _, by1, _, by2 in boxes: lanes.add(by1 - LINK_CLEAR) lanes.add(by2 + LINK_CLEAR) # Also offer the clear air above and below the whole sheet, and the middle of # each horizontal band between one row of boxes and the next. A lane pinned to # one box's edge is often still blocked by a taller neighbour, whereas the # middle of a band usually crosses the sheet unobstructed. tops = sorted({b[1] for b in boxes}) bottoms = sorted({b[3] for b in boxes}) lanes.add(min(tops) - LINK_CLEAR) lanes.add(max(bottoms) + LINK_CLEAR) for bottom in bottoms: nearest_below = [t for t in tops if t > bottom + 2 * LINK_CLEAR] if nearest_below: lanes.add((bottom + min(nearest_below)) / 2) midline = (src_y + dst_y) / 2 for base_cy in sorted(lanes, key=lambda c: abs(c - midline)): out_x = hug_lane(src_box[2], 1, min(src_y, base_cy), max(src_y, base_cy), boxes, claimed, max_offset) if out_x is None: continue in_x = hug_lane(dst_box[0], -1, min(base_cy, dst_y), max(base_cy, dst_y), boxes, claimed + [(out_x, min(src_y, base_cy), max(src_y, base_cy))], max_offset) if in_x is None: continue # The long run across the sheet is the one segment free to move, so step it # off any other wire already crossing at that height. Each clear height is # tried in turn, since a height clear of wires can still cross a box. span_lo, span_hi = min(out_x, in_x), max(out_x, in_x) options = free_rows(base_cy, span_lo, span_hi, claimed_rows, owner) if allow_stack and base_cy not in options: options.append(base_cy) # last resort: share the row rather than drop the wire for cy in options: out_lo, out_hi = min(src_y, cy), max(src_y, cy) in_lo, in_hi = min(cy, dst_y), max(cy, dst_y) # The lanes were picked against the unnudged height, and moving the # row lengthens both vertical runs, so their clearance is checked # again over the spans they actually cover. if (vertical_run_clearance(out_x, out_lo, out_hi, boxes) < WIRE_MIN_CLEAR - LINK_EPS): continue if (vertical_run_clearance(in_x, in_lo, in_hi, boxes) < WIRE_MIN_CLEAR - LINK_EPS): continue candidate = [start, (out_x, src_y), (out_x, cy), (in_x, cy), (in_x, dst_y), end] if path_is_clear(candidate, boxes): return candidate, [(out_x, out_lo, out_hi), (in_x, in_lo, in_hi)] return None, None # Tier by how much of the spacing guidance has to give way. Crossing a box is never # on the menu, so the last tier is "no wire", handled by the caller. if direct_only: # First pass: only the straight route, whose horizontal runs are pinned to their # slots and cannot be moved later. Letting these claim their rows first stops a # detouring wire from parking its movable lane on top of one of them. attempts = (lambda: straight_through(1),) else: attempts = ( lambda: straight_through(1), # hugging, own lane lambda: around_boxes(WIRE_HUG), # hugging, routed around lambda: straight_through(8), # lane nudged out of the band lambda: around_boxes(WIRE_STANDOFF_MAX), # standing off, routed around # Last resorts, in order. A wire sharing a row with another is still far # better than a link reduced to knobs, so stacking is only reached once # every route that keeps its own row has been ruled out. lambda: straight_through(1, allow_stack=True), lambda: straight_through(8, allow_stack=True), lambda: around_boxes(WIRE_STANDOFF_MAX, allow_stack=True), ) for attempt in attempts: candidate, runs = attempt() if candidate is not None: if taken is not None: taken["cols"].extend(runs) taken["rows"].extend(rows_of(candidate)) return candidate return None def resolve_slot_anchor(comp_tag, slot_name): """Returns (box_bounds, y) for a slot row, centring on the box when the row is hidden.""" box = get_box_bounds(comp_tag) if box is None: return None, None offset = app_state.component_slot_offsets.get(comp_tag, {}).get(slot_name) if offset is None: return box, (box[1] + box[3]) / 2 return box, box[1] + offset def draw_knob(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) # 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). """ if not app_state.canvas_comp_map: return 0, 0 components = list(app_state.canvas_comp_map.values()) columns, column_keys, demand = build_layout_items(components) if not column_keys: return 0, 0 scale = sheet_meta_scale(components) placements, clamped = plan_columns(columns, column_keys, demand, scale) changes = [ (item["comp"], item["meta"], step_x, step_y) for item, step_x, step_y in placements if (step_x, step_y) != (item["grid_x"], item["grid_y"]) ] if not changes: return 0, clamped command = undo.MetaMoveCommand(changes, label="tidy layout") command.redo() # apply it; the command is the single writer of meta undo.push(command) app_state.mark_dirty() render_current_sheet() return len(changes), clamped