Source code for simvx.core.ui.autocomplete

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

import logging

from simvx.core import Property, Signal

from ..input.controls import Key, Pointer
from ..input.enums import PointerPhase
from ..input.events import KeyEvent, PointerEvent, ScrollEvent
from .completion_types import CompletionItem, kind_abbreviation
from .core import Control
from .enums import PointerFilter
from .types import GuiInputEvent

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``). """ top_level = Property(True, on_change="_invalidate_transform", hint="Placed on the screen, not by its parent") visible = Property( False, coerce=bool, hint="A completion popup is up only while it has something to offer", on_change="_on_visible_changed", ) pointer_filter = Property(PointerFilter.STOP, hint="How this control takes part in the pointer hit test") 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 # ------------------------------------------------------------------ #: The keys the popup takes off the editor while it is open. Everything #: else, typed characters included, goes on reaching the editor and #: refilters the list. NAV_KEYS = frozenset( ( Key.UP, Key.DOWN, Key.ENTER, Key.TAB, Key.ESCAPE, Key.PAGE_UP, Key.PAGE_DOWN, Key.HOME, Key.END, ) )
[docs] def forward_key(self, event: GuiInputEvent) -> bool: """Consumer-side hook: forward a navigation key to the popup. Returns True if the popup consumed the key, which is the caller's answer as well: the popup took it, so the editor claims it. 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 True # ... usual editor key handling ... """ if not self.visible or not isinstance(event, KeyEvent) or not event.pressed: return False if event.key not in self.NAV_KEYS: return False self._handle_key(event) return True
[docs] def on_gui_input(self, event: GuiInputEvent): """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 None if isinstance(event, ScrollEvent): self._scroll_list(-SCROLL_STEP if event.scroll[1] > 0 else SCROLL_STEP) return True if isinstance(event, PointerEvent) and event.button in Pointer.PRIMARY: if event.phase is PointerPhase.DOWN: self._handle_click(event) return True return None
def _handle_key(self, event: KeyEvent): key = event.key if key is Key.UP: self._selected_index = max(0, self._selected_index - 1) self._clamp_scroll() elif key is Key.DOWN: self._selected_index = min(len(self._filtered) - 1, self._selected_index + 1) self._clamp_scroll() elif key is Key.ENTER or key is Key.TAB: self._accept() elif key is Key.ESCAPE: self.hide() elif key is Key.PAGE_UP: self._selected_index = max(0, self._selected_index - MAX_VISIBLE) self._clamp_scroll() elif key is Key.PAGE_DOWN: self._selected_index = min(len(self._filtered) - 1, self._selected_index + MAX_VISIBLE) self._clamp_scroll() elif key is Key.HOME: self._selected_index = 0 self._scroll_offset = 0 elif key is Key.END: self._selected_index = max(0, len(self._filtered) - 1) self._clamp_scroll() def _handle_click(self, event: PointerEvent): px, py = event.position 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.rect((x0, y0), (total_width, total_height), fill=_BG_BORDER) # Background inner_x = x0 + BORDER inner_y = y0 + BORDER inner_w = POPUP_WIDTH inner_h = visible_count * ITEM_HEIGHT renderer.rect((inner_x, inner_y), (inner_w, inner_h), fill=_BG) 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.rect((inner_x, iy), (inner_w, ITEM_HEIGHT), fill=_SELECTED) elif item_idx == self._hover_index: renderer.rect((inner_x, iy), (inner_w, ITEM_HEIGHT), fill=_HOVER) # Kind abbreviation abbr = kind_abbreviation(item.kind) renderer.text(abbr, (inner_x + PADDING_X, iy + PADDING_Y), fill=_KIND, scale=TEXT_SCALE) # Label label_x = inner_x + KIND_COL_WIDTH renderer.text(item.label, (label_x, iy + PADDING_Y), fill=_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.text(detail, (dx, iy + PADDING_Y), fill=_DIM, scale=TEXT_SCALE) renderer.pop_clip() # Scrollbar if len(self._filtered) > MAX_VISIBLE: sb_x = inner_x + inner_w - SCROLLBAR_WIDTH renderer.rect((sb_x, inner_y), (SCROLLBAR_WIDTH, inner_h), fill=_SCROLLBAR_BG) 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.rect((sb_x, thumb_y), (SCROLLBAR_WIDTH, thumb_h), fill=_SCROLLBAR_FG)
# --------------------------------------------------------------------------- # 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