Source code for simvx.editor.panels.signal_editor

"""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