Compare commits

4 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
4adace6c4c feat: subscribe to all players 2026-05-05 23:58:15 +02:00
cc3bac5ce7 feat: player animations 2026-05-05 23:47:47 +02:00
11 changed files with 644 additions and 84 deletions

View File

@@ -131,6 +131,37 @@
default = "emacsclient";
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 = {
enable = lib.mkOption {
@@ -210,6 +241,10 @@
enable = true;
notmuch_path = "notmuch";
emacsclient_command = "emacsclient";
debt_query = "tag:unread and date:..1w";
debt_warn_at = 1;
debt_alarm_at = 6;
saved_searches = [ ];
};
screenrec = {
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])
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"

View File

@@ -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",

View File

@@ -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:
@@ -131,7 +133,12 @@ def open_screenshot_menu():
@Application.action()
def refresh_notmuch():
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()

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

@@ -1,8 +1,9 @@
import contextlib
import os
import urllib.parse
import urllib.request
import tempfile
from gi.repository import Gtk, GLib, Gio, Gdk
from gi.repository import Gtk, GLib, Gio, Gdk, Pango
from fabric.widgets.box import Box
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.label import Label
@@ -578,6 +579,9 @@ class PlayerSmall(Box):
max_chars_width=26,
h_align="center",
)
self._width_tween_id = None
self._current_label_width_px = None
self._width_tween_duration_ms = 220
self.play_image = Image(
name="compact-mpris-button-icon",
@@ -621,18 +625,98 @@ class PlayerSmall(Box):
self.mpris_manager = MprisPlayerManager()
self.mpris_player = None
self.current_index = 0
self._players = {}
self._player_handlers = {}
self._last_status = {}
players = self.mpris_manager.players
if players:
mp = MprisPlayer(players[self.current_index])
self.mpris_player = mp
self.mpris_player.connect("changed", self._on_mpris_changed)
for p in self.mpris_manager.players:
self._track_player(p)
self._select_initial_player()
self._apply_mpris_properties()
self.mpris_manager.connect("player-appeared", self.on_player_appeared)
self.mpris_manager.connect("player-vanished", self.on_player_vanished)
def _char_width_px(self):
metrics = self.mpris_label.get_pango_context().get_metrics(None, None)
return max(1.0, metrics.get_approximate_char_width() / Pango.SCALE)
def _measure_text_width(self, text):
if not text:
return 0
layout = self.mpris_label.create_pango_layout(text)
text_width_px, _ = layout.get_pixel_size()
max_px = int(self._char_width_px() * 26)
return min(text_width_px, max_px)
def _apply_label_width(self, width_px):
if width_px <= 0:
self.mpris_label.set_size_request(0, -1)
if self.mpris_label.get_margin_start() != 0:
self.mpris_label.set_margin_start(0)
self.mpris_label.set_margin_end(0)
if self.mpris_label.get_visible():
self.mpris_label.set_visible(False)
self._current_label_width_px = 0
return
if not self.mpris_label.get_visible():
self.mpris_label.set_visible(True)
chars = max(1, int(round(width_px / self._char_width_px())))
if chars != self.mpris_label.get_max_width_chars():
self.mpris_label.set_max_width_chars(chars)
self.mpris_label.set_size_request(width_px, -1)
margin = min(6, width_px // 2)
if self.mpris_label.get_margin_start() != margin:
self.mpris_label.set_margin_start(margin)
self.mpris_label.set_margin_end(margin)
self._current_label_width_px = width_px
def _set_label_text(self, text):
target_text = text or ""
target_px = self._measure_text_width(target_text)
if self._width_tween_id is not None:
GLib.source_remove(self._width_tween_id)
self._width_tween_id = None
# First call — snap to target without animation.
if self._current_label_width_px is None:
self.mpris_label.set_text(target_text)
self._apply_label_width(target_px)
return
start_px = self._current_label_width_px
# Growing from collapsed: set new text first so it's ready to reveal.
# Cross-fading between two non-empty texts: also swap text immediately.
# Shrinking to empty: keep old text visible while it shrinks, clear at end.
if target_px > 0:
self.mpris_label.set_text(target_text)
if start_px == target_px:
self._apply_label_width(target_px)
return
duration_ms = self._width_tween_duration_ms
start_time = GLib.get_monotonic_time()
def tick():
elapsed_ms = (GLib.get_monotonic_time() - start_time) / 1000.0
progress = min(1.0, elapsed_ms / duration_ms)
t = 1 - (1 - progress) ** 3 # ease-out cubic
cur_px = int(start_px + (target_px - start_px) * t)
self._apply_label_width(cur_px)
if progress >= 1.0:
self._apply_label_width(target_px)
if target_px == 0:
self.mpris_label.set_text("")
self._width_tween_id = None
return False
return True
self._width_tween_id = GLib.timeout_add(16, tick)
def _set_cover_from_path(self, image_path):
if image_path and os.path.isfile(image_path):
try:
@@ -701,7 +785,7 @@ class PlayerSmall(Box):
def _apply_mpris_properties(self):
if not self.mpris_player:
self.mpris_label.set_text("Nothing Playing")
self._set_label_text("")
self.play_image.set_property("icon-name", self.PLAY_ICON)
self.cover_stack.set_visible_child(self.fallback_icon)
self.center_stack.set_visible_child(self.mpris_label)
@@ -713,34 +797,37 @@ class PlayerSmall(Box):
self.update_play_pause_icon()
if self._current_display == "title":
text = mp.title if mp.title and mp.title.strip() else "Nothing Playing"
text = mp.title.strip() if mp.title and mp.title.strip() else ""
else: # "artist"
text = mp.artist if mp.artist and mp.artist.strip() else "Nothing Playing"
self.mpris_label.set_text(text)
text = mp.artist.strip() if mp.artist and mp.artist.strip() else ""
self._set_label_text(text)
self.center_stack.set_visible_child(self.mpris_label)
def _on_icon_button_press(self, widget, event):
if event.type != Gdk.EventType.BUTTON_PRESS:
return True
players = self.mpris_manager.players
if not players:
return True
if event.button == 2:
if not self.mpris_player:
return True
self._display_index = (self._display_index + 1) % len(self._display_options)
self._current_display = self._display_options[self._display_index]
self._apply_mpris_properties()
return True
players = list(self._players.values())
if not players:
return True
idx = players.index(self.mpris_player) if self.mpris_player in players else -1
if event.button == 1:
self.current_index = (self.current_index + 1) % len(players)
idx = (idx + 1) % len(players)
elif event.button == 3:
self.current_index = (self.current_index - 1) % len(players)
idx = (idx - 1) % len(players)
else:
return True
self.mpris_player = MprisPlayer(players[self.current_index])
self.mpris_player.connect("changed", self._on_mpris_changed)
self.mpris_player = players[idx]
self._apply_mpris_properties()
return True
@@ -774,24 +861,71 @@ class PlayerSmall(Box):
)
self._update_spin()
def _on_mpris_changed(self, *args):
self._apply_mpris_properties()
def _track_player(self, playerctl_player):
mp = MprisPlayer(playerctl_player)
name = mp.player_name
handler_id = mp.connect("changed", self._on_any_player_changed)
self._players[name] = mp
self._player_handlers[name] = handler_id
self._last_status[name] = mp.playback_status
def _untrack_player(self, name):
mp = self._players.pop(name, None)
handler_id = self._player_handlers.pop(name, None)
self._last_status.pop(name, None)
if mp and handler_id is not None:
with contextlib.suppress(Exception):
mp.disconnect(handler_id)
def _select_initial_player(self):
for mp in self._players.values():
if mp.playback_status == "playing":
self.mpris_player = mp
return
if self._players:
self.mpris_player = next(iter(self._players.values()))
def _on_any_player_changed(self, player):
name = player.player_name
prev_status = self._last_status.get(name)
cur_status = player.playback_status
self._last_status[name] = cur_status
if player is self.mpris_player:
self._apply_mpris_properties()
return
# Auto-follow: only on a fresh transition into "playing", and only
# if the active player isn't already playing (so a manual selection
# of a paused player isn't overridden by the player it was already
# competing with).
if cur_status == "playing" and prev_status != "playing":
active_playing = (
self.mpris_player
and self.mpris_player.playback_status == "playing"
)
if not active_playing:
self.mpris_player = player
self._apply_mpris_properties()
def on_player_appeared(self, manager, player):
self._track_player(player)
if not self.mpris_player:
self.mpris_player = MprisPlayer(player)
self.mpris_player.connect("changed", self._on_mpris_changed)
name = player.get_property("player-name")
self.mpris_player = self._players.get(name)
self._apply_mpris_properties()
def on_player_vanished(self, manager, player_name):
players = self.mpris_manager.players
if self.mpris_player and self.mpris_player.player_name == player_name:
if players:
self.current_index = self.current_index % len(players)
self.mpris_player = MprisPlayer(players[self.current_index])
self.mpris_player.connect("changed", self._on_mpris_changed)
else:
self.mpris_player = None
elif not players:
self.mpris_player = None
self._apply_mpris_properties()
was_active = (
self.mpris_player and self.mpris_player.player_name == player_name
)
self._untrack_player(player_name)
if was_active:
replacement = next(
(mp for mp in self._players.values() if mp.playback_status == "playing"),
None,
)
if replacement is None and self._players:
replacement = next(iter(self._players.values()))
self.mpris_player = replacement
self._apply_mpris_properties()

View File

@@ -95,7 +95,6 @@
#compact-mpris-label {
color: var(--foreground);
font-size: 13px;
margin: 0 6px;
}
#nixos-label {

View File

@@ -55,3 +55,38 @@
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);
}
#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 {
color: var(--foreground);
font-size: 14px;
@@ -30,6 +46,8 @@
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);
}