model.go
package tui
import (
"fmt"
"sort"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"skraak/spectrogram"
"skraak/tools/calls"
)
// playbackTickMsg is sent every 50ms while audio is playing
type playbackTickMsg struct{}
// Styles
var (
titleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("15")).
Background(lipgloss.Color("62")).
Padding(0, 1)
labelStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("86"))
errorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("196"))
helpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241"))
helpDarkStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("86"))
commentBoxStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("62")).
Padding(0, 1)
)
// Model holds TUI state
type Model struct {
state *calls.ClassifyState
err string
quitting bool
bindingsHelp string // pre-computed bindings text
// Comment dialog state
commentMode bool // true when comment dialog is open
commentText string // current input text
commentCursor int // cursor position in comment text
// Label dialog state
labelMode bool // true when label dialog is open
labelText string // current input text
labelCursor int // cursor position in label text
// Clip dialog state
clipMode bool // true when clip dialog is open
clipInput string // current prefix input
// Shift+primary wait mode: when non-empty, the next keypress is looked up
// in Config.SecondaryBindings[awaitingSecondaryFor] as a calltype key.
awaitingSecondaryFor string
// Image generation counter - incremented on each segment change,
// used to discard stale inline images (sixel/iTerm).
// Pointer so it survives BubbleTea's value-copy update cycle.
imageGen *uint64
}
// New creates a new TUI model
func New(state *calls.ClassifyState) Model {
bindingsHelp := buildBindingsHelp(state.Config.Bindings)
gen := uint64(0)
return Model{
state: state,
bindingsHelp: bindingsHelp,
imageGen: &gen,
}
}
// buildBindingsHelp creates a sorted, formatted string of key bindings.
func buildBindingsHelp(bindings []calls.KeyBinding) string {
sorted := make([]calls.KeyBinding, len(bindings))
copy(sorted, bindings)
sort.SliceStable(sorted, func(i, j int) bool {
ri, rj := keyRank(sorted[i].Key), keyRank(sorted[j].Key)
if ri != rj {
return ri < rj
}
return sorted[i].Key < sorted[j].Key
})
var parts []string
for _, b := range sorted {
if b.CallType != "" {
parts = append(parts, fmt.Sprintf("%s=%s/%s", b.Key, b.Species, b.CallType))
} else {
parts = append(parts, fmt.Sprintf("%s=%s", b.Key, b.Species))
}
}
return strings.Join(parts, " ")
}
// keyRank returns sort priority for a key: a-z (0), A-Z (1), 0-9 (2), other (3).
func keyRank(k string) int {
if len(k) == 0 {
return 3
}
c := k[0]
switch {
case c >= 'a' && c <= 'z':
return 0
case c >= 'A' && c <= 'Z':
return 1
case c >= '0' && c <= '9':
return 2
default:
return 3
}
}
func (m Model) protocol() spectrogram.ImageProtocol {
if m.state.Config.ITerm {
return spectrogram.ProtocolITerm
}
if m.state.Config.Sixel {
return spectrogram.ProtocolSixel
}
return spectrogram.ProtocolKitty
}
// Init initializes the model
func (m Model) Init() tea.Cmd {
return m.inlineImageCmd(*m.imageGen, m.imageGen)
}
// wrapText wraps text at word boundaries to fit within maxWidth.
// Returns multiple lines joined with newlines.
func wrapText(text string, maxWidth int) string {
if len(text) <= maxWidth {
return text
}
lines := strings.Split(text, "\n")
var result []string
for _, line := range lines {
if len(line) <= maxWidth {
result = append(result, line)
continue
}
// Wrap at word boundaries
words := strings.Fields(line)
var currentLine string
for _, word := range words {
if len(currentLine)+len(word)+1 <= maxWidth {
if currentLine == "" {
currentLine = word
} else {
currentLine += " " + word
}
} else {
if currentLine != "" {
result = append(result, currentLine)
}
// If single word is longer than maxWidth, force break it
if len(word) > maxWidth {
result = append(result, word[:maxWidth])
word = word[maxWidth:]
}
currentLine = word
}
}
if currentLine != "" {
result = append(result, currentLine)
}
}
return strings.Join(result, "\n")
}