"""Signal Editor Panel -- UI for viewing and managing signal connections.
Lists all Signal instances on the selected node, shows existing connections,
and provides connect/disconnect operations with undo support.
Layout:
+---------------------------------------------------+
| Signal Connections [Node: Player] |
|---------------------------------------------------|
| v position_changed (Signal) |
| -> Enemy.on_player_moved [Disconnect] |
| -> HUD.update_position [Disconnect] |
| [+ Connect...] |
|---------------------------------------------------|
| v health_changed (Signal) |
| -> HUD.update_health [Disconnect] |
| [+ Connect...] |
|---------------------------------------------------|
| v died (Signal) |
| (no connections) |
| [+ Connect...] |
+---------------------------------------------------+
"""
from __future__ import annotations
from __future__ import annotations
import logging
from collections.abc import Callable
from simvx.core import (
CallableCommand,
Control,
Node,
Signal,
Vec2,
)
log = logging.getLogger(__name__)
__all__ = ["SignalEditorPanel"]
# ============================================================================
# Colours / Layout
# ============================================================================
_BG = (0.13, 0.13, 0.13, 1.0)
_HEADER_BG = (0.10, 0.10, 0.10, 1.0)
_SIGNAL_BG = (0.16, 0.16, 0.16, 1.0)
_CONN_BG = (0.15, 0.15, 0.15, 1.0)
_CONN_ALT = (0.17, 0.17, 0.17, 1.0)
_TEXT = (0.85, 0.85, 0.85, 1.0)
_TEXT_DIM = (0.55, 0.55, 0.55, 1.0)
_SIGNAL_COLOUR = (0.4, 0.8, 0.4, 1.0)
_CONN_COLOUR = (0.3, 0.7, 1.0, 1.0)
_REMOVE = (0.8, 0.3, 0.3, 1.0)
_BTN = (0.22, 0.22, 0.22, 1.0)
_SEPARATOR = (0.25, 0.25, 0.25, 1.0)
_HEADER_HEIGHT = 32.0
_SIGNAL_ROW = 26.0
_CONN_ROW = 22.0
_PADDING = 6.0
_INDENT = 16.0
_FONT = 11.0 / 14.0
# Dialog colours / layout
_DLG_BACKDROP = (0.0, 0.0, 0.0, 0.45)
_DLG_BG = (0.18, 0.18, 0.20, 1.0)
_DLG_TITLE_BG = (0.12, 0.12, 0.14, 1.0)
_DLG_INPUT_BG = (0.10, 0.10, 0.12, 1.0)
_DLG_ROW_BG = (0.16, 0.16, 0.18, 1.0)
_DLG_ROW_ALT = (0.19, 0.19, 0.21, 1.0)
_DLG_ROW_HOVER = (0.24, 0.24, 0.28, 1.0)
_DLG_ROW_SELECTED = (0.20, 0.40, 0.65, 1.0)
_DLG_BTN_BG = (0.22, 0.22, 0.24, 1.0)
_DLG_BTN_HOVER = (0.28, 0.28, 0.32, 1.0)
_DLG_ACCENT = (0.25, 0.55, 0.90, 1.0)
_DLG_NODE_HEADER = (0.60, 0.60, 0.60, 1.0)
_DLG_W = 320.0
_DLG_H = 370.0
_DLG_TITLE_H = 30.0
_DLG_INPUT_H = 26.0
_DLG_ROW_H = 22.0
_DLG_BTN_H = 28.0
_DLG_BTN_W = 80.0
_DLG_LIST_TOP = _DLG_TITLE_H + _DLG_INPUT_H + 4 # after title + filter
_DLG_BTN_AREA_H = _DLG_BTN_H + 12 # padding around buttons
# Methods to exclude from connectable targets (engine lifecycle internals)
_EXCLUDED_METHODS = frozenset({
"process", "physics_process", "ready", "enter_tree", "exit_tree", "draw",
"add_child", "remove_child", "destroy", "get_node", "find_child",
"find_all", "get_path", "get_rect", "get_global_rect", "is_inside_tree",
"is_point_inside", "reparent", "print_tree", "propagate_call",
"refresh", "set_node",
})
# ============================================================================
# SignalInfo -- Describes a Signal found on a node
# ============================================================================
[docs]
class SignalInfo:
"""Metadata about a signal on a node."""
__slots__ = ("name", "signal", "connections", "collapsed")
def __init__(self, name: str, signal: Signal):
self.name = name
self.signal = signal
self.connections: list[ConnectionInfo] = []
self.collapsed = False
[docs]
def refresh_connections(self):
"""Read current callbacks from the signal."""
self.connections.clear()
for cb in self.signal._callbacks:
self.connections.append(ConnectionInfo.from_callback(cb))
[docs]
class ConnectionInfo:
"""Describes a single connection from a signal to a callback."""
__slots__ = ("callback", "target_name", "method_name")
def __init__(self, callback: Callable, target_name: str, method_name: str):
self.callback = callback
self.target_name = target_name
self.method_name = method_name
[docs]
@classmethod
def from_callback(cls, cb: Callable) -> ConnectionInfo:
"""Infer target/method names from a callback."""
method_name = getattr(cb, "__name__", str(cb))
target = getattr(cb, "__self__", None)
if target is not None:
target_name = getattr(target, "name", type(target).__name__)
else:
qualname = getattr(cb, "__qualname__", "")
target_name = qualname.rsplit(".", 1)[0] if "." in qualname else "?"
return cls(callback=cb, target_name=target_name, method_name=method_name)
# ============================================================================
# SignalEditorPanel
# ============================================================================
[docs]
class SignalEditorPanel(Control):
"""Signal connection editor panel.
Displays all Signal instances on the selected node, shows connections,
and supports connect/disconnect with undo.
Args:
editor_state: The central EditorState instance.
"""
def __init__(self, editor_state=None, **kwargs):
super().__init__(**kwargs)
self.state = editor_state
self.bg_colour = _BG
self.size = Vec2(400, 400)
self._node: Node | None = None
self._signals: list[SignalInfo] = []
self._scroll_y: float = 0.0
# Connect dialog state
self._connect_dialog_visible = False
self._connect_signal_name: str | None = None
self._available_targets: list[tuple[Node, str]] = [] # (node, method_name)
self._dialog_filter: str = ""
self._dialog_selected_idx: int = -1
self._dialog_scroll_y: float = 0.0
self._dialog_hover_idx: int = -1
self._dialog_hover_btn: str = "" # "connect", "cancel", or ""
# Signals
self.connection_made = Signal()
self.connection_removed = Signal()
# ====================================================================
# Lifecycle
# ====================================================================
[docs]
def ready(self):
if self.state and hasattr(self.state, "selection_changed"):
self.state.selection_changed.connect(self._on_selection_changed)
# ====================================================================
# Node binding
# ====================================================================
[docs]
def set_node(self, node: Node | None):
"""Inspect signals on the given node."""
self._node = node
self._signals.clear()
self._connect_dialog_visible = False
if node is None:
return
self._scan_signals(node)
def _on_selection_changed(self):
node = self.state.selection.primary if self.state and hasattr(self.state.selection, "primary") else None
self.set_node(node)
def _scan_signals(self, node: Node):
"""Find all Signal instances on the node."""
self._signals.clear()
seen = set()
for cls in type(node).__mro__:
for attr_name in vars(cls):
if attr_name.startswith("_") or attr_name in seen:
continue
seen.add(attr_name)
try:
val = getattr(node, attr_name)
if isinstance(val, Signal):
info = SignalInfo(attr_name, val)
info.refresh_connections()
self._signals.append(info)
except Exception:
continue
# Also check instance __dict__
for attr_name, val in vars(node).items():
if attr_name.startswith("_") or attr_name in seen:
continue
if isinstance(val, Signal):
info = SignalInfo(attr_name, val)
info.refresh_connections()
self._signals.append(info)
self._signals.sort(key=lambda s: s.name)
[docs]
def refresh(self):
"""Refresh connection info for all signals."""
for info in self._signals:
info.refresh_connections()
# ====================================================================
# Connection management
# ====================================================================
[docs]
def connect_signal(self, signal_name: str, target: Node, method_name: str) -> bool:
"""Connect a signal to a method on a target node."""
info = self._find_signal(signal_name)
if info is None:
return False
method = getattr(target, method_name, None)
if method is None or not callable(method):
log.warning("Method %r not found on %r", method_name, target.name)
return False
def _do():
info.signal.connect(method)
info.refresh_connections()
def _undo():
info.signal.disconnect(method)
info.refresh_connections()
if self.state:
self.state.undo_stack.push(
CallableCommand(
_do,
_undo,
f"Connect {signal_name} -> {target.name}.{method_name}",
)
)
else:
_do()
self.connection_made.emit()
return True
[docs]
def disconnect_signal(self, signal_name: str, callback: Callable) -> bool:
"""Disconnect a specific callback from a signal."""
info = self._find_signal(signal_name)
if info is None:
return False
def _do():
try:
info.signal.disconnect(callback)
except ValueError:
pass
info.refresh_connections()
def _undo():
info.signal.connect(callback)
info.refresh_connections()
if self.state:
self.state.undo_stack.push(
CallableCommand(
_do,
_undo,
f"Disconnect {signal_name}",
)
)
else:
_do()
self.connection_removed.emit()
return True
[docs]
def disconnect_all(self, signal_name: str) -> int:
"""Disconnect all callbacks from a signal. Returns count removed."""
info = self._find_signal(signal_name)
if info is None:
return 0
callbacks = list(info.signal._callbacks)
count = len(callbacks)
def _do():
info.signal.clear()
info.refresh_connections()
def _undo():
for cb in callbacks:
info.signal.connect(cb)
info.refresh_connections()
if self.state:
self.state.undo_stack.push(CallableCommand(_do, _undo, f"Disconnect all from {signal_name}"))
else:
_do()
return count
def _find_signal(self, name: str) -> SignalInfo | None:
for info in self._signals:
if info.name == name:
return info
return None
[docs]
def get_connectable_methods(self, root: Node) -> list[tuple[Node, str]]:
"""Find all public methods on nodes in the tree suitable for connecting.
Excludes dunder methods, private methods (starting with _), and known
engine lifecycle/internal methods.
"""
results: list[tuple[Node, str]] = []
self._collect_methods(root, results)
return results
def _collect_methods(self, node: Node, results: list):
seen_on_node: set[str] = set()
for name in dir(type(node)):
if name.startswith("_") or name in _EXCLUDED_METHODS or name in seen_on_node:
continue
seen_on_node.add(name)
try:
val = getattr(node, name)
if callable(val) and not isinstance(val, Signal) and not isinstance(val, property):
results.append((node, name))
except Exception:
continue
for child in node.children:
self._collect_methods(child, results)
# ====================================================================
# Dialog helpers
# ====================================================================
def _open_connect_dialog(self, signal_name: str):
"""Open the connect dialog for the given signal."""
if not self._node or not hasattr(self._node, "_tree") or not self._node._tree:
return
self._connect_signal_name = signal_name
self._available_targets = self.get_connectable_methods(self._node._tree.root)
self._connect_dialog_visible = True
self._dialog_filter = ""
self._dialog_selected_idx = -1
self._dialog_scroll_y = 0.0
self._dialog_hover_idx = -1
self._dialog_hover_btn = ""
def _close_connect_dialog(self):
"""Close the connect dialog without making a connection."""
self._connect_dialog_visible = False
self._connect_signal_name = None
self._available_targets.clear()
self._dialog_filter = ""
self._dialog_selected_idx = -1
def _confirm_connect_dialog(self):
"""Confirm the current dialog selection and create the connection."""
filtered = self._get_filtered_targets()
if 0 <= self._dialog_selected_idx < len(filtered):
node, method_name = filtered[self._dialog_selected_idx]
if self._connect_signal_name:
self.connect_signal(self._connect_signal_name, node, method_name)
self._close_connect_dialog()
def _get_filtered_targets(self) -> list[tuple[Node, str]]:
"""Return available targets filtered by the current search text."""
if not self._dialog_filter:
return self._available_targets
query = self._dialog_filter.lower()
return [
(node, method)
for node, method in self._available_targets
if query in node.name.lower() or query in method.lower()
]
def _get_grouped_targets(self, targets: list[tuple[Node, str]]) -> list[tuple[str | None, Node | None, str]]:
"""Group targets by node for display. Returns list of (header_or_none, node, method).
When header_or_none is a string, the row is a group header (node name).
When it's None, the row is a selectable method entry.
"""
rows: list[tuple[str | None, Node | None, str]] = []
current_node_name: str | None = None
for node, method in targets:
if node.name != current_node_name:
current_node_name = node.name
rows.append((node.name, None, ""))
rows.append((None, node, method))
return rows
def _flat_index_for_row(self, grouped_rows: list, row_idx: int) -> int:
"""Convert a row index in grouped display to an index in the flat filtered list."""
selectable_count = -1
for i in range(row_idx + 1):
if grouped_rows[i][0] is None: # selectable row
selectable_count += 1
return selectable_count
# ====================================================================
# Drawing
# ====================================================================
[docs]
def draw(self, renderer):
gx, gy, gw, gh = self.get_global_rect()
renderer.draw_filled_rect(gx, gy, gw, gh, _BG)
if not self._node:
msg = "Select a node to view signals"
tw = renderer.text_width(msg, _FONT)
renderer.draw_text(msg, gx + (gw - tw) / 2, gy + gh / 2 - 7, _TEXT_DIM, _FONT)
return
renderer.push_clip(gx, gy, gw, gh)
y = gy
y = self._draw_header(renderer, gx, y, gw)
content_h = gh - (y - gy)
renderer.push_clip(gx, y, gw, content_h)
cy = y - self._scroll_y
if not self._signals:
renderer.draw_text("No signals found", gx + _PADDING, cy + 10, _TEXT_DIM, _FONT)
else:
for info in self._signals:
cy = self._draw_signal(renderer, gx, cy, gw, info)
renderer.pop_clip()
renderer.pop_clip()
# Draw dialog overlay on top of everything
if self._connect_dialog_visible:
self._draw_connect_dialog(renderer, gx, gy, gw, gh)
def _draw_header(self, renderer, x, y, w) -> float:
renderer.draw_filled_rect(x, y, w, _HEADER_HEIGHT, _HEADER_BG)
renderer.draw_text("Signal Connections", x + _PADDING, y + 9, _TEXT, _FONT)
if self._node:
node_label = self._node.name
nw = renderer.text_width(node_label, _FONT)
renderer.draw_text(node_label, x + w - nw - _PADDING, y + 9, _CONN_COLOUR, _FONT)
renderer.draw_filled_rect(x, y + _HEADER_HEIGHT - 1, w, 1, _SEPARATOR)
return y + _HEADER_HEIGHT
def _draw_signal(self, renderer, x, y, w, info: SignalInfo) -> float:
# Signal header row
renderer.draw_filled_rect(x, y, w, _SIGNAL_ROW, _SIGNAL_BG)
arrow = ">" if info.collapsed else "v"
label = f"{arrow} {info.name}"
renderer.draw_text(label, x + _PADDING, y + 6, _SIGNAL_COLOUR, _FONT)
count_text = f"({len(info.connections)})"
cw = renderer.text_width(count_text, _FONT)
renderer.draw_text(count_text, x + w - cw - _PADDING, y + 6, _TEXT_DIM, _FONT)
renderer.draw_filled_rect(x, y + _SIGNAL_ROW - 1, w, 1, _SEPARATOR)
y += _SIGNAL_ROW
if info.collapsed:
return y
# Connection rows
if not info.connections:
renderer.draw_filled_rect(x, y, w, _CONN_ROW, _CONN_BG)
renderer.draw_text("(no connections)", x + _INDENT + _PADDING, y + 4, _TEXT_DIM, _FONT)
y += _CONN_ROW
else:
for i, conn in enumerate(info.connections):
bg = _CONN_ALT if i % 2 else _CONN_BG
renderer.draw_filled_rect(x, y, w, _CONN_ROW, bg)
target_text = f"-> {conn.target_name}.{conn.method_name}"
renderer.draw_text(target_text, x + _INDENT + _PADDING, y + 4, _CONN_COLOUR, _FONT)
# Disconnect button
renderer.draw_text("[x]", x + w - _PADDING - 20, y + 4, _REMOVE, _FONT)
y += _CONN_ROW
# Connect button
renderer.draw_filled_rect(x, y, w, _CONN_ROW, _CONN_BG)
renderer.draw_text("[+ Connect...]", x + _INDENT + _PADDING, y + 4, _SIGNAL_COLOUR, _FONT)
y += _CONN_ROW
return y
def _draw_connect_dialog(self, renderer, gx, gy, gw, gh):
"""Draw the modal Connect Signal dialog overlay."""
# Semi-transparent backdrop
renderer.draw_filled_rect(gx, gy, gw, gh, _DLG_BACKDROP)
# Centre the dialog
dx = gx + (gw - _DLG_W) / 2
dy = gy + (gh - _DLG_H) / 2
# Dialog frame
renderer.draw_filled_rect(dx, dy, _DLG_W, _DLG_H, _DLG_BG)
# Title bar
renderer.draw_filled_rect(dx, dy, _DLG_W, _DLG_TITLE_H, _DLG_TITLE_BG)
title = f"Connect: {self._connect_signal_name or '?'}"
renderer.draw_text(title, dx + _PADDING, dy + 8, _TEXT, _FONT)
renderer.draw_filled_rect(dx, dy + _DLG_TITLE_H - 1, _DLG_W, 1, _SEPARATOR)
# Filter input field
fy = dy + _DLG_TITLE_H + 2
renderer.draw_filled_rect(dx + _PADDING, fy, _DLG_W - 2 * _PADDING, _DLG_INPUT_H, _DLG_INPUT_BG)
filter_text = self._dialog_filter or ""
display_text = filter_text + "|" if not filter_text else filter_text
if not filter_text:
renderer.draw_text("Type to filter...", dx + _PADDING + 4, fy + 6, _TEXT_DIM, _FONT)
else:
renderer.draw_text(filter_text, dx + _PADDING + 4, fy + 6, _TEXT, _FONT)
# Cursor
cursor_x = dx + _PADDING + 4 + renderer.text_width(filter_text, _FONT)
renderer.draw_filled_rect(cursor_x, fy + 4, 1, _DLG_INPUT_H - 8, _TEXT)
# Method list area
list_y = dy + _DLG_LIST_TOP
list_h = _DLG_H - _DLG_LIST_TOP - _DLG_BTN_AREA_H
renderer.draw_filled_rect(dx, list_y, _DLG_W, list_h, _DLG_ROW_BG)
filtered = self._get_filtered_targets()
grouped = self._get_grouped_targets(filtered)
renderer.push_clip(dx, list_y, _DLG_W, list_h)
ry = list_y - self._dialog_scroll_y
selectable_idx = -1
for row in grouped:
header_name, node, method = row
if header_name is not None:
# Node group header
renderer.draw_filled_rect(dx, ry, _DLG_W, _DLG_ROW_H, _DLG_TITLE_BG)
renderer.draw_text(header_name, dx + _PADDING, ry + 4, _DLG_NODE_HEADER, _FONT)
else:
selectable_idx += 1
# Determine row colour
if selectable_idx == self._dialog_selected_idx:
bg = _DLG_ROW_SELECTED
elif selectable_idx == self._dialog_hover_idx:
bg = _DLG_ROW_HOVER
else:
bg = _DLG_ROW_ALT if selectable_idx % 2 else _DLG_ROW_BG
renderer.draw_filled_rect(dx, ry, _DLG_W, _DLG_ROW_H, bg)
label = f" {method}"
colour = _CONN_COLOUR if selectable_idx == self._dialog_selected_idx else _TEXT
renderer.draw_text(label, dx + _PADDING + 8, ry + 4, colour, _FONT)
ry += _DLG_ROW_H
renderer.pop_clip()
if not grouped:
renderer.draw_text("No matching methods", dx + _PADDING + 8, list_y + 10, _TEXT_DIM, _FONT)
# Buttons area
btn_y = dy + _DLG_H - _DLG_BTN_AREA_H + 6
# Connect button
cbtn_x = dx + _DLG_W - 2 * _DLG_BTN_W - _PADDING - 8
cbtn_bg = _DLG_ACCENT if self._dialog_selected_idx >= 0 else _DLG_BTN_BG
if self._dialog_hover_btn == "connect":
cbtn_bg = _DLG_BTN_HOVER if self._dialog_selected_idx < 0 else (0.30, 0.60, 0.95, 1.0)
renderer.draw_filled_rect(cbtn_x, btn_y, _DLG_BTN_W, _DLG_BTN_H, cbtn_bg)
ctw = renderer.text_width("Connect", _FONT)
renderer.draw_text("Connect", cbtn_x + (_DLG_BTN_W - ctw) / 2, btn_y + 7, _TEXT, _FONT)
# Cancel button
xbtn_x = dx + _DLG_W - _DLG_BTN_W - _PADDING
xbtn_bg = _DLG_BTN_HOVER if self._dialog_hover_btn == "cancel" else _DLG_BTN_BG
renderer.draw_filled_rect(xbtn_x, btn_y, _DLG_BTN_W, _DLG_BTN_H, xbtn_bg)
xtw = renderer.text_width("Cancel", _FONT)
renderer.draw_text("Cancel", xbtn_x + (_DLG_BTN_W - xtw) / 2, btn_y + 7, _TEXT, _FONT)
# ====================================================================
# Input handling
# ====================================================================
def _on_gui_input(self, event):
# If dialog is open, intercept ALL input
if self._connect_dialog_visible:
self._on_dialog_input(event)
return
gx, gy, gw, gh = self.get_global_rect()
if not hasattr(event, "position"):
return
ex, ey = event.position
if not (gx <= ex <= gx + gw and gy <= ey <= gy + gh):
return
# Left click
if hasattr(event, "pressed") and event.pressed and getattr(event, "button", 0) == 1:
self._handle_panel_click(gx, gy, gw, gh, ex, ey)
return
# Scroll
if hasattr(event, "delta"):
_, dy = event.delta if isinstance(event.delta, tuple) else (0, event.delta)
self._scroll_y = max(0.0, self._scroll_y - dy * 20.0)
def _handle_panel_click(self, gx, gy, gw, gh, ex, ey):
"""Handle a left click on the main panel (not dialog)."""
row_y = gy + _HEADER_HEIGHT - self._scroll_y
for info in self._signals:
# Signal header row -- collapse toggle
if row_y <= ey < row_y + _SIGNAL_ROW:
info.collapsed = not info.collapsed
return
row_y += _SIGNAL_ROW
if info.collapsed:
continue
# Connection rows
if info.connections:
for i, conn in enumerate(info.connections):
if row_y <= ey < row_y + _CONN_ROW:
# Check if click is on the [x] disconnect button
btn_x = gx + gw - _PADDING - 20
btn_w = 20
if btn_x <= ex <= btn_x + btn_w:
self.disconnect_signal(info.name, conn.callback)
return
row_y += _CONN_ROW
else:
# "(no connections)" row
row_y += _CONN_ROW
# [+ Connect...] button row
if row_y <= ey < row_y + _CONN_ROW:
self._open_connect_dialog(info.name)
return
row_y += _CONN_ROW
def _on_dialog_input(self, event):
"""Handle input when the connect dialog is visible (modal)."""
gx, gy, gw, gh = self.get_global_rect()
dx = gx + (gw - _DLG_W) / 2
dy = gy + (gh - _DLG_H) / 2
# Keyboard events
key = getattr(event, "key", "")
if key and getattr(event, "pressed", False):
if key == "escape":
self._close_connect_dialog()
return
if key == "return" or key == "enter":
self._confirm_connect_dialog()
return
if key == "backspace":
if self._dialog_filter:
self._dialog_filter = self._dialog_filter[:-1]
self._dialog_selected_idx = -1
self._dialog_scroll_y = 0.0
return
if key == "up":
if self._dialog_selected_idx > 0:
self._dialog_selected_idx -= 1
return
if key == "down":
filtered = self._get_filtered_targets()
if self._dialog_selected_idx < len(filtered) - 1:
self._dialog_selected_idx += 1
return
# Character typing for filter
char = getattr(event, "char", "")
if char and len(char) == 1 and char.isprintable():
self._dialog_filter += char
self._dialog_selected_idx = -1
self._dialog_scroll_y = 0.0
return
if not hasattr(event, "position"):
return
ex, ey = event.position
# Scroll in dialog list area
if hasattr(event, "delta"):
list_y = dy + _DLG_LIST_TOP
list_h = _DLG_H - _DLG_LIST_TOP - _DLG_BTN_AREA_H
if dx <= ex <= dx + _DLG_W and list_y <= ey <= list_y + list_h:
_, scroll_dy = event.delta if isinstance(event.delta, tuple) else (0, event.delta)
self._dialog_scroll_y = max(0.0, self._dialog_scroll_y - scroll_dy * 20.0)
return
# Mouse hover tracking
if not (hasattr(event, "pressed")):
self._update_dialog_hover(dx, dy, ex, ey)
return
# Left click
if getattr(event, "button", 0) == 1 and getattr(event, "pressed", False):
# Check if click is inside the dialog
if not (dx <= ex <= dx + _DLG_W and dy <= ey <= dy + _DLG_H):
# Click on backdrop — close
self._close_connect_dialog()
return
# Check button area
btn_y = dy + _DLG_H - _DLG_BTN_AREA_H + 6
cbtn_x = dx + _DLG_W - 2 * _DLG_BTN_W - _PADDING - 8
xbtn_x = dx + _DLG_W - _DLG_BTN_W - _PADDING
if btn_y <= ey <= btn_y + _DLG_BTN_H:
if cbtn_x <= ex <= cbtn_x + _DLG_BTN_W:
self._confirm_connect_dialog()
return
if xbtn_x <= ex <= xbtn_x + _DLG_BTN_W:
self._close_connect_dialog()
return
# Check method list click
list_y = dy + _DLG_LIST_TOP
list_h = _DLG_H - _DLG_LIST_TOP - _DLG_BTN_AREA_H
if list_y <= ey <= list_y + list_h:
filtered = self._get_filtered_targets()
grouped = self._get_grouped_targets(filtered)
ry = list_y - self._dialog_scroll_y
selectable_idx = -1
for row in grouped:
header_name = row[0]
if header_name is not None:
ry += _DLG_ROW_H
continue
selectable_idx += 1
if ry <= ey < ry + _DLG_ROW_H:
if self._dialog_selected_idx == selectable_idx:
# Double-click-like: already selected, confirm
self._confirm_connect_dialog()
else:
self._dialog_selected_idx = selectable_idx
return
ry += _DLG_ROW_H
def _update_dialog_hover(self, dx, dy, ex, ey):
"""Update hover state for the dialog."""
self._dialog_hover_idx = -1
self._dialog_hover_btn = ""
# Button hover
btn_y = dy + _DLG_H - _DLG_BTN_AREA_H + 6
cbtn_x = dx + _DLG_W - 2 * _DLG_BTN_W - _PADDING - 8
xbtn_x = dx + _DLG_W - _DLG_BTN_W - _PADDING
if btn_y <= ey <= btn_y + _DLG_BTN_H:
if cbtn_x <= ex <= cbtn_x + _DLG_BTN_W:
self._dialog_hover_btn = "connect"
return
if xbtn_x <= ex <= xbtn_x + _DLG_BTN_W:
self._dialog_hover_btn = "cancel"
return
# Row hover
list_y = dy + _DLG_LIST_TOP
list_h = _DLG_H - _DLG_LIST_TOP - _DLG_BTN_AREA_H
if list_y <= ey <= list_y + list_h:
filtered = self._get_filtered_targets()
grouped = self._get_grouped_targets(filtered)
ry = list_y - self._dialog_scroll_y
selectable_idx = -1
for row in grouped:
if row[0] is not None:
ry += _DLG_ROW_H
continue
selectable_idx += 1
if ry <= ey < ry + _DLG_ROW_H:
self._dialog_hover_idx = selectable_idx
return
ry += _DLG_ROW_H