sedona_editor/wiresheet.py
arda.aydin@ontrol.com.tr 9c283c7eea First Commit
Sedona sax editor AI yardimi ile yaziliyor
2026-08-13 17:59:08 +03:00

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