From 07919fc687769ee50766519a57969443160a2e28 Mon Sep 17 00:00:00 2001 From: Makesesama Date: Sun, 10 May 2026 01:55:21 +0200 Subject: [PATCH] feat: org todos in bar --- example-stylix-dev.yaml | 11 + flake.nix | 558 +++++++++++++++++++++++----------------- nix/derivation.nix | 1 + nix/shell.nix | 1 + sims/config.py | 9 + sims/main.py | 16 +- sims/modules/bar.py | 13 +- sims/modules/org.py | 283 ++++++++++++++++++++ sims/services/org.py | 172 +++++++++++++ sims/styles/main.css | 1 + sims/styles/org.css | 128 +++++++++ 11 files changed, 958 insertions(+), 235 deletions(-) create mode 100644 sims/modules/org.py create mode 100644 sims/services/org.py create mode 100644 sims/styles/org.css diff --git a/example-stylix-dev.yaml b/example-stylix-dev.yaml index 28af0c4..1af7fea 100644 --- a/example-stylix-dev.yaml +++ b/example-stylix-dev.yaml @@ -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" diff --git a/flake.nix b/flake.nix index 88e6c59..cafbc77 100644 --- a/flake.nix +++ b/flake.nix @@ -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; }; }; diff --git a/nix/derivation.nix b/nix/derivation.nix index 6a50370..011f510 100644 --- a/nix/derivation.nix +++ b/nix/derivation.nix @@ -50,6 +50,7 @@ python3Packages.buildPythonApplication { pywayland pyyaml platformdirs + orgparse ]; doCheck = false; dontWrapGApps = true; diff --git a/nix/shell.nix b/nix/shell.nix index e966ba5..561355e 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -44,6 +44,7 @@ pkgs.mkShell { python-lsp-ruff pyyaml platformdirs + orgparse ] )) ]; diff --git a/sims/config.py b/sims/config.py index 23f272c..9b7fbae 100644 --- a/sims/config.py +++ b/sims/config.py @@ -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) diff --git a/sims/main.py b/sims/main.py index 026e332..2e6f43a 100644 --- a/sims/main.py +++ b/sims/main.py @@ -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: diff --git a/sims/modules/bar.py b/sims/modules/bar.py index d411c17..cfb4dc5 100644 --- a/sims/modules/bar.py +++ b/sims/modules/bar.py @@ -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) diff --git a/sims/modules/org.py b/sims/modules/org.py new file mode 100644 index 0000000..c6828d9 --- /dev/null +++ b/sims/modules/org.py @@ -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") diff --git a/sims/services/org.py b/sims/services/org.py new file mode 100644 index 0000000..366849e --- /dev/null +++ b/sims/services/org.py @@ -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, + ) diff --git a/sims/styles/main.css b/sims/styles/main.css index 2a6593e..881d071 100644 --- a/sims/styles/main.css +++ b/sims/styles/main.css @@ -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. */ diff --git a/sims/styles/org.css b/sims/styles/org.css new file mode 100644 index 0000000..5d97dc7 --- /dev/null +++ b/sims/styles/org.css @@ -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; +}