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:
+11 -4
View File
@@ -9,6 +9,8 @@ from gi.repository import Gdk, Gtk
from sims.services.fenster import focused_output_index
from .ranking import rank_items
class LauncherProvider(Protocol):
def items(self) -> list[Any]: ...
@@ -47,10 +49,15 @@ class StaticActionProvider:
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()]
# Fixed menus (screenshot, power, screenrec) have no history: an
# empty query keeps the declaration order, a real query is ranked
# like everything else in the launcher.
return rank_items(
items,
query,
lambda action: {"label": action.label},
weights={"label": 1.0},
)
def render(self, item: StaticAction) -> Gtk.Widget:
return Box(
+9 -4
View File
@@ -6,6 +6,7 @@ from fabric.widgets.label import Label
from gi.repository import Gtk
from .base import FuzzyMenu
from .ranking import rank_items
@dataclass
@@ -34,10 +35,14 @@ class ClipboardProvider:
return entries
def filter(self, items: list[ClipEntry], query: str) -> list[ClipEntry]:
if not query:
return items
q = query.lower()
return [e for e in items if q in e.preview.lower()]
# Empty query keeps cliphist's newest-first order; a query is
# ranked like every other launcher menu.
return rank_items(
items,
query,
lambda entry: {"preview": entry.preview},
weights={"preview": 1.0},
)
def render(self, item: ClipEntry) -> Gtk.Widget:
return Box(
+167
View File
@@ -0,0 +1,167 @@
"""Persistent launch history for the app launcher.
Remembers how often and how recently each application was launched so
``AppProvider`` can rank frequently used apps first (frecency, the
same frequency + recency idea as zoxide). The file lives in the system
cache directory - losing it only resets the ranking, never breaks the
launcher.
Layout (JSON, written atomically)::
{"version": 1, "entries": {"steam.desktop": {"count": 12, "last_used": 1712345678.0}}}
"""
from __future__ import annotations
import json
import os
import tempfile
import time
from dataclasses import dataclass
from pathlib import Path
from loguru import logger
from platformdirs import user_cache_dir
from .ranking import frecency, popularity_bonus
CACHE_VERSION = 1
MAX_ENTRIES = 500
MAX_AGE_DAYS = 365.0
DEFAULT_HALF_LIFE_DAYS = 30.0
def default_cache_path() -> Path:
return Path(user_cache_dir("sims")) / "launcher-history.json"
@dataclass
class _Entry:
count: int = 0
last_used: float = 0.0
class LaunchHistory:
"""Launch counts/recency, keyed by launcher-specific item id."""
def __init__(
self,
path: Path | str | None = None,
*,
half_life_days: float = DEFAULT_HALF_LIFE_DAYS,
):
self.path = Path(path) if path is not None else default_cache_path()
self._half_life_days = half_life_days
self._entries: dict[str, _Entry] = self._load()
def record(self, key: str, *, now: float | None = None) -> None:
"""Count one launch of *key* and persist the history."""
if not key:
return
now = time.time() if now is None else now
entry = self._entries.get(key)
if entry is None:
entry = self._entries[key] = _Entry()
entry.count += 1
entry.last_used = now
self._save()
def bonus(
self,
key: str,
*,
now: float | None = None,
max_bonus: float = 150.0,
) -> float:
"""Bounded popularity bonus for *key*, 0.0 when unknown."""
entry = self._entries.get(key)
if entry is None or entry.count <= 0:
return 0.0
now = time.time() if now is None else now
score = frecency(
entry.count,
entry.last_used,
now,
half_life_days=self._half_life_days,
)
return popularity_bonus(score, max_bonus=max_bonus)
# -- persistence ------------------------------------------------------
def _load(self) -> dict[str, _Entry]:
try:
with open(self.path, "r", encoding="utf-8") as handle:
data = json.load(handle)
except FileNotFoundError:
return {}
except (OSError, ValueError) as exc:
logger.warning("launcher history {} unreadable: {}", self.path, exc)
return {}
raw_entries = data.get("entries") if isinstance(data, dict) else None
if not isinstance(raw_entries, dict):
logger.warning("launcher history {}: unexpected format", self.path)
return {}
entries: dict[str, _Entry] = {}
for key, raw in raw_entries.items():
if not isinstance(raw, dict):
continue
try:
count = int(raw.get("count", 0))
last_used = float(raw.get("last_used", 0.0))
except (TypeError, ValueError):
continue
if count > 0:
entries[key] = _Entry(count=count, last_used=last_used)
return self._prune(entries, time.time())
def _prune(self, entries: dict[str, _Entry], now: float) -> dict[str, _Entry]:
cutoff = now - MAX_AGE_DAYS * 86_400.0
kept = {
key: entry
for key, entry in entries.items()
if entry.count > 0 and entry.last_used >= cutoff
}
if len(kept) > MAX_ENTRIES:
top = sorted(
kept.items(),
key=lambda item: frecency(
item[1].count,
item[1].last_used,
now,
half_life_days=self._half_life_days,
),
reverse=True,
)[:MAX_ENTRIES]
kept = dict(top)
return kept
def _save(self) -> None:
self._entries = self._prune(self._entries, time.time())
payload = {
"version": CACHE_VERSION,
"entries": {
key: {"count": entry.count, "last_used": entry.last_used}
for key, entry in sorted(self._entries.items())
},
}
temp_path: str | None = None
try:
self.path.parent.mkdir(parents=True, exist_ok=True)
fd, temp_path = tempfile.mkstemp(
dir=self.path.parent, prefix=self.path.name, suffix=".tmp"
)
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(payload, handle)
os.replace(temp_path, self.path)
temp_path = None
except OSError as exc:
logger.warning("could not write launcher history {}: {}", self.path, exc)
finally:
if temp_path is not None:
try:
os.unlink(temp_path)
except OSError:
pass
+263
View File
@@ -0,0 +1,263 @@
"""Search ranking for launcher items.
The launcher used to keep items in provider order (alphabetical for
desktop apps) and filter by plain substring. That breaks as soon as a
query matches several apps through different fields: typing ``steam``
listed every installed Steam game (each of their desktop files has
``Exec=steam steam://rungameid/...``) in the middle of the list, with
Steam itself somewhere in between.
This module scores a query against a set of named fields:
* every whitespace-separated token must match somewhere (AND semantics);
* match *kind* dominates the score:
exact > prefix > word start > substring > subsequence;
* fields are weighted so a match on the user-visible name outranks the
same kind of match on the generic name or the executable;
* a bounded bonus can be added on top (launch frecency, see
``history.py``). The bonus is small enough that it can never beat a
better match kind on the same field.
The score is only used for ordering, so the absolute numbers do not
matter - only the gaps between them.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import IntEnum
from typing import Any, Callable, Mapping, Sequence, TypeVar
# Fuzzy (subsequence) matching on tiny queries matches almost anything,
# so only use it once the query is long enough to be meaningful.
MIN_SUBSEQUENCE_LEN = 3
Item = TypeVar("Item")
class MatchKind(IntEnum):
NONE = 0
SUBSEQUENCE = 1
SUBSTRING = 2
WORD_PREFIX = 3
PREFIX = 4
EXACT = 5
# Base score per match kind. The gaps are much larger than any quality
# or popularity bonus, so a better kind of match on the same field always
# wins over a bonus-carrying worse one.
_KIND_SCORE: Mapping[MatchKind, float] = {
MatchKind.EXACT: 1000.0,
MatchKind.PREFIX: 650.0,
MatchKind.WORD_PREFIX: 450.0,
MatchKind.SUBSTRING: 250.0,
MatchKind.SUBSEQUENCE: 90.0,
}
# Weight of the match *quality* (max 75 points total):
_POSITION_WEIGHT = 25.0 # earlier in the field is better
_COVERAGE_WEIGHT = 25.0 # token covers more of the field is better
_CONTIGUITY_WEIGHT = 25.0 # subsequence: fewer skipped chars is better
# How much each desktop-entry field counts. The name must always beat
# generic-name/executable matches, otherwise every Steam game (Exec=
# "steam steam://rungameid/<id>") drowns out Steam itself.
DEFAULT_FIELD_WEIGHTS: Mapping[str, float] = {
"name": 1.0,
"display_name": 0.95,
"generic_name": 0.45,
"executable": 0.55,
}
@dataclass(frozen=True)
class FieldMatch:
"""Where and how a token matched a single field."""
kind: MatchKind
position: int
span: int
def quality(self, value_len: int, token_len: int) -> float:
value_len = max(value_len, 1)
position = 1.0 - self.position / value_len
coverage = token_len / value_len
contiguity = token_len / max(self.span, 1)
return (
_POSITION_WEIGHT * position
+ _COVERAGE_WEIGHT * coverage
+ _CONTIGUITY_WEIGHT * contiguity
)
def _is_word_start(value: str, position: int) -> bool:
return position == 0 or not value[position - 1].isalnum()
def _subsequence_span(value: str, token: str) -> tuple[int, int] | None:
"""Greedy subsequence match; returns (start, span) or None."""
first = -1
last = -1
index = 0
for position, char in enumerate(value):
if char == token[index]:
if first == -1:
first = position
last = position
index += 1
if index == len(token):
return first, last - first + 1
return None
def find_match(value: str, token: str) -> FieldMatch | None:
"""Best match of *token* inside *value* (both lowercased here).
Returns None when the token does not occur in the field at all.
"""
if not value or not token:
return None
value = value.lower()
token = token.lower()
if value == token:
return FieldMatch(MatchKind.EXACT, 0, len(token))
if value.startswith(token):
return FieldMatch(MatchKind.PREFIX, 0, len(token))
position = value.find(token)
if position != -1:
kind = (
MatchKind.WORD_PREFIX
if _is_word_start(value, position)
else MatchKind.SUBSTRING
)
return FieldMatch(kind, position, len(token))
if len(token) >= MIN_SUBSEQUENCE_LEN:
span = _subsequence_span(value, token)
if span is not None:
return FieldMatch(MatchKind.SUBSEQUENCE, span[0], span[1])
return None
def score_token(
token: str,
fields: Mapping[str, str | None],
weights: Mapping[str, float] = DEFAULT_FIELD_WEIGHTS,
) -> float | None:
"""Best weighted score of *token* across *fields*, or None."""
best: float | None = None
for field, weight in weights.items():
value = fields.get(field)
if not value:
continue
match = find_match(value, token)
if match is None:
continue
score = weight * (
_KIND_SCORE[match.kind] + match.quality(len(value), len(token))
)
if best is None or score > best:
best = score
return best
def score_query(
query: str,
fields: Mapping[str, str | None],
weights: Mapping[str, float] = DEFAULT_FIELD_WEIGHTS,
) -> float | None:
"""Score an item for *query*: None unless every token matches.
An empty query matches everything with score 0.0, which lets callers
fall back to time/frequency based ordering.
"""
tokens = query.split()
if not tokens:
return 0.0
total = 0.0
for token in tokens:
token_score = score_token(token, fields, weights)
if token_score is None:
return None
total += token_score
return total / len(tokens)
def rank_items(
items: Sequence[Item],
query: str,
fields_of: Callable[[Item], Mapping[str, str | None]],
*,
weights: Mapping[str, float] = DEFAULT_FIELD_WEIGHTS,
bonus_of: Callable[[Item], float] | None = None,
tie_break_of: Callable[[Item], Any] | None = None,
) -> list[Item]:
"""Rank *items* by how well they match *query*, best first.
This is the shared filter for every launcher provider: apps, windows,
clipboard entries and static action menus all describe their searchable
text as weighted fields via *fields_of*.
With an empty query the provider order is kept, unless *bonus_of* is
given - then items are ordered by bonus instead (used by the app
launcher to show launch frecency). *bonus_of* is added to the match
score and must stay small enough not to outrank a better match kind.
Ties keep provider order, or fall back to *tie_break_of* when given.
"""
if not query.split():
if bonus_of is None:
return list(items)
scored = [(bonus_of(item), item) for item in items]
else:
scored = []
for item in items:
score = score_query(query, fields_of(item), weights)
if score is None:
continue
if bonus_of is not None:
score += bonus_of(item)
scored.append((score, item))
if tie_break_of is None:
scored.sort(key=lambda pair: -pair[0])
else:
scored.sort(key=lambda pair: (-pair[0], tie_break_of(pair[1])))
return [item for _, item in scored]
def frecency(
count: int,
last_used: float | None,
now: float,
*,
half_life_days: float = 30.0,
) -> float:
"""Launch count decayed by age with the given half-life."""
if count <= 0:
return 0.0
if last_used is None:
return float(count)
age_days = max(0.0, (now - last_used) / 86_400.0)
return count * 0.5 ** (age_days / max(half_life_days, 1e-6))
def popularity_bonus(
frecency_score: float,
*,
max_bonus: float = 150.0,
half_bonus_at: float = 5.0,
) -> float:
"""Saturating, monotonic map of frecency into ``[0, max_bonus)``.
Keeps the launch-history signal bounded so it can reorder equal
matches but never beat a better match kind.
"""
if frecency_score <= 0.0:
return 0.0
return max_bonus * frecency_score / (frecency_score + half_bonus_at)
+20 -8
View File
@@ -3,6 +3,15 @@ from fabric.widgets.box import Box
from fabric.widgets.label import Label
from gi.repository import Gtk
from .ranking import rank_items
# A window title is what people search for; the app id is a fallback.
# Weighted below 0.65 on purpose: an exact app-id match must not beat a
# window whose *title* starts with the query, so a query like "firefox"
# still orders the Firefox windows by their titles.
_WINDOW_WEIGHTS = {"title": 1.0, "app_id": 0.6}
class WindowProvider:
def items(self) -> list[dict]:
@@ -34,14 +43,17 @@ class WindowProvider:
return windows
def filter(self, items: list[dict], query: str) -> list[dict]:
if not query:
return items
q = query.lower()
return [
w for w in items
if q in w.get("title", "").lower()
or q in w.get("app_id", "").lower()
]
# No history for windows - empty query keeps workspace order,
# typed queries are ranked by title/app id match quality.
return rank_items(
items,
query,
lambda window: {
"title": window.get("title", ""),
"app_id": window.get("app_id", ""),
},
weights=_WINDOW_WEIGHTS,
)
def render(self, item: dict) -> Gtk.Widget:
title = item.get("title", "")