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