feat: org todos in bar
This commit is contained in:
@@ -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
558
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;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -50,6 +50,7 @@ python3Packages.buildPythonApplication {
|
||||
pywayland
|
||||
pyyaml
|
||||
platformdirs
|
||||
orgparse
|
||||
];
|
||||
doCheck = false;
|
||||
dontWrapGApps = true;
|
||||
|
||||
@@ -44,6 +44,7 @@ pkgs.mkShell {
|
||||
python-lsp-ruff
|
||||
pyyaml
|
||||
platformdirs
|
||||
orgparse
|
||||
]
|
||||
))
|
||||
];
|
||||
|
||||
@@ -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)
|
||||
|
||||
16
sims/main.py
16
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:
|
||||
|
||||
@@ -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
283
sims/modules/org.py
Normal 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
172
sims/services/org.py
Normal 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,
|
||||
)
|
||||
@@ -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
128
sims/styles/org.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user