feat: org todos in bar

This commit is contained in:
2026-05-10 01:55:21 +02:00
parent a8d96b7481
commit 07919fc687
11 changed files with 958 additions and 235 deletions

View File

@@ -18,6 +18,17 @@ notmuch:
screenrec:
enable: true
output_dir: "~/Videos/wl-screenrec"
org:
enable: true
paths:
- "~/Documents/notes"
todo_keywords: ["TODO", "NEXT", "IN-PROGRESS"]
done_keywords: ["DONE", "CANCELLED"]
counted_states: ["TODO", "NEXT", "IN-PROGRESS"]
update_interval: 60000
emacsclient_command: "emacsclient"
dropdown_width: 420
dropdown_height: 480
notifications:
enable: true
anchor: "top center"

558
flake.nix
View File

@@ -45,258 +45,350 @@
// {
homeManagerModules = {
sims =
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.sims;
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.sims;
settingsFormat = pkgs.formats.yaml { };
in
{
options.services.sims = {
enable = lib.mkEnableOption "sims status bar";
settingsFormat = pkgs.formats.yaml { };
in
{
options.services.sims = {
enable = lib.mkEnableOption "sims status bar";
package = lib.mkOption {
type = lib.types.package;
default = self.packages.${pkgs.system}.default;
description = "The sims package to use.";
};
package = lib.mkOption {
type = lib.types.package;
default = self.packages.${pkgs.system}.default;
description = "The sims package to use.";
};
settings = lib.mkOption {
type = lib.types.submodule {
options = {
vinyl = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
settings = lib.mkOption {
type = lib.types.submodule {
options = {
vinyl = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
};
};
battery = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
};
};
buddy = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to enable the bar buddy (animated pixel-art slime that reacts to system state)";
};
};
height = lib.mkOption {
type = lib.types.int;
default = 40;
description = "Height of the status bar in pixels";
};
logLevel = lib.mkOption {
type = lib.types.enum [
"TRACE"
"DEBUG"
"INFO"
"SUCCESS"
"WARNING"
"ERROR"
"CRITICAL"
];
default = "WARNING";
description = "Log level for the status bar (loguru levels: TRACE, DEBUG, INFO, SUCCESS, WARNING, ERROR, CRITICAL)";
};
window_title = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to show the window title in the center of the bar";
};
};
stylix = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
default = {
enable = false;
};
description = "Stylix configuration passed from the stylix module";
};
calendar = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to enable the calendar widget";
};
khal_path = lib.mkOption {
type = lib.types.str;
default = "khal";
description = "Path to the khal binary";
};
};
notmuch = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to enable the notmuch email widget";
};
notmuch_path = lib.mkOption {
type = lib.types.str;
default = "notmuch";
description = "Path to the notmuch binary";
};
emacsclient_command = lib.mkOption {
type = lib.types.str;
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 {
type = lib.types.bool;
default = false;
description = "Whether to enable the screenrec widget and menu";
};
output_dir = lib.mkOption {
type = lib.types.str;
default = "~/Videos/wl-screenrec";
description = "Directory to save recordings into";
};
};
power = {
lock_command = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "waylock" ];
description = "argv for the Lock action in the power menu";
};
};
org = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to enable the org-mode agenda widget";
};
paths = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "Org files / directories / globs to scan (~ is expanded)";
};
todo_keywords = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [
"TODO"
"NEXT"
"IN-PROGRESS"
];
description = "Active TODO keywords used by orgparse when no #+TODO: header is present";
};
done_keywords = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [
"DONE"
"CANCELLED"
];
description = "Done keywords (filtered out of the count)";
};
counted_states = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [
"TODO"
"NEXT"
"IN-PROGRESS"
];
description = "States summed for the bar badge count";
};
update_interval = lib.mkOption {
type = lib.types.int;
default = 60000;
description = "Polling interval in milliseconds (mtime-checked, only re-parses on change)";
};
emacsclient_command = lib.mkOption {
type = lib.types.str;
default = "emacsclient";
description = "Command used to open headlines (split on whitespace)";
};
dropdown_width = lib.mkOption {
type = lib.types.int;
default = 420;
description = "Dropdown width in pixels";
};
dropdown_height = lib.mkOption {
type = lib.types.int;
default = 480;
description = "Dropdown height in pixels";
};
};
notifications = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to enable the notification toast service. Owns org.freedesktop.Notifications, so other notification daemons (mako, dunst, swaync) must be disabled.";
};
anchor = lib.mkOption {
type = lib.types.str;
default = "top center";
description = "Layer-shell anchor for the toast stack";
};
margin = lib.mkOption {
type = lib.types.str;
default = "8px";
description = "Layer-shell margin for the toast stack";
};
width = lib.mkOption {
type = lib.types.int;
default = 360;
description = "Width of each notification toast in pixels";
};
timeout_ms = lib.mkOption {
type = lib.types.int;
default = 10000;
description = "Auto-close timeout for notifications in milliseconds";
};
history_size = lib.mkOption {
type = lib.types.int;
default = 50;
description = "How many past notifications the in-memory center keeps";
};
image_max_px = lib.mkOption {
type = lib.types.int;
default = 128;
description = "Max edge in pixels for stored notification thumbnails";
};
center_width = lib.mkOption {
type = lib.types.int;
default = 380;
description = "Width of the notification center side rail in pixels";
};
};
};
battery = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
};
};
buddy = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to enable the bar buddy (animated pixel-art slime that reacts to system state)";
};
};
height = lib.mkOption {
type = lib.types.int;
default = 40;
description = "Height of the status bar in pixels";
};
logLevel = lib.mkOption {
type = lib.types.enum [ "TRACE" "DEBUG" "INFO" "SUCCESS" "WARNING" "ERROR" "CRITICAL" ];
default = "WARNING";
description = "Log level for the status bar (loguru levels: TRACE, DEBUG, INFO, SUCCESS, WARNING, ERROR, CRITICAL)";
};
window_title = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to show the window title in the center of the bar";
};
};
stylix = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
default = { enable = false; };
description = "Stylix configuration passed from the stylix module";
};
};
default = {
vinyl.enable = false;
battery.enable = false;
buddy.enable = false;
height = 40;
logLevel = "WARNING";
window_title.enable = true;
stylix.enable = false;
calendar = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to enable the calendar widget";
};
khal_path = lib.mkOption {
type = lib.types.str;
default = "khal";
description = "Path to the khal binary";
};
enable = true;
khal_path = "khal";
};
notmuch = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to enable the notmuch email widget";
};
notmuch_path = lib.mkOption {
type = lib.types.str;
default = "notmuch";
description = "Path to the notmuch binary";
};
emacsclient_command = lib.mkOption {
type = lib.types.str;
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";
};
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 = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to enable the screenrec widget and menu";
};
output_dir = lib.mkOption {
type = lib.types.str;
default = "~/Videos/wl-screenrec";
description = "Directory to save recordings into";
};
enable = false;
output_dir = "~/Videos/wl-screenrec";
};
power = {
lock_command = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "waylock" ];
description = "argv for the Lock action in the power menu";
};
lock_command = [ "waylock" ];
};
org = {
enable = false;
paths = [ ];
todo_keywords = [
"TODO"
"NEXT"
"IN-PROGRESS"
];
done_keywords = [
"DONE"
"CANCELLED"
];
counted_states = [
"TODO"
"NEXT"
"IN-PROGRESS"
];
update_interval = 60000;
emacsclient_command = "emacsclient";
dropdown_width = 420;
dropdown_height = 480;
};
notifications = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to enable the notification toast service. Owns org.freedesktop.Notifications, so other notification daemons (mako, dunst, swaync) must be disabled.";
};
anchor = lib.mkOption {
type = lib.types.str;
default = "top center";
description = "Layer-shell anchor for the toast stack";
};
margin = lib.mkOption {
type = lib.types.str;
default = "8px";
description = "Layer-shell margin for the toast stack";
};
width = lib.mkOption {
type = lib.types.int;
default = 360;
description = "Width of each notification toast in pixels";
};
timeout_ms = lib.mkOption {
type = lib.types.int;
default = 10000;
description = "Auto-close timeout for notifications in milliseconds";
};
history_size = lib.mkOption {
type = lib.types.int;
default = 50;
description = "How many past notifications the in-memory center keeps";
};
image_max_px = lib.mkOption {
type = lib.types.int;
default = 128;
description = "Max edge in pixels for stored notification thumbnails";
};
center_width = lib.mkOption {
type = lib.types.int;
default = 380;
description = "Width of the notification center side rail in pixels";
};
enable = false;
anchor = "top center";
margin = "8px";
width = 360;
timeout_ms = 10000;
history_size = 50;
image_max_px = 128;
center_width = 380;
};
};
};
default = {
vinyl.enable = false;
battery.enable = false;
buddy.enable = false;
height = 40;
logLevel = "WARNING";
window_title.enable = true;
stylix.enable = false;
calendar = {
enable = true;
khal_path = "khal";
};
notmuch = {
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;
output_dir = "~/Videos/wl-screenrec";
};
power = {
lock_command = [ "waylock" ];
};
notifications = {
enable = false;
anchor = "top center";
margin = "8px";
width = 360;
timeout_ms = 10000;
history_size = 50;
image_max_px = 128;
center_width = 380;
};
};
};
config = lib.mkIf config.services.sims.enable {
systemd.user.services.sims =
let
configFile = settingsFormat.generate "config.yaml" cfg.settings;
in
{
Unit = {
Description = "sims status bar";
After = [ "graphical-session.target" ];
};
Service = {
ExecStart = "${config.services.sims.package}/bin/sims --config ${configFile}";
Restart = "on-failure";
};
Install = {
WantedBy = [ "default.target" ];
};
};
};
};
config = lib.mkIf config.services.sims.enable {
systemd.user.services.sims =
let
configFile = settingsFormat.generate "config.yaml" cfg.settings;
in
{
Unit = {
Description = "sims status bar";
After = [ "graphical-session.target" ];
};
Service = {
ExecStart = "${config.services.sims.package}/bin/sims --config ${configFile}";
Restart = "on-failure";
};
Install = {
WantedBy = [ "default.target" ];
};
};
};
};
stylix-sims = import ./nix/stylix/hm.nix;
};
};

