c7ea86e8d166896ed606bd1462feb394006ffa22

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

Use bubbles textinput for prompt editing

Replaces the append-only string buffer with the standard textinput
widget: arrow keys move the cursor, ctrl+arrows and alt+arrows move
by word, home/end jump to line edges, and ctrl+w / ctrl+backspace
delete a word. Ghostty encodes ctrl+backspace as 0x08, which bubbletea
reports as "ctrl+h", so DeleteWordBackward is rebound to include it.

Diff

This diff is truncated to protect this page.

  1diff --git a/go.mod b/go.mod
  2index ddbb7eb0163378b3fdf980b12747b28778fa2c7e..9eb7802f39776007ca032c68ceed94919cecde05 100644
  3--- a/go.mod
  4+++ b/go.mod
  5@@ -3,6 +3,7 @@ module japanese
  6 go 1.27.0
  7 
  8 require (
  9+	github.com/charmbracelet/bubbles v1.0.0
 10 	github.com/charmbracelet/bubbletea v1.3.10
 11 	github.com/charmbracelet/lipgloss v1.1.0
 12 	github.com/ebitengine/oto/v3 v3.5.0
 13@@ -10,8 +11,8 @@ require (
 14 )
 15 
 16 require (
 17+	github.com/atotto/clipboard v0.1.4 // indirect
 18 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
 19-	github.com/charmbracelet/bubbles v1.0.0 // indirect
 20 	github.com/charmbracelet/colorprofile v0.4.3 // indirect
 21 	github.com/charmbracelet/x/ansi v0.11.8 // indirect
 22 	github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
 23diff --git a/go.sum b/go.sum
 24index c2dccee5f8a38d04788a04044f117b61c301b275..ef3a9cb93d0b42814a9f101776b34d57ec245b9b 100644
 25--- a/go.sum
 26+++ b/go.sum
 27@@ -1,3 +1,5 @@
 28+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
 29+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
 30 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
 31 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
 32 github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
 33diff --git a/internal/ui/app.go b/internal/ui/app.go
 34index 91f2d8ddc0122eb2cb38b88d99eb7f5753e5aba8..65a720d9665f149f7456ac72d912df14f06fc5be 100644
 35--- a/internal/ui/app.go
 36+++ b/internal/ui/app.go
 37@@ -6,6 +6,8 @@ import (
 38 	"strings"
 39 	"time"
 40 
 41+	"github.com/charmbracelet/bubbles/key"
 42+	"github.com/charmbracelet/bubbles/textinput"
 43 	"github.com/charmbracelet/bubbletea"
 44 	"github.com/charmbracelet/lipgloss"
 45 
 46@@ -50,17 +52,22 @@ type model struct {
 47 	beginning bool
 48 	recSince  time.Time
 49 
 50-	input  string
 51+	input  textinput.Model
 52 	status string
 53 	height int
 54 	width  int
 55 }
 56 
 57 func NewModel(state *game.State, orch *game.Orchestrator, recordCapSec int) *model {
 58+	ti := textinput.New()
 59+	// Ghostty encodes ctrl+backspace as 0x08, which bubbletea reports as "ctrl+h".
 60+	ti.KeyMap.DeleteWordBackward = key.NewBinding(key.WithKeys("ctrl+w", "ctrl+h", "alt+backspace"))
 61+	ti.Focus()
 62 	return &model{
 63 		state:        state,
 64 		orch:         orch,
 65 		recordCapSec: recordCapSec,
 66+		input:        ti,
 67 		height:       24,
 68 	}
 69 }
 70@@ -109,37 +116,40 @@ func (m *model) handleKey(k tea.KeyMsg) tea.Cmd {
 71 			m.state.RevealEnglish()
 72 		}
 73 	case tea.KeyEnter:
 74-		if m.phase == phaseIdle && m.input != "" {
 75-			if strings.HasPrefix(m.input, "?") {
 76-				question := strings.TrimSpace(strings.TrimPrefix(m.input, "?"))
 77-				if question == "" {
 78-					return nil // bare "?": keep the buffer
 79-				}
 80-			}
 81-			action := m.input
 82-			m.input = ""
 83-			m.phase = phaseProcessing
 84-			if strings.HasPrefix(action, "?") {
 85-				return askTurn(m.orch, action)
 86-			}
 87-			return actionTurn(m.orch, action)
 88-		}
 89-	case tea.KeyBackspace:
 90-		if m.phase != phaseRecording {
 91-			m.input = chopRune(m.input)
 92-		}
 93-	case tea.KeyRunes:
 94-		if m.phase != phaseRecording {
 95-			m.input += string(k.Runes)
 96-		}
 97-	case tea.KeySpace:
 98+		return m.submit()
 99+	default:
100 		if m.phase != phaseRecording {
101-			m.input += " "
102+			var cmd tea.Cmd
103+			m.input, cmd = m.input.Update(k)
104+			return cmd
105 		}
106 	}
107 	return nil
108 }
109 
110+// submit sends the input buffer as an action or scratch question.
111+func (m *model) submit() tea.Cmd {
112+	if m.phase != phaseIdle {
113+		return nil
114+	}
115+	action := m.input.Value()
116+	if action == "" {
117+		return nil
118+	}
119+	if strings.HasPrefix(action, "?") {
120+		question := strings.TrimSpace(strings.TrimPrefix(action, "?"))
121+		if question == "" {
122+			return nil // bare "?": keep the buffer
123+		}
124+	}
125+	m.input.SetValue("")
126+	m.phase = phaseProcessing
127+	if strings.HasPrefix(action, "?") {
128+		return askTurn(m.orch, action)
129+	}
130+	return actionTurn(m.orch, action)
131+}
132+
133 // talkToggle starts or stops push-to-talk.
134 func (m *model) talkToggle() tea.Cmd {
135 	switch m.phase {
136@@ -196,7 +206,8 @@ func (m *model) View() string {
137diff --git a/internal/ui/render.go b/internal/ui/render.go
138index 48b9ad31c7e8da98654f480147ef0fb4e574764b..24015192b0fa598893665aabe6965c12044d0348 100644
139--- a/internal/ui/render.go
140+++ b/internal/ui/render.go
141@@ -16,6 +16,7 @@ const controlsHint = "F2 talk · F3 romaji · F4 english · ? ask · Esc quit"
142 type viewState struct {
143 	logLines   []string
144 	input      string
145+	cursor     int
146 	status     string
147 	phaseWord  string
148 	recElapsed int
149@@ -31,7 +32,7 @@ func render(vs viewState) string {
150 		lines = append(lines, vs.logLines...)
151 	}
152 	lines = append(lines, "")
153-	lines = append(lines, inputLine(vs.input))
154+	lines = append(lines, inputLine(vs.input, vs.cursor))
155 	lines = append(lines, statusLine(vs.status, vs.phaseWord, vs.recElapsed, vs.recCap))
156 	if len(lines) > vs.height && vs.height > 0 {
157 		lines = lines[len(lines)-vs.height:]
158@@ -39,8 +40,12 @@ func render(vs viewState) string {
159 	return strings.Join(lines, "\n")
160 }
161 
162-func inputLine(buf string) string {
163-	return "> " + sanitizeRomajiOnly(buf) + "▌"
164+func inputLine(buf string, cursor int) string {
165+	r := []rune(buf)
166+	if cursor > len(r) {
167+		cursor = len(r)
168+	}
169+	return "> " + sanitizeRomajiOnly(string(r[:cursor])) + "▌" + sanitizeRomajiOnly(string(r[cursor:]))
170 }
171 
172 func statusLine(status, phaseWord string, recElapsed, recCap int) string {