Fork channel

Create a new channel as a copy of main.

Rename channel

Rename main to:

Delete channel

Delete main? This cannot be undone.

update.go
package tui

import (
	"fmt"
	"image"
	"os"
	"strings"
	"time"

	tea "charm.land/bubbletea/v2"

	"skraak/audio"
	"skraak/datafile"
	"skraak/spectrogram"
	"skraak/tools/calls"
	"skraak/wav"
)

// Update handles messages
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.KeyPressMsg:
		return m.handleKey(msg)
	case playbackTickMsg:
		if m.state.Player == nil || !m.state.Player.IsPlaying() {
			return m, nil // done, triggers re-render to clear "Playing..." text
		}
		return m, playbackTick()
	}

	return m, nil
}

// segmentChangeCmd returns the appropriate command after a segment change.
// Clears screen then generates and writes the spectrogram image asynchronously.
func (m Model) segmentChangeCmd() tea.Cmd {
	(*m.imageGen)++
	gen := *m.imageGen
	return tea.Sequence(tea.ClearScreen, m.inlineImageCmd(gen, m.imageGen))
}

func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
	if m.commentMode {
		return m.handleCommentKey(msg)
	}
	if m.labelMode {
		return m.handleLabelKey(msg)
	}
	if m.clipMode {
		return m.handleClipKey(msg)
	}

	m.err = ""

	if m.awaitingSecondaryFor != "" {
		if handled, model, cmd := m.handleSecondaryWait(msg); handled {
			return model, cmd
		}
	}

	if handled, model, cmd := m.handleSpecialKey(msg); handled {
		return model, cmd
	}

	return m.handleSwitchKey(msg)
}

// handleSecondaryWait handles keypresses while awaiting a secondary calltype key.
// Returns (true, model, cmd) if the key was consumed; (false, model, cmd) to fall through.
func (m Model) handleSecondaryWait(msg tea.KeyPressMsg) (bool, tea.Model, tea.Cmd) {
	primary := m.awaitingSecondaryFor
	m.awaitingSecondaryFor = ""

	if msg.Key().Code == tea.KeyEscape || msg.Key().Code == tea.KeyEsc {
		return true, m, nil
	}

	s := msg.String()
	if len(s) == 1 {
		if callType, ok := m.state.Config.SecondaryBindings[primary][s]; ok {
			m.stopPlayer()
			m.state.ApplyCallTypeOnly(callType)
			if err := m.state.Save(); err != nil {
				m.err = err.Error()
			}
			model, cmd := m.advanceOrQuit()
			return true, model, cmd
		}
	}
	return false, m, nil
}

// handleSpecialKey handles single-key-code bindings (Enter, Esc, Space, Ctrl+S).
// Returns (true, model, cmd) if the key was consumed.
func (m Model) handleSpecialKey(msg tea.KeyPressMsg) (bool, tea.Model, tea.Cmd) {
	key := msg.Key()

	if key.Code == tea.KeyEnter || key.Code == tea.KeyKpEnter {
		speed := 1.0
		if key.Mod&tea.ModShift != 0 {
			speed = 0.5
		}
		if errMsg := m.state.PlaySegmentAtSpeed(speed); errMsg != "" {
			m.err = errMsg
		}
		return true, m, playbackTick()
	}

	if key.Code == tea.KeyEscape || key.Code == tea.KeyEsc {
		m.stopPlayer()
		m.quitting = true
		return true, m, tea.Quit
	}

	if key.Code == tea.KeySpace {
		m.commentText = m.state.GetCurrentComment()
		m.commentCursor = len(m.commentText)
		m.commentMode = true
		return true, m, nil
	}

	if msg.String() == "ctrl+l" {
		m.labelText = m.state.GetCurrentSpeciesCallType()
		m.labelCursor = len(m.labelText)
		m.labelMode = true
		return true, m, nil
	}

	if msg.String() == "ctrl+s" {
		m.clipInput = ""
		m.clipMode = true
		return true, m, nil
	}

	return false, m, nil
}

