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]
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