feat: buddy

This commit is contained in:
2026-05-09 22:42:27 +02:00
parent 6da7e97f19
commit a8d96b7481
8 changed files with 595 additions and 6 deletions

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:

View File

@@ -81,6 +81,13 @@
default = false; 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 { height = lib.mkOption {
type = lib.types.int; type = lib.types.int;
default = 40; default = 40;
@@ -229,6 +236,7 @@
default = { default = {
vinyl.enable = false; vinyl.enable = false;
battery.enable = false; battery.enable = false;
buddy.enable = false;
height = 40; height = 40;
logLevel = "WARNING"; logLevel = "WARNING";
window_title.enable = true; window_title.enable = true;

View File

@@ -76,3 +76,4 @@ 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})

View File

@@ -8,6 +8,7 @@ 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.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
@@ -18,7 +19,7 @@ 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
class StatusBar(Window): class StatusBar(Window):
@@ -92,6 +93,11 @@ 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)
@@ -124,17 +130,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
sims/modules/buddy.py Normal file
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()

148
sims/services/buddy.py Normal file
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

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;

388
sims/widgets/buddy.py Normal file
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)