// handleSwitchKey handles string-based key bindings (ctrl+c, arrows, digits, bindings).
func (m Model) handleSwitchKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
	switch msg.String() {
	case "ctrl+c":
		m.stopPlayer()
		m.quitting = true
		return m, tea.Quit

	case ",", "left":
		m.stopPlayer()
		m.state.PrevSegment()
		return m, m.segmentChangeCmd()

	case ".", "right":
		m.stopPlayer()
		return m.advanceOrQuit()

	case "ctrl+d":
		return m.handleBookmarkToggle()

	case "ctrl+,":
		return m.handleBookmarkNav(m.state.PrevBookmark)

	case "ctrl+.":
		return m.handleBookmarkNav(m.state.NextBookmark)

	case "0":
		return m.handleConfirmLabel()

	default:
		return m.handleBindingKey(msg)
	}
}

// handleBookmarkToggle toggles the bookmark and saves.
func (m Model) handleBookmarkToggle() (tea.Model, tea.Cmd) {
	m.state.ToggleBookmark()
	if err := m.state.Save(); err != nil {
		m.err = err.Error()
	}
	return m, nil
}

// handleBookmarkNav navigates to a bookmark. navFn returns true if found.
func (m Model) handleBookmarkNav(navFn func() bool) (tea.Model, tea.Cmd) {
	m.stopPlayer()
	if navFn() {
		return m, m.segmentChangeCmd()
	}
	m.err = "No bookmarks found"
	return m, nil
}

// handleConfirmLabel confirms the current label and advances.
func (m Model) handleConfirmLabel() (tea.Model, tea.Cmd) {
	m.stopPlayer()
	if m.state.ConfirmLabel() {
		if err := m.state.Save(); err != nil {
			m.err = err.Error()
			return m, nil
		}
	}
	return m.advanceOrQuit()
}

// handleBindingKey handles single-character key bindings (species/calltype shortcuts).
func (m Model) handleBindingKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
	s := msg.String()
	if len(s) != 1 {
		return m, nil
	}

	k := s
	key := msg.Key()

	// Shift+letter: if the lowercase primary has secondary bindings,
	// label species-only and enter wait mode. Otherwise map to the
	// lowercase equivalent and dispatch as a normal primary keypress.
	if key.Mod&tea.ModShift != 0 {
		lower := strings.ToLower(s)
		if lower != s {
			if m.state.HasSecondary(lower) {
				if result := m.state.ParseKeyBuffer(lower); result != nil {
					m.stopPlayer()
					m.state.ApplyBinding(&calls.BindingResult{Species: result.Species})
					if err := m.state.Save(); err != nil {
						m.err = err.Error()
					}
					m.awaitingSecondaryFor = lower
					return m, nil
				}
			}
			k = lower
		}
	}

	if result := m.state.ParseKeyBuffer(k); result != nil {
		m.stopPlayer()
		m.state.ApplyBinding(result)
		if err := m.state.Save(); err != nil {
			m.err = err.Error()
		}
		return m.advanceOrQuit()
	}
	return m, nil
}

// stopPlayer stops the audio player if it exists.
func (m Model) stopPlayer() {
	if m.state.Player != nil {
		m.state.Player.Stop()
	}
}

// advanceOrQuit advances to the next segment, or quits if none remain.
func (m Model) advanceOrQuit() (tea.Model, tea.Cmd) {
	if !m.state.NextSegment() {
		m.quitting = true
		return m, tea.Quit
	}
	return m, m.segmentChangeCmd()
}

// handleCommentKey handles key presses in comment mode
func (m Model) handleCommentKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
	key := msg.Key()

	// Enter: save comment
	if key.Code == tea.KeyEnter {
		m.state.SetComment(m.commentText)
		if err := m.state.Save(); err != nil {
			m.err = err.Error()
		}
		m.commentMode = false
		return m, nil
	}

	// Escape: cancel
	if key.Code == tea.KeyEscape || key.Code == tea.KeyEsc {
		m.commentMode = false
		return m, nil
	}

	// Navigation and editing keys
	if handled := m.handleCommentKeyCode(key); handled {
		return m, nil
	}

	// Ctrl combos
	if handled := m.handleCommentCtrl(msg.String()); handled {
		return m, nil
	}

	// Printable ASCII character (space handled above via KeySpace)
	s := msg.String()
	if len(s) == 1 && s[0] >= 33 && s[0] <= 126 {
		if len(m.commentText) < maxCommentLength {
			m.commentText = m.commentText[:m.commentCursor] + s + m.commentText[m.commentCursor:]
			m.commentCursor++
		}
	}
	return m, nil
}

