"""Board state: the ordered collection of sound buttons. Pure/headless (no audio or GUI imports) so add/remove/rename/persistence are unit-testable. The GUI observes changes via an optional ``on_change`` callback and renders from ``board.buttons``. """ from __future__ import annotations import os from typing import Any, Callable, Optional from config import default_config, load_config, save_config def _base_label(file_path: str) -> str: """Default button label from a file path: the base name without extension.""" return os.path.splitext(os.path.basename(file_path))[0] class Board: def __init__(self, config_path: str): self.config_path = config_path cfg = load_config(config_path) self.output_device: Optional[str] = cfg["output_device"] self.volume: int = cfg["volume"] self.buttons: list[dict[str, Any]] = cfg["buttons"] self._next_id = self._compute_next_id() self.on_change: Optional[Callable[[], None]] = None # ---- id allocation ----------------------------------------------------- def _compute_next_id(self) -> int: """Next free numeric suffix for ids of the form ``btn_NNN``.""" highest = 0 for b in self.buttons: bid = b.get("id", "") if bid.startswith("btn_"): try: highest = max(highest, int(bid[4:])) except ValueError: pass return highest + 1 def _new_id(self) -> str: bid = f"btn_{self._next_id:03d}" self._next_id += 1 return bid # ---- internal ---------------------------------------------------------- def _index_of(self, button_id: str) -> int: for i, b in enumerate(self.buttons): if b["id"] == button_id: return i raise KeyError(button_id) def _changed(self): self.save() if self.on_change: self.on_change() # ---- mutations --------------------------------------------------------- def add_button(self, file_path: str, label: Optional[str] = None) -> dict[str, Any]: """Register a sound file as a new button (file is referenced, never copied).""" button = { "id": self._new_id(), "label": label if label else _base_label(file_path), "file_path": file_path, } self.buttons.append(button) self._changed() return button def remove_button(self, button_id: str) -> None: del self.buttons[self._index_of(button_id)] self._changed() def rename_button(self, button_id: str, new_label: str) -> None: new_label = new_label.strip() if not new_label: raise ValueError("label must not be empty") self.buttons[self._index_of(button_id)]["label"] = new_label self._changed() def get_button(self, button_id: str) -> dict[str, Any]: return self.buttons[self._index_of(button_id)] # ---- settings ---------------------------------------------------------- def set_output_device(self, device_name: Optional[str]) -> None: self.output_device = device_name self._changed() def set_volume(self, volume: int) -> None: self.volume = int(max(0, min(100, volume))) self._changed() # ---- persistence ------------------------------------------------------- def to_config(self) -> dict[str, Any]: cfg = default_config() cfg["output_device"] = self.output_device cfg["volume"] = self.volume cfg["buttons"] = [dict(b) for b in self.buttons] return cfg def save(self) -> None: save_config(self.config_path, self.to_config())