render.go
3217 bytes
1// Package ui is the Bubble Tea front end. It renders romaji only and keeps all
2// long-running work off the event loop by running it in tea.Cmd workers that
3// return results as tea.Msg values.
4package ui
5
6import (
7 "fmt"
8 "strings"
9)
10
11// controlsHint is the idle status line. It lists only keys wired in this build.
12const controlsHint = "F2 talk · F3 romaji · F4 english · F5 replay · ? ask · ! cards · Esc quit"
13
14// viewState is the plain, render-only snapshot the pure render functions take.
15// It holds no pointers and no game types, so rendering stays trivially pure.
16type viewState struct {
17 logLines []string
18 input string
19 cursor int
20 status string
21 phaseWord string
22 recElapsed int
23 recCap int
24 height int
25}
26
27func render(vs viewState) string {
28 lines := make([]string, 0, len(vs.logLines)+3)
29 lines = append(lines, vs.logLines...)
30 lines = append(lines, "")
31 lines = append(lines, inputLine(vs.input, vs.cursor))
32 lines = append(lines, statusLine(vs.status, vs.phaseWord, vs.recElapsed, vs.recCap))
33 if len(lines) > vs.height && vs.height > 0 {
34 lines = lines[len(lines)-vs.height:]
35 }
36 return strings.Join(lines, "\n")
37}
38
39func inputLine(buf string, cursor int) string {
40 r := []rune(buf)
41 if cursor > len(r) {
42 cursor = len(r)
43 }
44 at, after := " ", ""
45 if cursor < len(r) {
46 at = string(r[cursor])
47 after = string(r[cursor+1:])
48 }
49 return "> " + sanitizeRomajiOnly(string(r[:cursor])) +
50 cursorStyle.Render(sanitizeRomajiOnly(at)) +
51 sanitizeRomajiOnly(after)
52}
53
54func statusLine(status, phaseWord string, recElapsed, recCap int) string {
55 switch phaseWord {
56 case "recording":
57 return fmt.Sprintf("● recording %ds / %ds — F2 to stop", recElapsed, recCap)
58 case "processing":
59 if status != "" {
60 return "thinking… " + sanitizeRomajiOnly(status)
61 }
62 return "thinking…"
63 case "replaying":
64 return "replaying…"
65 }
66 if status != "" {
67 return sanitizeRomajiOnly(status) + " " + hintStyle.Render(controlsHint)
68 }
69 return hintStyle.Render(controlsHint)
70}
71
72func valueOrNone(s string) string {
73 if s == "" {
74 return "(none)"
75 }
76 return s
77}
78
79// isJapaneseRune reports whether r is a kana or CJK ideograph. The UI must never
80// render these; the only Japanese that may exist at runtime is inside worker
81// goroutines for LLM/TTS prompts, which is never passed to the view.
82func isJapaneseRune(r rune) bool {
83 return (r >= 0x3041 && r <= 0x309F) || // hiragana
84 (r >= 0x30A0 && r <= 0x30FF) || // katakana (incl. long-vowel mark)
85 (r >= 0x4E00 && r <= 0x9FFF) || // CJK unified ideographs
86 (r >= 0x3400 && r <= 0x4DBF) // CJK extension A
87}
88
89func hasJapaneseScript(s string) bool {
90 for _, r := range s {
91 if isJapaneseRune(r) {
92 return true
93 }
94 }
95 return false
96}
97
98// sanitizeRomajiOnly is the romaji-only guard at the view boundary. It replaces
99// any kana or kanji rune with '?' so a field that could ever carry Japanese can
100// never leak script into the TUI, even if the upstream pipeline misbehaves.
101func sanitizeRomajiOnly(s string) string {
102 if !hasJapaneseScript(s) {
103 return s
104 }
105 var b strings.Builder
106 b.Grow(len(s))
107 for _, r := range s {
108 if isJapaneseRune(r) {
109 b.WriteRune('?')
110 } else {
111 b.WriteRune(r)
112 }
113 }
114 return b.String()
115}