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
dev: true
buddy:
enable: true
window_title:
enable: true
vinyl:

View File

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

View File

@@ -76,3 +76,4 @@ NOTIFICATIONS = app_config.get("notifications", {
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})

View File

@@ -8,6 +8,7 @@ from sims.modules.player import Player, PlayerSmall
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.screenrec import ScreenrecWidget
from fabric.widgets.wayland import WaylandWindow as Window
from fabric.system_tray.widgets import SystemTray
@@ -18,7 +19,7 @@ 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
from sims.config import BATTERY, BAR_HEIGHT, WINDOW_TITLE, NOTMUCH, BUDDY
class StatusBar(Window):
@@ -92,6 +93,11 @@ class StatusBar(Window):
if NOTMUCH["enable"]:
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
if screenrec_service is not None:
self.screenrec = ScreenrecWidget(screenrec_service)
@@ -124,17 +130,21 @@ class StatusBar(Window):
if WINDOW_TITLE["enable"]:
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(
name="sims-inner",
start_children=Box(
name="start-container",
spacing=6,
orientation="h",
children=[
Image(name="nixos-label", icon_name="nix-snowflake-white", icon_size=20),
self.workspaces,
self.player_small,
],
children=start_container_children,
),
center_children=Box(
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;
}
#buddy-container,
#buddy {
padding: 0;
}
#buddy-button {
padding: 0;
margin: 0 2px;
background: transparent;
border: none;
}
#bat-icon {
color: var(--blue);
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)