206 lines
6.9 KiB
Python
206 lines
6.9 KiB
Python
from dataclasses import dataclass
|
|
from typing import Any, Callable, Protocol
|
|
|
|
from fabric.widgets.box import Box
|
|
from fabric.widgets.entry import Entry
|
|
from fabric.widgets.label import Label
|
|
from fabric.widgets.wayland import WaylandWindow as Window
|
|
from gi.repository import Gdk, Gtk
|
|
|
|
from sims.services.fenster import focused_output_index
|
|
|
|
|
|
class LauncherProvider(Protocol):
|
|
def items(self) -> list[Any]: ...
|
|
def filter(self, items: list[Any], query: str) -> list[Any]: ...
|
|
def render(self, item: Any) -> Gtk.Widget: ...
|
|
def activate(self, item: Any) -> None: ...
|
|
|
|
|
|
@dataclass
|
|
class StaticAction:
|
|
label: str
|
|
handler: Callable[[], None]
|
|
|
|
|
|
class StaticActionProvider:
|
|
"""Provider for menus whose items are a fixed list of (label, handler) pairs.
|
|
|
|
Pass either StaticAction instances or (label, handler) tuples; tuples are
|
|
coerced. items_factory lets the list re-evaluate on each open (e.g. for
|
|
state-dependent menus) — otherwise the list is captured at construction.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
actions: list[StaticAction | tuple[str, Callable[[], None]]] | None = None,
|
|
items_factory: Callable[[], list[StaticAction | tuple[str, Callable[[], None]]]] | None = None,
|
|
):
|
|
if (actions is None) == (items_factory is None):
|
|
raise ValueError("pass exactly one of actions or items_factory")
|
|
self._static = [_coerce(a) for a in actions] if actions is not None else None
|
|
self._factory = items_factory
|
|
|
|
def items(self) -> list[StaticAction]:
|
|
if self._factory is not None:
|
|
return [_coerce(a) for a in self._factory()]
|
|
return list(self._static or [])
|
|
|
|
def filter(self, items: list[StaticAction], query: str) -> list[StaticAction]:
|
|
if not query:
|
|
return items
|
|
q = query.lower()
|
|
return [i for i in items if q in i.label.lower()]
|
|
|
|
def render(self, item: StaticAction) -> Gtk.Widget:
|
|
return Box(
|
|
name="slot-box",
|
|
orientation="h",
|
|
children=[Label(label=item.label, h_align="start")],
|
|
)
|
|
|
|
def activate(self, item: StaticAction) -> None:
|
|
item.handler()
|
|
|
|
|
|
def _coerce(a: StaticAction | tuple[str, Callable[[], None]]) -> StaticAction:
|
|
if isinstance(a, StaticAction):
|
|
return a
|
|
label, handler = a
|
|
return StaticAction(label=label, handler=handler)
|
|
|
|
|
|
class FuzzyMenu(Window):
|
|
def __init__(
|
|
self,
|
|
provider: LauncherProvider,
|
|
monitor: int = 0,
|
|
placeholder: str = "Search...",
|
|
window_name: str = "finder",
|
|
max_results: int | None = None,
|
|
):
|
|
self._max_results = max_results
|
|
super().__init__(
|
|
name=window_name,
|
|
anchor="center",
|
|
monitor=monitor,
|
|
keyboard_mode="exclusive",
|
|
type="popup",
|
|
visible=False,
|
|
)
|
|
self._provider = provider
|
|
self._items: list[Any] = []
|
|
self._filtered: list[Any] = []
|
|
self._selected_index: int = 0
|
|
self._scroll_offset: int = 0
|
|
|
|
self.viewport = Box(name="viewport", spacing=4, orientation="v")
|
|
|
|
self.search_entry = Entry(
|
|
name="search-entry",
|
|
placeholder=placeholder,
|
|
h_expand=True,
|
|
editable=True,
|
|
notify_text=self._on_text_changed,
|
|
)
|
|
self.picker_box = Box(
|
|
name="picker-box",
|
|
spacing=4,
|
|
orientation="v",
|
|
children=[self.search_entry, self.viewport],
|
|
)
|
|
|
|
self.add(self.picker_box)
|
|
self.connect("key-press-event", self._on_key_press)
|
|
self._refresh_viewport("")
|
|
|
|
def show(self):
|
|
self._items = self._provider.items()
|
|
self.search_entry.set_text("")
|
|
self._selected_index = 0
|
|
self._scroll_offset = 0
|
|
self._refresh_viewport("")
|
|
self.monitor = focused_output_index()
|
|
super().show()
|
|
self.search_entry.grab_focus()
|
|
|
|
def _on_text_changed(self, entry, *_):
|
|
self._selected_index = 0
|
|
self._scroll_offset = 0
|
|
self._refresh_viewport(entry.get_text())
|
|
|
|
def _on_key_press(self, _widget, event):
|
|
ctrl = bool(event.state & Gdk.ModifierType.CONTROL_MASK)
|
|
keyval = event.keyval
|
|
|
|
if keyval == Gdk.KEY_Escape:
|
|
self.hide()
|
|
return True
|
|
if ctrl and keyval in (Gdk.KEY_g, Gdk.KEY_G):
|
|
self.hide()
|
|
return True
|
|
if keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
|
|
self._activate_selected()
|
|
return True
|
|
if keyval == Gdk.KEY_Down or (ctrl and keyval in (Gdk.KEY_n, Gdk.KEY_N)):
|
|
self._move_selection(1)
|
|
return True
|
|
if keyval == Gdk.KEY_Up or (ctrl and keyval in (Gdk.KEY_p, Gdk.KEY_P)):
|
|
self._move_selection(-1)
|
|
return True
|
|
return False
|
|
|
|
def _window_size(self) -> int:
|
|
return self._max_results if self._max_results is not None else len(self._filtered)
|
|
|
|
def _move_selection(self, delta: int):
|
|
if not self._filtered:
|
|
return
|
|
new_index = self._selected_index + delta
|
|
new_index = max(0, min(new_index, len(self._filtered) - 1))
|
|
if new_index == self._selected_index:
|
|
return
|
|
self._selected_index = new_index
|
|
window = self._window_size()
|
|
if window <= 0:
|
|
self._scroll_offset = 0
|
|
elif self._selected_index < self._scroll_offset:
|
|
self._scroll_offset = self._selected_index
|
|
elif self._selected_index >= self._scroll_offset + window:
|
|
self._scroll_offset = self._selected_index - window + 1
|
|
self._render_visible()
|
|
|
|
def _refresh_viewport(self, query: str):
|
|
self._filtered = self._provider.filter(self._items, query)
|
|
if self._selected_index >= len(self._filtered):
|
|
self._selected_index = 0
|
|
self._scroll_offset = 0
|
|
self._render_visible()
|
|
|
|
def _render_visible(self):
|
|
window = self._window_size()
|
|
if window <= 0:
|
|
visible: list[Any] = []
|
|
else:
|
|
max_offset = max(0, len(self._filtered) - window)
|
|
self._scroll_offset = min(self._scroll_offset, max_offset)
|
|
visible = self._filtered[self._scroll_offset : self._scroll_offset + window]
|
|
self.viewport.children = []
|
|
for item in visible:
|
|
self.viewport.add(self._provider.render(item))
|
|
self._update_selection_highlight()
|
|
|
|
def _update_selection_highlight(self):
|
|
visible_index = self._selected_index - self._scroll_offset
|
|
for i, child in enumerate(self.viewport.get_children()):
|
|
ctx = child.get_style_context()
|
|
if i == visible_index:
|
|
ctx.add_class("selected")
|
|
else:
|
|
ctx.remove_class("selected")
|
|
|
|
def _activate_selected(self):
|
|
if self._filtered and 0 <= self._selected_index < len(self._filtered):
|
|
self._provider.activate(self._filtered[self._selected_index])
|
|
self.hide()
|