// handleCommentKeyCode handles navigation and editing keys in comment mode.
// Returns true if the key was consumed.
func (m *Model) handleCommentKeyCode(key tea.Key) bool {
	switch key.Code {
	case tea.KeyLeft:
		if m.commentCursor > 0 {
			m.commentCursor--
		}
		return true
	case tea.KeyRight:
		if m.commentCursor < len(m.commentText) {
			m.commentCursor++
		}
		return true
	case tea.KeySpace:
		if len(m.commentText) < maxCommentLength {
			m.commentText = m.commentText[:m.commentCursor] + " " + m.commentText[m.commentCursor:]
			m.commentCursor++
		}
		return true
	case tea.KeyBackspace:
		if m.commentCursor > 0 {
			m.commentText = m.commentText[:m.commentCursor-1] + m.commentText[m.commentCursor:]
			m.commentCursor--
		}
		return true
	case tea.KeyDelete:
		if m.commentCursor < len(m.commentText) {
			m.commentText = m.commentText[:m.commentCursor] + m.commentText[m.commentCursor+1:]
		}
		return true
	}
	return false
}

// handleCommentCtrl handles ctrl-key combos in comment mode.
// Returns true if the key was consumed.
func (m *Model) handleCommentCtrl(s string) bool {
	switch s {
	case "ctrl+u":
		m.commentText = ""
		m.commentCursor = 0
		return true
	case "ctrl+a":
		m.commentCursor = 0
		return true
	case "ctrl+e":
		m.commentCursor = len(m.commentText)
		return true
	}
	return false
}

// handleLabelKey handles key presses in label mode
func (m Model) handleLabelKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
	key := msg.Key()

	// Enter: save label
	if key.Code == tea.KeyEnter {
		return m.handleLabelEnter()
	}

	// Escape: cancel
	if key.Code == tea.KeyEscape || key.Code == tea.KeyEsc {
		m.labelMode = false
		return m, nil
	}

	// Navigation and editing keys
	if handled := m.handleLabelKeyCode(key); handled {
		return m, nil
	}

	// Ctrl combos
	if handled := m.handleLabelCtrl(msg.String()); handled {
		return m, nil
	}

	// Printable ASCII character (space handled via KeySpace below)
	s := msg.String()
	if len(s) == 1 && s[0] >= 33 && s[0] <= 126 {
		if len(m.labelText) < maxLabelLength {
			m.labelText = m.labelText[:m.labelCursor] + s + m.labelText[m.labelCursor:]
			m.labelCursor++
		}
	}
	return m, nil
}

// handleLabelEnter handles Enter key in label mode.
func (m Model) handleLabelEnter() (tea.Model, tea.Cmd) {
	m.labelMode = false
	if m.labelText == "" {
		return m, nil
	}

	species, callType := datafile.ParseSpeciesCallType(m.labelText)
	if species == "" {
		return m, nil
	}

	m.stopPlayer()
	m.state.ApplyBinding(&calls.BindingResult{Species: species, CallType: callType})
	if err := m.state.Save(); err != nil {
		m.err = err.Error()
	}
	return m.advanceOrQuit()
}

// handleLabelKeyCode handles navigation and editing keys in label mode.
// Returns true if the key was consumed.
func (m *Model) handleLabelKeyCode(key tea.Key) bool {
	switch key.Code {
	case tea.KeyLeft:
		if m.labelCursor > 0 {
			m.labelCursor--
		}
		return true
	case tea.KeyRight:
		if m.labelCursor < len(m.labelText) {
			m.labelCursor++
		}
		return true
	case tea.KeySpace:
		if len(m.labelText) < maxLabelLength {
			m.labelText = m.labelText[:m.labelCursor] + " " + m.labelText[m.labelCursor:]
			m.labelCursor++
		}
		return true
	case tea.KeyBackspace:
		if m.labelCursor > 0 {
			m.labelText = m.labelText[:m.labelCursor-1] + m.labelText[m.labelCursor:]
			m.labelCursor--
		}
		return true
	case tea.KeyDelete:
		if m.labelCursor < len(m.labelText) {
			m.labelText = m.labelText[:m.labelCursor] + m.labelText[m.labelCursor+1:]
		}
		return true
	}
	return false
}

// handleLabelCtrl handles ctrl-key combos in label mode.
// Returns true if the key was consumed.
func (m *Model) handleLabelCtrl(s string) bool {
	switch s {
	case "ctrl+u":
		m.labelText = ""
		m.labelCursor = 0
		return true
	case "ctrl+a":
		m.labelCursor = 0
		return true
	case "ctrl+e":
		m.labelCursor = len(m.labelText)
		return true
	}
	return false
}

