Drag links out of slots on the wiresheet; hide @palette=false types

Hovering a slot row highlights the half under the pointer and shows a
direction cursor. Pressing on a slot drags a rubber band; dropping on
another slot links them, dropping on a title bar opens the Link dialog
with the dragged slot preselected, and a no-entry cursor marks a slot
that would refuse the link. A right click that never moves still opens
the context menu.

The palette now leaves out types whose facets carry @palette=false, and
kits with nothing placeable left.

Also points the default device host at 192.168.1.251.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 23:15:47 +03:00
co-authored by Claude Opus 5.5
parent f22d4852c9
commit 85b6fa62f3
7 changed files with 538 additions and 11 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it.
import configparser
import os
VERSION = "0.0.0.138"
VERSION = "0.0.0.139"
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
DEFAULT_GRID_SIZE = 10
DEFAULT_ADD_TIMESTAMP = True
Binary file not shown.

After

Width:  |  Height:  |  Size: 444 B

+1 -1
View File
@@ -7,7 +7,7 @@ left_pane_width = 230
trace_dasp = True
[Device]
host = 192.168.1.29
host = 192.168.1.251
port = 1876
user = admin
+495
View File
@@ -0,0 +1,495 @@
"""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 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"
_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"}
_menu_callback = None # opens the ordinary right-click menu for an event
_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("<IiiHHIIiiII", 40, size, size * 2, 1, 32, 0,
len(xor) + len(mask), 0, 0, 0, 0)
image = header + bytes(xor) + bytes(mask)
icondir = struct.pack("<HHH", 0, 2, 1)
entry = struct.pack("<BBBBHHII", size, size, 0, 0, hotspot[0], hotspot[1],
len(image), 6 + 16)
with open(path, "wb") as f:
f.write(icondir + entry + image)
def _inside_polygon(px, py, poly):
inside = False
j = len(poly) - 1
for i in range(len(poly)):
xi, yi = poly[i]
xj, yj = poly[j]
if (yi > 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
canvas = app_state.canvas
if canvas is None:
return
canvas.delete("slot_hover")
canvas.delete("link_rubber")
_press = None
_drag = 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, and a press that never moves is the menu.
def on_right_press(event):
if _start_press(event) is None:
_menu_callback(event)
def on_right_motion(event):
_drag_motion(event)
def on_right_release(event):
if _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):
"""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.
"""
global _menu_callback, _link_dialog
_menu_callback = menu_callback
_link_dialog = link_dialog
_box_handlers.update(press=box_press, motion=box_motion, release=box_release)
canvas.bind("<Motion>", on_motion)
canvas.bind("<Leave>", on_leave, add="+")
canvas.bind("<ButtonPress-1>", on_left_press)
canvas.bind("<B1-Motion>", on_left_motion)
canvas.bind("<ButtonRelease-1>", on_left_release)
canvas.bind("<ButtonPress-3>", on_right_press)
canvas.bind("<B3-Motion>", on_right_motion)
canvas.bind("<ButtonRelease-3>", on_right_release)
+22 -7
View File
@@ -10,6 +10,7 @@ import threading
import app_state
import connect_dialog
import device_session
import link_drag
import link_view
import live_hooks
import manage_kits
@@ -1201,8 +1202,12 @@ def _slot_linkable(slot_flags, slot_type, readonly, is_target):
return True
def open_link_dialog(source_element, target_element):
"""Opens a dialog to select source and target slots, validates types, and creates a link."""
def open_link_dialog(source_element, target_element, source_slot=None, target_slot=None):
"""Opens a dialog to select source and target slots, validates types, and creates a link.
source_slot / target_slot pre-select a slot on that side, as when a link dragged out
of a slot is dropped on a component's title bar rather than on one of its slots.
"""
window = app_state.window
sides = [source_element, target_element]
@@ -1346,6 +1351,11 @@ def open_link_dialog(source_element, target_element):
ttk.Button(mid_frame, text="⇄", width=3, command=on_swap).pack(pady=5)
refresh_panels()
for var, slot, info, is_target in ((src_var, source_slot, src_slot_info, False),
(tgt_var, target_slot, tgt_slot_info, True)):
if slot in info and _slot_linkable(info[slot][1], info[slot][0], info[slot][2],
is_target):
var.set(slot)
status_label = tk.Label(main_frame, textvariable=status_var, anchor="w", fg="#333333")
status_label.grid(row=1, column=0, columnspan=3, sticky="w", pady=(8, 0))
@@ -2804,7 +2814,11 @@ def refresh_palette_kits():
if _schema_has_incomplete_checksums():
palette_kit_names = []
else:
palette_kit_names = sorted(app_state.active_schema_kits.keys())
# A kit with nothing placeable (all abstract or @palette=false) is left out.
palette_kit_names = [
kit_name for kit_name in sorted(app_state.active_schema_kits.keys())
if palette.list_component_types(kit_name)
]
labels = []
for kit_name in palette_kit_names:
@@ -2966,12 +2980,13 @@ canvas.pack(fill=tk.BOTH, expand=True)
canvas_vscroll.config(command=canvas.yview)
canvas_hscroll.config(command=canvas.xview)
canvas.bind("<ButtonPress-1>", on_canvas_click)
canvas.bind("<B1-Motion>", on_canvas_drag)
canvas.bind("<ButtonRelease-1>", on_canvas_release)
canvas.bind("<Configure>", lambda e: wiresheet.draw_grid())
canvas.bind("<ButtonPress-3>", on_canvas_right_click)
# Either button pressed on a slot row drags out a new link. Elsewhere the left button
# selects and moves boxes and a plain right click is the menu, as before.
link_drag.install(canvas, on_canvas_right_click,
on_canvas_click, on_canvas_drag, on_canvas_release,
link_dialog=open_link_dialog)
canvas.bind("<ButtonPress-2>", on_canvas_right_click)
canvas.bind("<Double-1>", on_canvas_double_click)
+18 -2
View File
@@ -227,11 +227,25 @@ def _is_component(qualified_name, seen=None):
return result
def _hidden_from_palette(type_element):
"""True when the type's own facets carry @palette=false.
Only the type-level <facets> counts; slots have <facets> of their own nested deeper.
"""
facets = type_element.find("facets")
if facets is None:
return False
for facet in facets.findall("bool"):
if facet.get("name") == "palette" and facet.get("val") == "false":
return True
return False
def list_component_types(kit_name):
"""Concrete component types in a kit, as (type name, base) pairs.
Primitives (no base) and abstract types (the 'a' flag) are left out — neither can be
placed in an application.
Primitives (no base), abstract types (the 'a' flag) and types marked @palette=false
are left out — none of them belong in the palette.
"""
_, types = _load_kit(kit_name)
@@ -239,6 +253,8 @@ def list_component_types(kit_name):
for type_name, type_element in types.items():
if "a" in (type_element.get("flags") or ""):
continue
if _hidden_from_palette(type_element):
continue
if not _is_component(f"{kit_name}::{type_name}"):
continue
entries.append((type_name, type_element.get("base") or ""))
+1
View File
@@ -948,6 +948,7 @@ def render_components(components_to_render):
canvas.delete("component")
canvas.delete("link")
canvas.delete("slot_hover") # link_drag's hover ring, stale once boxes repaint
app_state.canvas_comp_map.clear()
app_state.canvas_tag_by_element.clear()
app_state.component_slot_offsets.clear()