Fork channel

Create a new channel as a copy of main.

Rename channel

Rename main to:

Delete channel

Delete main? This cannot be undone.

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