View File

@@ -50,6 +50,7 @@ python3Packages.buildPythonApplication {
pywayland
pyyaml
platformdirs
orgparse
];
doCheck = false;
dontWrapGApps = true;

View File

@@ -44,6 +44,7 @@ pkgs.mkShell {
python-lsp-ruff
pyyaml
platformdirs
orgparse
]
))
];

View File

@@ -77,3 +77,12 @@ BAR_HEIGHT = app_config.get("height", 40)
LOG_LEVEL = app_config.get("logLevel", "WARNING")
DEV = app_config.get("dev", False)
BUDDY = app_config.get("buddy", {"enable": False})
ORG = app_config.get("org", {"enable": False})
ORG.setdefault("paths", [])
ORG.setdefault("todo_keywords", ["TODO", "NEXT", "IN-PROGRESS"])
ORG.setdefault("done_keywords", ["DONE", "CANCELLED"])
ORG.setdefault("counted_states", ["TODO", "NEXT", "IN-PROGRESS"])
ORG.setdefault("update_interval", 60_000)
ORG.setdefault("emacsclient_command", "emacsclient")
ORG.setdefault("dropdown_width", 420)
ORG.setdefault("dropdown_height", 480)

View File

@@ -29,9 +29,11 @@ from .modules.launcher.screenshot import ScreenshotMenu
from .modules.calendar import CalendarService
from .modules.control_center import ControlCenter
from .modules.notifications import NotificationToasts
from .modules.org import OrgDropdown
from .modules.stylix import get_stylix_css_path
from .modules.vinyl import VinylButton
from .config import CALENDAR, NOTIFICATIONS, POWER, SCREENREC, STYLIX, VINYL
from .services.org import OrgService
from .config import CALENDAR, NOTIFICATIONS, ORG, POWER, SCREENREC, STYLIX, VINYL
from .services.fenster import get_i3_connection
from .services.notification_history import NotificationHistoryService
from .services.screenrec import ScreenrecService
@@ -92,6 +94,14 @@ if notification_history is not None:
width=NOTIFICATIONS.get("center_width", 380),
)
org_service: OrgService | None = None
org_dropdown: OrgDropdown | None = None
if ORG.get("enable", False):
org_service = OrgService(
update_interval=int(ORG.get("update_interval", 60_000)),
)
org_dropdown = OrgDropdown(service=org_service, monitor=0)
bar_windows = []
notmuch_widget = None
@@ -102,6 +112,8 @@ if notification_toasts is not None:
_app_windows.append(notification_toasts)
if control_center is not None:
_app_windows.append(control_center)
if org_dropdown is not None:
_app_windows.append(org_dropdown)
app = Application("sims", *_app_windows)
@@ -229,6 +241,8 @@ def spawn_bars():
monitor=i,
screenrec_service=screenrec_service if i == 0 else None,
control_center=control_center if i == 0 else None,
org_service=org_service,
org_dropdown=org_dropdown if i == 0 else None,
)
bar_windows.append(bar)
if i == 0 and bar.notmuch:

