Files

106 lines
3.6 KiB
Python

"""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())