Source code for simvx.core.ui.autocomplete

"""Autocomplete popup widget for code completion suggestions."""

import logging

from simvx.core import Property, Signal

from ..input.enums import MouseButton
from .completion_types import CompletionItem, kind_abbreviation
from .core import Control, UIInputEvent

log = logging.getLogger(__name__)

# Theme colours
_BG = (0.18, 0.18, 0.20, 1.0)
_BG_BORDER = (0.30, 0.30, 0.35, 1.0)
_SELECTED = (0.26, 0.56, 0.96, 0.30)
_HOVER = (0.26, 0.56, 0.96, 0.12)
_TEXT = (0.85, 0.85, 0.85, 1.0)
_DIM = (0.50, 0.50, 0.55, 1.0)
_KIND = (0.55, 0.75, 0.95, 1.0)
_SCROLLBAR_BG = (0.22, 0.22, 0.24, 1.0)
_SCROLLBAR_FG = (0.45, 0.45, 0.50, 1.0)

MAX_VISIBLE = 10
ITEM_HEIGHT = 22.0
POPUP_WIDTH = 350.0
KIND_COL_WIDTH = 36.0
SCROLLBAR_WIDTH = 8.0
TEXT_SCALE = 0.55
PADDING_X = 6.0
PADDING_Y = 2.0
BORDER = 1.0
SCROLL_STEP = 3