// handleClipKey handles key presses in clip mode
func (m Model) handleClipKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
	key := msg.Key()

	// Enter: save clip
	if key.Code == tea.KeyEnter {
		return m.handleClipEnter()
	}

	// Escape: cancel
	if key.Code == tea.KeyEscape || key.Code == tea.KeyEsc {
		m.clipMode = false
		return m, nil
	}

	// Backspace: remove last character
	if key.Code == tea.KeyBackspace {
		if len(m.clipInput) > 0 {
			m.clipInput = m.clipInput[:len(m.clipInput)-1]
		}
		return m, nil
	}

	// Printable characters: append to input
	return m.handleClipPrintable(msg.String())
}

// handleClipEnter handles Enter key in clip mode.
func (m Model) handleClipEnter() (tea.Model, tea.Cmd) {
	if m.clipInput == "" {
		m.clipMode = false
		return m, nil
	}
	m.clipMode = false

	cwd, err := os.Getwd()
	if err != nil {
		m.err = err.Error()
		return m, nil
	}

	paths, err := m.state.SaveClip(cwd, m.clipInput)
	if err != nil {
		m.err = err.Error()
	} else if len(paths) > 0 {
		// Show the base filename (without path) of the saved clip
		m.err = "Clip saved: " + paths[0] // full path to PNG
	}
	return m, nil
}

// handleClipPrintable handles printable character input in clip mode.
func (m Model) handleClipPrintable(s string) (tea.Model, tea.Cmd) {
	if len(s) == 1 && s[0] >= 32 && s[0] <= 126 {
		if len(m.clipInput) < maxClipPrefixLength {
			m.clipInput += s
		}
	}
	return m, nil
}

// playbackTick returns a command that sends a playbackTickMsg after 50ms.
func playbackTick() tea.Cmd {
	return tea.Tick(50*time.Millisecond, func(t time.Time) tea.Msg {
		return playbackTickMsg{}
	})
}

// inlineImageCmd returns a tea.Cmd that generates and writes an inline image
// directly to the terminal, bypassing BubbleTea's renderer.
// gen is the generation at dispatch time; currentGen points to the live counter.
// If they differ when the image is ready, a newer segment change has occurred
// and this image is stale — discard it instead of writing.
func (m Model) inlineImageCmd(gen uint64, currentGen *uint64) tea.Cmd {
	return func() tea.Msg {
		df := m.state.CurrentFile()
		seg := m.state.CurrentSegment()
		if df == nil || seg == nil {
			return nil
		}

		img := m.generateSpectrogramImage(df.FilePath, seg)
		if img == nil {
			return nil
		}

		// Discard if a newer segment change has superseded this one
		if *currentGen != gen {
			return nil
		}

		// Clear previous kitty images before writing new one.
		// Terminal write errors during render are non-recoverable; ignore.
		_ = spectrogram.ClearImages(os.Stdout, m.protocol())
		_, _ = fmt.Fprint(os.Stdout, "\r\n\r\n")
		_ = spectrogram.WriteImage(img, os.Stdout, m.protocol())
		return nil
	}
}

// generateSpectrogramImage creates a resized spectrogram image from a segment.
func (m Model) generateSpectrogramImage(dataPath string, seg *datafile.Segment) image.Image {
	imgSize := m.state.Config.ImageSize
	if imgSize == 0 {
		imgSize = spectrogram.SpectrogramDisplaySize
	}

	// For bandpass, load and filter samples manually
	if m.state.Config.BandpassLow > 0 || m.state.Config.BandpassHigh > 0 {
		wavPath := dataPath[:len(dataPath)-5] // strip ".data"
		samples, sampleRate, err := wav.ReadWAVSegmentSamples(wavPath, seg.StartTime, seg.EndTime)
		if err != nil || len(samples) == 0 {
			return nil
		}
		samples, sampleRate = audio.BandpassShiftFilter(samples, sampleRate, m.state.Config.BandpassLow, m.state.Config.BandpassHigh)
		return spectrogram.SpectrogramImageFromSamples(samples, sampleRate, m.state.Config.Color, imgSize)
	}

	// Standard path
	img, err := spectrogram.GenerateSegmentSpectrogram(dataPath, seg.StartTime, seg.EndTime, m.state.Config.Color, imgSize)
	if err != nil {
		return nil
	}
	return img
}