Source code for simvx.core.ui.autocomplete

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


from __future__ import annotations

import logging

from simvx.core import Signal

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


[docs] class AutocompletePopup(Control): """Floating completion popup that shows filtered suggestions near the cursor.""" 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._visible: bool = False self._popup_x: float = 0.0 self._popup_y: float = 0.0 self.mouse_filter = True self.z_index = 1000 # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ @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 self._visible = len(self._filtered) > 0
[docs] def hide(self): if self._visible: self._visible = False 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 # ------------------------------------------------------------------ def _on_gui_input(self, event: UIInputEvent): if not self._visible: return if event.key and event.pressed: self._handle_key(event) elif event.button == 1 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 if hasattr(event.position, "x") else event.position[0] py = event.position.y if hasattr(event.position, "y") else event.position[1] 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.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) # ------------------------------------------------------------------ # Popup geometry helpers # ------------------------------------------------------------------
[docs] def is_popup_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)
[docs] def popup_input(self, event): self._on_gui_input(event)
[docs] def dismiss_popup(self): self.hide()
# ------------------------------------------------------------------ # Drawing # ------------------------------------------------------------------
[docs] def draw(self, renderer): super().draw(renderer) self.draw_popup(renderer)
[docs] def draw_popup(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_filled_rect(x0, y0, total_width, total_height, _BG_BORDER) # Background inner_x = x0 + BORDER inner_y = y0 + BORDER inner_w = POPUP_WIDTH inner_h = visible_count * ITEM_HEIGHT renderer.draw_filled_rect(inner_x, inner_y, inner_w, inner_h, _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.draw_filled_rect(inner_x, iy, inner_w, ITEM_HEIGHT, _SELECTED) elif item_idx == self._hover_index: renderer.draw_filled_rect(inner_x, iy, inner_w, ITEM_HEIGHT, _HOVER) # Kind abbreviation abbr = kind_abbreviation(item.kind) renderer.draw_text_coloured(abbr, inner_x + PADDING_X, iy + PADDING_Y, TEXT_SCALE, _KIND) # Label label_x = inner_x + KIND_COL_WIDTH renderer.draw_text_coloured(item.label, label_x, iy + PADDING_Y, TEXT_SCALE, _TEXT) # 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_coloured(detail, dx, iy + PADDING_Y, TEXT_SCALE, _DIM) renderer.pop_clip() # Scrollbar if len(self._filtered) > MAX_VISIBLE: sb_x = inner_x + inner_w - SCROLLBAR_WIDTH renderer.draw_filled_rect(sb_x, inner_y, SCROLLBAR_WIDTH, inner_h, _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.draw_filled_rect(sb_x, thumb_y, SCROLLBAR_WIDTH, thumb_h, _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