3 Commits

Author SHA1 Message Date
Makussu 9c1c612a04 fix: start sims after graphical so it gets correct env 2026-06-16 18:17:15 +02:00
Makussu 07919fc687 feat: org todos in bar 2026-05-10 01:55:21 +02:00
Makussu a8d96b7481 feat: buddy 2026-05-09 22:42:27 +02:00
15 changed files with 1551 additions and 231 deletions
+13
View File
@@ -1,5 +1,7 @@
height: 42 height: 42
dev: true dev: true
buddy:
enable: true
window_title: window_title:
enable: true enable: true
vinyl: vinyl:
@@ -16,6 +18,17 @@ notmuch:
screenrec: screenrec:
enable: true enable: true
output_dir: "~/Videos/wl-screenrec" 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: notifications:
enable: true enable: true
anchor: "top center" anchor: "top center"
+332 -224
View File
@@ -45,250 +45,358 @@
// { // {
homeManagerModules = { homeManagerModules = {
sims = sims =
{ {
config, config,
lib, lib,
pkgs, pkgs,
... ...
}: }:
let let
cfg = config.services.sims; cfg = config.services.sims;
settingsFormat = pkgs.formats.yaml { }; settingsFormat = pkgs.formats.yaml { };
in in
{ {
options.services.sims = { options.services.sims = {
enable = lib.mkEnableOption "sims status bar"; enable = lib.mkEnableOption "sims status bar";
package = lib.mkOption { package = lib.mkOption {
type = lib.types.package; type = lib.types.package;
default = self.packages.${pkgs.system}.default; default = self.packages.${pkgs.system}.default;
description = "The sims package to use."; description = "The sims package to use.";
}; };
settings = lib.mkOption { settings = lib.mkOption {
type = lib.types.submodule { type = lib.types.submodule {
options = { options = {
vinyl = { vinyl = {
enable = lib.mkOption { enable = lib.mkOption {
type = lib.types.bool; type = lib.types.bool;
default = false; 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 { default = {
type = lib.types.bool; vinyl.enable = false;
default = false; battery.enable = false;
}; buddy.enable = false;
}; height = 40;
height = lib.mkOption { logLevel = "WARNING";
type = lib.types.int; window_title.enable = true;
default = 40; stylix.enable = false;
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 = { calendar = {
enable = lib.mkOption { enable = true;
type = lib.types.bool; khal_path = "khal";
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 = { notmuch = {
enable = lib.mkOption { enable = true;
type = lib.types.bool; notmuch_path = "notmuch";
default = true; emacsclient_command = "emacsclient";
description = "Whether to enable the notmuch email widget"; debt_query = "tag:unread and date:..1w";
}; debt_warn_at = 1;
notmuch_path = lib.mkOption { debt_alarm_at = 6;
type = lib.types.str; saved_searches = [ ];
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 = { screenrec = {
enable = lib.mkOption { enable = false;
type = lib.types.bool; output_dir = "~/Videos/wl-screenrec";
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 = { power = {
lock_command = lib.mkOption { lock_command = [ "waylock" ];
type = lib.types.listOf lib.types.str; };
default = [ "waylock" ]; org = {
description = "argv for the Lock action in the power menu"; 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 = { notifications = {
enable = lib.mkOption { enable = false;
type = lib.types.bool; anchor = "top center";
default = false; margin = "8px";
description = "Whether to enable the notification toast service. Owns org.freedesktop.Notifications, so other notification daemons (mako, dunst, swaync) must be disabled."; width = 360;
}; timeout_ms = 10000;
anchor = lib.mkOption { history_size = 50;
type = lib.types.str; image_max_px = 128;
default = "top center"; center_width = 380;
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";
};
}; };
}; };
}; };
default = { };
vinyl.enable = false;
battery.enable = false; config = lib.mkIf config.services.sims.enable {
height = 40; systemd.user.services.sims =
logLevel = "WARNING"; let
window_title.enable = true; configFile = settingsFormat.generate "config.yaml" cfg.settings;
stylix.enable = false; in
calendar = { {
enable = true; Unit = {
khal_path = "khal"; Description = "sims status bar";
# Start as part of the graphical session rather than at
# user-manager boot. The compositor populates the systemd
# user environment (WAYLAND_DISPLAY, NIXOS_OZONE_WL, ...)
# via `import-environment` before it activates
# graphical-session.target, so binding here guarantees sims
# — and every app it launches via `systemd-run --scope` —
# inherits the correct Wayland environment.
PartOf = [ "graphical-session.target" ];
After = [ "graphical-session.target" ];
};
Service = {
ExecStart = "${config.services.sims.package}/bin/sims --config ${configFile}";
Restart = "on-failure";
};
Install = {
WantedBy = [ "graphical-session.target" ];
};
}; };
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" ];
};
};
};
};
stylix-sims = import ./nix/stylix/hm.nix; stylix-sims = import ./nix/stylix/hm.nix;
}; };
}; };
+1
View File
@@ -50,6 +50,7 @@ python3Packages.buildPythonApplication {
pywayland pywayland
pyyaml pyyaml
platformdirs platformdirs
orgparse
]; ];
doCheck = false; doCheck = false;
dontWrapGApps = true; dontWrapGApps = true;
+1
View File
@@ -44,6 +44,7 @@ pkgs.mkShell {
python-lsp-ruff python-lsp-ruff
pyyaml pyyaml
platformdirs platformdirs
orgparse
] ]
)) ))
]; ];
+10
View File
@@ -76,3 +76,13 @@ NOTIFICATIONS = app_config.get("notifications", {
BAR_HEIGHT = app_config.get("height", 40) BAR_HEIGHT = app_config.get("height", 40)
LOG_LEVEL = app_config.get("logLevel", "WARNING") LOG_LEVEL = app_config.get("logLevel", "WARNING")
DEV = app_config.get("dev", False) 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)
+15 -1
View File
@@ -29,9 +29,11 @@ from .modules.launcher.screenshot import ScreenshotMenu
from .modules.calendar import CalendarService from .modules.calendar import CalendarService
from .modules.control_center import ControlCenter from .modules.control_center import ControlCenter
from .modules.notifications import NotificationToasts from .modules.notifications import NotificationToasts
from .modules.org import OrgDropdown
from .modules.stylix import get_stylix_css_path from .modules.stylix import get_stylix_css_path
from .modules.vinyl import VinylButton 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.fenster import get_i3_connection
from .services.notification_history import NotificationHistoryService from .services.notification_history import NotificationHistoryService
from .services.screenrec import ScreenrecService from .services.screenrec import ScreenrecService
@@ -92,6 +94,14 @@ if notification_history is not None:
width=NOTIFICATIONS.get("center_width", 380), 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 = [] bar_windows = []
notmuch_widget = None notmuch_widget = None
@@ -102,6 +112,8 @@ if notification_toasts is not None:
_app_windows.append(notification_toasts) _app_windows.append(notification_toasts)
if control_center is not None: if control_center is not None:
_app_windows.append(control_center) _app_windows.append(control_center)
if org_dropdown is not None:
_app_windows.append(org_dropdown)
app = Application("sims", *_app_windows) app = Application("sims", *_app_windows)
@@ -229,6 +241,8 @@ def spawn_bars():
monitor=i, monitor=i,
screenrec_service=screenrec_service if i == 0 else None, screenrec_service=screenrec_service if i == 0 else None,
control_center=control_center 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) bar_windows.append(bar)
if i == 0 and bar.notmuch: if i == 0 and bar.notmuch:
+27 -6
View File
@@ -8,17 +8,20 @@ from sims.modules.player import Player, PlayerSmall
from sims.modules.battery import Battery from sims.modules.battery import Battery
from sims.modules.control_center import ControlCenter from sims.modules.control_center import ControlCenter
from sims.modules.notmuch import NotmuchWidget 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 sims.modules.screenrec import ScreenrecWidget
from fabric.widgets.wayland import WaylandWindow as Window from fabric.widgets.wayland import WaylandWindow as Window
from fabric.system_tray.widgets import SystemTray from fabric.system_tray.widgets import SystemTray
from sims.widgets.fenster import FensterWorkspaces, FensterActiveWindow from sims.widgets.fenster import FensterWorkspaces, FensterActiveWindow
from sims.services.org import OrgService
from sims.services.screenrec import ScreenrecService from sims.services.screenrec import ScreenrecService
from sims.services.smart_corners import get_smart_corners_service from sims.services.smart_corners import get_smart_corners_service
from fabric.widgets.button import Button from fabric.widgets.button import Button
from fabric.widgets.circularprogressbar import CircularProgressBar from fabric.widgets.circularprogressbar import CircularProgressBar
from sims.services.system_stats import SystemStatsService from sims.services.system_stats import SystemStatsService
from sims.config import BATTERY, BAR_HEIGHT, WINDOW_TITLE, NOTMUCH from sims.config import BATTERY, BAR_HEIGHT, WINDOW_TITLE, NOTMUCH, BUDDY, ORG
class StatusBar(Window): class StatusBar(Window):
@@ -29,6 +32,8 @@ class StatusBar(Window):
monitor: int = 1, monitor: int = 1,
screenrec_service: ScreenrecService | None = None, screenrec_service: ScreenrecService | None = None,
control_center: ControlCenter | None = None, control_center: ControlCenter | None = None,
org_service: OrgService | None = None,
org_dropdown: OrgDropdown | None = None,
): ):
super().__init__( super().__init__(
name="sims", name="sims",
@@ -92,10 +97,19 @@ class StatusBar(Window):
if NOTMUCH["enable"]: if NOTMUCH["enable"]:
self.notmuch = NotmuchWidget() self.notmuch = NotmuchWidget()
self.buddy = None
if BUDDY.get("enable", False):
notmuch_service = self.notmuch.service if self.notmuch is not None else None
self.buddy = Buddy(notmuch_service=notmuch_service)
self.screenrec = None self.screenrec = None
if screenrec_service is not None: if screenrec_service is not None:
self.screenrec = ScreenrecWidget(screenrec_service) 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( self.status_container = Box(
name="widgets-container", name="widgets-container",
spacing=4, spacing=4,
@@ -105,6 +119,9 @@ class StatusBar(Window):
end_container_children = [] end_container_children = []
if self.org is not None:
end_container_children.append(self.org)
end_container_children.append(self.status_container) end_container_children.append(self.status_container)
if self.system_tray: if self.system_tray:
end_container_children.append(self.system_tray) end_container_children.append(self.system_tray)
@@ -124,17 +141,21 @@ class StatusBar(Window):
if WINDOW_TITLE["enable"]: if WINDOW_TITLE["enable"]:
center_children.append(self.active_window) center_children.append(self.active_window)
start_container_children = [
Image(name="nixos-label", icon_name="nix-snowflake-white", icon_size=20),
self.workspaces,
self.player_small,
]
if self.buddy is not None:
start_container_children.append(self.buddy)
self.inner = CenterBox( self.inner = CenterBox(
name="sims-inner", name="sims-inner",
start_children=Box( start_children=Box(
name="start-container", name="start-container",
spacing=6, spacing=6,
orientation="h", orientation="h",
children=[ children=start_container_children,
Image(name="nixos-label", icon_name="nix-snowflake-white", icon_size=20),
self.workspaces,
self.player_small,
],
), ),
center_children=Box( center_children=Box(
name="center-container", name="center-container",
+20
View File
@@ -0,0 +1,20 @@
from fabric.widgets.box import Box
from sims.widgets.buddy import BuddyWidget
from sims.services.buddy import BuddyService
class Buddy(Box):
def __init__(self, notmuch_service=None, **kwargs):
super().__init__(name="buddy-container", v_align="center", **kwargs)
self.service = BuddyService(notmuch_service=notmuch_service)
self.sprite = BuddyWidget(on_pet=self._on_pet)
self.service.connect("mood-changed", self._on_mood_changed)
self.children = [self.sprite]
def _on_mood_changed(self, _service, mood: str):
self.sprite.update_mood(mood)
def _on_pet(self, **kwargs):
if kwargs.get("petted"):
self.service.pet()
+283
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")
+148
View File
@@ -0,0 +1,148 @@
"""Mood arbitration for the bar buddy.
Fuses CPU load, battery state, time of day, mail counts and click events into a
single `mood-changed` signal. Highest-priority active mood wins.
"""
import time
from datetime import datetime
from fabric.core.service import Service, Signal
from fabric.utils import invoke_repeater
from sims.services.system_stats import SystemStatsService
from sims.services.battery import BatteryService
from sims.services.mpris import MprisPlayerManager, MprisPlayer
from sims.config import BATTERY, NOTMUCH
# Highest priority first. The first mood whose state is True wins.
MOOD_PRIORITY = ["petted", "excited", "busy", "bopping", "sleepy", "idle"]
PETTED_DURATION_S = 1.5
EXCITED_DURATION_S = 5.0
BUSY_CPU_THRESHOLD = 0.80
SLEEPY_BATTERY_THRESHOLD = 20 # percent
SLEEPY_HOUR_START = 23 # 23:00 inclusive
SLEEPY_HOUR_END = 7 # exclusive
TICK_INTERVAL_MS = 1000
class BuddyService(Service):
@Signal
def mood_changed(self, mood: str) -> None:
"""Emitted when the arbitrated mood changes."""
def __init__(self, notmuch_service=None, **kwargs):
super().__init__(**kwargs)
self._mood = "idle"
self._cpu = 0.0
self._battery_pct = 100
self._battery_charging = True
self._petted_until = 0.0
self._excited_until = 0.0
self._last_unread = None
self._stats = SystemStatsService(update_interval=3000)
self._stats.connect("stats-changed", self._on_stats)
self._battery = None
if BATTERY.get("enable", False):
self._battery = BatteryService(update_interval=15000)
self._battery.connect("battery-changed", self._on_battery)
self._notmuch = notmuch_service
if self._notmuch is not None:
self._notmuch.connect("counts-changed", self._on_notmuch)
self._mpris_manager = MprisPlayerManager()
self._mpris_players: dict[str, MprisPlayer] = {}
for p in self._mpris_manager.players or []:
self._attach_player(p)
self._mpris_manager.connect("player-appeared", self._on_player_appeared)
self._mpris_manager.connect("player-vanished", self._on_player_vanished)
invoke_repeater(TICK_INTERVAL_MS, self._tick)
self._recompute()
# External triggers ---------------------------------------------------
def pet(self):
"""Called when the buddy is clicked."""
self._petted_until = time.monotonic() + PETTED_DURATION_S
self._recompute()
# Service callbacks ---------------------------------------------------
def _on_stats(self, _service, cpu_fraction, _mem_fraction):
self._cpu = cpu_fraction
self._recompute()
def _on_battery(self, _service, percent, charging):
self._battery_pct = percent
self._battery_charging = charging
self._recompute()
def _on_notmuch(self, _service, unread, _debt):
if self._last_unread is not None and unread > self._last_unread:
self._excited_until = time.monotonic() + EXCITED_DURATION_S
self._last_unread = unread
self._recompute()
def _attach_player(self, player):
mp = MprisPlayer(player)
self._mpris_players[mp.player_name] = mp
mp.connect("changed", self._on_player_changed)
def _on_player_appeared(self, _manager, player):
self._attach_player(player)
self._recompute()
def _on_player_vanished(self, _manager, player_name: str):
self._mpris_players.pop(player_name, None)
self._recompute()
def _on_player_changed(self, _player):
self._recompute()
def _is_anything_playing(self) -> bool:
for mp in self._mpris_players.values():
try:
if mp.playback_status == "playing":
return True
except Exception:
continue
return False
def _tick(self):
# Re-arbitrate every second so transient moods (petted/excited) and
# the clock-driven sleepy window expire correctly.
self._recompute()
return True
# Arbitration ---------------------------------------------------------
def _states(self) -> dict[str, bool]:
now = time.monotonic()
hour = datetime.now().hour
is_night = hour >= SLEEPY_HOUR_START or hour < SLEEPY_HOUR_END
battery_low = (self._battery is not None
and self._battery_pct < SLEEPY_BATTERY_THRESHOLD
and not self._battery_charging)
return {
"petted": now < self._petted_until,
"excited": now < self._excited_until,
"busy": self._cpu > BUSY_CPU_THRESHOLD,
"bopping": self._is_anything_playing(),
"sleepy": battery_low or is_night,
"idle": True,
}
def _recompute(self):
states = self._states()
for mood in MOOD_PRIORITY:
if states.get(mood, False):
if mood != self._mood:
self._mood = mood
self.mood_changed(mood)
return
+172
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,
)
+12
View File
@@ -26,6 +26,18 @@
border-radius: 4px; border-radius: 4px;
} }
#buddy-container,
#buddy {
padding: 0;
}
#buddy-button {
padding: 0;
margin: 0 2px;
background: transparent;
border: none;
}
#bat-icon { #bat-icon {
color: var(--blue); color: var(--blue);
margin-right: 2px; margin-right: 2px;
+1
View File
@@ -7,6 +7,7 @@
@import url("./calendar.css"); @import url("./calendar.css");
@import url("./notmuch.css"); @import url("./notmuch.css");
@import url("./notifications.css"); @import url("./notifications.css");
@import url("./org.css");
/* unset so we can style everything from the ground up. */ /* unset so we can style everything from the ground up. */
+128
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;
}
+388
View File
@@ -0,0 +1,388 @@
from gi.repository import GdkPixbuf, GLib
from fabric.widgets.box import Box
from fabric.widgets.image import Image
from fabric.widgets.button import Button
from sims.config import STYLIX
# 16x16 sprite grid, displayed at SCALE x scale (32x32) with crisp pixels.
SPRITE_SIZE = 16
SCALE = 2
# Palette character → semantic role. Resolved to RGBA from stylix at render time.
# . transparent
# B body main
# H body highlight (lighter)
# b body shadow (darker)
# e eye dark
# w eye sparkle / white
# m mouth / closed-eye line
# c blush
# s sweat drop
# z z text
# * sparkle / excitement
# _ semi-transparent ground shadow
# ---------------------------------------------------------------------------
# Sprite construction: layered grid with strict 16x16 validation.
# ---------------------------------------------------------------------------
def _grid(rows: list[str]) -> list[str]:
if len(rows) != SPRITE_SIZE:
raise ValueError(f"sprite must be {SPRITE_SIZE} rows, got {len(rows)}")
for i, r in enumerate(rows):
if len(r) != SPRITE_SIZE:
raise ValueError(f"row {i} has {len(r)} chars (want {SPRITE_SIZE}): {r!r}")
return rows
def _overlay(base: list[str], *layers: list[tuple[int, int, str]]) -> list[str]:
grid = [list(r) for r in base]
for layer in layers:
for (y, x, ch) in layer:
if 0 <= y < SPRITE_SIZE and 0 <= x < SPRITE_SIZE:
grid[y][x] = ch
return ["".join(r) for r in grid]
def _shift(base: list[str], dy: int = 0, dx: int = 0) -> list[str]:
out = []
for y in range(SPRITE_SIZE):
src_y = y - dy
if 0 <= src_y < SPRITE_SIZE:
row = base[src_y]
else:
row = "." * SPRITE_SIZE
if dx > 0:
row = "." * dx + row[:-dx]
elif dx < 0:
row = row[-dx:] + "." * (-dx)
out.append(row)
return out
def _shift_overlay(overlay: list[tuple[int, int, str]], dy: int = 0, dx: int = 0) -> list[tuple[int, int, str]]:
return [(y + dy, x + dx, ch) for (y, x, ch) in overlay]
# Body shapes ---------------------------------------------------------------
BODY_REST = _grid([
"................",
"................",
"................",
".......BB.......",
"......BBBB......",
".....BBBBBB.....",
"....HBBBBBBB....",
"...HBBBBBBBBB...",
"..HBBBBBBBBBBB..",
"..BBBBBBBBBBBB..",
".BBBBBBBBBBBBBb.",
".BBBBBBBBBBBBBb.",
".BBBBBBBBBBBBbb.",
"BBBBBBBBBBBBBbbb",
"BBBBBBBBBBBBBbbb",
"................",
])
BODY_BOB = _shift(BODY_REST, dy=-1) # bobbed up by 1 row
BODY_SQUISH = _grid([
"................",
"................",
"................",
"................",
"......BBBB......",
".....BBBBBB.....",
"....BBBBBBBB....",
"...HBBBBBBBBB...",
"..HBBBBBBBBBBB..",
"..BBBBBBBBBBBB..",
".BBBBBBBBBBBBBb.",
".BBBBBBBBBBBBBb.",
"BBBBBBBBBBBBBbbb",
"BBBBBBBBBBBBBbbb",
"BBBBBBBBBBBBBbbb",
"................",
])
BODY_LEAN_L = _shift(BODY_REST, dx=-1)
BODY_LEAN_R = _shift(BODY_REST, dx=1)
# Face overlays. Eye row defaults to row 8 (rest pose); pass r= for bob/squish.
def eyes_open(r: int = 8) -> list[tuple[int, int, str]]:
return [(r, 5, "e"), (r, 10, "e")]
def eyes_blink(r: int = 8) -> list[tuple[int, int, str]]:
return [(r, 4, "m"), (r, 5, "m"), (r, 9, "m"), (r, 10, "m")]
def eyes_closed_happy(r: int = 8) -> list[tuple[int, int, str]]:
# ^^ shaped happy eyes
return [(r - 1, 4, "m"), (r - 1, 9, "m"),
(r, 5, "m"), (r, 10, "m"),
(r, 3, "m"), (r, 8, "m")]
def eyes_wide(r: int = 8) -> list[tuple[int, int, str]]:
return [(r - 1, 5, "w"), (r - 1, 10, "w"),
(r, 5, "e"), (r, 10, "e")]
def mouth_neutral(r: int = 11) -> list[tuple[int, int, str]]:
return [(r, 6, "m"), (r, 7, "m"), (r, 8, "m"), (r, 9, "m")]
def mouth_smile(r: int = 11) -> list[tuple[int, int, str]]:
return [(r, 6, "m"), (r, 9, "m"),
(r + 1, 7, "m"), (r + 1, 8, "m")]
def mouth_frown(r: int = 11) -> list[tuple[int, int, str]]:
return [(r, 7, "m"), (r, 8, "m"),
(r - 1, 6, "m"), (r - 1, 9, "m")]
def mouth_o(r: int = 11) -> list[tuple[int, int, str]]:
return [(r, 7, "m"), (r, 8, "m"),
(r + 1, 7, "m"), (r + 1, 8, "m")]
def blush(r: int = 9) -> list[tuple[int, int, str]]:
return [(r, 3, "c"), (r, 12, "c")]
# Accent overlays
SWEAT_HIGH = [(2, 13, "s"), (3, 13, "s")]
SWEAT_LOW = [(5, 13, "s"), (6, 13, "s")]
Z_SMALL = [(3, 12, "z"), (3, 13, "z")]
Z_LARGE = [(1, 13, "z"), (1, 14, "z"),
(2, 13, "z"), (2, 14, "z"),
(3, 12, "z"), (3, 13, "z")]
SPARKLE_LEFT = [(3, 2, "*"), (5, 1, "*")]
SPARKLE_RIGHT = [(3, 14, "*"), (5, 14, "*")]
# ---------------------------------------------------------------------------
# Frame compositions per mood
# ---------------------------------------------------------------------------
# Idle: bob + occasional blink. Most frames are eyes-open at rest.
IDLE_REST = _overlay(BODY_REST, eyes_open(8), mouth_neutral(11))
IDLE_BOB = _overlay(BODY_BOB, eyes_open(7), mouth_neutral(10))
IDLE_BLINK = _overlay(BODY_REST, eyes_blink(8), mouth_neutral(11))
# Petted: closed-happy eyes + smile + blush. Squish lowers face by 1 row.
PETTED_FLAT = _overlay(BODY_SQUISH, eyes_closed_happy(9), mouth_smile(12), blush(10))
PETTED_RECOVER = _overlay(BODY_REST, eyes_closed_happy(8), mouth_smile(11), blush(9))
# Excited: wide eyes, open mouth, sparkles, bouncing
EXCITED_LOW = _overlay(BODY_REST, eyes_wide(8), mouth_o(11),
SPARKLE_LEFT)
EXCITED_HIGH = _overlay(BODY_BOB, eyes_wide(7), mouth_o(10),
SPARKLE_LEFT, SPARKLE_RIGHT)
# Bopping: sway in time with music. Shift face with the body.
BOP_LEFT = _overlay(BODY_LEAN_L,
_shift_overlay(eyes_open(8), dx=-1),
_shift_overlay(mouth_smile(11), dx=-1))
BOP_RIGHT = _overlay(BODY_LEAN_R,
_shift_overlay(eyes_open(8), dx=1),
_shift_overlay(mouth_smile(11), dx=1))
# Busy: frown + sweat drops
BUSY_DROP_HIGH = _overlay(BODY_REST, eyes_open(8), mouth_frown(11), SWEAT_HIGH)
BUSY_DROP_LOW = _overlay(BODY_REST, eyes_open(8), mouth_frown(11), SWEAT_LOW)
# Sleepy: closed eyes, z floats, no mouth
SLEEPY_NO_Z = _overlay(BODY_REST, eyes_blink(8))
SLEEPY_Z_SMALL = _overlay(BODY_REST, eyes_blink(8), Z_SMALL)
SLEEPY_Z_LARGE = _overlay(BODY_REST, eyes_blink(8), Z_LARGE)
# (frames, frame_duration_ms, loop)
MOOD_FRAMES: dict[str, tuple[list[list[str]], int, bool]] = {
"idle": (
[IDLE_REST, IDLE_REST, IDLE_BOB, IDLE_REST, IDLE_REST, IDLE_BLINK],
300, True,
),
"petted": (
[PETTED_FLAT, PETTED_FLAT, PETTED_RECOVER, PETTED_RECOVER],
180, False,
),
"excited": (
[EXCITED_LOW, EXCITED_HIGH],
180, True,
),
"bopping": (
[BOP_LEFT, IDLE_REST, BOP_RIGHT, IDLE_REST],
220, True,
),
"busy": (
[BUSY_DROP_HIGH, BUSY_DROP_LOW],
260, True,
),
"sleepy": (
[SLEEPY_NO_Z, SLEEPY_NO_Z, SLEEPY_Z_SMALL, SLEEPY_Z_LARGE],
500, True,
),
}
# ---------------------------------------------------------------------------
# Pixbuf rendering
# ---------------------------------------------------------------------------
def _hex_to_rgba(hex_str: str, alpha: int = 255) -> tuple[int, int, int, int]:
h = hex_str.lstrip("#")
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), alpha)
def _mix(c1: tuple[int, int, int], c2: tuple[int, int, int], t: float) -> tuple[int, int, int]:
return (int(c1[0] * (1 - t) + c2[0] * t),
int(c1[1] * (1 - t) + c2[1] * t),
int(c1[2] * (1 - t) + c2[2] * t))
def _resolve_palette() -> dict[str, tuple[int, int, int, int]]:
defaults = {
"base00": "1e1e2e", "base05": "cdd6f4", "base07": "b4befe",
"base08": "f38ba8", "base0A": "f9e2af", "base0C": "94e2d5",
"base0D": "89b4fa",
}
colors = dict(STYLIX.get("colors", {})) if STYLIX.get("enable", False) else {}
for k, v in defaults.items():
colors.setdefault(k, v)
body_rgba = _hex_to_rgba(colors["base0D"])
body_rgb = body_rgba[:3]
light_rgb = _mix(body_rgb, (255, 255, 255), 0.35)
dark_rgb = _mix(body_rgb, (0, 0, 0), 0.30)
return {
".": (0, 0, 0, 0),
"B": body_rgba,
"H": light_rgb + (255,),
"b": dark_rgb + (255,),
"e": _hex_to_rgba(colors["base00"]),
"w": _hex_to_rgba(colors["base05"]),
"m": _hex_to_rgba(colors["base00"]),
"c": _hex_to_rgba(colors["base08"]),
"s": _hex_to_rgba(colors["base0C"]),
"z": _hex_to_rgba(colors["base05"]),
"*": _hex_to_rgba(colors["base0A"]),
"_": _hex_to_rgba(colors["base00"], alpha=80),
}
def _frame_to_pixbuf(rows: list[str], palette: dict[str, tuple[int, int, int, int]]) -> GdkPixbuf.Pixbuf:
out_size = SPRITE_SIZE * SCALE
rowstride = out_size * 4
buf = bytearray(rowstride * out_size)
for y in range(SPRITE_SIZE):
for x in range(SPRITE_SIZE):
ch = rows[y][x]
r, g, b, a = palette.get(ch, (0, 0, 0, 0))
for sy in range(SCALE):
for sx in range(SCALE):
o = (y * SCALE + sy) * rowstride + (x * SCALE + sx) * 4
buf[o] = r
buf[o + 1] = g
buf[o + 2] = b
buf[o + 3] = a
return GdkPixbuf.Pixbuf.new_from_bytes(
GLib.Bytes.new(bytes(buf)),
GdkPixbuf.Colorspace.RGB,
True, 8,
out_size, out_size, rowstride,
)
# ---------------------------------------------------------------------------
# Widget
# ---------------------------------------------------------------------------
class BuddyWidget(Box):
def __init__(self, on_pet=None, **kwargs):
super().__init__(name="buddy", v_align="center", **kwargs)
self._palette = _resolve_palette()
self._cache: dict[int, GdkPixbuf.Pixbuf] = {}
self._mood = "idle"
self._frame_idx = 0
self._timer_id = None
self._on_pet = on_pet
out = SPRITE_SIZE * SCALE
self._image = Image(name="buddy-image")
self._image.set_size_request(out, out)
self._button = Button(
name="buddy-button",
child=self._image,
on_clicked=self._handle_click,
style="background: transparent; border: none; padding: 0; margin: 0; box-shadow: none;",
)
self.children = [self._button]
self._set_mood("idle")
self.show_all()
def _pixbuf_for(self, frame_rows: list[str]) -> GdkPixbuf.Pixbuf:
key = id(frame_rows)
if key not in self._cache:
self._cache[key] = _frame_to_pixbuf(frame_rows, self._palette)
return self._cache[key]
def _set_mood(self, mood: str):
if mood not in MOOD_FRAMES:
mood = "idle"
self._mood = mood
self._frame_idx = 0
if self._timer_id is not None:
GLib.source_remove(self._timer_id)
self._timer_id = None
_frames, dur, _loop = MOOD_FRAMES[mood]
self._render_current()
self._timer_id = GLib.timeout_add(dur, self._tick)
def _tick(self):
frames, _dur, loop = MOOD_FRAMES[self._mood]
self._frame_idx += 1
if self._frame_idx >= len(frames):
if loop:
self._frame_idx = 0
else:
self._frame_idx = len(frames) - 1
self._timer_id = None
if callable(self._on_pet) and self._mood == "petted":
GLib.timeout_add(120, self._notify_petted_done)
return False
self._render_current()
return True
def _render_current(self):
frames, _dur, _loop = MOOD_FRAMES[self._mood]
rows = frames[self._frame_idx]
self._image.set_from_pixbuf(self._pixbuf_for(rows))
def _notify_petted_done(self):
if callable(self._on_pet):
self._on_pet(done=True)
return False
def _handle_click(self, *_args):
if callable(self._on_pet):
self._on_pet(petted=True)
def update_mood(self, mood: str):
if mood == self._mood:
return
self._set_mood(mood)