[docs] class AutocompletePopup(Control): """Floating completion popup that shows filtered suggestions near the cursor. Non-capturing overlay (``modality="none"``): the popup draws on top via the overlay layer but does NOT scope input, so the host editor keeps keyboard focus and keeps receiving typed characters while the popup is up. Consumers forward navigation keys (Up/Down/Tab/Enter/Esc/PageUp/PageDown/Home/End) to :meth:`forward_key` while typed characters keep flowing to the editor for live filter updates. Matches every IDE in the wild (VS Code, JetBrains, Sublime, Emacs ``corfu``). """ visible = Property( False, hint="A completion popup is up only while it has something to offer", on_change="_on_visible_changed", ) mouse_filter = Property(True, hint="Take part in hit-testing (False = clicks pass through)") def __init__(self, **kwargs): super().__init__(**kwargs) self.accepted = Signal() self.dismissed = Signal() self._items: list[CompletionItem] = [] self._filtered: list[CompletionItem] = [] self._filter_text: str = "" self._selected_index: int = 0 self._scroll_offset: int = 0 self._hover_index: int = -1 self._popup_x: float = 0.0 self._popup_y: float = 0.0 # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------
[docs] @property def is_visible(self) -> bool: return bool(self.visible)
[docs] def show(self, items: list[CompletionItem], x: float, y: float): self._items = items self._filter_text = "" self._apply_filter() self._selected_index = 0 self._scroll_offset = 0 self._hover_index = -1 self._popup_x = x self._popup_y = y # Re-showing replaces any prior registration; close first so we never # strand a duplicate entry in the overlay registry. self.close_overlay() if self._filtered: # Non-capturing overlay: draws on top without stealing input, so the # host editor keeps focus and keeps receiving typed characters. self.show_overlay("none") else: self.visible = False
[docs] def hide(self): if self.visible: self.visible = False self.close_overlay() self.dismissed.emit()
[docs] def update_filter(self, text: str): self._filter_text = text old_selected = ( self._filtered[self._selected_index] if self._filtered and self._selected_index < len(self._filtered) else None ) self._apply_filter() if not self._filtered: self.hide() return if old_selected and old_selected in self._filtered: self._selected_index = self._filtered.index(old_selected) else: self._selected_index = 0 self._clamp_scroll()
# ------------------------------------------------------------------ # Filtering # ------------------------------------------------------------------ def _apply_filter(self): if not self._filter_text: self._filtered = list(self._items) return query = self._filter_text.lower() scored: list[tuple[int, CompletionItem]] = [] for item in self._items: score = _fuzzy_score(query, item.label.lower()) if score >= 0: scored.append((score, item)) scored.sort(key=lambda s: s[0]) self._filtered = [item for _, item in scored] # ------------------------------------------------------------------ # Input handling # ------------------------------------------------------------------ NAV_KEYS = frozenset( ( "up", "arrow_up", "down", "arrow_down", "return", "enter", "tab", "escape", "page_up", "pageup", "page_down", "pagedown", "home", "end", ) )
[docs] def forward_key(self, event: UIInputEvent) -> bool: """Consumer-side hook: forward a navigation key to the popup. Returns True if the popup consumed the key (caller should suppress its own handling). Mark the event handled by setting ``event.handled``. Editors call this before processing typed characters in their own ``_on_gui_input``:: def _on_gui_input(self, event): if self._autocomplete.is_visible and self._autocomplete.forward_key(event): return # ... usual editor key handling ... """ if not self.visible or not event.pressed or not event.key: return False if event.key.lower() not in self.NAV_KEYS: return False self._handle_key(event) event.handled = True return True
def _on_gui_input(self, event: UIInputEvent): """Direct mouse handling for clicks and the wheel on the popup. Keyboard arrives via :meth:`forward_key` from the editor that owns focus; this method only handles mouse events on the popup itself. The wheel scrolls the list and is claimed, so it never reaches the editor underneath while the popup is up. """ if not self.visible: return if event.key == "scroll_up": self._scroll_list(-SCROLL_STEP) event.handled = True return if event.key == "scroll_down": self._scroll_list(SCROLL_STEP) event.handled = True return if event.button == MouseButton.LEFT and event.pressed: self._handle_click(event) event.handled = True def _handle_key(self, event: UIInputEvent): key = event.key.lower() if key in ("up", "arrow_up"): self._selected_index = max(0, self._selected_index - 1) self._clamp_scroll() elif key in ("down", "arrow_down"): self._selected_index = min(len(self._filtered) - 1, self._selected_index + 1) self._clamp_scroll() elif key in ("return", "enter", "tab"): self._accept() elif key == "escape": self.hide() elif key in ("page_up", "pageup"): self._selected_index = max(0, self._selected_index - MAX_VISIBLE) self._clamp_scroll() elif key in ("page_down", "pagedown"): self._selected_index = min(len(self._filtered) - 1, self._selected_index + MAX_VISIBLE) self._clamp_scroll() elif key == "home": self._selected_index = 0 self._scroll_offset = 0 elif key == "end": self._selected_index = max(0, len(self._filtered) - 1) self._clamp_scroll() def _handle_click(self, event: UIInputEvent): px = event.position.x py = event.position.y rx = px - self._popup_x ry = py - self._popup_y - BORDER if rx < 0 or rx > POPUP_WIDTH or ry < 0: self.hide() return row = int(ry / ITEM_HEIGHT) idx = row + self._scroll_offset if 0 <= idx < len(self._filtered): self._selected_index = idx self._accept() else: self.hide() def _accept(self): if not self._filtered or self._selected_index >= len(self._filtered): self.hide() return item = self._filtered[self._selected_index] self.visible = False self.close_overlay() self.accepted.emit(item) # ------------------------------------------------------------------ # Scroll # ------------------------------------------------------------------ def _scroll_list(self, delta: int): """Move the visible window by ``delta`` rows, leaving the selection alone. The wheel browses the list; the selection only follows the keyboard, and the next arrow key brings it back into view via :meth:`_clamp_scroll`. """ limit = max(0, len(self._filtered) - MAX_VISIBLE) self._scroll_offset = max(0, min(limit, self._scroll_offset + delta)) def _clamp_scroll(self): if self._selected_index < self._scroll_offset: self._scroll_offset = self._selected_index visible = min(MAX_VISIBLE, len(self._filtered)) if self._selected_index >= self._scroll_offset + visible: self._scroll_offset = self._selected_index - visible + 1 self._scroll_offset = max(0, self._scroll_offset) # ------------------------------------------------------------------ # Hit testing: point inside the visible popup rect # ------------------------------------------------------------------
[docs] def is_point_inside(self, point) -> bool: if not self.visible: return False px = point.x if hasattr(point, "x") else point[0] py = point.y if hasattr(point, "y") else point[1] visible = min(MAX_VISIBLE, len(self._filtered)) h = visible * ITEM_HEIGHT + 2 * BORDER return ( self._popup_x <= px <= self._popup_x + POPUP_WIDTH + 2 * BORDER and self._popup_y <= py <= self._popup_y + h )
# ------------------------------------------------------------------ # Drawing # ------------------------------------------------------------------
[docs] def on_draw(self, renderer): if not self.visible or not self._filtered: return visible_count = min(MAX_VISIBLE, len(self._filtered)) total_height = visible_count * ITEM_HEIGHT + 2 * BORDER total_width = POPUP_WIDTH + 2 * BORDER x0 = self._popup_x y0 = self._popup_y # Border renderer.draw_rect((x0, y0), (total_width, total_height), colour=_BG_BORDER, filled=True) # Background inner_x = x0 + BORDER inner_y = y0 + BORDER inner_w = POPUP_WIDTH inner_h = visible_count * ITEM_HEIGHT renderer.draw_rect((inner_x, inner_y), (inner_w, inner_h), colour=_BG, filled=True) renderer.push_clip(inner_x, inner_y, inner_w, inner_h) # Mouse hover detection for i in range(visible_count): item_idx = i + self._scroll_offset if item_idx >= len(self._filtered): break item = self._filtered[item_idx] iy = inner_y + i * ITEM_HEIGHT # Selection / hover highlight if item_idx == self._selected_index: renderer.draw_rect((inner_x, iy), (inner_w, ITEM_HEIGHT), colour=_SELECTED, filled=True) elif item_idx == self._hover_index: renderer.draw_rect((inner_x, iy), (inner_w, ITEM_HEIGHT), colour=_HOVER, filled=True) # Kind abbreviation abbr = kind_abbreviation(item.kind) renderer.draw_text(abbr, (inner_x + PADDING_X, iy + PADDING_Y), colour=_KIND, scale=TEXT_SCALE) # Label label_x = inner_x + KIND_COL_WIDTH renderer.draw_text(item.label, (label_x, iy + PADDING_Y), colour=_TEXT, scale=TEXT_SCALE) # Detail (right-aligned, truncated) if item.detail: detail = item.detail if len(item.detail) <= 30 else item.detail[:27] + "..." dw = renderer.text_width(detail, TEXT_SCALE) dx = inner_x + inner_w - dw - PADDING_X - (SCROLLBAR_WIDTH if len(self._filtered) > MAX_VISIBLE else 0) if dx > label_x + renderer.text_width(item.label, TEXT_SCALE) + 12: renderer.draw_text(detail, (dx, iy + PADDING_Y), colour=_DIM, scale=TEXT_SCALE) renderer.pop_clip() # Scrollbar if len(self._filtered) > MAX_VISIBLE: sb_x = inner_x + inner_w - SCROLLBAR_WIDTH renderer.draw_rect((sb_x, inner_y), (SCROLLBAR_WIDTH, inner_h), colour=_SCROLLBAR_BG, filled=True) total_items = len(self._filtered) thumb_h = max(12.0, inner_h * (MAX_VISIBLE / total_items)) scrollable = total_items - MAX_VISIBLE thumb_y = inner_y + (self._scroll_offset / scrollable) * (inner_h - thumb_h) if scrollable > 0 else inner_y renderer.draw_rect((sb_x, thumb_y), (SCROLLBAR_WIDTH, thumb_h), colour=_SCROLLBAR_FG, filled=True)
# --------------------------------------------------------------------------- # Fuzzy matching # --------------------------------------------------------------------------- def _fuzzy_score(query: str, text: str) -> int: """Return match score (lower is better), or -1 if no match. Simple sequential character matching with gap penalty. Exact prefix matches get the best (lowest) score. """ if not query: return 0 qi = 0 score = 0 last_match = -1 for ti, ch in enumerate(text): if qi < len(query) and ch == query[qi]: gap = ti - last_match - 1 if last_match >= 0 else ti score += gap last_match = ti qi += 1 if qi < len(query): return -1 # Bonus: exact prefix gets score 0; word-boundary matches get small boost return score