round from from Claude Opus 4.8
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user