Compare commits

2 Commits

Author SHA1 Message Date
6da7e97f19 feat: mail finder 2026-05-06 23:12:57 +02:00
cfdcf0c039 feat: email debt timer 2026-05-06 21:22:27 +02:00
9 changed files with 477 additions and 50 deletions

View File

@@ -131,6 +131,37 @@
default = "emacsclient"; default = "emacsclient";
description = "Path to the emacsclient binary"; description = "Path to the emacsclient binary";
}; };
debt_query = lib.mkOption {
type = lib.types.str;
default = "tag:unread and date:..1w";
description = "notmuch query whose count drives the mail-debt severity color on the bar widget";
};
debt_warn_at = lib.mkOption {
type = lib.types.int;
default = 1;
description = "Debt count at which the widget switches to the warn (orange) color";
};
debt_alarm_at = lib.mkOption {
type = lib.types.int;
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 = { screenrec = {
enable = lib.mkOption { enable = lib.mkOption {
@@ -210,6 +241,10 @@
enable = true; enable = true;
notmuch_path = "notmuch"; notmuch_path = "notmuch";
emacsclient_command = "emacsclient"; emacsclient_command = "emacsclient";
debt_query = "tag:unread and date:..1w";
debt_warn_at = 1;
debt_alarm_at = 6;
saved_searches = [ ];
}; };
screenrec = { screenrec = {
enable = false; enable = false;

0
scripts/launcher.py Normal file → Executable file
View File

View File

@@ -73,6 +73,14 @@ def _cmd_screenrec(ns: argparse.Namespace) -> None:
invoke_action(mapping[ns.screenrec_cmd]) 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: def _cmd_corners(ns: argparse.Namespace) -> None:
mapping = { mapping = {
"rounded": "set-bar-corners-rounded", "rounded": "set-bar-corners-rounded",
@@ -118,6 +126,15 @@ def build_parser() -> argparse.ArgumentParser:
rec_sub.add_parser(sub_name, help=sub_help) rec_sub.add_parser(sub_name, help=sub_help)
rec.set_defaults(func=_cmd_screenrec) 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.add_parser("corners", help="bar bottom-corner rounding")
corners_sub = corners.add_subparsers( corners_sub = corners.add_subparsers(
dest="corners_cmd", required=True, metavar="STATE" dest="corners_cmd", required=True, metavar="STATE"

View File

@@ -54,7 +54,8 @@ BATTERY = app_config.get("battery", {"enable": False})
WINDOW_TITLE = app_config.get("window_title", {"enable": True}) WINDOW_TITLE = app_config.get("window_title", {"enable": True})
STYLIX = app_config.get("stylix", {"enable": False}) STYLIX = app_config.get("stylix", {"enable": False})
CALENDAR = app_config.get("calendar", {"enable": True, "khal_path": "khal"}) 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", { SCREENREC = app_config.get("screenrec", {
"enable": False, "enable": False,
"output_dir": "~/Videos/wl-screenrec", "output_dir": "~/Videos/wl-screenrec",

View File

@@ -22,6 +22,7 @@ from .modules.bar import StatusBar
from .modules.window_fuzzy import FuzzyWindowFinder from .modules.window_fuzzy import FuzzyWindowFinder
from .modules.launcher.apps import AppLauncher from .modules.launcher.apps import AppLauncher
from .modules.launcher.clipboard import ClipboardMenu from .modules.launcher.clipboard import ClipboardMenu
from .modules.launcher.notmuch_search import NotmuchSearchMenu
from .modules.launcher.power import PowerMenu from .modules.launcher.power import PowerMenu
from .modules.launcher.screenrec import ScreenrecMenu from .modules.launcher.screenrec import ScreenrecMenu
from .modules.launcher.screenshot import ScreenshotMenu from .modules.launcher.screenshot import ScreenshotMenu
@@ -47,6 +48,7 @@ app_launcher = AppLauncher()
clipboard_menu = ClipboardMenu() clipboard_menu = ClipboardMenu()
power_menu = PowerMenu(lock_command=POWER.get("lock_command", ["waylock"])) power_menu = PowerMenu(lock_command=POWER.get("lock_command", ["waylock"]))
screenshot_menu = ScreenshotMenu() screenshot_menu = ScreenshotMenu()
notmuch_search_menu = NotmuchSearchMenu()
screenrec_service: ScreenrecService | None = None screenrec_service: ScreenrecService | None = None
screenrec_menu = None screenrec_menu = None
@@ -93,7 +95,7 @@ if notification_history is not None:
bar_windows = [] bar_windows = []
notmuch_widget = None 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: if screenrec_menu is not None:
_app_windows.append(screenrec_menu) _app_windows.append(screenrec_menu)
if notification_toasts is not None: if notification_toasts is not None:
@@ -131,7 +133,12 @@ def open_screenshot_menu():
@Application.action() @Application.action()
def refresh_notmuch(): def refresh_notmuch():
if notmuch_widget is not None: if notmuch_widget is not None:
notmuch_widget.service.update_unread_count() notmuch_widget.service.update_counts()
@Application.action()
def open_notmuch_search():
notmuch_search_menu.show()
@Application.action() @Application.action()

View File

@@ -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, h_expand=True)
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, h_expand=True)
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

View File

@@ -12,27 +12,33 @@ from loguru import logger
from sims.config import NOTMUCH from sims.config import NOTMUCH
DEFAULT_DEBT_QUERY = "tag:unread and date:..1w"
DEFAULT_DEBT_WARN_AT = 1
DEFAULT_DEBT_ALARM_AT = 6
class NotmuchService: class NotmuchService:
def __init__(self, update_interval=60000): # 1 minute default def __init__(self, update_interval=60000): # 1 minute default
self.unread_count = 0 self.unread_count = 0
self.debt_count = 0
self.callbacks = [] self.callbacks = []
self._update_interval = update_interval self._update_interval = update_interval
self._timer_id = None self._timer_id = None
# Initial load # Initial load
self.update_unread_count() self.update_counts()
# Start periodic updates # Start periodic updates
self.start_monitoring() self.start_monitoring()
def connect(self, signal_name, callback): def connect(self, signal_name, callback):
"""Simple callback system to replace signals""" """Simple callback system to replace signals"""
if signal_name == "unread-changed": if signal_name == "counts-changed":
self.callbacks.append(callback) self.callbacks.append(callback)
def emit_unread_changed(self, count): def emit_counts_changed(self):
"""Emit unread changed to all callbacks""" """Emit counts changed to all callbacks"""
for callback in self.callbacks: for callback in self.callbacks:
callback(self, count) callback(self, self.unread_count, self.debt_count)
def start_monitoring(self): def start_monitoring(self):
"""Start periodic unread count updates""" """Start periodic unread count updates"""
@@ -57,21 +63,38 @@ class NotmuchService:
def _periodic_update(self): def _periodic_update(self):
"""Periodic update callback""" """Periodic update callback"""
logger.info("[Notmuch] Performing periodic unread count update") logger.info("[Notmuch] Performing periodic count update")
self.update_unread_count() self.update_counts()
return True # Keep the timer running return True # Keep the timer running
def get_cached_count(self): def get_cached_count(self):
"""Get cached unread count without triggering update""" """Get cached unread count without triggering update"""
return self.unread_count return self.unread_count
def update_unread_count(self): def get_cached_debt_count(self):
"""Fetch unread email count from notmuch""" """Get cached debt count without triggering update"""
return self.debt_count
def _run_count(self, notmuch_path, query):
cmd = [notmuch_path, "count", query]
logger.info(f"[Notmuch] Running command: {' '.join(cmd)}")
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=True,
)
out = result.stdout.strip()
return int(out) if out else 0
def update_counts(self):
"""Fetch unread + debt counts from notmuch"""
# Check if notmuch is enabled # Check if notmuch is enabled
if not NOTMUCH.get("enable", True): if not NOTMUCH.get("enable", True):
logger.info("[Notmuch] Notmuch is disabled in config") logger.info("[Notmuch] Notmuch is disabled in config")
self.unread_count = 0 self.unread_count = 0
self.emit_unread_changed(self.unread_count) self.debt_count = 0
self.emit_counts_changed()
return return
# Get notmuch path from config # Get notmuch path from config
@@ -81,42 +104,34 @@ class NotmuchService:
if not shutil.which(notmuch_path): if not shutil.which(notmuch_path):
logger.warning(f"[Notmuch] notmuch not found at '{notmuch_path}'. Please install notmuch or configure the correct path.") logger.warning(f"[Notmuch] notmuch not found at '{notmuch_path}'. Please install notmuch or configure the correct path.")
self.unread_count = 0 self.unread_count = 0
self.emit_unread_changed(self.unread_count) self.debt_count = 0
self.emit_counts_changed()
return return
debt_query = NOTMUCH.get("debt_query", DEFAULT_DEBT_QUERY)
try: try:
# Get unread email count self.unread_count = self._run_count(notmuch_path, "tag:unread")
cmd = [notmuch_path, "count", "tag:unread"] self.debt_count = self._run_count(notmuch_path, debt_query)
logger.info(f"[Notmuch] Running command: {' '.join(cmd)}") logger.info(
result = subprocess.run( f"[Notmuch] {self.unread_count} unread, {self.debt_count} aging (debt query: {debt_query!r})"
cmd,
capture_output=True,
text=True,
check=True,
) )
logger.info(f"[Notmuch] Command stdout: '{result.stdout.strip()}'") self.emit_counts_changed()
logger.info(f"[Notmuch] Command stderr: '{result.stderr.strip()}'")
if result.stdout.strip():
self.unread_count = int(result.stdout.strip())
logger.info(f"[Notmuch] Found {self.unread_count} unread emails")
self.emit_unread_changed(self.unread_count)
else:
self.unread_count = 0
self.emit_unread_changed(self.unread_count)
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
logger.error(f"[Notmuch] Failed to fetch unread count: {e}") logger.error(f"[Notmuch] Failed to fetch counts: {e}")
self.unread_count = 0 self.unread_count = 0
self.emit_unread_changed(self.unread_count) self.debt_count = 0
self.emit_counts_changed()
except ValueError as e: except ValueError as e:
logger.error(f"[Notmuch] Error parsing unread count: {e}") logger.error(f"[Notmuch] Error parsing count: {e}")
self.unread_count = 0 self.unread_count = 0
self.emit_unread_changed(self.unread_count) self.debt_count = 0
self.emit_counts_changed()
except Exception as e: except Exception as e:
logger.error(f"[Notmuch] Error getting unread count: {e}") logger.error(f"[Notmuch] Error getting counts: {e}")
self.unread_count = 0 self.unread_count = 0
self.emit_unread_changed(self.unread_count) self.debt_count = 0
self.emit_counts_changed()
class NotmuchWidget(Button): class NotmuchWidget(Button):
@@ -141,12 +156,14 @@ class NotmuchWidget(Button):
# Initialize the service # Initialize the service
self.service = NotmuchService() self.service = NotmuchService()
self.service.connect("unread-changed", self.update_display) self.service.connect("counts-changed", self.update_display)
logger.info("[Notmuch] Notmuch widget initialized") logger.info("[Notmuch] Notmuch widget initialized")
# Initial update # Initial update
self.update_display(self.service, self.service.unread_count) self.update_display(
self.service, self.service.unread_count, self.service.debt_count
)
def open_email_client(self, button=None): def open_email_client(self, button=None):
"""Open notmuch in emacsclient""" """Open notmuch in emacsclient"""
@@ -161,18 +178,31 @@ class NotmuchWidget(Button):
except Exception as e: except Exception as e:
logger.error(f"[Notmuch] Failed to open notmuch in emacsclient '{emacsclient_command}': {e}") logger.error(f"[Notmuch] Failed to open notmuch in emacsclient '{emacsclient_command}': {e}")
def update_display(self, service, count): def update_display(self, service, unread, debt):
"""Update the widget display with unread count""" """Update the widget display with unread + debt counts"""
# Only show count if there are unread emails warn_at = NOTMUCH.get("debt_warn_at", DEFAULT_DEBT_WARN_AT)
if count > 0: alarm_at = NOTMUCH.get("debt_alarm_at", DEFAULT_DEBT_ALARM_AT)
self.label.set_text(str(count))
classes = ["notmuch-widget"]
if unread > 0:
self.label.set_text(str(unread))
self.label.set_visible(True) self.label.set_visible(True)
self.icon.set_property("icon-name", "mail-unread-symbolic") self.icon.set_property("icon-name", "mail-unread-symbolic")
self.set_style_classes(["notmuch-widget", "has-unread"]) classes.append("has-unread")
else: else:
self.label.set_text("") self.label.set_text("")
self.label.set_visible(False) self.label.set_visible(False)
self.icon.set_property("icon-name", "mail-read-symbolic") self.icon.set_property("icon-name", "mail-read-symbolic")
self.set_style_classes(["notmuch-widget", "no-unread"]) classes.append("no-unread")
logger.info(f"[Notmuch] Updated display: {count} unread emails") if debt >= alarm_at:
classes.append("debt-alarm")
elif debt >= warn_at:
classes.append("debt-warn")
self.set_style_classes(classes)
self.set_tooltip_text(f"{unread} unread · {debt} aging")
logger.info(
f"[Notmuch] Updated display: {unread} unread, {debt} aging — classes={classes}"
)

View File

@@ -55,3 +55,38 @@
opacity: 0.6; 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;
}
#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;
}

View File

@@ -23,6 +23,22 @@
background-color: var(--module-bg); background-color: var(--module-bg);
} }
#notmuch-widget.debt-warn {
background-color: var(--orange);
}
#notmuch-widget.debt-warn:hover {
background-color: var(--gold);
}
#notmuch-widget.debt-alarm {
background-color: var(--red);
}
#notmuch-widget.debt-alarm:hover {
background-color: var(--pink);
}
#unread-count { #unread-count {
color: var(--foreground); color: var(--foreground);
font-size: 14px; font-size: 14px;
@@ -30,6 +46,8 @@
min-width: 16px; min-width: 16px;
} }
#notmuch-widget.has-unread #unread-count { #notmuch-widget.has-unread #unread-count,
#notmuch-widget.debt-warn #unread-count,
#notmuch-widget.debt-alarm #unread-count {
color: var(--background); color: var(--background);
} }