Parent directory

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}