106 lines
3.6 KiB
Python
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())
|