"""Linking on the wiresheet with the mouse: slot hover, drag-out rubber band, drop. Hovering a slot row highlights the half of it the pointer is in and swaps the cursor for a horizontal arrow: the right half of a row is where a link leaves a slot, so it points right, and the left half is where one arrives, so it points left. Pressing either button on a slot and dragging pulls a rubber-band line out of that side; letting go on another slot makes the link, and letting go on a component's title bar opens the Link dialog for the two components with the dragged slot already picked. Started on the right half, the slot under the pointer is the target; started on the left half, it is the source -- so a link can be drawn from either end. While dragging, the cursor points along the flow of the link over a slot that would take it, and turns into a no-entry sign over one that would not, so a bad drop is visible before the button is released. A right press that never moves still opens the component's context menu. A right drag that starts off a slot row draws a selection rectangle instead, and selects the boxes wholly inside it on release; with Ctrl held they join the current selection. A left press anywhere on a box selects it, but only a press on its title bar moves it; on a slot row it draws a link instead, as in Workbench. """ import os import struct import tempfile import tkinter as tk import app_state import sax_file import wiresheet from sax_dom import get_component_path NO_ENTRY_PNG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets", "cursors", "no_entry.png") DRAG_THRESHOLD = 4 # pixels the pointer must travel before a press is a drag HOVER_COLOR = "#1f4e79" DROP_OK_COLOR = "#2e9d3a" DROP_BAD_COLOR = "#d62828" RUBBER_COLOR = "#2f6fba" MARQUEE_COLOR = "#2f6fba" CTRL_MASK = 0x0004 _cursors = {} # role -> Tk cursor spec, built on first use _press = None # pending right press: {"hit", "x", "y", "event"} _drag = None # live link drag: {"hit", "anchor"} _marquee = None # right press off a slot: {"x", "y", "cx", "cy", "state", "active"} _menu_callback = None # opens the ordinary right-click menu for an event _select_boxes = None # makes a list of comp tags the box selection _link_dialog = None # opens the Link dialog: (source, target, src_slot, tgt_slot) # --------------------------------------------------------------------------- cursors def _write_cur(path, pixels, hotspot): """Writes a 32x32 32-bit Windows .cur from rows of (r, g, b, a) tuples.""" size = 32 xor = bytearray() for row in reversed(pixels): # DIBs are stored bottom-up for r, g, b, a in row: xor += bytes((b, g, r, a)) mask = bytearray() for row in reversed(pixels): bits = 0 for x, (_, _, _, a) in enumerate(row): if a == 0: bits |= 1 << (31 - x) mask += struct.pack(">I", bits) header = struct.pack(" py) != (yj > py) and px < (xj - xi) * (py - yi) / (yj - yi) + xi: inside = not inside j = i return inside def _arrow_pixels(pointing_right): """A hollow block arrow: white body, black rim -- the Workbench link cursor.""" poly = [(9, 14), (16, 14), (16, 10), (23, 16), (16, 22), (16, 18), (9, 18)] if not pointing_right: poly = [(32 - x, y) for x, y in poly] inside = [[_inside_polygon(x + 0.5, y + 0.5, poly) for x in range(32)] for y in range(32)] rows = [] for y in range(32): row = [] for x in range(32): if not inside[y][x]: row.append((0, 0, 0, 0)) continue rim = any(not (0 <= x + dx < 32 and 0 <= y + dy < 32 and inside[y + dy][x + dx]) for dx in (-1, 0, 1) for dy in (-1, 0, 1)) row.append((0, 0, 0, 255) if rim else (255, 255, 255, 255)) rows.append(row) return rows def _no_entry_pixels(): """assets/cursors/no_entry.png, centred on a 32x32 cursor; None if unreadable. Read with PIL when it is installed, which keeps the image's soft edges; otherwise with Tk's own PNG reader, which only knows whether a pixel is transparent or not. """ if not os.path.exists(NO_ENTRY_PNG): return None try: from PIL import Image image = Image.open(NO_ENTRY_PNG).convert("RGBA") width, height = image.size def pixel(x, y): return image.getpixel((x, y)) except ImportError: image = tk.PhotoImage(file=NO_ENTRY_PNG) width, height = image.width(), image.height() def pixel(x, y): if image.transparency_get(x, y): return (0, 0, 0, 0) return tuple(image.get(x, y)) + (255,) if width > 32 or height > 32: return None left, top = 16 - width // 2, 16 - height // 2 rows = [[(0, 0, 0, 0)] * 32 for _ in range(32)] for y in range(height): for x in range(width): rows[top + y][left + x] = pixel(x, y) return rows def _cursor(role): """The Tk cursor for 'right', 'left' or 'no', drawn once and cached.""" if role in _cursors: return _cursors[role] fallback = {"right": "sb_right_arrow", "left": "sb_left_arrow", "no": "X_cursor"}[role] spec = fallback if os.name == "nt": try: folder = os.path.join(tempfile.gettempdir(), "sedona_editor_cursors") os.makedirs(folder, exist_ok=True) path = os.path.join(folder, f"link_{role}.cur") if role == "right": _write_cur(path, _arrow_pixels(True), (23, 16)) elif role == "left": _write_cur(path, _arrow_pixels(False), (9, 16)) else: pixels = _no_entry_pixels() if pixels is None: raise OSError("no_entry.png missing or unreadable") _write_cur(path, pixels, (16, 16)) candidate = "{@" + path.replace("\\", "/") + "}" app_state.canvas.configure(cursor=candidate) # proves Tk can load it spec = candidate except (OSError, tk.TclError): spec = "no" if role == "no" else fallback _cursors[role] = spec return spec def _set_cursor(role): canvas = app_state.canvas try: canvas.configure(cursor=_cursor(role) if role else "") except tk.TclError: canvas.configure(cursor="") # --------------------------------------------------------------------------- hit test def slot_at(cx, cy): """The slot row under a canvas point, as a dict, or None. Picks the topmost box under the point, so a box lying over another wins, and says which half of the row the point is in. """ canvas = app_state.canvas for item in reversed(canvas.find_overlapping(cx, cy, cx, cy)): tags = canvas.gettags(item) if "component" not in tags: continue comp_tag = next((t for t in tags if t.startswith("comp_group_")), None) if comp_tag is None: continue box = wiresheet.get_box_bounds(comp_tag) if box is None: return None zoom = app_state.zoom half_row = wiresheet.SLOT_ROW_HEIGHT * zoom / 2 for slot_name, offset in app_state.component_slot_offsets.get(comp_tag, {}).items(): row_y = box[1] + offset * zoom if row_y - half_row <= cy < row_y + half_row: return { "comp_tag": comp_tag, "comp": app_state.canvas_comp_map.get(comp_tag), "slot": slot_name, "side": "right" if cx >= (box[0] + box[2]) / 2 else "left", "box": box, "row_y": row_y, "half_row": half_row, } return None # over a header or footer, not a slot return None def title_at(cx, cy): """The comp tag whose title bar is topmost under a canvas point, or None.""" canvas = app_state.canvas for item in reversed(canvas.find_overlapping(cx, cy, cx, cy)): tags = canvas.gettags(item) if "component" not in tags: continue comp_tag = next((t for t in tags if t.startswith("comp_group_")), None) if comp_tag is None: continue box = wiresheet.get_box_bounds(comp_tag) if box and box[1] <= cy <= box[1] + wiresheet.HEADER_HEIGHT * app_state.zoom: return comp_tag return None return None def _slot_info(comp, slot_name): for slot in app_state.final_components_registry.get(comp.get("type", ""), []): if slot["name"] == slot_name: return slot return None def _check_link(source, target): """Returns (ok, reason) for a link from one hit slot to another.""" if source["comp"] is None or target["comp"] is None: return False, "No component there." if source["comp"] is target["comp"] and source["slot"] == target["slot"]: return False, "A slot cannot be linked to itself." src = _slot_info(source["comp"], source["slot"]) tgt = _slot_info(target["comp"], target["slot"]) if src is None or tgt is None: return False, "Slot not found in the kit manifest." for slot in (src, tgt): if "a" in slot["flags"] or slot["type"] == "void": return False, f"'{slot['name']}' is an action and cannot be linked." if tgt["readonly"]: return False, f"'{tgt['name']}' is read-only and cannot be a link target." if src["type"] != tgt["type"]: return False, (f"Type mismatch: {source['slot']} is {src['type']}, " f"{target['slot']} is {tgt['type']}.") from_ref, to_ref = _refs(source, target) links = app_state.xml_root_element.find("links") if links is not None: for link in links.findall("link"): if link.get("from") == from_ref and link.get("to") == to_ref: return False, "That link already exists." return True, f"Link {from_ref} → {to_ref}" def _refs(source, target): root = app_state.xml_root_element return (f"{get_component_path(source['comp'], root)}.{source['slot']}", f"{get_component_path(target['comp'], root)}.{target['slot']}") # --------------------------------------------------------------------------- drawing def _highlight(hit, color): """Rings the half of a slot row the pointer is over, with a bar on its outer edge.""" canvas = app_state.canvas canvas.delete("slot_hover") if hit is None: return left, _, right, _ = hit["box"] mid = (left + right) / 2 top, bottom = hit["row_y"] - hit["half_row"], hit["row_y"] + hit["half_row"] x1, x2 = (mid, right - 1) if hit["side"] == "right" else (left + 1, mid) canvas.create_rectangle(x1, top, x2, bottom, outline=color, width=2, tags=("slot_hover",)) edge = right - 2 if hit["side"] == "right" else left + 2 canvas.create_line(edge, top, edge, bottom, fill=color, width=4, tags=("slot_hover",)) canvas.tag_raise("slot_hover") def _highlight_title(comp_tag, color): """Rings a box's title bar: the drop that opens the Link dialog for that box.""" canvas = app_state.canvas canvas.delete("slot_hover") left, top, right, _ = wiresheet.get_box_bounds(comp_tag) canvas.create_rectangle(left + 1, top + 1, right - 1, top + wiresheet.HEADER_HEIGHT * app_state.zoom, outline=color, width=2, tags=("slot_hover",)) canvas.tag_raise("slot_hover") def _anchor(hit): left, _, right, _ = hit["box"] return (right if hit["side"] == "right" else left), hit["row_y"] def clear(): """Drops any hover ring, rubber band and drag state; called on every repaint.""" global _press, _drag, _marquee canvas = app_state.canvas if canvas is None: return canvas.delete("slot_hover") canvas.delete("link_rubber") canvas.delete("marquee") _press = None _drag = None _marquee = None _set_cursor(None) # --------------------------------------------------------------------------- events def on_motion(event): """Plain pointer movement: ring the slot half under the pointer.""" if _drag is not None or app_state.drag_data.get("tag"): return canvas = app_state.canvas hit = slot_at(canvas.canvasx(event.x), canvas.canvasy(event.y)) _highlight(hit, HOVER_COLOR) _set_cursor(hit["side"] if hit else None) def on_leave(_event): if _drag is None: app_state.canvas.delete("slot_hover") _set_cursor(None) def _start_press(event): """Records a press on a slot row as a link drag in waiting; None if not on a slot.""" global _press canvas = app_state.canvas hit = slot_at(canvas.canvasx(event.x), canvas.canvasy(event.y)) if hit is None or hit["comp"] is None: _press = None return None _press = {"hit": hit, "x": event.x, "y": event.y} return _press def _drag_motion(event): """Pulls the rubber band to the pointer and judges the slot under it, if any.""" global _drag canvas = app_state.canvas if _drag is None: if _press is None: return if abs(event.x - _press["x"]) < DRAG_THRESHOLD and abs(event.y - _press["y"]) < DRAG_THRESHOLD: return _drag = {"hit": _press["hit"], "anchor": _anchor(_press["hit"])} app_state.set_status("Drop on a slot to link it; release elsewhere to cancel.") cx, cy = canvas.canvasx(event.x), canvas.canvasy(event.y) start = _drag["hit"] ax, ay = _drag["anchor"] canvas.delete("link_rubber") canvas.create_line(ax, ay, cx, cy, fill=RUBBER_COLOR, width=2, dash=(4, 2), tags=("link_rubber",)) canvas.tag_raise("link_rubber") outgoing = start["side"] == "right" hit = slot_at(cx, cy) if hit is None: title_tag = title_at(cx, cy) if title_tag is not None: # Its own title too: a component may link one of its slots to another. _highlight_title(title_tag, DROP_OK_COLOR) _set_cursor("right" if outgoing else "left") app_state.set_status("Drop to pick the other slot in the Link dialog.") return if hit is None or (hit["comp_tag"] == start["comp_tag"] and hit["slot"] == start["slot"]): canvas.delete("slot_hover") _set_cursor(None) return ok, reason = _check_link(start, hit) if outgoing else _check_link(hit, start) # Ring the side of the drop slot the link would attach to, not the side under the # pointer: a link always arrives on the left of a row and leaves from its right. shown = dict(hit, side="left" if outgoing else "right") _highlight(shown, DROP_OK_COLOR if ok else DROP_BAD_COLOR) _set_cursor(("right" if outgoing else "left") if ok else "no") app_state.set_status(reason, warn=not ok) def _finish(event): """Ends a press. Returns 'link' after a drag (made or refused), 'click' after a press that never moved, None if no press on a slot was pending.""" global _press, _drag canvas = app_state.canvas press, drag = _press, _drag _press = _drag = None canvas.delete("link_rubber") canvas.delete("slot_hover") _set_cursor(None) if drag is None: return "click" if press is not None else None cx, cy = canvas.canvasx(event.x), canvas.canvasy(event.y) hit = slot_at(cx, cy) start = drag["hit"] title_tag = title_at(cx, cy) if hit is None else None if title_tag is not None: other = app_state.canvas_comp_map.get(title_tag) if other is None: app_state.set_status("Link cancelled.") elif start["side"] == "right": _link_dialog(start["comp"], other, start["slot"], None) else: _link_dialog(other, start["comp"], None, start["slot"]) return "link" if hit is None or (hit["comp_tag"] == start["comp_tag"] and hit["slot"] == start["slot"]): app_state.set_status("Link cancelled.") return "link" source, target = (start, hit) if start["side"] == "right" else (hit, start) ok, reason = _check_link(source, target) if not ok: app_state.set_status(f"Cannot link: {reason}", warn=True) return "link" from_ref, to_ref = _refs(source, target) sax_file.add_link(from_ref, to_ref) app_state.set_status(f"Linked {from_ref} → {to_ref}") return "link" # Right button: on a slot it drags a link. Anywhere else, dragging pulls out a selection # rectangle that selects every box wholly inside it on release (Ctrl adds them to the # current selection). A press that never moves is the menu. def _marquee_motion(event): """Draws the selection rectangle once the pointer has moved far enough.""" global _marquee if _marquee is None: return if not _marquee["active"]: if abs(event.x - _marquee["x"]) < DRAG_THRESHOLD and \ abs(event.y - _marquee["y"]) < DRAG_THRESHOLD: return _marquee["active"] = True app_state.canvas.delete("slot_hover") canvas = app_state.canvas canvas.delete("marquee") canvas.create_rectangle(_marquee["cx"], _marquee["cy"], canvas.canvasx(event.x), canvas.canvasy(event.y), outline=MARQUEE_COLOR, width=1, dash=(4, 2), tags=("marquee",)) canvas.tag_raise("marquee") def _marquee_finish(event): """Ends a rectangle drag. Returns True if it selected, False if it was a click.""" global _marquee marquee, _marquee = _marquee, None canvas = app_state.canvas canvas.delete("marquee") if marquee is None or not marquee["active"]: return False cx, cy = canvas.canvasx(event.x), canvas.canvasy(event.y) left, right = sorted((marquee["cx"], cx)) top, bottom = sorted((marquee["cy"], cy)) inside = [] for comp_tag in app_state.canvas_comp_map: box = wiresheet.get_box_bounds(comp_tag) if box and left <= box[0] and top <= box[1] and box[2] <= right and box[3] <= bottom: inside.append(comp_tag) adding = (marquee["state"] | event.state) & CTRL_MASK wiresheet.deselect_link() # links and boxes are never selected together if adding: current = [t for t in app_state.selected_canvas_tags if t not in inside] _select_boxes(current + inside) else: _select_boxes(inside) count = len(app_state.selected_canvas_tags) app_state.set_status(f"{count} component{'s' if count != 1 else ''} selected.") return True def on_right_press(event): global _marquee _marquee = None if _start_press(event) is None: canvas = app_state.canvas _marquee = {"x": event.x, "y": event.y, "cx": canvas.canvasx(event.x), "cy": canvas.canvasy(event.y), "state": event.state, "active": False} def on_right_motion(event): if _marquee is not None: _marquee_motion(event) else: _drag_motion(event) def on_right_release(event): if _marquee is not None: if not _marquee_finish(event): _menu_callback(event) elif _finish(event) == "click": _menu_callback(event) # Left button: anywhere on a box selects it. Only the title bar moves it; a slot row # drags out a link, and the rest of the box does nothing more than select. def _on_title_bar(comp_tag, cy): box = wiresheet.get_box_bounds(comp_tag) return box is not None and box[1] <= cy <= box[1] + wiresheet.HEADER_HEIGHT * app_state.zoom def on_left_press(event): _box_handlers["press"](event) # selection, and arms a box move if _start_press(event) is not None: app_state.canvas.delete("slot_hover") comp_tag = app_state.drag_data.get("tag") if comp_tag and not _on_title_bar(comp_tag, app_state.canvas.canvasy(event.y)): app_state.drag_data["tag"] = None # selected, but not picked up def on_left_motion(event): if _press is not None or _drag is not None: _drag_motion(event) else: _box_handlers["motion"](event) def on_left_release(event): if _finish(event) is None: _box_handlers["release"](event) _box_handlers = {} def install(canvas, menu_callback, box_press, box_motion, box_release, link_dialog, select_boxes): """Binds hover, link dragging on both buttons, and the box handlers it wraps. menu_callback opens the context menu; box_* are the sheet's own left-button select-and-move handlers, which still run whenever a press is not on a slot row; link_dialog opens the Link dialog when a link is dropped on a title bar; select_boxes makes a list of comp tags the selection after a rectangle drag. """ global _menu_callback, _link_dialog, _select_boxes _menu_callback = menu_callback _link_dialog = link_dialog _select_boxes = select_boxes _box_handlers.update(press=box_press, motion=box_motion, release=box_release) canvas.bind("", on_motion) canvas.bind("", on_leave, add="+") canvas.bind("", on_left_press) canvas.bind("", on_left_motion) canvas.bind("", on_left_release) canvas.bind("", on_right_press) canvas.bind("", on_right_motion) canvas.bind("", on_right_release)