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}