Compare commits
2 Commits
4adace6c4c
...
5b7398de2c
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b7398de2c | |||
| cfdcf0c039 |
35
flake.nix
35
flake.nix
@@ -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;
|
||||||
|
|||||||
17
sims/cli.py
17
sims/cli.py
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
11
sims/main.py
11
sims/main.py
@@ -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()
|
||||||
|
|||||||
284
sims/modules/launcher/notmuch_search.py
Normal file
284
sims/modules/launcher/notmuch_search.py
Normal 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)
|
||||||
|
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
|
||||||
@@ -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}"
|
||||||
|
)
|
||||||
@@ -55,3 +55,39 @@
|
|||||||
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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user