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:
+1
-1
@@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it.
|
|||||||
import configparser
|
import configparser
|
||||||
import os
|
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")
|
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
|
||||||
DEFAULT_GRID_SIZE = 10
|
DEFAULT_GRID_SIZE = 10
|
||||||
DEFAULT_ADD_TIMESTAMP = True
|
DEFAULT_ADD_TIMESTAMP = True
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 444 B |
+1
-1
@@ -7,7 +7,7 @@ left_pane_width = 230
|
|||||||
trace_dasp = True
|
trace_dasp = True
|
||||||
|
|
||||||
[Device]
|
[Device]
|
||||||
host = 192.168.1.29
|
host = 192.168.1.251
|
||||||
port = 1876
|
port = 1876
|
||||||
user = admin
|
user = admin
|
||||||
|
|
||||||
|
|||||||
+495
@@ -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)
|
||||||
@@ -10,6 +10,7 @@ import threading
|
|||||||
import app_state
|
import app_state
|
||||||
import connect_dialog
|
import connect_dialog
|
||||||
import device_session
|
import device_session
|
||||||
|
import link_drag
|
||||||
import link_view
|
import link_view
|
||||||
import live_hooks
|
import live_hooks
|
||||||
import manage_kits
|
import manage_kits
|
||||||
@@ -1201,8 +1202,12 @@ def _slot_linkable(slot_flags, slot_type, readonly, is_target):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def open_link_dialog(source_element, target_element):
|
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."""
|
"""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
|
window = app_state.window
|
||||||
|
|
||||||
sides = [source_element, target_element]
|
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)
|
ttk.Button(mid_frame, text="⇄", width=3, command=on_swap).pack(pady=5)
|
||||||
|
|
||||||
refresh_panels()
|
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 = 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))
|
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():
|
if _schema_has_incomplete_checksums():
|
||||||
palette_kit_names = []
|
palette_kit_names = []
|
||||||
else:
|
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 = []
|
labels = []
|
||||||
for kit_name in palette_kit_names:
|
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_vscroll.config(command=canvas.yview)
|
||||||
canvas_hscroll.config(command=canvas.xview)
|
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("<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("<ButtonPress-2>", on_canvas_right_click)
|
||||||
canvas.bind("<Double-1>", on_canvas_double_click)
|
canvas.bind("<Double-1>", on_canvas_double_click)
|
||||||
|
|
||||||
|
|||||||
+18
-2
@@ -227,11 +227,25 @@ def _is_component(qualified_name, seen=None):
|
|||||||
return result
|
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):
|
def list_component_types(kit_name):
|
||||||
"""Concrete component types in a kit, as (type name, base) pairs.
|
"""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
|
Primitives (no base), abstract types (the 'a' flag) and types marked @palette=false
|
||||||
placed in an application.
|
are left out — none of them belong in the palette.
|
||||||
"""
|
"""
|
||||||
_, types = _load_kit(kit_name)
|
_, types = _load_kit(kit_name)
|
||||||
|
|
||||||
@@ -239,6 +253,8 @@ def list_component_types(kit_name):
|
|||||||
for type_name, type_element in types.items():
|
for type_name, type_element in types.items():
|
||||||
if "a" in (type_element.get("flags") or ""):
|
if "a" in (type_element.get("flags") or ""):
|
||||||
continue
|
continue
|
||||||
|
if _hidden_from_palette(type_element):
|
||||||
|
continue
|
||||||
if not _is_component(f"{kit_name}::{type_name}"):
|
if not _is_component(f"{kit_name}::{type_name}"):
|
||||||
continue
|
continue
|
||||||
entries.append((type_name, type_element.get("base") or ""))
|
entries.append((type_name, type_element.get("base") or ""))
|
||||||
|
|||||||
@@ -948,6 +948,7 @@ def render_components(components_to_render):
|
|||||||
|
|
||||||
canvas.delete("component")
|
canvas.delete("component")
|
||||||
canvas.delete("link")
|
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_comp_map.clear()
|
||||||
app_state.canvas_tag_by_element.clear()
|
app_state.canvas_tag_by_element.clear()
|
||||||
app_state.component_slot_offsets.clear()
|
app_state.component_slot_offsets.clear()
|
||||||
|
|||||||
Reference in New Issue
Block a user