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 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)
# ------------------------------------------------------------------
# Drawing
# ------------------------------------------------------------------
[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