import re import shlex import subprocess import time from fabric.utils.helpers import DesktopApp, get_desktop_applications from fabric.widgets.box import Box from fabric.widgets.image import Image 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 _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]: """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] = [] pixbuf = item.get_icon_pixbuf(size=ICON_SIZE) if pixbuf is not None: children.append(Image(pixbuf=pixbuf, name="app-icon")) primary = item.display_name or item.name or "" text_box = Box(name="app-text", orientation="v", spacing=0) text_box.add(Label(label=primary, name="app-name", h_align="start")) if item.generic_name and item.generic_name != primary: text_box.add( Label(label=item.generic_name, name="app-generic", h_align="start") ) children.append(text_box) 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 # would kill children with sims on stop (default KillMode=control-group). if item.command_line: argv = [ t for t in shlex.split(item.command_line) if not _FIELD_CODE_RE.match(t) ] if argv: subprocess.Popen( [ "systemd-run", "--quiet", "--user", "--scope", "--collect", "--", *argv, ], start_new_session=True, ) return item.launch() 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: return FuzzyMenu( provider=AppProvider(), monitor=monitor, placeholder="Search Apps...", window_name="app-launcher", max_results=8, )