First Commit
Sedona sax editor AI yardimi ile yaziliyor
This commit is contained in:
+486
@@ -0,0 +1,486 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user