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 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
@@ -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
@@ -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 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
@@ -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 ""))
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user