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
}