From a8d96b7481072d339ab581f8c2cbe9f5213a7310 Mon Sep 17 00:00:00 2001 From: Makesesama Date: Sat, 9 May 2026 22:42:27 +0200 Subject: [PATCH] feat: buddy --- example-stylix-dev.yaml | 2 + flake.nix | 8 + sims/config.py | 1 + sims/modules/bar.py | 22 ++- sims/modules/buddy.py | 20 +++ sims/services/buddy.py | 148 +++++++++++++++ sims/styles/bar.css | 12 ++ sims/widgets/buddy.py | 388 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 595 insertions(+), 6 deletions(-) create mode 100644 sims/modules/buddy.py create mode 100644 sims/services/buddy.py create mode 100644 sims/widgets/buddy.py diff --git a/example-stylix-dev.yaml b/example-stylix-dev.yaml index a8c1ff2..28af0c4 100644 --- a/example-stylix-dev.yaml +++ b/example-stylix-dev.yaml @@ -1,5 +1,7 @@ height: 42 dev: true +buddy: + enable: true window_title: enable: true vinyl: diff --git a/flake.nix b/flake.nix index 11facbb..88e6c59 100644 --- a/flake.nix +++ b/flake.nix @@ -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; diff --git a/sims/config.py b/sims/config.py index e50be94..23f272c 100644 --- a/sims/config.py +++ b/sims/config.py @@ -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}) diff --git a/sims/modules/bar.py b/sims/modules/bar.py index 8e8e321..d411c17 100644 --- a/sims/modules/bar.py +++ b/sims/modules/bar.py @@ -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", diff --git a/sims/modules/buddy.py b/sims/modules/buddy.py new file mode 100644 index 0000000..e40e417 --- /dev/null +++ b/sims/modules/buddy.py @@ -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() diff --git a/sims/services/buddy.py b/sims/services/buddy.py new file mode 100644 index 0000000..e65bf48 --- /dev/null +++ b/sims/services/buddy.py @@ -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 diff --git a/sims/styles/bar.css b/sims/styles/bar.css index 46175fd..b0d19cf 100644 --- a/sims/styles/bar.css +++ b/sims/styles/bar.css @@ -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; diff --git a/sims/widgets/buddy.py b/sims/widgets/buddy.py new file mode 100644 index 0000000..e673280 --- /dev/null +++ b/sims/widgets/buddy.py @@ -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)