feat: launcher recommendation

This commit is contained in:
2026-09-15 19:53:43 +02:00
parent ff200e3580
commit 2fc0854a11
6 changed files with 521 additions and 25 deletions
+51 -9
View File
@@ -1,6 +1,7 @@
import re
import shlex
import subprocess
import time
from fabric.utils.helpers import DesktopApp, get_desktop_applications
from fabric.widgets.box import Box
@@ -9,6 +10,8 @@ from fabric.widgets.label import Label
from gi.repository import Gtk
from .base import FuzzyMenu
from .history import LaunchHistory
from .ranking import rank_items
ICON_SIZE = 32
@@ -17,14 +20,27 @@ _FIELD_CODE_RE = re.compile(r"^%[fFuUickdDnNvm]$")
class AppProvider:
def __init__(self, history: LaunchHistory | None = None):
self._history = history if history is not None else LaunchHistory()
def items(self) -> list[DesktopApp]:
return get_desktop_applications()
def filter(self, items: list[DesktopApp], query: str) -> list[DesktopApp]:
if not query:
return items
q = query.lower()
return [a for a in items if _matches(a, q)]
"""Rank apps against *query*, best match first.
With an empty query everything matches equally, so the order is
purely "what gets launched most" - the launcher opens on your
most useful apps instead of an arbitrary alphabetical slice.
"""
now = time.time()
return rank_items(
items,
query,
_fields,
bonus_of=lambda app: self._history.bonus(_app_key(app), now=now),
tie_break_of=_sort_name,
)
def render(self, item: DesktopApp) -> Gtk.Widget:
children: list[Gtk.Widget] = []
@@ -45,6 +61,7 @@ class AppProvider:
return Box(name="slot-box", orientation="h", spacing=10, children=children)
def activate(self, item: DesktopApp) -> None:
self._history.record(_app_key(item))
# Launch in a transient systemd --user scope so the app gets its own
# cgroup instead of inheriting sims.service's. start_new_session alone
# only changes POSIX session/pgid; systemd tracks units by cgroup and
@@ -70,11 +87,36 @@ class AppProvider:
item.launch()
def _matches(app: DesktopApp, q: str) -> bool:
for field in (app.name, app.display_name, app.generic_name, app.executable):
if field and q in field.lower():
return True
return False
def _fields(app: DesktopApp) -> dict[str, str | None]:
return {
"name": app.name,
"display_name": app.display_name,
"generic_name": app.generic_name,
"executable": app.executable,
}
def _sort_name(app: DesktopApp) -> str:
return (app.display_name or app.name or "").casefold()
def _app_key(app: DesktopApp) -> str:
"""Stable identity for the launch history.
DesktopApp does not expose the desktop file id, but it wraps the
GioUnix.DesktopAppInfo which does. Fall back to the command line
(unique per Steam game) and finally the name.
"""
info = getattr(app, "_app", None)
get_id = getattr(info, "get_id", None) if info is not None else None
if callable(get_id):
try:
app_id = get_id()
except Exception: # pragma: no cover - defensive
app_id = None
if isinstance(app_id, str) and app_id:
return app_id
return app.command_line or app.executable or app.name or ""
def AppLauncher(monitor: int = 0) -> FuzzyMenu: