"""Canvas painting: the grid, component boxes, and the routed link wires.""" 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 routed wire and any box it passes LINK_EPS = 0.5 # tolerance so a wire touching its own box edge is not a collision LINK_COLOR = "#2f6fba" KNOB_RADIUS = 4 # 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 TIDY_GAP = 20 # clear space left between boxes by the tidy command def short_type_name(comp_type): """'ontrolControl::HvacControl' -> 'HvacControl'. The kit prefix alone cost ~40px.""" return comp_type.split("::")[-1] if "::" in comp_type else comp_type def fit_text(text, font, max_px): """Truncates with an ellipsis so a label can never widen or overflow its box.""" if max_px <= 0: return "" if font.measure(text) <= max_px: return text ellipsis = "…" if font.measure(ellipsis) > max_px: return "" trimmed = text while trimmed and font.measure(trimmed + ellipsis) > max_px: trimmed = trimmed[:-1] return (trimmed + ellipsis) if trimmed else ellipsis def draw_grid(): """Draws a grid onto the Canvas based on the scrollable region.""" canvas = app_state.canvas canvas.delete("grid") width = 3000 height = 3000 for x in range(0, width, app_state.GRID_SIZE): canvas.create_line(x, 0, x, height, fill="#e0e0e0", tags="grid") for y in range(0, height, app_state.GRID_SIZE): canvas.create_line(0, y, width, y, fill="#e0e0e0", tags="grid") canvas.tag_lower("grid") def create_round_rectangle(canvas, x1, y1, x2, y2, radius=4, **kwargs): """Draws a visually smooth rounded rectangle using native canvas arcs and polygons.""" points = [ x1+radius, y1, x1+radius, y1, x2-radius, y1, x2-radius, y1, x2, y1, x2, y1+radius, x2, y1+radius, x2, y2-radius, x2, y2-radius, x2, y2, x2-radius, y2, x2-radius, y2, x1+radius, y2, x1+radius, y2, x1, y2, x1, y2-radius, x1, y2-radius, x1, y1+radius, x1, y1+radius, x1, y1 ] return canvas.create_polygon(points, **kwargs, smooth=True) # --- Link indexing and routing --- def build_linked_slot_index(): """Maps component path -> set of slot names touched by a link, either end.""" index = {} if app_state.xml_root_element is None: return index links_elem = app_state.xml_root_element.find("links") if links_elem is None: return index for link in links_elem.findall("link"): for ref in (link.get("from", ""), link.get("to", "")): if "." not in ref: continue comp_path, slot_name = ref.rsplit(".", 1) index.setdefault(comp_path, set()).add(slot_name) return index def get_box_bounds(comp_tag): """Current on-canvas bounds of a component group, valid after drags.""" box = app_state.canvas.bbox(comp_tag) return box if box 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 route_link_path(src_box, src_y, dst_box, dst_y, boxes): """Routes an orthogonal wire from a source slot to a target slot around every box.""" 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) preferred = (leave[0] + arrive[0]) / 2 # Preference 1: a clear vertical channel somewhere between the two boxes. channels = {preferred} for bx1, _, bx2, _ in boxes: channels.add(bx1 - LINK_CLEAR) channels.add(bx2 + LINK_CLEAR) low, high = min(leave[0], arrive[0]), max(leave[0], arrive[0]) for cx in sorted((c for c in channels if low <= c <= high), key=lambda c: abs(c - preferred)): candidate = [start, leave, (cx, src_y), (cx, dst_y), arrive, end] if path_is_clear(candidate, boxes): return candidate # Preference 2: go over or under everything. Covers feedback links running right to left. lanes = set() for _, by1, _, by2 in boxes: lanes.add(by1 - LINK_CLEAR) lanes.add(by2 + LINK_CLEAR) midline = (src_y + dst_y) / 2 for cy in sorted(lanes, key=lambda c: abs(c - midline)): candidate = [start, leave, (leave[0], cy), (arrive[0], cy), arrive, end] if path_is_clear(candidate, boxes): return candidate # Nothing clear: fall back to the direct dog-leg rather than dropping the wire. return [start, leave, (preferred, src_y), (preferred, dst_y), arrive, end] def resolve_slot_anchor(comp_tag, slot_name): """Returns (box_bounds, y) for a slot row, centring on the box when the row is hidden.""" box = get_box_bounds(comp_tag) if box is None: return None, None offset = app_state.component_slot_offsets.get(comp_tag, {}).get(slot_name) if offset is None: return box, (box[1] + box[3]) / 2 return box, box[1] + offset def draw_knob(x, y): """Marks a link whose far end lives on another wiresheet.""" app_state.canvas.create_oval( x - KNOB_RADIUS, y - KNOB_RADIUS, x + KNOB_RADIUS, y + KNOB_RADIUS, fill=LINK_COLOR, outline="white", width=1, tags=("link", "link_knob") ) def draw_links(): """Paints wires between slots on this sheet and knobs for links leaving it.""" canvas = app_state.canvas canvas.delete("link") 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] 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 points = route_link_path(src_box, src_y, dst_box, dst_y, boxes) 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",) ) elif src_tag: # Source is here, target is on another sheet. src_box, src_y = resolve_slot_anchor(src_tag, from_slot) if src_box is not None: draw_knob(src_box[2] + KNOB_RADIUS, src_y) elif dst_tag: # Target is here, source is on another sheet. dst_box, dst_y = resolve_slot_anchor(dst_tag, to_slot) if dst_box is not None: draw_knob(dst_box[0] - KNOB_RADIUS, dst_y) # Wires and knobs sit behind the boxes so they never paint over a neighbouring component. if canvas.find_withtag("component"): canvas.tag_lower("link", "component") # --- Component painting --- def render_components(components_to_render): """Handles parsing and visual painting of given component listings on canvas with slot attributes.""" canvas = app_state.canvas app_state.active_components_list = components_to_render app_state.selected_canvas_tag = None canvas.delete("component") canvas.delete("link") app_state.canvas_comp_map.clear() app_state.canvas_tag_by_element.clear() app_state.component_slot_offsets.clear() name_font = tkfont.Font(family="Segoe UI", size=9, weight="bold") type_font = tkfont.Font(family="Segoe UI", size=7) slot_font = tkfont.Font(family="Segoe UI", size=8) linked_slot_index = build_linked_slot_index() max_reached_x = 1000 max_reached_y = 1000 header_height = HEADER_HEIGHT slot_row_height = SLOT_ROW_HEIGHT footer_height = FOOTER_HEIGHT for comp in components_to_render: comp_name = comp.get("name", "Unknown") comp_type = comp.get("type", "sys::Component") comp_id = comp.get("id") if comp_id is not None: full_display_name = f"{comp_name} [id: {comp_id}]" else: full_display_name = comp_name meta_str = None meta_prop = comp.find("prop[@name='meta']") if meta_prop is not None: meta_str = meta_prop.get("val") raw_x, raw_y, reserved, g1, g2, g3, g4, ug_res = read_meta_property(meta_str) coord_x = (raw_x * app_state.GRID_SIZE) coord_y = (raw_y * app_state.GRID_SIZE) # Retrieve manifest slot definitions, keeping any slot a link needs to land on 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_data_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_data_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) name_width = name_font.measure(full_display_name) type_width = type_font.measure(display_type) max_content_width = max(name_width, type_width) + 30 for _, display_label, val_str in slot_data_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 box_width = min(app_state.BOX_WIDTH, max(MIN_BOX_WIDTH, max_content_width)) box_height = header_height + (len(slot_data_rows) * slot_row_height) + footer_height if coord_x + box_width > max_reached_x: max_reached_x = coord_x + box_width if coord_y + box_height > max_reached_y: max_reached_y = coord_y + box_height comp_tag = f"comp_group_{id(comp)}" app_state.canvas_comp_map[comp_tag] = comp app_state.canvas_tag_by_element[comp] = comp_tag # Filled in as rows are drawn below; keyed by slot name for link anchoring. slot_offsets = {} app_state.component_slot_offsets[comp_tag] = slot_offsets create_round_rectangle( canvas, coord_x, coord_y, coord_x + box_width, coord_y + box_height, radius=5, fill="white", outline="#7f9db9", width=2, tags=("component", comp_tag, "block_outline") ) # Draw Header bar canvas.create_rectangle( coord_x + 1, coord_y + 1, coord_x + box_width - 1, coord_y + header_height, fill="#dce6f2", outline="", tags=("component", comp_tag) ) canvas.create_line( coord_x, coord_y + header_height, coord_x + box_width, coord_y + header_height, fill="#7f9db9", tags=("component", comp_tag) ) # Header textual labels, ellipsised to leave room for the indicator square header_text_width = box_width - 26 canvas.create_text( coord_x + 5, coord_y + 8, anchor="w", text=fit_text(full_display_name, name_font, header_text_width), font=name_font, fill="black", tags=("component", comp_tag) ) canvas.create_text( coord_x + 5, coord_y + 19, anchor="w", text=fit_text(display_type, type_font, header_text_width), font=type_font, fill="#555555", tags=("component", comp_tag) ) # Quick access indicator icon background slot placeholder canvas.create_rectangle( coord_x + box_width - 17, coord_y + 6, coord_x + box_width - 5, coord_y + 18, fill="#ffe699", outline="#b4c6e7", tags=("component", comp_tag) ) # Render dynamic slots rows current_row_y = coord_y + header_height for idx, (raw_name, s_name, val_str) in enumerate(slot_data_rows): row_bg = "#eaeaea" if idx % 2 == 0 else "#dfdfdf" slot_offsets[raw_name] = (current_row_y + (slot_row_height // 2)) - coord_y canvas.create_rectangle( coord_x + 1, current_row_y, coord_x + box_width - 1, current_row_y + slot_row_height, fill=row_bg, outline="", tags=("component", comp_tag) ) # Value keeps its full text; the name yields space to it when the row is tight value_width = slot_font.measure(val_str) label_space = box_width - 16 - value_width canvas.create_text( coord_x + 6, current_row_y + (slot_row_height // 2), anchor="w", text=fit_text(s_name, slot_font, label_space), font=slot_font, fill="black", tags=("component", comp_tag) ) canvas.create_text( coord_x + box_width - 6, current_row_y + (slot_row_height // 2), anchor="e", text=val_str, font=slot_font, fill="#333333", tags=("component", comp_tag) ) current_row_y += slot_row_height canvas.create_line( coord_x, current_row_y, coord_x + box_width, current_row_y, fill="#b8b8b8", tags=("component", comp_tag) ) canvas.config(scrollregion=(0, 0, max_reached_x + 200, max_reached_y + 200)) draw_links() def render_current_sheet(): """Repaints whichever sheet is open, re-reading children from the DOM. Undo of a delete puts an element back, so the component list has to be rebuilt from the document rather than reused from the previous render. """ if app_state.xml_root_element is None: return parent = app_state.current_sheet_parent if parent is None: app_element = app_state.xml_root_element.find("app") if app_element is None: app_element = app_state.xml_root_element parent = app_element render_components([child for child in parent if child.tag == "comp"]) def 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. Returns (moved_count, clamped_count). """ if not app_state.canvas_comp_map: return 0, 0 placed = [] for comp_tag, comp in app_state.canvas_comp_map.items(): bounds = get_box_bounds(comp_tag) if bounds is None: continue 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] placed.append({ "comp": comp, "meta": meta_val, "grid_x": grid_x, "grid_y": grid_y, "width": bounds[2] - bounds[0], "height": bounds[3] - bounds[1], }) if not placed: return 0, 0 columns = {} for item in placed: columns.setdefault(item["grid_x"], []).append(item) grid_size = app_state.GRID_SIZE changes = [] clamped = 0 cursor_x = grid_size # leave one grid square of margin at the left for grid_x in sorted(columns): column = sorted(columns[grid_x], key=lambda i: i["grid_y"]) cursor_y = grid_size for item in column: new_grid_x = min(META_COORD_MAX, round(cursor_x / grid_size)) new_grid_y = min(META_COORD_MAX, round(cursor_y / grid_size)) if new_grid_x == META_COORD_MAX or new_grid_y == META_COORD_MAX: clamped += 1 if (new_grid_x, new_grid_y) != (item["grid_x"], item["grid_y"]): changes.append((item["comp"], item["meta"], new_grid_x, new_grid_y)) cursor_y += item["height"] + TIDY_GAP cursor_x += max(i["width"] for i in column) + TIDY_GAP 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