View File

@@ -9,17 +9,19 @@ from sims.modules.battery import Battery
from sims.modules.control_center import ControlCenter
from sims.modules.notmuch import NotmuchWidget
from sims.modules.buddy import Buddy
from sims.modules.org import OrgDropdown, OrgWidget
from sims.modules.screenrec import ScreenrecWidget
from fabric.widgets.wayland import WaylandWindow as Window
from fabric.system_tray.widgets import SystemTray
from sims.widgets.fenster import FensterWorkspaces, FensterActiveWindow
from sims.services.org import OrgService
from sims.services.screenrec import ScreenrecService
from sims.services.smart_corners import get_smart_corners_service
from fabric.widgets.button import Button
from fabric.widgets.circularprogressbar import CircularProgressBar
from sims.services.system_stats import SystemStatsService
from sims.config import BATTERY, BAR_HEIGHT, WINDOW_TITLE, NOTMUCH, BUDDY
from sims.config import BATTERY, BAR_HEIGHT, WINDOW_TITLE, NOTMUCH, BUDDY, ORG
class StatusBar(Window):
@@ -30,6 +32,8 @@ class StatusBar(Window):
monitor: int = 1,
screenrec_service: ScreenrecService | None = None,
control_center: ControlCenter | None = None,
org_service: OrgService | None = None,
org_dropdown: OrgDropdown | None = None,
):
super().__init__(
name="sims",
@@ -102,6 +106,10 @@ class StatusBar(Window):
if screenrec_service is not None:
self.screenrec = ScreenrecWidget(screenrec_service)
self.org = None
if org_service is not None:
self.org = OrgWidget(service=org_service, dropdown=org_dropdown)
self.status_container = Box(
name="widgets-container",
spacing=4,
@@ -111,6 +119,9 @@ class StatusBar(Window):
end_container_children = []
if self.org is not None:
end_container_children.append(self.org)
end_container_children.append(self.status_container)
if self.system_tray:
end_container_children.append(self.system_tray)

283
sims/modules/org.py Normal file
View File

@@ -0,0 +1,283 @@
"""Org-agenda bar widget + dropdown popup.
Bar slot is a small button showing an icon + count of open items. Clicking
toggles a layer-shell dropdown listing items grouped by state. Clicking an
item opens emacsclient at the headline's line.
"""
from __future__ import annotations
import os
import subprocess
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from fabric.widgets.scrolledwindow import ScrolledWindow
from fabric.widgets.wayland import WaylandWindow as Window
from gi.repository import Gdk
from loguru import logger
from sims.config import BAR_HEIGHT, ORG
from sims.services.org import OrgItem, OrgService, OrgSnapshot
# Order in which states are shown / counted. Anything else discovered at parse
# time is appended after these.
DEFAULT_STATE_ORDER = ["NEXT", "IN-PROGRESS", "TODO"]
def _state_order(states: list[str]) -> list[str]:
seen = set()
ordered: list[str] = []
for s in DEFAULT_STATE_ORDER:
if s in states and s not in seen:
ordered.append(s)
seen.add(s)
for s in states:
if s not in seen:
ordered.append(s)
seen.add(s)
return ordered
def _open_in_emacs(item: OrgItem) -> None:
cmd_template = ORG.get("emacsclient_command", "emacsclient")
parts = cmd_template.split() if isinstance(cmd_template, str) else list(cmd_template)
cmd = [*parts, "-c", f"+{item.line}", item.file]
try:
subprocess.Popen(cmd, start_new_session=True)
logger.info(f"[Org] Opened {item.file}:{item.line} in emacsclient")
except Exception as e:
logger.error(f"[Org] Failed to open emacsclient: {e}")
class OrgDropdown(Window):
def __init__(self, service: OrgService, monitor: int = 0):
width = int(ORG.get("dropdown_width", 420))
super().__init__(
name="org-dropdown",
layer="top",
anchor="top",
margin=f"{BAR_HEIGHT}px 0 0 0",
keyboard_mode="on-demand",
exclusivity="none",
visible=False,
monitor=monitor,
)
self._service = service
self._width = width
self._header = Label(
name="org-dropdown-title",
label="Org Agenda",
h_align="start",
)
close_button = Button(
name="org-dropdown-close",
image=Image(icon_name="window-close-symbolic", icon_size=16),
on_clicked=lambda *_: self.hide(),
)
header_row = Box(name="org-dropdown-header", orientation="h", spacing=8)
header_row.pack_start(self._header, True, True, 0)
header_row.pack_end(close_button, False, False, 0)
self._sections_box = Box(
name="org-dropdown-sections",
orientation="v",
spacing=10,
h_expand=True,
)
scroll = ScrolledWindow(
name="org-dropdown-scroll",
h_scrollbar_policy="never",
v_scrollbar_policy="automatic",
child=self._sections_box,
h_expand=True,
v_expand=True,
)
body = Box(
name="org-dropdown-body",
orientation="v",
spacing=8,
children=[header_row, scroll],
h_expand=True,
v_expand=True,
)
body.set_size_request(self._width, int(ORG.get("dropdown_height", 480)))
self.add(body)
self.connect("key-press-event", self._on_key_press)
self._service.connect("items-changed", lambda _s, snap: self._refresh(snap))
self._refresh(self._service.snapshot)
def toggle(self) -> None:
if self.get_visible():
self.hide()
else:
self.show()
def show(self) -> None: # type: ignore[override]
super().show()
self.show_all()
def _on_key_press(self, _w, event):
if event.keyval == Gdk.KEY_Escape:
self.hide()
return True
return False
def _refresh(self, snapshot: OrgSnapshot) -> None:
for child in self._sections_box.get_children():
self._sections_box.remove(child)
child.destroy()
if not snapshot.items:
self._sections_box.add(
Label(
name="org-dropdown-empty",
label="Nothing on the agenda.",
h_align="start",
)
)
self._sections_box.show_all()
return
by_state: dict[str, list[OrgItem]] = {}
for it in snapshot.items:
by_state.setdefault(it.state, []).append(it)
for state in _state_order(list(by_state.keys())):
items = by_state.get(state, [])
if not items:
continue
section = self._build_section(state, items)
self._sections_box.add(section)
self._sections_box.show_all()
def _build_section(self, state: str, items: list[OrgItem]) -> Box:
section = Box(
name="org-dropdown-section",
orientation="v",
spacing=4,
style_classes=["org-state", f"org-state-{state.lower()}"],
)
header = Box(orientation="h", spacing=6)
header.add(
Label(
name="org-dropdown-section-title",
label=state,
h_align="start",
style_classes=["org-state-label"],
)
)
header.add(
Label(
label=f"({len(items)})",
h_align="start",
style_classes=["org-state-count"],
)
)
section.add(header)
for item in items:
section.add(self._build_item(item))
return section
def _build_item(self, item: OrgItem) -> Button:
title_parts = []
if item.priority:
title_parts.append(f"#{item.priority}")
title_parts.append(item.headline)
if item.tags:
title_parts.append(":" + ":".join(item.tags) + ":")
title = " ".join(title_parts)
title_label = Label(
label=title,
h_align="start",
ellipsization="end",
style_classes=["org-item-title"],
)
meta_bits = []
if item.scheduled:
meta_bits.append(f"S: {item.scheduled}")
if item.deadline:
meta_bits.append(f"D: {item.deadline}")
meta_bits.append(os.path.basename(item.file))
meta_label = Label(
label=" · ".join(meta_bits),
h_align="start",
style_classes=["org-item-meta"],
)
content = Box(orientation="v", spacing=2, h_expand=True)
content.add(title_label)
content.add(meta_label)
button = Button(
name="org-item",
child=content,
on_clicked=lambda *_: self._on_item_clicked(item),
style_classes=["org-item"],
)
return button
def _on_item_clicked(self, item: OrgItem) -> None:
_open_in_emacs(item)
self.hide()
class OrgWidget(Button):
def __init__(self, service: OrgService, dropdown: OrgDropdown | None, **kwargs):
self._service = service
self._dropdown = dropdown
self.icon = Label(label="", name="org-icon")
self.label = Label("0", name="org-count")
container = Box(orientation="h", spacing=4, children=[self.icon, self.label])
super().__init__(
name="org-widget",
child=container,
on_clicked=lambda *_: self._on_clicked(),
**kwargs,
)
self._service.connect("items-changed", lambda _s, snap: self._refresh(snap))
self._refresh(self._service.snapshot)
def _on_clicked(self) -> None:
if self._dropdown is not None:
self._dropdown.toggle()
def _refresh(self, snapshot: OrgSnapshot) -> None:
counted = list(ORG.get("counted_states", ["TODO", "NEXT", "IN-PROGRESS"]))
total = snapshot.total_counted(counted)
next_count = snapshot.counts.get("NEXT", 0)
in_progress = snapshot.counts.get("IN-PROGRESS", 0)
self.label.set_text(str(total))
classes = ["org-widget"]
if total == 0:
classes.append("empty")
if next_count > 0:
classes.append("has-next")
if in_progress > 0:
classes.append("has-in-progress")
self.set_style_classes(classes)
tooltip_lines = []
for state in _state_order(list(snapshot.counts.keys())):
n = snapshot.counts.get(state, 0)
if n:
tooltip_lines.append(f"{state}: {n}")
self.set_tooltip_text("\n".join(tooltip_lines) if tooltip_lines else "No open org items")

172
sims/services/org.py Normal file
View File

@@ -0,0 +1,172 @@
"""Org-mode agenda service.
Parses configured org files via `orgparse`, exposes a flat list of TODO-state
items grouped by state. Polls on an interval and re-reads files whose mtime
changed since the last sweep.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from glob import glob
from pathlib import Path
from typing import Iterable
from fabric.utils import invoke_repeater
from loguru import logger
try:
import orgparse
ORGPARSE_AVAILABLE = True
except ImportError:
ORGPARSE_AVAILABLE = False
logger.warning("[Org] orgparse not available — org widget disabled at runtime")
from sims.config import ORG
@dataclass
class OrgItem:
state: str
headline: str
file: str
line: int
tags: tuple[str, ...] = ()
scheduled: str | None = None
deadline: str | None = None
priority: str | None = None
@dataclass
class OrgSnapshot:
items: list[OrgItem] = field(default_factory=list)
counts: dict[str, int] = field(default_factory=dict)
def total_counted(self, counted_states: list[str]) -> int:
return sum(self.counts.get(s, 0) for s in counted_states)
class OrgService:
def __init__(self, update_interval: int = 60_000):
self._update_interval = update_interval
self._timer_id = None
self._mtimes: dict[str, float] = {}
self._snapshot = OrgSnapshot()
self._callbacks: list = []
self.update()
self._start_monitoring()
@property
def snapshot(self) -> OrgSnapshot:
return self._snapshot
def connect(self, signal_name: str, callback) -> None:
if signal_name == "items-changed":
self._callbacks.append(callback)
def _emit(self, snapshot: OrgSnapshot) -> None:
for cb in self._callbacks:
cb(self, snapshot)
def _start_monitoring(self):
if self._timer_id is None:
self._timer_id = invoke_repeater(self._update_interval, self._tick)
logger.info(
f"[Org] Started periodic updates every {self._update_interval/1000:.0f}s"
)
def _tick(self):
self.update()
return True
def _expand_paths(self) -> list[Path]:
raw = ORG.get("paths", []) or []
out: list[Path] = []
for entry in raw:
entry = os.path.expanduser(entry)
p = Path(entry)
if any(c in entry for c in "*?["):
for match in sorted(glob(entry, recursive=True)):
mp = Path(match)
if mp.is_file() and mp.suffix == ".org":
out.append(mp)
continue
if p.is_dir():
for match in sorted(p.rglob("*.org")):
out.append(match)
elif p.is_file():
out.append(p)
return out
def update(self):
if not ORG.get("enable", False):
return
if not ORGPARSE_AVAILABLE:
return
files = self._expand_paths()
if not files:
if self._snapshot.items:
self._snapshot = OrgSnapshot()
self._emit(self._snapshot)
return
# mtime-based change detection — skip parse if nothing changed
new_mtimes: dict[str, float] = {}
for f in files:
try:
new_mtimes[str(f)] = f.stat().st_mtime
except OSError:
continue
if new_mtimes == self._mtimes and self._snapshot.items:
return
self._mtimes = new_mtimes
todo_keywords = list(ORG.get("todo_keywords", ["TODO", "NEXT", "IN-PROGRESS"]))
done_keywords = list(ORG.get("done_keywords", ["DONE", "CANCELLED"]))
items: list[OrgItem] = []
for f in files:
try:
items.extend(_parse_file(f, todo_keywords, done_keywords))
except Exception as e:
logger.error(f"[Org] Failed to parse {f}: {e}")
counts: dict[str, int] = {}
for it in items:
counts[it.state] = counts.get(it.state, 0) + 1
self._snapshot = OrgSnapshot(items=items, counts=counts)
logger.info(f"[Org] Parsed {len(items)} items across {len(files)} files: {counts}")
self._emit(self._snapshot)
def _parse_file(
path: Path,
todo_keywords: list[str],
done_keywords: list[str],
) -> Iterable[OrgItem]:
env = orgparse.OrgEnv(
todos=todo_keywords,
dones=done_keywords,
filename=str(path),
)
root = orgparse.load(str(path), env=env)
for node in root[1:]:
state = node.todo
if not state or state in done_keywords:
continue
yield OrgItem(
state=state,
headline=node.heading or "(untitled)",
file=str(path),
line=node.linenumber,
tags=tuple(sorted(node.tags)),
scheduled=str(node.scheduled) if node.scheduled else None,
deadline=str(node.deadline) if node.deadline else None,
priority=node.priority,
)

View File

@@ -7,6 +7,7 @@
@import url("./calendar.css");
@import url("./notmuch.css");
@import url("./notifications.css");
@import url("./org.css");
/* unset so we can style everything from the ground up. */

128
sims/styles/org.css Normal file
View File

@@ -0,0 +1,128 @@
/* Org-agenda bar widget + dropdown */
#org-widget {
background-color: var(--module-bg);
padding: 4px 8px;
border-radius: 12px;
transition: background-color 0.2s ease;
}
#org-widget:hover {
background-color: var(--light-bg);
}
#org-widget.empty {
background-color: var(--module-bg);
opacity: 0.6;
}
#org-widget.has-next {
background-color: var(--blue);
}
#org-widget.has-next:hover {
background-color: var(--turquoise);
}
#org-widget.has-in-progress {
background-color: var(--violet);
}
#org-widget.has-in-progress.has-next {
background-color: var(--turquoise);
}
#org-count {
font-size: 14px;
font-weight: bold;
min-width: 16px;
}
#org-widget.has-next #org-count,
#org-widget.has-in-progress #org-count {
color: var(--background);
}
/* Dropdown */
#org-dropdown {
background-color: var(--window-bg);
border-radius: 0 0 16px 16px;
border: 1px solid var(--border-color);
border-top: none;
}
#org-dropdown-body {
padding: 14px 16px;
}
#org-dropdown-header {
margin-bottom: 8px;
}
#org-dropdown-title {
font-size: 18px;
font-weight: bold;
}
#org-dropdown-close {
padding: 4px;
border-radius: 8px;
transition: background-color 0.15s ease;
}
#org-dropdown-close:hover {
background-color: var(--light-bg);
}
#org-dropdown-empty {
color: var(--light-grey);
font-style: italic;
padding: 12px 4px;
}
#org-dropdown-section {
padding: 6px 0;
}
#org-dropdown-section-title {
font-size: 13px;
font-weight: bold;
letter-spacing: 0.6px;
}
.org-state-next #org-dropdown-section-title {
color: var(--blue);
}
.org-state-in-progress #org-dropdown-section-title {
color: var(--violet);
}
.org-state-todo #org-dropdown-section-title {
color: var(--orange);
}
.org-state-count {
color: var(--light-grey);
font-size: 12px;
}
#org-item {
padding: 6px 8px;
border-radius: 8px;
transition: background-color 0.15s ease;
}
#org-item:hover {
background-color: var(--light-bg);
}
.org-item-title {
font-size: 14px;
}
.org-item-meta {
color: var(--light-grey);
font-size: 11px;
}