// 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) }