🎉 live server seems to be working now
This commit is contained in:
208
third_party/sendspin-go/internal/ui/device_picker.go
vendored
Normal file
208
third_party/sendspin-go/internal/ui/device_picker.go
vendored
Normal file
@@ -0,0 +1,208 @@
|
||||
// ABOUTME: Modal picker for audio output devices — shown over the main view
|
||||
// ABOUTME: Save-and-restart model: selection writes audio_device to player.yaml
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/audio/output"
|
||||
)
|
||||
|
||||
// pickerAction is what handlePickerKey tells the Model to do next. The
|
||||
// picker itself never writes to disk; the Model layer owns side effects so
|
||||
// the pure state machine here stays trivially testable.
|
||||
type pickerAction int
|
||||
|
||||
const (
|
||||
pickerNoop pickerAction = iota // key was consumed, nothing further to do
|
||||
pickerClose // close without saving
|
||||
pickerSave // save the currently-selected device and close
|
||||
pickerPropagate // key was not handled by the picker; caller may handle it
|
||||
)
|
||||
|
||||
type devicePickerState struct {
|
||||
devices []output.PlaybackDevice
|
||||
index int // currently-highlighted row
|
||||
current string // device name currently in effect (for the "(current)" annotation)
|
||||
loadErr error // non-nil when ListPlaybackDevices failed
|
||||
saveEnabled bool // false when no config path is available — save is disabled, Esc still works
|
||||
}
|
||||
|
||||
// newDevicePicker builds the state shown when the user first opens the
|
||||
// modal. listFn is the enumerator — parameterised so tests can stub it.
|
||||
func newDevicePicker(listFn func() ([]output.PlaybackDevice, error), current string, saveEnabled bool) devicePickerState {
|
||||
devices, err := listFn()
|
||||
s := devicePickerState{
|
||||
current: current,
|
||||
loadErr: err,
|
||||
saveEnabled: saveEnabled,
|
||||
}
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
s.devices = devices
|
||||
// Seed selection: current device if matched; else default; else first.
|
||||
for i, d := range devices {
|
||||
if d.Name == current {
|
||||
s.index = i
|
||||
return s
|
||||
}
|
||||
}
|
||||
for i, d := range devices {
|
||||
if d.IsDefault {
|
||||
s.index = i
|
||||
return s
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// handlePickerKey is a pure state transition used by both production and
|
||||
// tests. It returns the next state and an action the caller performs (close,
|
||||
// save, etc.). The Model translates actions into side effects.
|
||||
func (s devicePickerState) handlePickerKey(key string) (devicePickerState, pickerAction) {
|
||||
switch key {
|
||||
case "up":
|
||||
if len(s.devices) > 0 && s.index > 0 {
|
||||
s.index--
|
||||
}
|
||||
return s, pickerNoop
|
||||
case "down":
|
||||
if s.index < len(s.devices)-1 {
|
||||
s.index++
|
||||
}
|
||||
return s, pickerNoop
|
||||
case "esc", "q":
|
||||
return s, pickerClose
|
||||
case "enter":
|
||||
if !s.saveEnabled || len(s.devices) == 0 {
|
||||
return s, pickerNoop
|
||||
}
|
||||
return s, pickerSave
|
||||
}
|
||||
return s, pickerPropagate
|
||||
}
|
||||
|
||||
// selected returns the device the user would save if they pressed Enter
|
||||
// right now, or nil when the list is empty or failed to load.
|
||||
func (s devicePickerState) selected() *output.PlaybackDevice {
|
||||
if s.loadErr != nil || len(s.devices) == 0 {
|
||||
return nil
|
||||
}
|
||||
if s.index < 0 || s.index >= len(s.devices) {
|
||||
return nil
|
||||
}
|
||||
return &s.devices[s.index]
|
||||
}
|
||||
|
||||
// renderPicker draws the modal. width is the TUI width; the picker sizes
|
||||
// itself accordingly. If the list failed to load, the error is surfaced
|
||||
// inside the modal so the user knows why the picker is empty.
|
||||
func renderPicker(s devicePickerState, width int) string {
|
||||
if width < 60 {
|
||||
width = 60
|
||||
}
|
||||
inner := width - 4
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString("\u250c\u2500 Select playback device " + repeatString("\u2500", width-26) + "\u2510\n")
|
||||
|
||||
if s.loadErr != nil {
|
||||
line := fmt.Sprintf(" %s", truncateRunes(s.loadErr.Error(), inner-2))
|
||||
b.WriteString(fmt.Sprintf("\u2502 %-*s \u2502\n", inner, line))
|
||||
} else if len(s.devices) == 0 {
|
||||
b.WriteString(fmt.Sprintf("\u2502 %-*s \u2502\n", inner, " (no playback devices found)"))
|
||||
} else {
|
||||
for i, d := range s.devices {
|
||||
marker := " "
|
||||
if i == s.index {
|
||||
marker = ">"
|
||||
}
|
||||
annotations := []string{}
|
||||
if d.IsDefault {
|
||||
annotations = append(annotations, "default")
|
||||
}
|
||||
if d.Name == s.current {
|
||||
annotations = append(annotations, "current")
|
||||
}
|
||||
name := d.Name
|
||||
if len(annotations) > 0 {
|
||||
name = fmt.Sprintf("%s (%s)", d.Name, strings.Join(annotations, ", "))
|
||||
}
|
||||
line := fmt.Sprintf("%s %s", marker, truncateRunes(name, inner-2))
|
||||
// Highlight the selected row with reverse video so it reads even
|
||||
// when the user's terminal doesn't do dim/bright styling well.
|
||||
if i == s.index {
|
||||
line = ansiReverse + padRight(line, inner) + ansiReset
|
||||
b.WriteString(fmt.Sprintf("\u2502 %s \u2502\n", line))
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf("\u2502 %-*s \u2502\n", inner, line))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Blank row then the keybinding hints.
|
||||
b.WriteString(fmt.Sprintf("\u2502 %-*s \u2502\n", inner, ""))
|
||||
hints := " " + hotkey(KeyUpDown, "move") + " " + hotkey(KeyEnter, "save") + " " + hotkey(KeyEsc, "cancel")
|
||||
if !s.saveEnabled {
|
||||
hints = " " + hotkey(KeyUpDown, "move") + " save unavailable (no writable config path) " + hotkey(KeyEsc, "cancel")
|
||||
}
|
||||
// Padding accounts for the fact that hints contains ANSI escape codes
|
||||
// that don't count toward display width.
|
||||
displayLen := displayLen(hints)
|
||||
pad := inner - displayLen
|
||||
if pad < 0 {
|
||||
pad = 0
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("\u2502 %s%s \u2502\n", hints, strings.Repeat(" ", pad)))
|
||||
|
||||
b.WriteString("\u2514" + repeatString("\u2500", width-2) + "\u2518\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// displayLen approximates how many terminal columns a string takes, ignoring
|
||||
// ANSI escape sequences. Close enough for alignment padding; we're not
|
||||
// supporting combining characters or East Asian width here.
|
||||
func displayLen(s string) int {
|
||||
count := 0
|
||||
inEsc := false
|
||||
for _, r := range s {
|
||||
if r == '\x1b' {
|
||||
inEsc = true
|
||||
continue
|
||||
}
|
||||
if inEsc {
|
||||
if r == 'm' {
|
||||
inEsc = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// truncateRunes returns s clipped to maxCols runes, appending an ellipsis
|
||||
// when truncation actually happens. Rune-safe variant of model.go's
|
||||
// byte-based truncate — device names may contain non-ASCII characters.
|
||||
func truncateRunes(s string, maxCols int) string {
|
||||
if len([]rune(s)) <= maxCols {
|
||||
return s
|
||||
}
|
||||
if maxCols <= 1 {
|
||||
return "\u2026"
|
||||
}
|
||||
runes := []rune(s)
|
||||
return string(runes[:maxCols-1]) + "\u2026"
|
||||
}
|
||||
|
||||
// padRight pads s with spaces up to width display columns. Used for the
|
||||
// reverse-video row so the highlight extends across the full row.
|
||||
func padRight(s string, width int) string {
|
||||
d := displayLen(s)
|
||||
if d >= width {
|
||||
return s
|
||||
}
|
||||
return s + strings.Repeat(" ", width-d)
|
||||
}
|
||||
274
third_party/sendspin-go/internal/ui/device_picker_test.go
vendored
Normal file
274
third_party/sendspin-go/internal/ui/device_picker_test.go
vendored
Normal file
@@ -0,0 +1,274 @@
|
||||
// ABOUTME: Tests for the device-picker state machine — pure functions, no TUI loop required
|
||||
package ui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/audio/output"
|
||||
)
|
||||
|
||||
func fixtureDevices() []output.PlaybackDevice {
|
||||
return []output.PlaybackDevice{
|
||||
{Name: "USB Audio"},
|
||||
{Name: "Built-in Analog", IsDefault: true},
|
||||
{Name: "HDMI"},
|
||||
}
|
||||
}
|
||||
|
||||
func listOK() ([]output.PlaybackDevice, error) {
|
||||
return fixtureDevices(), nil
|
||||
}
|
||||
|
||||
func TestNewDevicePicker_SeedsIndexFromCurrent(t *testing.T) {
|
||||
s := newDevicePicker(listOK, "HDMI", true)
|
||||
if s.index != 2 {
|
||||
t.Errorf("index = %d, want 2 (HDMI)", s.index)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDevicePicker_SeedsIndexFromDefaultWhenCurrentMissing(t *testing.T) {
|
||||
s := newDevicePicker(listOK, "No such device", true)
|
||||
if s.index != 1 {
|
||||
t.Errorf("index = %d, want 1 (the IsDefault entry)", s.index)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDevicePicker_SeedsZeroWhenNoDefaultAndNoCurrent(t *testing.T) {
|
||||
listNoDefault := func() ([]output.PlaybackDevice, error) {
|
||||
return []output.PlaybackDevice{
|
||||
{Name: "One"},
|
||||
{Name: "Two"},
|
||||
}, nil
|
||||
}
|
||||
s := newDevicePicker(listNoDefault, "", true)
|
||||
if s.index != 0 {
|
||||
t.Errorf("index = %d, want 0", s.index)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDevicePicker_PropagatesLoadError(t *testing.T) {
|
||||
listErr := func() ([]output.PlaybackDevice, error) {
|
||||
return nil, errors.New("no audio stack")
|
||||
}
|
||||
s := newDevicePicker(listErr, "", true)
|
||||
if s.loadErr == nil {
|
||||
t.Error("loadErr should be set when enumerator fails")
|
||||
}
|
||||
if s.selected() != nil {
|
||||
t.Error("selected() should be nil when load failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePickerKey_UpDownClamp(t *testing.T) {
|
||||
s := newDevicePicker(listOK, "USB Audio", true) // index 0
|
||||
|
||||
// Up at the top clamps.
|
||||
s, act := s.handlePickerKey("up")
|
||||
if s.index != 0 || act != pickerNoop {
|
||||
t.Errorf("up at top: index=%d act=%d", s.index, act)
|
||||
}
|
||||
|
||||
// Two downs.
|
||||
s, _ = s.handlePickerKey("down")
|
||||
s, _ = s.handlePickerKey("down")
|
||||
if s.index != 2 {
|
||||
t.Fatalf("after 2 downs, index = %d, want 2", s.index)
|
||||
}
|
||||
|
||||
// Down at the bottom clamps.
|
||||
s, _ = s.handlePickerKey("down")
|
||||
if s.index != 2 {
|
||||
t.Errorf("down at bottom: index = %d, want 2", s.index)
|
||||
}
|
||||
|
||||
// One up.
|
||||
s, _ = s.handlePickerKey("up")
|
||||
if s.index != 1 {
|
||||
t.Errorf("up from 2: index = %d, want 1", s.index)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePickerKey_EnterSavesWhenEnabled(t *testing.T) {
|
||||
s := newDevicePicker(listOK, "", true)
|
||||
_, act := s.handlePickerKey("enter")
|
||||
if act != pickerSave {
|
||||
t.Errorf("enter action = %d, want pickerSave", act)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePickerKey_EnterNoopWhenSaveDisabled(t *testing.T) {
|
||||
s := newDevicePicker(listOK, "", false)
|
||||
_, act := s.handlePickerKey("enter")
|
||||
if act != pickerNoop {
|
||||
t.Errorf("enter with save disabled action = %d, want pickerNoop", act)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePickerKey_EnterNoopOnEmptyList(t *testing.T) {
|
||||
s := newDevicePicker(func() ([]output.PlaybackDevice, error) { return nil, nil }, "", true)
|
||||
_, act := s.handlePickerKey("enter")
|
||||
if act != pickerNoop {
|
||||
t.Errorf("enter on empty action = %d, want pickerNoop", act)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePickerKey_EscAndQClose(t *testing.T) {
|
||||
for _, key := range []string{"esc", "q"} {
|
||||
s := newDevicePicker(listOK, "", true)
|
||||
_, act := s.handlePickerKey(key)
|
||||
if act != pickerClose {
|
||||
t.Errorf("%q action = %d, want pickerClose", key, act)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePickerKey_UnknownPropagates(t *testing.T) {
|
||||
s := newDevicePicker(listOK, "", true)
|
||||
_, act := s.handlePickerKey("x")
|
||||
if act != pickerPropagate {
|
||||
t.Errorf("unknown key action = %d, want pickerPropagate", act)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelected_ReturnsCurrentRow(t *testing.T) {
|
||||
s := newDevicePicker(listOK, "HDMI", true)
|
||||
got := s.selected()
|
||||
if got == nil {
|
||||
t.Fatal("selected() = nil")
|
||||
}
|
||||
if got.Name != "HDMI" {
|
||||
t.Errorf("selected().Name = %q, want HDMI", got.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPicker_ShowsDefaultAndCurrentAnnotations(t *testing.T) {
|
||||
s := newDevicePicker(listOK, "HDMI", true)
|
||||
out := renderPicker(s, 80)
|
||||
// Built-in Analog carries (default), HDMI carries (current).
|
||||
if !strings.Contains(out, "Built-in Analog (default)") {
|
||||
t.Errorf("expected '(default)' annotation; got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "HDMI (current)") {
|
||||
t.Errorf("expected '(current)' annotation on HDMI; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPicker_AnnotatesBothWhenCurrentIsDefault(t *testing.T) {
|
||||
s := newDevicePicker(listOK, "Built-in Analog", true)
|
||||
out := renderPicker(s, 80)
|
||||
if !strings.Contains(out, "Built-in Analog (default, current)") {
|
||||
t.Errorf("expected combined annotation; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPicker_EmptyAndErrorStates(t *testing.T) {
|
||||
empty := newDevicePicker(func() ([]output.PlaybackDevice, error) { return nil, nil }, "", true)
|
||||
emptyOut := renderPicker(empty, 80)
|
||||
if !strings.Contains(emptyOut, "no playback devices found") {
|
||||
t.Errorf("empty state did not render expected message; got:\n%s", emptyOut)
|
||||
}
|
||||
|
||||
errState := newDevicePicker(func() ([]output.PlaybackDevice, error) {
|
||||
return nil, errors.New("enumeration boom")
|
||||
}, "", true)
|
||||
errOut := renderPicker(errState, 80)
|
||||
if !strings.Contains(errOut, "enumeration boom") {
|
||||
t.Errorf("error state did not render error; got:\n%s", errOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPicker_SaveUnavailableHint(t *testing.T) {
|
||||
s := newDevicePicker(listOK, "", false)
|
||||
out := renderPicker(s, 80)
|
||||
if !strings.Contains(out, "save unavailable") {
|
||||
t.Errorf("expected 'save unavailable' hint when saveEnabled=false; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// The two tests below integrate picker state with the Model's key dispatch
|
||||
// to verify the end-to-end "press o → navigate → save" flow without
|
||||
// standing up a full bubbletea runtime.
|
||||
|
||||
func TestModel_OKeyOpensPicker(t *testing.T) {
|
||||
m := NewModel(Config{ConfigPath: "/tmp/test-player.yaml"})
|
||||
m.listPlaybackDevices = listOK
|
||||
m.width = 80
|
||||
|
||||
next, _ := m.handleKey(fakeKeyMsg("o"))
|
||||
nm := next.(Model)
|
||||
if nm.mode != modeDevicePicker {
|
||||
t.Errorf("mode = %v, want modeDevicePicker", nm.mode)
|
||||
}
|
||||
if len(nm.picker.devices) != 3 {
|
||||
t.Errorf("picker.devices len = %d, want 3", len(nm.picker.devices))
|
||||
}
|
||||
}
|
||||
|
||||
func TestModel_EnterInPickerSavesAndClosesModal(t *testing.T) {
|
||||
var savedPath, savedKey, savedValue string
|
||||
writer := func(path, key, value string) error {
|
||||
savedPath, savedKey, savedValue = path, key, value
|
||||
return nil
|
||||
}
|
||||
|
||||
// Seed the current device so the picker opens highlighted on "USB Audio"
|
||||
// (index 0). One "down" then lands on the IsDefault entry at index 1.
|
||||
m := NewModel(Config{ConfigPath: "/tmp/test-player.yaml", AudioDevice: "USB Audio"})
|
||||
m.listPlaybackDevices = listOK
|
||||
m.writeConfigKey = writer
|
||||
m.width = 80
|
||||
|
||||
// Open picker.
|
||||
next, _ := m.handleKey(fakeKeyMsg("o"))
|
||||
m = next.(Model)
|
||||
|
||||
// Navigate down to Built-in Analog (index 1).
|
||||
next, _ = m.handleKey(fakeKeyMsg("down"))
|
||||
m = next.(Model)
|
||||
|
||||
// Enter saves. Returns a Cmd (the 3s expire tick); we don't invoke it.
|
||||
next, cmd := m.handleKey(fakeKeyMsg("enter"))
|
||||
m = next.(Model)
|
||||
|
||||
if m.mode != modeNormal {
|
||||
t.Errorf("mode after save = %v, want modeNormal", m.mode)
|
||||
}
|
||||
if savedPath != "/tmp/test-player.yaml" || savedKey != "audio_device" || savedValue != "Built-in Analog" {
|
||||
t.Errorf("write recorded (%q, %q, %q); want (/tmp/test-player.yaml, audio_device, Built-in Analog)",
|
||||
savedPath, savedKey, savedValue)
|
||||
}
|
||||
if m.transientMsg == "" {
|
||||
t.Error("transient banner should be set after save")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Error("save should schedule a transient-expire tick")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModel_EnterWhenSelectionMatchesSkipsWrite(t *testing.T) {
|
||||
writeCalled := false
|
||||
writer := func(path, key, value string) error {
|
||||
writeCalled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Current is the HDMI entry; picker seeds selection there.
|
||||
m := NewModel(Config{ConfigPath: "/tmp/test.yaml", AudioDevice: "HDMI"})
|
||||
m.listPlaybackDevices = listOK
|
||||
m.writeConfigKey = writer
|
||||
m.width = 80
|
||||
|
||||
next, _ := m.handleKey(fakeKeyMsg("o"))
|
||||
m = next.(Model)
|
||||
next, _ = m.handleKey(fakeKeyMsg("enter"))
|
||||
m = next.(Model)
|
||||
|
||||
if writeCalled {
|
||||
t.Error("writeConfigKey should not be called when selection matches current")
|
||||
}
|
||||
if m.mode != modeNormal {
|
||||
t.Errorf("mode = %v, want modeNormal", m.mode)
|
||||
}
|
||||
}
|
||||
115
third_party/sendspin-go/internal/ui/hotkey.go
vendored
Normal file
115
third_party/sendspin-go/internal/ui/hotkey.go
vendored
Normal file
@@ -0,0 +1,115 @@
|
||||
// ABOUTME: Hotkey label rendering helper — marks the trigger character with reverse video
|
||||
// ABOUTME: Used by all TUI labels that advertise a keybinding so the cue is uniform
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// ANSI escape sequences for SGR reverse-video. Kept here rather than in a
|
||||
// styling library because we only need two codes — pulling in lipgloss for
|
||||
// this would be overkill.
|
||||
const (
|
||||
ansiReverse = "\x1b[7m"
|
||||
ansiReset = "\x1b[0m"
|
||||
)
|
||||
|
||||
// hotkey renders a human-readable label with its trigger key reverse-
|
||||
// highlighted. The visual cue — a single inverted character embedded in the
|
||||
// label — is uniform across every keybinding in the TUI so users learn the
|
||||
// pattern once.
|
||||
//
|
||||
// Resolution rules:
|
||||
// - Printable letter trigger with a case-insensitive match in the label:
|
||||
// highlight the first occurrence in place.
|
||||
// hotkey('o', "Output") -> "\x1b[7mO\x1b[0mutput"
|
||||
// - Printable letter trigger NOT present in the label:
|
||||
// fall back to a bracket prefix.
|
||||
// hotkey('q', "Bye") -> "[\x1b[7mQ\x1b[0m] Bye"
|
||||
// - Non-letter triggers (space, arrow sentinels, etc.) always use the
|
||||
// bracket form with a friendly name for the key.
|
||||
// hotkey(KeySpace, "Play/Pause") -> "[\x1b[7mSpace\x1b[0m] Play/Pause"
|
||||
//
|
||||
// Empty labels still render the bracket prefix so the binding is visible.
|
||||
func hotkey(trigger rune, label string) string {
|
||||
if name, ok := specialKeyName(trigger); ok {
|
||||
return bracketPrefix(name, label)
|
||||
}
|
||||
if !unicode.IsLetter(trigger) && !unicode.IsDigit(trigger) {
|
||||
// Punctuation or symbols — use bracket form with the literal rune.
|
||||
return bracketPrefix(string(trigger), label)
|
||||
}
|
||||
if idx := firstCaseFoldIndex(label, trigger); idx >= 0 {
|
||||
r, width := runeAt(label, idx)
|
||||
return label[:idx] + ansiReverse + string(r) + ansiReset + label[idx+width:]
|
||||
}
|
||||
return bracketPrefix(strings.ToUpper(string(trigger)), label)
|
||||
}
|
||||
|
||||
// Special-key sentinels. These aren't valid Unicode code points that could
|
||||
// appear in a label, so overloading a rune-sized value is safe. Any value
|
||||
// outside the printable range works; using specific private-use-area code
|
||||
// points keeps them self-documenting in stack traces.
|
||||
const (
|
||||
KeySpace rune = '\uE000' // "Space"
|
||||
KeyUp rune = '\uE001' // "↑"
|
||||
KeyDown rune = '\uE002' // "↓"
|
||||
KeyUpDown rune = '\uE003' // "↑↓"
|
||||
KeyEnter rune = '\uE004' // "Enter"
|
||||
KeyEsc rune = '\uE005' // "Esc"
|
||||
)
|
||||
|
||||
func specialKeyName(r rune) (string, bool) {
|
||||
switch r {
|
||||
case KeySpace:
|
||||
return "Space", true
|
||||
case KeyUp:
|
||||
return "\u2191", true
|
||||
case KeyDown:
|
||||
return "\u2193", true
|
||||
case KeyUpDown:
|
||||
return "\u2191\u2193", true
|
||||
case KeyEnter:
|
||||
return "Enter", true
|
||||
case KeyEsc:
|
||||
return "Esc", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// bracketPrefix renders "[<highlighted keyName>] label" with the key name
|
||||
// reverse-highlighted. When label is empty, emits just the bracket (still
|
||||
// useful so the binding is visible in compact hint rows).
|
||||
func bracketPrefix(keyName, label string) string {
|
||||
prefix := "[" + ansiReverse + keyName + ansiReset + "]"
|
||||
if label == "" {
|
||||
return prefix
|
||||
}
|
||||
return prefix + " " + label
|
||||
}
|
||||
|
||||
// firstCaseFoldIndex returns the byte index of the first rune in s that
|
||||
// case-folds equal to needle, or -1 if not found. Works for any letter —
|
||||
// multi-byte ones included, though this codebase only uses ASCII triggers.
|
||||
func firstCaseFoldIndex(s string, needle rune) int {
|
||||
target := unicode.ToLower(needle)
|
||||
for i, r := range s {
|
||||
if unicode.ToLower(r) == target {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// runeAt returns the rune at byte index i and its encoded byte width. Only
|
||||
// called after firstCaseFoldIndex has confirmed a match, so the index is
|
||||
// guaranteed to be on a rune boundary.
|
||||
func runeAt(s string, i int) (rune, int) {
|
||||
for j, r := range s {
|
||||
if j == i {
|
||||
return r, len(string(r))
|
||||
}
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
123
third_party/sendspin-go/internal/ui/hotkey_test.go
vendored
Normal file
123
third_party/sendspin-go/internal/ui/hotkey_test.go
vendored
Normal file
@@ -0,0 +1,123 @@
|
||||
// ABOUTME: Tests for the hotkey label helper — in-place reverse highlighting
|
||||
package ui
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHotkey_LetterInLabel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
trigger rune
|
||||
label string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "first letter match",
|
||||
trigger: 'o',
|
||||
label: "Output",
|
||||
want: ansiReverse + "O" + ansiReset + "utput",
|
||||
},
|
||||
{
|
||||
name: "case-insensitive — lowercase trigger, uppercase in label",
|
||||
trigger: 'q',
|
||||
label: "Quit",
|
||||
want: ansiReverse + "Q" + ansiReset + "uit",
|
||||
},
|
||||
{
|
||||
name: "case-insensitive — uppercase trigger, lowercase in label",
|
||||
trigger: 'E',
|
||||
label: "enter",
|
||||
want: ansiReverse + "e" + ansiReset + "nter",
|
||||
},
|
||||
{
|
||||
name: "trigger matches a non-initial letter",
|
||||
trigger: 'u',
|
||||
label: "Quit",
|
||||
want: "Q" + ansiReverse + "u" + ansiReset + "it",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := hotkey(tt.trigger, tt.label)
|
||||
if got != tt.want {
|
||||
t.Errorf("hotkey(%q, %q) =\n %q\nwant\n %q", tt.trigger, tt.label, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHotkey_LetterNotInLabel(t *testing.T) {
|
||||
got := hotkey('x', "Bye")
|
||||
want := "[" + ansiReverse + "X" + ansiReset + "] Bye"
|
||||
if got != want {
|
||||
t.Errorf("missing-letter fallback =\n %q\nwant\n %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHotkey_SpecialKeys(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
trigger rune
|
||||
label string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "space",
|
||||
trigger: KeySpace,
|
||||
label: "Play/Pause",
|
||||
want: "[" + ansiReverse + "Space" + ansiReset + "] Play/Pause",
|
||||
},
|
||||
{
|
||||
name: "up-down combined",
|
||||
trigger: KeyUpDown,
|
||||
label: "Volume",
|
||||
want: "[" + ansiReverse + "\u2191\u2193" + ansiReset + "] Volume",
|
||||
},
|
||||
{
|
||||
name: "enter",
|
||||
trigger: KeyEnter,
|
||||
label: "Save",
|
||||
want: "[" + ansiReverse + "Enter" + ansiReset + "] Save",
|
||||
},
|
||||
{
|
||||
name: "esc",
|
||||
trigger: KeyEsc,
|
||||
label: "Cancel",
|
||||
want: "[" + ansiReverse + "Esc" + ansiReset + "] Cancel",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := hotkey(tt.trigger, tt.label)
|
||||
if got != tt.want {
|
||||
t.Errorf("hotkey(%q) =\n %q\nwant\n %q", tt.trigger, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHotkey_EmptyLabel(t *testing.T) {
|
||||
// Letter with empty label uses the bracket fallback since nothing to match.
|
||||
got := hotkey('q', "")
|
||||
want := "[" + ansiReverse + "Q" + ansiReset + "]"
|
||||
if got != want {
|
||||
t.Errorf("empty label with letter =\n %q\nwant\n %q", got, want)
|
||||
}
|
||||
|
||||
// Special key with empty label uses bracket without trailing space.
|
||||
got = hotkey(KeyEsc, "")
|
||||
want = "[" + ansiReverse + "Esc" + ansiReset + "]"
|
||||
if got != want {
|
||||
t.Errorf("empty label with special =\n %q\nwant\n %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHotkey_Symbol(t *testing.T) {
|
||||
// Symbols aren't letters; they use the bracket prefix with the literal rune.
|
||||
got := hotkey('/', "Slash")
|
||||
want := "[" + ansiReverse + "/" + ansiReset + "] Slash"
|
||||
if got != want {
|
||||
t.Errorf("symbol trigger =\n %q\nwant\n %q", got, want)
|
||||
}
|
||||
}
|
||||
625
third_party/sendspin-go/internal/ui/model.go
vendored
Normal file
625
third_party/sendspin-go/internal/ui/model.go
vendored
Normal file
@@ -0,0 +1,625 @@
|
||||
// ABOUTME: Bubbletea model for player TUI
|
||||
// ABOUTME: Defines application state and update logic
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/audio/output"
|
||||
"github.com/Sendspin/sendspin-go/pkg/sendspin"
|
||||
"github.com/Sendspin/sendspin-go/pkg/sync"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
type uiMode int
|
||||
|
||||
const (
|
||||
modeNormal uiMode = iota
|
||||
modeDevicePicker
|
||||
)
|
||||
|
||||
// transientExpireMsg is fired after a transient banner's lifetime to clear
|
||||
// it. Bubbletea's scheduler guarantees at-most-once delivery.
|
||||
type transientExpireMsg struct{ nonce uint64 }
|
||||
|
||||
// Model represents the TUI state
|
||||
type Model struct {
|
||||
// Connection
|
||||
connected bool
|
||||
serverName string
|
||||
|
||||
// Sync
|
||||
syncRTT int64
|
||||
syncQuality sync.Quality
|
||||
|
||||
// Stream
|
||||
codec string
|
||||
sampleRate int
|
||||
channels int
|
||||
bitDepth int
|
||||
|
||||
// Metadata
|
||||
title string
|
||||
artist string
|
||||
album string
|
||||
artworkPath string
|
||||
|
||||
// Playback
|
||||
state string
|
||||
playbackState string // "playing", "paused", "stopped", "idle", "reconnecting"
|
||||
volume int
|
||||
muted bool
|
||||
|
||||
// Stats
|
||||
received int64
|
||||
played int64
|
||||
dropped int64
|
||||
bufferDepth int
|
||||
|
||||
// Debug
|
||||
showDebug bool
|
||||
goroutines int
|
||||
|
||||
// Dimensions
|
||||
width int
|
||||
height int
|
||||
|
||||
// Controls
|
||||
volumeCtrl *VolumeControl
|
||||
transportCtrl *TransportControl
|
||||
|
||||
// Audio device selection
|
||||
audioDevice string // current device driving playback; shown in status row
|
||||
configPath string // where picker saves audio_device; empty disables save
|
||||
|
||||
// Modal state
|
||||
mode uiMode
|
||||
picker devicePickerState
|
||||
|
||||
// Transient banner shown in the controls area (e.g. "Saved. Restart to apply.")
|
||||
transientMsg string
|
||||
transientNonce uint64 // incremented per banner so stale expire ticks are ignored
|
||||
|
||||
// listPlaybackDevices is indirected so tests can stub it.
|
||||
listPlaybackDevices func() ([]output.PlaybackDevice, error)
|
||||
// writeConfigKey persists a key to the config file. Indirected for tests.
|
||||
writeConfigKey func(path, key, value string) error
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
return m.handleKey(msg)
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
case StatusMsg:
|
||||
m.applyStatus(msg)
|
||||
case transientExpireMsg:
|
||||
if msg.nonce == m.transientNonce {
|
||||
m.transientMsg = ""
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
if m.width == 0 {
|
||||
return "Loading..."
|
||||
}
|
||||
|
||||
if m.mode == modeDevicePicker {
|
||||
// Modal takes over the viewport; the normal view is suspended. A
|
||||
// minimal header keeps context ("which player am I configuring?").
|
||||
return m.renderHeader() + renderPicker(m.picker, m.width)
|
||||
}
|
||||
|
||||
s := ""
|
||||
s += m.renderHeader()
|
||||
s += m.renderStreamInfo()
|
||||
s += m.renderControls()
|
||||
s += m.renderStats()
|
||||
|
||||
if m.showDebug {
|
||||
s += m.renderDebug()
|
||||
}
|
||||
|
||||
s += m.renderHelp()
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (m Model) renderHeader() string {
|
||||
connStatus := "Disconnected"
|
||||
if m.connected {
|
||||
connStatus = fmt.Sprintf("Connected to %s", m.serverName)
|
||||
}
|
||||
|
||||
stateDisplay := playbackStateDisplay(m.playbackState)
|
||||
|
||||
syncDisplay := "Sync: \u2717 Lost"
|
||||
switch m.syncQuality {
|
||||
case sync.QualityGood:
|
||||
syncDisplay = fmt.Sprintf("Sync: \u2713 Good (RTT: %.1fms)", float64(m.syncRTT)/1000.0)
|
||||
case sync.QualityDegraded:
|
||||
syncDisplay = "Sync: \u26a0 Degraded"
|
||||
}
|
||||
|
||||
// Use terminal width for responsive layout
|
||||
width := m.width
|
||||
if width < 60 {
|
||||
width = 60 // Minimum width
|
||||
}
|
||||
innerWidth := width - 4 // Account for borders
|
||||
|
||||
titleWidth := width - 20 // Space for "┌─ Sendspin Player " prefix
|
||||
title := "\u250c\u2500 Sendspin Player " + repeatString("\u2500", titleWidth) + "\u2510\n"
|
||||
|
||||
statusLine := fmt.Sprintf("\u2502 Status: %-*s \u2502\n", innerWidth-9, truncate(connStatus, innerWidth-9))
|
||||
|
||||
// State + Sync on same line
|
||||
statePrefix := fmt.Sprintf("State: %s", stateDisplay)
|
||||
// Calculate available space: innerWidth minus "State: <state>" minus spacing minus sync display
|
||||
stateSyncLine := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth,
|
||||
fmt.Sprintf("%-30s %s", statePrefix, syncDisplay))
|
||||
|
||||
separator := "\u251c" + repeatString("\u2500", width-2) + "\u2524\n"
|
||||
|
||||
return title + statusLine + stateSyncLine + separator
|
||||
}
|
||||
|
||||
func (m Model) renderStreamInfo() string {
|
||||
width := m.width
|
||||
if width < 60 {
|
||||
width = 60
|
||||
}
|
||||
innerWidth := width - 4
|
||||
|
||||
if !m.connected || m.codec == "" {
|
||||
return fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, "No stream")
|
||||
}
|
||||
|
||||
s := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, "Now Playing:")
|
||||
if m.title != "" {
|
||||
metaWidth := innerWidth - 10 // Account for " Track: " prefix
|
||||
s += fmt.Sprintf("\u2502 Track: %-*s \u2502\n", innerWidth-10, truncate(m.title, metaWidth))
|
||||
s += fmt.Sprintf("\u2502 Artist: %-*s \u2502\n", innerWidth-10, truncate(m.artist, metaWidth))
|
||||
s += fmt.Sprintf("\u2502 Album: %-*s \u2502\n", innerWidth-10, truncate(m.album, metaWidth))
|
||||
if m.artworkPath != "" {
|
||||
s += fmt.Sprintf("\u2502 Art: %-*s \u2502\n", innerWidth-10, truncate(m.artworkPath, metaWidth))
|
||||
}
|
||||
} else {
|
||||
s += fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth-3, "(No metadata)")
|
||||
}
|
||||
|
||||
s += fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, "")
|
||||
formatStr := formatCodecDisplay(m.codec, m.sampleRate, m.bitDepth)
|
||||
s += fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, formatStr)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (m Model) renderControls() string {
|
||||
width := m.width
|
||||
if width < 60 {
|
||||
width = 60
|
||||
}
|
||||
innerWidth := width - 4
|
||||
|
||||
muteIcon := ""
|
||||
if m.muted {
|
||||
muteIcon = " \U0001f507"
|
||||
}
|
||||
|
||||
volumeBar := renderBar(m.volume, 100, 20)
|
||||
|
||||
s := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, "")
|
||||
volumeStr := fmt.Sprintf("Volume: [%s] %d%%%s", volumeBar, m.volume, muteIcon)
|
||||
s += fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, volumeStr)
|
||||
|
||||
bufferStr := fmt.Sprintf("Buffer: %dms", m.bufferDepth)
|
||||
s += fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, bufferStr)
|
||||
|
||||
// Output device status row. Uses the hotkey helper so the 'O' rendering
|
||||
// stays in sync with the help line that advertises the trigger.
|
||||
outputName := m.audioDevice
|
||||
if outputName == "" {
|
||||
outputName = "(miniaudio default)"
|
||||
}
|
||||
outputLine := hotkey('o', "Output: "+truncate(outputName, innerWidth-10))
|
||||
s += renderLineWithANSI(innerWidth, outputLine)
|
||||
|
||||
if m.transientMsg != "" {
|
||||
s += renderLineWithANSI(innerWidth, " "+m.transientMsg)
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// renderLineWithANSI emits a bordered row whose contents contain ANSI
|
||||
// escape sequences. fmt's padding verbs count those escape bytes toward
|
||||
// width, leading to short rows; this helper pads using visible-column
|
||||
// count instead so every border stays aligned.
|
||||
func renderLineWithANSI(innerWidth int, content string) string {
|
||||
pad := innerWidth - displayLen(content)
|
||||
if pad < 0 {
|
||||
pad = 0
|
||||
}
|
||||
return fmt.Sprintf("\u2502 %s%s \u2502\n", content, strings.Repeat(" ", pad))
|
||||
}
|
||||
|
||||
func (m Model) renderStats() string {
|
||||
width := m.width
|
||||
if width < 60 {
|
||||
width = 60
|
||||
}
|
||||
innerWidth := width - 4
|
||||
|
||||
separator := "\u251c" + repeatString("\u2500", width-2) + "\u2524\n"
|
||||
statsStr := fmt.Sprintf("Stats: RX: %d Played: %d Dropped: %d", m.received, m.played, m.dropped)
|
||||
statsLine := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, statsStr)
|
||||
emptyLine := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, "")
|
||||
|
||||
return separator + statsLine + emptyLine
|
||||
}
|
||||
|
||||
func (m Model) renderHelp() string {
|
||||
width := m.width
|
||||
if width < 60 {
|
||||
width = 60
|
||||
}
|
||||
innerWidth := width - 4
|
||||
|
||||
// Uniform hotkey rendering — every trigger character is reverse-
|
||||
// highlighted inline via hotkey(). Separators are two spaces; we rely
|
||||
// on renderLineWithANSI for correct padding because hotkey() emits
|
||||
// escape codes that fmt would miscount.
|
||||
parts := []string{
|
||||
hotkey(KeySpace, "Play/Pause"),
|
||||
hotkey('n', "Next"),
|
||||
hotkey('p', "Prev"),
|
||||
hotkey(KeyUpDown, "Volume"),
|
||||
hotkey('m', "Mute"),
|
||||
hotkey('o', "Output"),
|
||||
hotkey('q', "Quit"),
|
||||
}
|
||||
helpStr := strings.Join(parts, " ")
|
||||
helpLine := renderLineWithANSI(innerWidth, helpStr)
|
||||
bottom := "\u2514" + repeatString("\u2500", width-2) + "\u2518\n"
|
||||
|
||||
return helpLine + bottom
|
||||
}
|
||||
|
||||
func (m Model) renderDebug() string {
|
||||
width := m.width
|
||||
if width < 60 {
|
||||
width = 60
|
||||
}
|
||||
innerWidth := width - 4
|
||||
|
||||
debugTitle := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, "DEBUG:")
|
||||
goroutineStr := fmt.Sprintf(" Goroutines: %d", m.goroutines)
|
||||
goroutineLine := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, goroutineStr)
|
||||
playbackStr := fmt.Sprintf(" Playback: %s", m.playbackState)
|
||||
playbackLine := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, playbackStr)
|
||||
codecStr := fmt.Sprintf(" Preferred codec: %s", m.codec)
|
||||
codecLine := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, codecStr)
|
||||
|
||||
return debugTitle + goroutineLine + playbackLine + codecLine
|
||||
}
|
||||
|
||||
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if m.mode == modeDevicePicker {
|
||||
return m.handlePickerMode(msg)
|
||||
}
|
||||
switch msg.String() {
|
||||
case "o":
|
||||
return m.openDevicePicker()
|
||||
case "q", "ctrl+c":
|
||||
if m.volumeCtrl != nil {
|
||||
select {
|
||||
case m.volumeCtrl.Quit <- QuitMsg{}:
|
||||
default:
|
||||
// Channel full, skip
|
||||
}
|
||||
}
|
||||
return m, tea.Quit
|
||||
case "up":
|
||||
if m.volume < 100 {
|
||||
m.volume += 5
|
||||
if m.volume > 100 {
|
||||
m.volume = 100
|
||||
}
|
||||
if m.volumeCtrl != nil {
|
||||
select {
|
||||
case m.volumeCtrl.Changes <- VolumeChangeMsg{Volume: m.volume, Muted: m.muted}:
|
||||
default:
|
||||
// Channel full, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
case "down":
|
||||
if m.volume > 0 {
|
||||
m.volume -= 5
|
||||
if m.volume < 0 {
|
||||
m.volume = 0
|
||||
}
|
||||
if m.volumeCtrl != nil {
|
||||
select {
|
||||
case m.volumeCtrl.Changes <- VolumeChangeMsg{Volume: m.volume, Muted: m.muted}:
|
||||
default:
|
||||
// Channel full, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
case "m":
|
||||
m.muted = !m.muted
|
||||
// Send volume change to player via channel
|
||||
if m.volumeCtrl != nil {
|
||||
select {
|
||||
case m.volumeCtrl.Changes <- VolumeChangeMsg{Volume: m.volume, Muted: m.muted}:
|
||||
default:
|
||||
// Channel full, skip
|
||||
}
|
||||
}
|
||||
case " ":
|
||||
if m.transportCtrl != nil {
|
||||
select {
|
||||
case m.transportCtrl.Commands <- TransportMsg{Command: "toggle"}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
case "n":
|
||||
if m.transportCtrl != nil {
|
||||
select {
|
||||
case m.transportCtrl.Commands <- TransportMsg{Command: "next"}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
case "p":
|
||||
if m.transportCtrl != nil {
|
||||
select {
|
||||
case m.transportCtrl.Commands <- TransportMsg{Command: "previous"}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
case "r":
|
||||
if m.transportCtrl != nil {
|
||||
select {
|
||||
case m.transportCtrl.Commands <- TransportMsg{Command: "reconnect"}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
case "d":
|
||||
m.showDebug = !m.showDebug
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) applyStatus(msg StatusMsg) {
|
||||
if msg.Connected != nil {
|
||||
m.connected = *msg.Connected
|
||||
}
|
||||
if msg.ServerName != "" {
|
||||
m.serverName = msg.ServerName
|
||||
}
|
||||
if msg.PlaybackState != "" {
|
||||
m.playbackState = msg.PlaybackState
|
||||
}
|
||||
// Sync stats are always applied when sent
|
||||
if msg.SyncRTT != 0 {
|
||||
m.syncRTT = msg.SyncRTT
|
||||
m.syncQuality = msg.SyncQuality
|
||||
}
|
||||
if msg.Codec != "" {
|
||||
m.codec = msg.Codec
|
||||
m.sampleRate = msg.SampleRate
|
||||
m.channels = msg.Channels
|
||||
m.bitDepth = msg.BitDepth
|
||||
}
|
||||
if msg.Title != "" {
|
||||
m.title = msg.Title
|
||||
m.artist = msg.Artist
|
||||
m.album = msg.Album
|
||||
}
|
||||
if msg.ArtworkPath != "" {
|
||||
m.artworkPath = msg.ArtworkPath
|
||||
}
|
||||
// Volume is always applied when explicitly sent (can be 0 for silent)
|
||||
// We rely on caller not sending Volume=0 in messages unless it's intentional
|
||||
if msg.Volume != 0 {
|
||||
m.volume = msg.Volume
|
||||
}
|
||||
// Always apply stats - they can legitimately be zero
|
||||
m.received = msg.Received
|
||||
m.played = msg.Played
|
||||
m.dropped = msg.Dropped
|
||||
m.bufferDepth = msg.BufferDepth
|
||||
m.goroutines = msg.Goroutines
|
||||
}
|
||||
|
||||
type StatusMsg struct {
|
||||
Connected *bool
|
||||
ServerName string
|
||||
PlaybackState string
|
||||
SyncRTT int64
|
||||
SyncQuality sync.Quality
|
||||
Codec string
|
||||
SampleRate int
|
||||
Channels int
|
||||
BitDepth int
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
ArtworkPath string
|
||||
Volume int
|
||||
Received int64
|
||||
Played int64
|
||||
Dropped int64
|
||||
BufferDepth int
|
||||
Goroutines int
|
||||
}
|
||||
|
||||
type VolumeChangeMsg struct {
|
||||
Volume int
|
||||
Muted bool
|
||||
}
|
||||
|
||||
type QuitMsg struct{}
|
||||
|
||||
func renderBar(value, max, width int) string {
|
||||
filled := (value * width) / max
|
||||
return strings.Repeat("\u2588", filled) + strings.Repeat("\u2591", width-filled)
|
||||
}
|
||||
|
||||
func truncate(s string, length int) string {
|
||||
if len(s) <= length {
|
||||
return s
|
||||
}
|
||||
return s[:length-3] + "..."
|
||||
}
|
||||
|
||||
func channelName(channels int) string {
|
||||
if channels == 1 {
|
||||
return "Mono"
|
||||
}
|
||||
return "Stereo"
|
||||
}
|
||||
|
||||
func repeatString(s string, count int) string {
|
||||
if count <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Repeat(s, count)
|
||||
}
|
||||
|
||||
// playbackStateDisplay returns a display string with icon for the given playback state.
|
||||
func playbackStateDisplay(state string) string {
|
||||
switch state {
|
||||
case "playing":
|
||||
return "\u25b6 Playing"
|
||||
case "paused":
|
||||
return "\u23f8 Paused"
|
||||
case "stopped":
|
||||
return "\u23f9 Stopped"
|
||||
case "idle":
|
||||
return "\u25cf Idle"
|
||||
case "reconnecting":
|
||||
return "\u21bb Reconnecting..."
|
||||
default:
|
||||
return "\u25cf Idle"
|
||||
}
|
||||
}
|
||||
|
||||
// formatSampleRate returns a human-friendly sample rate string.
|
||||
func formatSampleRate(rate int) string {
|
||||
switch rate {
|
||||
case 44100:
|
||||
return "44.1kHz"
|
||||
case 48000:
|
||||
return "48kHz"
|
||||
case 88200:
|
||||
return "88.2kHz"
|
||||
case 96000:
|
||||
return "96kHz"
|
||||
case 176400:
|
||||
return "176.4kHz"
|
||||
case 192000:
|
||||
return "192kHz"
|
||||
default:
|
||||
if rate%1000 == 0 {
|
||||
return fmt.Sprintf("%dkHz", rate/1000)
|
||||
}
|
||||
return fmt.Sprintf("%.1fkHz", float64(rate)/1000.0)
|
||||
}
|
||||
}
|
||||
|
||||
// formatCodecDisplay returns a rich codec description string.
|
||||
func formatCodecDisplay(codec string, sampleRate, bitDepth int) string {
|
||||
rate := formatSampleRate(sampleRate)
|
||||
upper := strings.ToUpper(codec)
|
||||
|
||||
switch strings.ToLower(codec) {
|
||||
case "pcm":
|
||||
return fmt.Sprintf("%s %s/%dbit lossless", upper, rate, bitDepth)
|
||||
case "flac":
|
||||
return fmt.Sprintf("%s %s/%dbit lossless", upper, rate, bitDepth)
|
||||
case "opus":
|
||||
return fmt.Sprintf("%s %s/%dbit", upper, rate, bitDepth)
|
||||
default:
|
||||
return fmt.Sprintf("%s %s/%dbit", upper, rate, bitDepth)
|
||||
}
|
||||
}
|
||||
|
||||
// openDevicePicker transitions the Model into modeDevicePicker, enumerating
|
||||
// available devices via the injected lister (defaulting to the real
|
||||
// output.ListPlaybackDevices). The picker's own state tracks enumeration
|
||||
// errors, so we never fail to enter the modal — the user always gets to
|
||||
// see *something*, even if it's "couldn't enumerate devices".
|
||||
func (m Model) openDevicePicker() (tea.Model, tea.Cmd) {
|
||||
lister := m.listPlaybackDevices
|
||||
if lister == nil {
|
||||
lister = output.ListPlaybackDevices
|
||||
}
|
||||
saveEnabled := m.configPath != ""
|
||||
m.picker = newDevicePicker(lister, m.audioDevice, saveEnabled)
|
||||
m.mode = modeDevicePicker
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// handlePickerMode dispatches a key to the picker's pure state machine and
|
||||
// executes the requested action. Save writes audio_device to the config
|
||||
// file and shows a transient "Saved. Restart to apply." banner for 3s.
|
||||
func (m Model) handlePickerMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
next, action := m.picker.handlePickerKey(msg.String())
|
||||
m.picker = next
|
||||
switch action {
|
||||
case pickerNoop:
|
||||
return m, nil
|
||||
case pickerClose, pickerPropagate:
|
||||
m.mode = modeNormal
|
||||
return m, nil
|
||||
case pickerSave:
|
||||
chosen := m.picker.selected()
|
||||
if chosen == nil {
|
||||
m.mode = modeNormal
|
||||
return m, nil
|
||||
}
|
||||
writer := m.writeConfigKey
|
||||
if writer == nil {
|
||||
writer = sendspin.WriteStringKey
|
||||
}
|
||||
if chosen.Name != m.audioDevice {
|
||||
// No-op when the chosen value already matches what's saved —
|
||||
// matches the client_id write-back behavior and avoids
|
||||
// churning user-edited config files for no reason.
|
||||
if err := writer(m.configPath, "audio_device", chosen.Name); err != nil {
|
||||
m.mode = modeNormal
|
||||
return m.withTransient(fmt.Sprintf("Save failed: %v", err))
|
||||
}
|
||||
}
|
||||
m.mode = modeNormal
|
||||
return m.withTransient(fmt.Sprintf("Saved audio_device=%q. Restart to apply.", chosen.Name))
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// withTransient schedules msg to be shown in the status row for ~3 seconds.
|
||||
// The nonce guards against a later expire firing for a previously-shown
|
||||
// banner that was already overwritten by a newer one.
|
||||
func (m Model) withTransient(msg string) (tea.Model, tea.Cmd) {
|
||||
m.transientNonce++
|
||||
m.transientMsg = msg
|
||||
nonce := m.transientNonce
|
||||
return m, tea.Tick(3*time.Second, func(time.Time) tea.Msg {
|
||||
return transientExpireMsg{nonce: nonce}
|
||||
})
|
||||
}
|
||||
561
third_party/sendspin-go/internal/ui/model_test.go
vendored
Normal file
561
third_party/sendspin-go/internal/ui/model_test.go
vendored
Normal file
@@ -0,0 +1,561 @@
|
||||
// ABOUTME: Tests for TUI model and state management
|
||||
// ABOUTME: Tests status updates, message handling, and state transitions
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/sync"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
func TestNewModel(t *testing.T) {
|
||||
model := NewModel(Config{}) // VolumeControl and TransportControl are optional for testing
|
||||
|
||||
// Check initial state
|
||||
if model.connected {
|
||||
t.Error("expected connected to be false initially")
|
||||
}
|
||||
|
||||
if model.volume != 100 {
|
||||
t.Errorf("expected default volume 100, got %d", model.volume)
|
||||
}
|
||||
|
||||
if model.muted {
|
||||
t.Error("expected muted to be false initially")
|
||||
}
|
||||
|
||||
if model.showDebug {
|
||||
t.Error("expected showDebug to be false initially")
|
||||
}
|
||||
|
||||
if model.playbackState != "idle" {
|
||||
t.Errorf("expected playbackState 'idle', got '%s'", model.playbackState)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusMsgConnected(t *testing.T) {
|
||||
model := NewModel(Config{})
|
||||
|
||||
connected := true
|
||||
msg := StatusMsg{
|
||||
Connected: &connected,
|
||||
ServerName: "test-server",
|
||||
}
|
||||
|
||||
model.applyStatus(msg)
|
||||
|
||||
if !model.connected {
|
||||
t.Error("expected connected to be true after status update")
|
||||
}
|
||||
|
||||
if model.serverName != "test-server" {
|
||||
t.Errorf("expected serverName 'test-server', got '%s'", model.serverName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusMsgDisconnected(t *testing.T) {
|
||||
model := NewModel(Config{})
|
||||
|
||||
// First connect
|
||||
connected := true
|
||||
model.applyStatus(StatusMsg{Connected: &connected})
|
||||
|
||||
// Then disconnect
|
||||
disconnected := false
|
||||
model.applyStatus(StatusMsg{Connected: &disconnected})
|
||||
|
||||
if model.connected {
|
||||
t.Error("expected connected to be false after disconnect")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusMsgSyncStats(t *testing.T) {
|
||||
model := NewModel(Config{})
|
||||
|
||||
msg := StatusMsg{
|
||||
SyncRTT: 5000,
|
||||
SyncQuality: sync.QualityGood,
|
||||
}
|
||||
|
||||
model.applyStatus(msg)
|
||||
|
||||
if model.syncRTT != 5000 {
|
||||
t.Errorf("expected syncRTT 5000, got %d", model.syncRTT)
|
||||
}
|
||||
|
||||
if model.syncQuality != sync.QualityGood {
|
||||
t.Errorf("expected QualityGood, got %v", model.syncQuality)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusMsgStreamInfo(t *testing.T) {
|
||||
model := NewModel(Config{})
|
||||
|
||||
msg := StatusMsg{
|
||||
Codec: "opus",
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
BitDepth: 16,
|
||||
}
|
||||
|
||||
model.applyStatus(msg)
|
||||
|
||||
if model.codec != "opus" {
|
||||
t.Errorf("expected codec 'opus', got '%s'", model.codec)
|
||||
}
|
||||
|
||||
if model.sampleRate != 48000 {
|
||||
t.Errorf("expected sampleRate 48000, got %d", model.sampleRate)
|
||||
}
|
||||
|
||||
if model.channels != 2 {
|
||||
t.Errorf("expected channels 2, got %d", model.channels)
|
||||
}
|
||||
|
||||
if model.bitDepth != 16 {
|
||||
t.Errorf("expected bitDepth 16, got %d", model.bitDepth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusMsgMetadata(t *testing.T) {
|
||||
model := NewModel(Config{})
|
||||
|
||||
msg := StatusMsg{
|
||||
Title: "Test Song",
|
||||
Artist: "Test Artist",
|
||||
Album: "Test Album",
|
||||
}
|
||||
|
||||
model.applyStatus(msg)
|
||||
|
||||
if model.title != "Test Song" {
|
||||
t.Errorf("expected title 'Test Song', got '%s'", model.title)
|
||||
}
|
||||
|
||||
if model.artist != "Test Artist" {
|
||||
t.Errorf("expected artist 'Test Artist', got '%s'", model.artist)
|
||||
}
|
||||
|
||||
if model.album != "Test Album" {
|
||||
t.Errorf("expected album 'Test Album', got '%s'", model.album)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusMsgArtworkPath(t *testing.T) {
|
||||
model := NewModel(Config{})
|
||||
|
||||
msg := StatusMsg{
|
||||
ArtworkPath: "/tmp/artwork.jpg",
|
||||
}
|
||||
|
||||
model.applyStatus(msg)
|
||||
|
||||
if model.artworkPath != "/tmp/artwork.jpg" {
|
||||
t.Errorf("expected artworkPath '/tmp/artwork.jpg', got '%s'", model.artworkPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusMsgVolume(t *testing.T) {
|
||||
model := NewModel(Config{})
|
||||
|
||||
msg := StatusMsg{
|
||||
Volume: 75,
|
||||
}
|
||||
|
||||
model.applyStatus(msg)
|
||||
|
||||
if model.volume != 75 {
|
||||
t.Errorf("expected volume 75, got %d", model.volume)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusMsgStats(t *testing.T) {
|
||||
model := NewModel(Config{})
|
||||
|
||||
msg := StatusMsg{
|
||||
Received: 1000,
|
||||
Played: 950,
|
||||
Dropped: 50,
|
||||
BufferDepth: 300,
|
||||
}
|
||||
|
||||
model.applyStatus(msg)
|
||||
|
||||
if model.received != 1000 {
|
||||
t.Errorf("expected received 1000, got %d", model.received)
|
||||
}
|
||||
|
||||
if model.played != 950 {
|
||||
t.Errorf("expected played 950, got %d", model.played)
|
||||
}
|
||||
|
||||
if model.dropped != 50 {
|
||||
t.Errorf("expected dropped 50, got %d", model.dropped)
|
||||
}
|
||||
|
||||
if model.bufferDepth != 300 {
|
||||
t.Errorf("expected bufferDepth 300, got %d", model.bufferDepth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusMsgRuntimeStats(t *testing.T) {
|
||||
model := NewModel(Config{})
|
||||
|
||||
msg := StatusMsg{
|
||||
Goroutines: 42,
|
||||
}
|
||||
|
||||
model.applyStatus(msg)
|
||||
|
||||
if model.goroutines != 42 {
|
||||
t.Errorf("expected goroutines 42, got %d", model.goroutines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipleStatusUpdates(t *testing.T) {
|
||||
model := NewModel(Config{})
|
||||
|
||||
// First update
|
||||
connected := true
|
||||
model.applyStatus(StatusMsg{
|
||||
Connected: &connected,
|
||||
Codec: "opus",
|
||||
})
|
||||
|
||||
if model.codec != "opus" {
|
||||
t.Error("first update failed")
|
||||
}
|
||||
|
||||
// Second update (partial) - sampleRate requires Codec to be in same message
|
||||
model.applyStatus(StatusMsg{
|
||||
Codec: "opus",
|
||||
SampleRate: 48000,
|
||||
})
|
||||
|
||||
// Previous values should be retained
|
||||
if model.codec != "opus" {
|
||||
t.Error("previous codec value was lost")
|
||||
}
|
||||
|
||||
if model.sampleRate != 48000 {
|
||||
t.Error("new sampleRate not applied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusMsgZeroValues(t *testing.T) {
|
||||
model := NewModel(Config{})
|
||||
|
||||
// Set some non-zero values first
|
||||
model.applyStatus(StatusMsg{
|
||||
Volume: 75,
|
||||
Received: 100,
|
||||
})
|
||||
|
||||
// Apply zero values - Volume should not update (0 is ignored), but stats should
|
||||
model.applyStatus(StatusMsg{
|
||||
Volume: 0,
|
||||
Received: 0,
|
||||
})
|
||||
|
||||
// Volume should NOT be updated to 0 (0 is special case)
|
||||
if model.volume == 0 {
|
||||
t.Error("volume should not be updated to 0")
|
||||
}
|
||||
|
||||
// Stats should be updated (0 is valid)
|
||||
if model.received != 0 {
|
||||
t.Error("received stats should be updated to 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateFunction(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
maxLen int
|
||||
expected string
|
||||
}{
|
||||
{"short", 10, "short"},
|
||||
{"exactly ten c", 14, "exactly ten c"},
|
||||
{"this is longer than allowed", 10, "this is..."},
|
||||
{"this is longer than allowed", 15, "this is long..."}, // 15-3=12 chars + "..."
|
||||
{"", 10, ""},
|
||||
{"a", 10, "a"},
|
||||
{"abc", 3, "abc"},
|
||||
{"abcd", 4, "abcd"},
|
||||
{"abcde", 4, "a..."},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := truncate(tt.input, tt.maxLen)
|
||||
if result != tt.expected {
|
||||
t.Errorf("truncate(%q, %d) = %q, expected %q",
|
||||
tt.input, tt.maxLen, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelNameFunction(t *testing.T) {
|
||||
tests := []struct {
|
||||
channels int
|
||||
expected string
|
||||
}{
|
||||
{1, "Mono"},
|
||||
{2, "Stereo"},
|
||||
{3, "Stereo"}, // Function only distinguishes 1 vs other
|
||||
{6, "Stereo"},
|
||||
{0, "Stereo"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := channelName(tt.channels)
|
||||
if result != tt.expected {
|
||||
t.Errorf("channelName(%d) = %q, expected %q",
|
||||
tt.channels, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncQualityDisplay(t *testing.T) {
|
||||
model := NewModel(Config{})
|
||||
|
||||
// Test different quality levels
|
||||
// Note: quality is only applied when SyncRTT is non-zero
|
||||
qualities := []sync.Quality{
|
||||
sync.QualityGood,
|
||||
sync.QualityDegraded,
|
||||
sync.QualityLost,
|
||||
}
|
||||
|
||||
for _, q := range qualities {
|
||||
model.applyStatus(StatusMsg{
|
||||
SyncQuality: q,
|
||||
SyncRTT: 1000, // Must include RTT for quality to be applied
|
||||
})
|
||||
|
||||
if model.syncQuality != q {
|
||||
t.Errorf("quality not updated to %v", q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataClearing(t *testing.T) {
|
||||
model := NewModel(Config{})
|
||||
|
||||
// Set metadata
|
||||
model.applyStatus(StatusMsg{
|
||||
Title: "Song",
|
||||
Artist: "Artist",
|
||||
Album: "Album",
|
||||
})
|
||||
|
||||
// Clear metadata with empty strings
|
||||
model.applyStatus(StatusMsg{
|
||||
Title: "",
|
||||
Artist: "",
|
||||
Album: "",
|
||||
})
|
||||
|
||||
// Empty strings should not clear (only non-empty values are applied)
|
||||
if model.title != "Song" {
|
||||
t.Error("title should not be cleared by empty string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaybackStateApplication(t *testing.T) {
|
||||
model := NewModel(Config{})
|
||||
|
||||
// Initial state should be idle
|
||||
if model.playbackState != "idle" {
|
||||
t.Errorf("expected initial playbackState 'idle', got '%s'", model.playbackState)
|
||||
}
|
||||
|
||||
// Apply playing state
|
||||
model.applyStatus(StatusMsg{PlaybackState: "playing"})
|
||||
if model.playbackState != "playing" {
|
||||
t.Errorf("expected playbackState 'playing', got '%s'", model.playbackState)
|
||||
}
|
||||
|
||||
// Apply paused state
|
||||
model.applyStatus(StatusMsg{PlaybackState: "paused"})
|
||||
if model.playbackState != "paused" {
|
||||
t.Errorf("expected playbackState 'paused', got '%s'", model.playbackState)
|
||||
}
|
||||
|
||||
// Empty string should not overwrite
|
||||
model.applyStatus(StatusMsg{PlaybackState: ""})
|
||||
if model.playbackState != "paused" {
|
||||
t.Errorf("expected playbackState to remain 'paused', got '%s'", model.playbackState)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportKeyToggle(t *testing.T) {
|
||||
tc := NewTransportControl()
|
||||
model := NewModel(Config{TransportCtrl: tc})
|
||||
model.width = 80
|
||||
model.height = 24
|
||||
|
||||
// Simulate space key
|
||||
msg := fakeKeyMsg(" ")
|
||||
model.handleKey(msg)
|
||||
|
||||
select {
|
||||
case cmd := <-tc.Commands:
|
||||
if cmd.Command != "toggle" {
|
||||
t.Errorf("expected 'toggle' command, got '%s'", cmd.Command)
|
||||
}
|
||||
default:
|
||||
t.Error("expected toggle command on transport channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportKeyNext(t *testing.T) {
|
||||
tc := NewTransportControl()
|
||||
model := NewModel(Config{TransportCtrl: tc})
|
||||
model.width = 80
|
||||
model.height = 24
|
||||
|
||||
msg := fakeKeyMsg("n")
|
||||
model.handleKey(msg)
|
||||
|
||||
select {
|
||||
case cmd := <-tc.Commands:
|
||||
if cmd.Command != "next" {
|
||||
t.Errorf("expected 'next' command, got '%s'", cmd.Command)
|
||||
}
|
||||
default:
|
||||
t.Error("expected next command on transport channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportKeyPrevious(t *testing.T) {
|
||||
tc := NewTransportControl()
|
||||
model := NewModel(Config{TransportCtrl: tc})
|
||||
model.width = 80
|
||||
model.height = 24
|
||||
|
||||
msg := fakeKeyMsg("p")
|
||||
model.handleKey(msg)
|
||||
|
||||
select {
|
||||
case cmd := <-tc.Commands:
|
||||
if cmd.Command != "previous" {
|
||||
t.Errorf("expected 'previous' command, got '%s'", cmd.Command)
|
||||
}
|
||||
default:
|
||||
t.Error("expected previous command on transport channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportKeyReconnect(t *testing.T) {
|
||||
tc := NewTransportControl()
|
||||
model := NewModel(Config{TransportCtrl: tc})
|
||||
model.width = 80
|
||||
model.height = 24
|
||||
|
||||
msg := fakeKeyMsg("r")
|
||||
model.handleKey(msg)
|
||||
|
||||
select {
|
||||
case cmd := <-tc.Commands:
|
||||
if cmd.Command != "reconnect" {
|
||||
t.Errorf("expected 'reconnect' command, got '%s'", cmd.Command)
|
||||
}
|
||||
default:
|
||||
t.Error("expected reconnect command on transport channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportNilSafe(t *testing.T) {
|
||||
// Transport keys should not panic when transportCtrl is nil
|
||||
model := NewModel(Config{})
|
||||
model.width = 80
|
||||
model.height = 24
|
||||
|
||||
// These should not panic
|
||||
model.handleKey(fakeKeyMsg(" "))
|
||||
model.handleKey(fakeKeyMsg("n"))
|
||||
model.handleKey(fakeKeyMsg("p"))
|
||||
model.handleKey(fakeKeyMsg("r"))
|
||||
}
|
||||
|
||||
func TestPlaybackStateDisplay(t *testing.T) {
|
||||
tests := []struct {
|
||||
state string
|
||||
expected string
|
||||
}{
|
||||
{"playing", "\u25b6 Playing"},
|
||||
{"paused", "\u23f8 Paused"},
|
||||
{"stopped", "\u23f9 Stopped"},
|
||||
{"idle", "\u25cf Idle"},
|
||||
{"reconnecting", "\u21bb Reconnecting..."},
|
||||
{"unknown", "\u25cf Idle"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := playbackStateDisplay(tt.state)
|
||||
if result != tt.expected {
|
||||
t.Errorf("playbackStateDisplay(%q) = %q, expected %q",
|
||||
tt.state, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSampleRate(t *testing.T) {
|
||||
tests := []struct {
|
||||
rate int
|
||||
expected string
|
||||
}{
|
||||
{44100, "44.1kHz"},
|
||||
{48000, "48kHz"},
|
||||
{96000, "96kHz"},
|
||||
{192000, "192kHz"},
|
||||
{88200, "88.2kHz"},
|
||||
{176400, "176.4kHz"},
|
||||
{32000, "32kHz"},
|
||||
{22050, "22.1kHz"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := formatSampleRate(tt.rate)
|
||||
if result != tt.expected {
|
||||
t.Errorf("formatSampleRate(%d) = %q, expected %q",
|
||||
tt.rate, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCodecDisplay(t *testing.T) {
|
||||
tests := []struct {
|
||||
codec string
|
||||
sampleRate int
|
||||
bitDepth int
|
||||
expected string
|
||||
}{
|
||||
{"pcm", 192000, 24, "PCM 192kHz/24bit lossless"},
|
||||
{"opus", 48000, 16, "OPUS 48kHz/16bit"},
|
||||
{"flac", 48000, 24, "FLAC 48kHz/24bit lossless"},
|
||||
{"flac", 44100, 16, "FLAC 44.1kHz/16bit lossless"},
|
||||
{"aac", 44100, 16, "AAC 44.1kHz/16bit"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := formatCodecDisplay(tt.codec, tt.sampleRate, tt.bitDepth)
|
||||
if result != tt.expected {
|
||||
t.Errorf("formatCodecDisplay(%q, %d, %d) = %q, expected %q",
|
||||
tt.codec, tt.sampleRate, tt.bitDepth, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fakeKeyMsg creates a tea.KeyMsg for testing.
|
||||
func fakeKeyMsg(key string) tea.KeyMsg {
|
||||
if key == " " {
|
||||
return tea.KeyMsg{Type: tea.KeySpace, Runes: []rune(key)}
|
||||
}
|
||||
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}
|
||||
}
|
||||
|
||||
// NOTE: TestConcurrentStatusUpdates was removed because Bubble Tea
|
||||
// guarantees sequential Update() calls - the Model is never accessed
|
||||
// concurrently in real usage, so testing concurrent access is unrealistic.
|
||||
65
third_party/sendspin-go/internal/ui/tui.go
vendored
Normal file
65
third_party/sendspin-go/internal/ui/tui.go
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
// ABOUTME: TUI initialization and control
|
||||
// ABOUTME: Wraps bubbletea program for player UI
|
||||
package ui
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
type VolumeControl struct {
|
||||
Changes chan VolumeChangeMsg
|
||||
Quit chan QuitMsg
|
||||
}
|
||||
|
||||
func NewVolumeControl() *VolumeControl {
|
||||
return &VolumeControl{
|
||||
Changes: make(chan VolumeChangeMsg, 10),
|
||||
Quit: make(chan QuitMsg, 1),
|
||||
}
|
||||
}
|
||||
|
||||
type TransportMsg struct {
|
||||
Command string // "play", "pause", "toggle", "next", "previous", "reconnect"
|
||||
}
|
||||
|
||||
type TransportControl struct {
|
||||
Commands chan TransportMsg
|
||||
}
|
||||
|
||||
func NewTransportControl() *TransportControl {
|
||||
return &TransportControl{
|
||||
Commands: make(chan TransportMsg, 10),
|
||||
}
|
||||
}
|
||||
|
||||
// Config bundles everything the TUI needs from main.go at construction.
|
||||
// Extending by adding fields is intentionally cheap — the alternative was
|
||||
// growing the Run() / NewModel() positional argument list every time a new
|
||||
// TUI feature needs a dependency.
|
||||
type Config struct {
|
||||
VolumeCtrl *VolumeControl
|
||||
TransportCtrl *TransportControl
|
||||
// ConfigPath is where WriteStringKey persists settings like audio_device.
|
||||
// Empty disables the Save action in modals that would write to disk.
|
||||
ConfigPath string
|
||||
// AudioDevice is the device currently driving playback, shown in the
|
||||
// status line and used to seed the picker's highlighted row.
|
||||
AudioDevice string
|
||||
}
|
||||
|
||||
func NewModel(cfg Config) Model {
|
||||
return Model{
|
||||
volume: 100,
|
||||
state: "idle",
|
||||
playbackState: "idle",
|
||||
volumeCtrl: cfg.VolumeCtrl,
|
||||
transportCtrl: cfg.TransportCtrl,
|
||||
configPath: cfg.ConfigPath,
|
||||
audioDevice: cfg.AudioDevice,
|
||||
}
|
||||
}
|
||||
|
||||
func Run(cfg Config) (*tea.Program, error) {
|
||||
p := tea.NewProgram(NewModel(cfg), tea.WithAltScreen())
|
||||
return p, nil
|
||||
}
|
||||
Reference in New Issue
Block a user