diff --git a/flake.nix b/flake.nix index 3139890..11facbb 100644 --- a/flake.nix +++ b/flake.nix @@ -146,6 +146,22 @@ default = 6; description = "Debt count at which the widget switches to the alarm (red) color"; }; + saved_searches = lib.mkOption { + type = lib.types.listOf (lib.types.submodule { + options = { + name = lib.mkOption { + type = lib.types.str; + description = "Display label shown in the search launcher"; + }; + query = lib.mkOption { + type = lib.types.str; + description = "notmuch query to run when this saved search is activated"; + }; + }; + }); + default = [ ]; + description = "Saved searches shown in the notmuch search launcher when the entry is empty"; + }; }; screenrec = { enable = lib.mkOption { @@ -228,6 +244,7 @@ debt_query = "tag:unread and date:..1w"; debt_warn_at = 1; debt_alarm_at = 6; + saved_searches = [ ]; }; screenrec = { enable = false; diff --git a/sims/cli.py b/sims/cli.py index ab75c14..7b47542 100644 --- a/sims/cli.py +++ b/sims/cli.py @@ -73,6 +73,14 @@ def _cmd_screenrec(ns: argparse.Namespace) -> None: invoke_action(mapping[ns.screenrec_cmd]) +def _cmd_mail(ns: argparse.Namespace) -> None: + mapping = { + "search": "open-notmuch-search", + "refresh": "refresh-notmuch", + } + invoke_action(mapping[ns.mail_cmd]) + + def _cmd_corners(ns: argparse.Namespace) -> None: mapping = { "rounded": "set-bar-corners-rounded", @@ -118,6 +126,15 @@ def build_parser() -> argparse.ArgumentParser: rec_sub.add_parser(sub_name, help=sub_help) rec.set_defaults(func=_cmd_screenrec) + mail = sub.add_parser("mail", help="notmuch mail controls") + mail_sub = mail.add_subparsers(dest="mail_cmd", required=True, metavar="ACTION") + for sub_name, sub_help in [ + ("search", "open the live notmuch search launcher"), + ("refresh", "refresh the bar's unread/debt counts"), + ]: + mail_sub.add_parser(sub_name, help=sub_help) + mail.set_defaults(func=_cmd_mail) + corners = sub.add_parser("corners", help="bar bottom-corner rounding") corners_sub = corners.add_subparsers( dest="corners_cmd", required=True, metavar="STATE" diff --git a/sims/config.py b/sims/config.py index 286cf03..e50be94 100644 --- a/sims/config.py +++ b/sims/config.py @@ -54,7 +54,8 @@ BATTERY = app_config.get("battery", {"enable": False}) WINDOW_TITLE = app_config.get("window_title", {"enable": True}) STYLIX = app_config.get("stylix", {"enable": False}) CALENDAR = app_config.get("calendar", {"enable": True, "khal_path": "khal"}) -NOTMUCH = app_config.get("notmuch", {"enable": True, "notmuch_path": "notmuch", "emacsclient_command": "emacsclient"}) +NOTMUCH = app_config.get("notmuch", {"enable": True, "notmuch_path": "notmuch", "emacsclient_command": "emacsclient", "saved_searches": []}) +NOTMUCH.setdefault("saved_searches", []) SCREENREC = app_config.get("screenrec", { "enable": False, "output_dir": "~/Videos/wl-screenrec", diff --git a/sims/main.py b/sims/main.py index 5623372..026e332 100644 --- a/sims/main.py +++ b/sims/main.py @@ -22,6 +22,7 @@ from .modules.bar import StatusBar from .modules.window_fuzzy import FuzzyWindowFinder from .modules.launcher.apps import AppLauncher from .modules.launcher.clipboard import ClipboardMenu +from .modules.launcher.notmuch_search import NotmuchSearchMenu from .modules.launcher.power import PowerMenu from .modules.launcher.screenrec import ScreenrecMenu from .modules.launcher.screenshot import ScreenshotMenu @@ -47,6 +48,7 @@ app_launcher = AppLauncher() clipboard_menu = ClipboardMenu() power_menu = PowerMenu(lock_command=POWER.get("lock_command", ["waylock"])) screenshot_menu = ScreenshotMenu() +notmuch_search_menu = NotmuchSearchMenu() screenrec_service: ScreenrecService | None = None screenrec_menu = None @@ -93,7 +95,7 @@ if notification_history is not None: bar_windows = [] notmuch_widget = None -_app_windows = [dummy, finder, app_launcher, clipboard_menu, power_menu, screenshot_menu] +_app_windows = [dummy, finder, app_launcher, clipboard_menu, power_menu, screenshot_menu, notmuch_search_menu] if screenrec_menu is not None: _app_windows.append(screenrec_menu) if notification_toasts is not None: @@ -134,6 +136,11 @@ def refresh_notmuch(): notmuch_widget.service.update_counts() +@Application.action() +def open_notmuch_search(): + notmuch_search_menu.show() + + @Application.action() def open_screenrec_menu(): if screenrec_menu is not None: diff --git a/sims/modules/launcher/notmuch_search.py b/sims/modules/launcher/notmuch_search.py new file mode 100644 index 0000000..9576357 --- /dev/null +++ b/sims/modules/launcher/notmuch_search.py @@ -0,0 +1,284 @@ +"""Live notmuch search launcher. + +A FuzzyMenu variant that runs `notmuch search` per keystroke (debounced), +renders thread summaries, and on activation opens the thread in emacs notmuch. +A bare-query handoff item is always appended so the user can defer to +notmuch-search inside emacs without having a matching result selected. +""" +from __future__ import annotations + +import json +import subprocess +from dataclasses import dataclass +from typing import Any + +from fabric.widgets.box import Box +from fabric.widgets.label import Label +from gi.repository import GLib, Gtk +from loguru import logger + +from sims.config import NOTMUCH + +from .base import FuzzyMenu + + +DEBOUNCE_MS = 120 +MIN_QUERY_LEN = 2 +SEARCH_LIMIT = 30 + + +@dataclass +class NotmuchHit: + thread: str + subject: str + authors: str + date_relative: str + + @property + def query(self) -> str: + return f"thread:{self.thread}" + + +@dataclass +class SavedSearch: + name: str + query: str + + +@dataclass +class BareQueryHandoff: + query: str + + +def _elisp_escape(s: str) -> str: + return s.replace("\\", "\\\\").replace('"', '\\"') + + +class NotmuchSearchProvider: + def __init__(self): + self._notmuch_path = NOTMUCH.get("notmuch_path", "notmuch") + self._emacsclient = NOTMUCH.get("emacsclient_command", "emacsclient") + raw_saved = NOTMUCH.get("saved_searches", []) or [] + self._saved: list[SavedSearch] = [] + for entry in raw_saved: + query = (entry.get("query") or "").strip() + if not query: + continue + name = entry.get("name") or query + self._saved.append(SavedSearch(name=name, query=query)) + + def saved_searches(self) -> list[SavedSearch]: + return list(self._saved) + + def search(self, query: str) -> list[NotmuchHit]: + cmd = [ + self._notmuch_path, + "search", + "--format=json", + "--output=summary", + f"--limit={SEARCH_LIMIT}", + query, + ] + try: + proc = subprocess.run( + cmd, capture_output=True, text=True, check=True, timeout=2 + ) + except subprocess.CalledProcessError as e: + logger.warning( + f"[NotmuchSearch] search failed for {query!r}: {e.stderr.strip()}" + ) + return [] + except (subprocess.TimeoutExpired, FileNotFoundError) as e: + logger.warning(f"[NotmuchSearch] {e}") + return [] + try: + raw = json.loads(proc.stdout) or [] + except json.JSONDecodeError: + return [] + hits: list[NotmuchHit] = [] + for r in raw: + hits.append( + NotmuchHit( + thread=str(r.get("thread", "")), + subject=r.get("subject") or "(no subject)", + authors=r.get("authors") or "", + date_relative=r.get("date_relative") or "", + ) + ) + return hits + + def render_hit(self, hit: NotmuchHit) -> Gtk.Widget: + text = Box(name="notmuch-text", orientation="v", spacing=0) + text.add( + Label( + label=hit.subject, + name="notmuch-subject", + h_align="start", + ellipsization="end", + max_chars_width=80, + ) + ) + text.add( + Label( + label=hit.authors, + name="notmuch-authors", + h_align="start", + ellipsization="end", + max_chars_width=80, + ) + ) + meta = Label( + label=hit.date_relative, name="notmuch-date", h_align="end" + ) + return Box( + name="slot-box", + orientation="h", + spacing=10, + children=[text, meta], + ) + + def render_saved(self, item: SavedSearch) -> Gtk.Widget: + text = Box(name="notmuch-text", orientation="v", spacing=0) + text.add( + Label(label=item.name, name="notmuch-saved-name", h_align="start") + ) + text.add( + Label( + label=item.query, + name="notmuch-saved-query", + h_align="start", + ellipsization="end", + max_chars_width=80, + ) + ) + return Box( + name="slot-box", orientation="h", spacing=10, children=[text] + ) + + def render_bare(self, item: BareQueryHandoff) -> Gtk.Widget: + return Box( + name="slot-box", + orientation="h", + children=[ + Label( + label=f"→ Search '{item.query}' in emacs", + name="notmuch-bare", + h_align="start", + ), + ], + ) + + def open_thread(self, hit: NotmuchHit) -> None: + self._emacs_eval(f'(notmuch-show "{_elisp_escape(hit.query)}")') + + def open_search(self, query: str) -> None: + self._emacs_eval(f'(notmuch-search "{_elisp_escape(query)}")') + + def _emacs_eval(self, sexp: str) -> None: + try: + subprocess.Popen( + [self._emacsclient, "-c", "-e", sexp], + start_new_session=True, + ) + except Exception as e: + logger.error(f"[NotmuchSearch] failed to launch emacsclient: {e}") + + +class _MenuProvider: + """LauncherProvider that defers to NotmuchSearchProvider. + + items() returns saved searches (shown when the entry is empty). + filter() is identity — NotmuchSearchMenu manages self._items directly + on every keystroke (live search instead of in-memory filtering). + """ + + def __init__(self, search: NotmuchSearchProvider): + self._search = search + + def items(self) -> list[Any]: + return list(self._search.saved_searches()) + + def filter(self, items: list[Any], query: str) -> list[Any]: + return items + + def render(self, item: Any) -> Gtk.Widget: + if isinstance(item, NotmuchHit): + return self._search.render_hit(item) + if isinstance(item, SavedSearch): + return self._search.render_saved(item) + if isinstance(item, BareQueryHandoff): + return self._search.render_bare(item) + return Box() + + def activate(self, item: Any) -> None: + if isinstance(item, NotmuchHit): + self._search.open_thread(item) + elif isinstance(item, SavedSearch): + self._search.open_search(item.query) + elif isinstance(item, BareQueryHandoff): + self._search.open_search(item.query) + + +class NotmuchSearchMenu(FuzzyMenu): + def __init__(self, monitor: int = 0): + self._search = NotmuchSearchProvider() + super().__init__( + provider=_MenuProvider(self._search), + monitor=monitor, + placeholder="notmuch search…", + window_name="notmuch-search", + max_results=12, + ) + self._debounce_id: int | None = None + + def show(self): + self._cancel_debounce() + super().show() + + def hide(self): + self._cancel_debounce() + super().hide() + + def _on_text_changed(self, entry, *_): + text = entry.get_text() + self._cancel_debounce() + self._selected_index = 0 + self._scroll_offset = 0 + + if not text: + # Empty query: re-show saved searches via the provider. + self._items = self._search.saved_searches() + self._refresh_viewport(text) + return + + if len(text) < MIN_QUERY_LEN: + # Too short to query notmuch; offer just the bare-query handoff. + self._items = [BareQueryHandoff(query=text)] + self._refresh_viewport(text) + return + + # Show the bare-query handoff immediately while we wait for the search + # — gives the user a way to commit before the debounce fires. + self._items = [BareQueryHandoff(query=text)] + self._refresh_viewport(text) + self._debounce_id = GLib.timeout_add( + DEBOUNCE_MS, self._on_debounce_fire, text + ) + + def _cancel_debounce(self): + if self._debounce_id is not None: + GLib.source_remove(self._debounce_id) + self._debounce_id = None + + def _on_debounce_fire(self, text: str): + self._debounce_id = None + if self.search_entry.get_text() != text: + return False + hits = self._search.search(text) + items: list[Any] = list(hits) + items.append(BareQueryHandoff(query=text)) + self._items = items + self._selected_index = 0 + self._scroll_offset = 0 + self._refresh_viewport(text) + return False diff --git a/sims/styles/launcher.css b/sims/styles/launcher.css index bea4a7e..54ddb47 100644 --- a/sims/styles/launcher.css +++ b/sims/styles/launcher.css @@ -55,3 +55,39 @@ opacity: 0.6; } +/* Provider-specific tweaks (notmuch search) */ +#notmuch-text { + /* Let the date column hug the right edge */ + margin-right: 8px; +} + +#notmuch-subject { + font-weight: 500; +} + +#notmuch-authors { + font-size: 11px; + opacity: 0.7; +} + +#notmuch-date { + font-size: 11px; + opacity: 0.6; + min-width: 90px; +} + +#notmuch-saved-name { + font-weight: 500; +} + +#notmuch-saved-query { + font-size: 11px; + opacity: 0.6; + font-family: monospace; +} + +#notmuch-bare { + font-style: italic; + opacity: 0.7; +} +