487 lines
18 KiB
Python
487 lines
18 KiB
Python
"""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
|