Parent directory

app.go

11340 bytes
  1package ui
  2
  3import (
  4	"context"
  5	"errors"
  6	"fmt"
  7	"strings"
  8	"time"
  9
 10	"github.com/charmbracelet/bubbles/key"
 11	"github.com/charmbracelet/bubbles/textinput"
 12	"github.com/charmbracelet/bubbletea"
 13	"github.com/charmbracelet/lipgloss"
 14
 15	"git.theedgeofrage.com/TheEdgeOfRage/kaiwari/internal/game"
 16)
 17
 18// tickInterval refreshes the recording elapsed indicator while a capture is open.
 19const tickInterval = 200 * time.Millisecond
 20
 21var (
 22	actionStyle     = lipgloss.NewStyle().Foreground(gruvBlue)
 23	narrationStyle  = lipgloss.NewStyle()
 24	playerSaidStyle = lipgloss.NewStyle().Foreground(gruvGreen)
 25	hintStyle       = lipgloss.NewStyle().Foreground(gruvGray)
 26	cursorStyle     = lipgloss.NewStyle().Reverse(true) // input cursor
 27	npcRomajiStyle  = lipgloss.NewStyle().Foreground(gruvYellow)
 28	npcEngStyle     = lipgloss.NewStyle().Foreground(gruvOrange)
 29)
 30
 31type phase int
 32
 33const (
 34	phaseIdle phase = iota
 35	phaseRecording
 36	phaseProcessing
 37)
 38
 39type beginResultMsg struct{ err error }
 40type finishResultMsg struct{ res game.TurnResult }
 41type replayResultMsg struct{ err error }
 42type flashcardResultMsg struct {
 43	count int
 44	err   error
 45}
 46type tickMsg struct{}
 47
 48// model is the Bubble Tea state. It owns only UI-local fields (input buffer,
 49// phase, status); all session truth lives in the embedded State, which is
 50// mutated either by reveal calls here or by the orchestrator workers below.
 51type model struct {
 52	state        *game.State
 53	orch         *game.Orchestrator
 54	recordCapSec int
 55
 56	phase     phase
 57	beginning bool
 58	replaying bool
 59	recSince  time.Time
 60
 61	input  textinput.Model
 62	status string
 63	height int
 64	width  int
 65}
 66
 67func NewModel(state *game.State, orch *game.Orchestrator, recordCapSec int) *model {
 68	ti := textinput.New()
 69	// Ghostty encodes ctrl+backspace as 0x08, which bubbletea reports as "ctrl+h".
 70	ti.KeyMap.DeleteWordBackward = key.NewBinding(key.WithKeys("ctrl+w", "ctrl+h", "alt+backspace"))
 71	ti.Focus()
 72	return &model{
 73		state:        state,
 74		orch:         orch,
 75		recordCapSec: recordCapSec,
 76		input:        ti,
 77		height:       24,
 78	}
 79}
 80
 81func (m *model) Init() tea.Cmd {
 82	m.phase = phaseProcessing
 83	return startAdventure(m.orch)
 84}
 85
 86func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 87	var cmd tea.Cmd
 88	switch v := msg.(type) {
 89	case tea.KeyMsg:
 90		cmd = m.handleKey(v)
 91	case beginResultMsg:
 92		m.applyBegin(v.err)
 93	case finishResultMsg:
 94		m.applyFinish(v.res)
 95	case replayResultMsg:
 96		m.applyReplay(v.err)
 97	case flashcardResultMsg:
 98		m.applyFlashcards(v.count, v.err)
 99	case tickMsg:
100		if m.phase == phaseRecording {
101			cmd = nextTick()
102		}
103	case tea.WindowSizeMsg:
104		if v.Height > 0 {
105			m.height = v.Height
106		}
107		if v.Width > 0 {
108			m.width = v.Width
109		}
110	}
111	return m, cmd
112}
113
114func (m *model) handleKey(k tea.KeyMsg) tea.Cmd {
115	switch k.Type {
116	case tea.KeyEsc, tea.KeyCtrlC:
117		return tea.Quit
118	case tea.KeyF2:
119		return m.talkToggle()
120	case tea.KeyF3:
121		if m.phase == phaseIdle {
122			m.state.RevealRomaji()
123		}
124	case tea.KeyF4:
125		if m.phase == phaseIdle {
126			m.state.RevealEnglish()
127		}
128	case tea.KeyF5:
129		if m.phase == phaseIdle {
130			m.replaying = true
131			m.phase = phaseProcessing
132			return replayTurn(m.orch)
133		}
134	case tea.KeyEnter:
135		return m.submit()
136	default:
137		if m.phase != phaseRecording {
138			var cmd tea.Cmd
139			m.input, cmd = m.input.Update(k)
140			return cmd
141		}
142	}
143	return nil
144}
145
146// submit sends the input buffer as an action, a "?" scratch question, or "!"
147// flashcard instructions.
148func (m *model) submit() tea.Cmd {
149	if m.phase != phaseIdle {
150		return nil
151	}
152	action := m.input.Value()
153	if action == "" {
154		return nil
155	}
156	if p := specialPrefix(action); p != "" && strings.TrimSpace(strings.TrimPrefix(action, p)) == "" {
157		return nil // bare "?" or "!": keep the buffer
158	}
159	m.input.SetValue("")
160	m.phase = phaseProcessing
161	switch {
162	case strings.HasPrefix(action, "?"):
163		return askTurn(m.orch, action)
164	case strings.HasPrefix(action, "!"):
165		return flashcardTurn(m.orch, action)
166	default:
167		return actionTurn(m.orch, action)
168	}
169}
170
171// specialPrefix returns the leading "?" or "!" when the action is a scratch
172// question or flashcard instructions; empty for a plain action.
173func specialPrefix(action string) string {
174	switch {
175	case strings.HasPrefix(action, "?"):
176		return "?"
177	case strings.HasPrefix(action, "!"):
178		return "!"
179	}
180	return ""
181}
182
183// talkToggle starts or stops push-to-talk.
184func (m *model) talkToggle() tea.Cmd {
185	switch m.phase {
186	case phaseIdle:
187		m.phase = phaseRecording
188		m.beginning = true
189		m.recSince = time.Now()
190		return tea.Batch(beginTurn(m.orch), nextTick())
191	case phaseRecording:
192		if m.beginning {
193			return nil // Begin still in flight; never run End against it concurrently.
194		}
195		m.phase = phaseProcessing
196		return finishTurn(m.orch)
197	}
198	return nil
199}
200
201func (m *model) applyBegin(err error) {
202	m.beginning = false
203	if err != nil {
204		m.phase = phaseIdle
205		m.status = "recording failed: " + err.Error()
206	}
207}
208
209// applyFinish maps a completed turn onto UI-local state. The orchestrator has
210// already recorded the display entry, so the view picks it up from State next
211// render; here we only surface transient errors and reset the phase.
212func (m *model) applyFinish(res game.TurnResult) {
213	m.status = turnStatus(res)
214	m.phase = phaseIdle
215}
216
217// applyReplay maps a completed replay onto UI-local state. On success the audio
218// is playing and no status is shown; on failure it surfaces a transient note.
219func (m *model) applyReplay(err error) {
220	m.replaying = false
221	m.phase = phaseIdle
222	switch {
223	case err == nil:
224	case errors.Is(err, game.ErrNoSpeech):
225		m.status = "nothing to replay yet"
226	default:
227		m.status = "replay failed: " + err.Error()
228	}
229}
230
231// applyFlashcards maps a completed flashcard generation onto UI-local state: the
232// number of cards added, or a transient note when it failed.
233func (m *model) applyFlashcards(count int, err error) {
234	m.phase = phaseIdle
235	switch {
236	case err != nil:
237		m.status = "flashcards failed: " + err.Error()
238	case count == 0:
239		m.status = "no cards added"
240	case count == 1:
241		m.status = "1 card added"
242	default:
243		m.status = fmt.Sprintf("%d cards added", count)
244	}
245}
246
247func turnStatus(res game.TurnResult) string {
248	switch {
249	case res.Err != nil:
250		return "turn failed: " + res.Err.Error()
251	case res.SpeakErr != nil:
252		return "audio failed: " + res.SpeakErr.Error()
253	case res.JudgeErr != nil:
254		return "judge unavailable: " + res.JudgeErr.Error()
255	}
256	return ""
257}
258
259func (m *model) View() string {
260	var logLines []string
261	for i, e := range m.state.Display() {
262		if i > 0 {
263			logLines = append(logLines, "")
264		}
265		logLines = append(logLines, entryLines(e, m.width)...)
266	}
267	vs := viewState{
268		logLines:   logLines,
269		input:      m.input.Value(),
270		cursor:     m.input.Position(),
271		status:     m.status,
272		phaseWord:  m.phaseWord(),
273		recElapsed: elapsed(m.recSince),
274		recCap:     m.recordCapSec,
275		height:     m.height,
276	}
277	return render(vs)
278}
279
280func (m *model) phaseWord() string {
281	switch m.phase {
282	case phaseRecording:
283		return "recording"
284	case phaseProcessing:
285		if m.replaying {
286			return "replaying"
287		}
288		return "processing"
289	}
290	return "idle"
291}
292
293// keyedBlock renders a titled block of key/value rows: a gray section rule,
294// then each row as a gray key padded to the widest key and its value in one
295// aligned column. width is the terminal width for the header rule; zero means
296// no rule tail.
297func keyedBlock(width int, title string, pairs ...[2]string) []string {
298	maxKey := 0
299	for _, p := range pairs {
300		if len(p[0]) > maxKey {
301			maxKey = len(p[0])
302		}
303	}
304	header := "  ── " + title
305	if pad := width - len(header); pad > 0 {
306		header += strings.Repeat("─", pad)
307	}
308	lines := make([]string, 0, len(pairs)+1)
309	lines = append(lines, hintStyle.Render(header))
310	for _, p := range pairs {
311		lines = append(lines, "  "+hintStyle.Render(fmt.Sprintf("%-*s", maxKey, p[0]))+"  "+p[1])
312	}
313	return lines
314}
315
316// maxLineLength caps wrapped log lines so narration does not stretch across a
317// wide terminal.
318const maxLineLength = 80
319
320// wrapWidth is the width world output wraps at: the terminal width, capped at
321// maxLineLength. A zero or unknown width falls back to the cap.
322func wrapWidth(width int) int {
323	if width <= 0 || width > maxLineLength {
324		return maxLineLength
325	}
326	return width
327}
328
329// entryLines renders one display-log turn into log lines. Speech fields are
330// revealed independently (romaji, then english); the judge line follows the turn
331// that produced it.
332func entryLines(e game.DisplayEntry, width int) []string {
333	w := wrapWidth(width)
334	var lines []string
335	if e.Action != "" {
336		lines = append(lines, actionStyle.Render("> "+sanitizeRomajiOnly(e.Action)))
337	}
338	if e.Desc != "" {
339		lines = append(lines, narrationStyle.Width(w).Render(sanitizeRomajiOnly(e.Desc)))
340	}
341	if e.HasSpeech {
342		if e.ShowRomaji {
343			lines = append(lines, npcRomajiStyle.Width(w).Render("  » "+sanitizeRomajiOnly(valueOrNone(e.Romaji))))
344		} else {
345			lines = append(lines, hintStyle.Render("  » [F3] romaji hidden"))
346		}
347		if e.ShowEnglish {
348			lines = append(lines, npcEngStyle.Width(w).Render("    "+sanitizeRomajiOnly(valueOrNone(e.English))))
349		} else {
350			lines = append(lines, hintStyle.Render("    [F4] english hidden"))
351		}
352	}
353	if e.HasJudge {
354		if e.PlayerRomaji != "" {
355			lines = append(lines, playerSaidStyle.Render("  said: "+sanitizeRomajiOnly(e.PlayerRomaji)))
356		}
357		lines = append(lines, keyedBlock(width, "judge",
358			[2]string{"Score", fmt.Sprintf("%d/10", e.Score)},
359			[2]string{"Feedback", sanitizeRomajiOnly(valueOrNone(e.Feedback))})...)
360	}
361	if e.HasAsk {
362		lines = append(lines, keyedBlock(width, "ask",
363			[2]string{"Romaji", sanitizeRomajiOnly(e.AskRomaji)},
364			[2]string{"Translation", sanitizeRomajiOnly(e.AskTranslation)},
365			[2]string{"Breakdown", sanitizeRomajiOnly(e.AskBreakdown)})...)
366	}
367	return lines
368}
369
370// beginTurn / finishTurn / actionTurn run a whole turn off the event loop. Each
371// is a tea.Cmd worker: Bubble Tea runs it in a goroutine and delivers the result
372// Msg back to Update, so recording, HTTP, SSE, transcription, and playback never
373// block the loop.
374func beginTurn(o *game.Orchestrator) tea.Cmd {
375	return func() tea.Msg {
376		err := o.Begin(context.Background())
377		return beginResultMsg{err: err}
378	}
379}
380
381func finishTurn(o *game.Orchestrator) tea.Cmd {
382	return func() tea.Msg {
383		return finishResultMsg{res: o.FinishSpeak(context.Background())}
384	}
385}
386
387func actionTurn(o *game.Orchestrator, action string) tea.Cmd {
388	return func() tea.Msg {
389		return finishResultMsg{res: o.ActionTurn(context.Background(), action)}
390	}
391}
392
393func startAdventure(o *game.Orchestrator) tea.Cmd {
394	return func() tea.Msg {
395		return finishResultMsg{res: o.StartAdventure(context.Background())}
396	}
397}
398
399func askTurn(o *game.Orchestrator, question string) tea.Cmd {
400	return func() tea.Msg {
401		return finishResultMsg{res: o.AskTurn(context.Background(), question)}
402	}
403}
404
405func replayTurn(o *game.Orchestrator) tea.Cmd {
406	return func() tea.Msg {
407		return replayResultMsg{err: o.ReplayLast()}
408	}
409}
410
411func flashcardTurn(o *game.Orchestrator, action string) tea.Cmd {
412	return func() tea.Msg {
413		count, err := o.GenerateFlashcards(context.Background(), action)
414		return flashcardResultMsg{count: count, err: err}
415	}
416}
417
418func nextTick() tea.Cmd {
419	return tea.Tick(tickInterval, func(time.Time) tea.Msg { return tickMsg{} })
420}
421
422func elapsed(since time.Time) int {
423	if since.IsZero() {
424		return 0
425	}
426	return int(time.Since(since).Seconds())
427}