Source code for simvx.core.ui.autocomplete

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

import logging

from simvx.core import Signal

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

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

[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``). """ 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 self.mouse_filter = True self.visible = False # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------
[docs] @property def is_visible(self) -> bool: return 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 on popup items. Keyboard arrives via :meth:`forward_key` from the editor that owns focus; this method only handles mouse events on the popup itself. """ if not self.visible: return if event.button == MouseButton.LEFT and event.pressed: self._handle_click(event) 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 _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