1package tui
2
3import (
4 "strings"
5
6 "github.com/mattn/go-runewidth"
7)
8
9// Editor is a simple multi-line text editor for the input area.
10//
11// Users can type, paste, move the cursor, and submit. The editor
12// exposes its rendered height and the current cursor row/col for the
13// outer renderer. It does NOT draw itself directly; instead, Render()
14// returns the visible lines.
15type Editor struct {
16 // Lines is the current buffer, one entry per line.
17 Lines []string
18 CursorR int // row index into Lines
19 CursorC int // rune index into Lines[CursorR]
20 Prompt string
21 MaxWidth int
22
23 // History is a ring of previously submitted buffers (newest last).
24 History []string
25 histIdx int // -1 means "editing current buffer"
26 savedDraft string
27}
28
29// NewEditor returns an empty editor with the given prompt.
30func NewEditor(prompt string) *Editor {
31 return &Editor{
32 Lines: []string{""},
33 Prompt: prompt,
34 histIdx: -1,
35 }
36}
37
38// Value returns the buffer as a single string.
39func (e *Editor) Value() string { return strings.Join(e.Lines, "\n") }
40
41// SetValue replaces the buffer and places the cursor at the end.
42func (e *Editor) SetValue(s string) {
43 e.Lines = strings.Split(s, "\n")
44 if len(e.Lines) == 0 {
45 e.Lines = []string{""}
46 }
47 e.CursorR = len(e.Lines) - 1
48 e.CursorC = runeLen(e.Lines[e.CursorR])
49 e.histIdx = -1
50}
51
52// Clear resets the buffer.
53func (e *Editor) Clear() { e.SetValue("") }
54
55// IsEmpty reports whether the buffer has no visible content.
56func (e *Editor) IsEmpty() bool {
57 return len(e.Lines) == 1 && e.Lines[0] == ""
58}
59
60// HandleKey applies k to the editor. It returns (submit, key).
61// If submit is true, the caller should read Value() and call
62// PushHistory + Clear.
63func (e *Editor) HandleKey(k Key) (submit bool) {
64 switch k.Kind {
65 case KeyRune:
66 if k.Alt && (k.Rune == '\r' || k.Rune == '\n') {
67 e.newline()
68 return false
69 }
70 e.insert(string(k.Rune))
71 case KeyEnter:
72 // Shift+Enter would be a separate key in some terminals; we treat
73 // the literal newline byte as Enter. Newline on submit is a decision
74 // for the outer UI via slash commands. Here Enter submits.
75 return true
76 case KeyBackspace:
77 if k.Alt {
78 // Alt+Backspace (Option+Delete on macOS) — delete previous word.
79 e.deleteWord()
80 } else {
81 e.backspace()
82 }
83 case KeyDelete:
84 e.delete()
85 case KeyLeft:
86 if k.Alt {
87 e.moveWordLeft()
88 } else {
89 e.moveLeft()
90 }
91 case KeyRight:
92 if k.Alt {
93 e.moveWordRight()
94 } else {
95 e.moveRight()
96 }
97 case KeyUp:
98 if e.CursorR == 0 {
99 e.historyPrev()
100 } else {
101 e.CursorR--
102 if e.CursorC > runeLen(e.Lines[e.CursorR]) {
103 e.CursorC = runeLen(e.Lines[e.CursorR])
104 }
105 }
106 case KeyDown:
107 if e.CursorR == len(e.Lines)-1 {
108 e.historyNext()
109 } else {
110 e.CursorR++
111 if e.CursorC > runeLen(e.Lines[e.CursorR]) {
112 e.CursorC = runeLen(e.Lines[e.CursorR])
113 }
114 }
115 case KeyHome, KeyCtrlA:
116 e.CursorC = 0
117 case KeyEnd, KeyCtrlE:
118 e.CursorC = runeLen(e.Lines[e.CursorR])
119 case KeyCtrlU:
120 e.Lines[e.CursorR] = substringAfter(e.Lines[e.CursorR], e.CursorC)
121 e.CursorC = 0
122 case KeyCtrlK:
123 e.Lines[e.CursorR] = substringBefore(e.Lines[e.CursorR], e.CursorC)
124 case KeyCtrlW:
125 e.deleteWord()
126 case KeyPaste:
127 e.insert(k.Paste)
128 case KeyEsc:
129 e.Clear()
130 }
131 return false
132}
133
134func (e *Editor) insert(s string) {
135 e.histIdx = -1
136 line := e.Lines[e.CursorR]
137 pre := substringBefore(line, e.CursorC)
138 post := substringAfter(line, e.CursorC)
139 // Split pasted text on newlines.
140 parts := strings.Split(s, "\n")
141 if len(parts) == 1 {
142 e.Lines[e.CursorR] = pre + s + post
143 e.CursorC += runeLen(s)
144 return
145 }
146 newLines := make([]string, 0, len(e.Lines)+len(parts)-1)
147 newLines = append(newLines, e.Lines[:e.CursorR]...)
148 newLines = append(newLines, pre+parts[0])
149 for i := 1; i < len(parts)-1; i++ {
150 newLines = append(newLines, parts[i])
151 }
152 last := parts[len(parts)-1]
153 newLines = append(newLines, last+post)
154 newLines = append(newLines, e.Lines[e.CursorR+1:]...)
155 e.Lines = newLines
156 e.CursorR += len(parts) - 1
157 e.CursorC = runeLen(last)
158}
159
160func (e *Editor) newline() {
161 e.histIdx = -1
162 line := e.Lines[e.CursorR]
163 pre := substringBefore(line, e.CursorC)
164 post := substringAfter(line, e.CursorC)
165 e.Lines[e.CursorR] = pre
166 e.Lines = append(e.Lines, "")
167 copy(e.Lines[e.CursorR+2:], e.Lines[e.CursorR+1:])
168 e.Lines[e.CursorR+1] = post
169 e.CursorR++
170 e.CursorC = 0
171}
172
173func (e *Editor) backspace() {
174 e.histIdx = -1
175 if e.CursorC == 0 {
176 if e.CursorR == 0 {
177 return
178 }
179 prev := e.Lines[e.CursorR-1]
180 cur := e.Lines[e.CursorR]
181 e.Lines = append(e.Lines[:e.CursorR], e.Lines[e.CursorR+1:]...)
182 e.CursorR--
183 e.CursorC = runeLen(prev)
184 e.Lines[e.CursorR] = prev + cur
185 return
186 }
187 line := e.Lines[e.CursorR]
188 e.Lines[e.CursorR] = substringBefore(line, e.CursorC-1) + substringAfter(line, e.CursorC)
189 e.CursorC--
190}
191
192func (e *Editor) delete() {
193 e.histIdx = -1
194 line := e.Lines[e.CursorR]
195 if e.CursorC == runeLen(line) {
196 if e.CursorR == len(e.Lines)-1 {
197 return
198 }
199 next := e.Lines[e.CursorR+1]
200 e.Lines = append(e.Lines[:e.CursorR+1], e.Lines[e.CursorR+2:]...)
201 e.Lines[e.CursorR] = line + next
202 return
203 }
204 e.Lines[e.CursorR] = substringBefore(line, e.CursorC) + substringAfter(line, e.CursorC+1)
205}
206
207// moveWordLeft jumps the cursor to the start of the previous word,
208// using the same word-separator rules as deleteWord. If already at the
209// start of the line, wraps to the end of the previous line.
210func (e *Editor) moveWordLeft() {
211 if e.CursorC == 0 {
212 if e.CursorR > 0 {
213 e.CursorR--
214 e.CursorC = runeLen(e.Lines[e.CursorR])
215 }
216 return
217 }
218 r := []rune(e.Lines[e.CursorR])
219 i := e.CursorC
220 for i > 0 && isWordSep(r[i-1]) {
221 i--
222 }
223 for i > 0 && !isWordSep(r[i-1]) {
224 i--
225 }
226 e.CursorC = i
227}
228
229// moveWordRight jumps the cursor to the start of the next word. If
230// already at the end of the line, wraps to the start of the next line.
231func (e *Editor) moveWordRight() {
232 line := e.Lines[e.CursorR]
233 if e.CursorC >= runeLen(line) {
234 if e.CursorR < len(e.Lines)-1 {
235 e.CursorR++
236 e.CursorC = 0
237 }
238 return
239 }
240 r := []rune(line)
241 i := e.CursorC
242 for i < len(r) && !isWordSep(r[i]) {
243 i++
244 }
245 for i < len(r) && isWordSep(r[i]) {
246 i++
247 }
248 e.CursorC = i
249}
250
251func (e *Editor) moveLeft() {
252 if e.CursorC > 0 {
253 e.CursorC--
254 return
255 }
256 if e.CursorR > 0 {
257 e.CursorR--
258 e.CursorC = runeLen(e.Lines[e.CursorR])
259 }
260}
261
262func (e *Editor) moveRight() {
263 if e.CursorC < runeLen(e.Lines[e.CursorR]) {
264 e.CursorC++
265 return
266 }
267 if e.CursorR < len(e.Lines)-1 {
268 e.CursorR++
269 e.CursorC = 0
270 }
271}
272
273func (e *Editor) deleteWord() {
274 e.histIdx = -1
275 line := e.Lines[e.CursorR]
276 if e.CursorC == 0 {
277 e.backspace()
278 return
279 }
280 r := []rune(line)
281 i := e.CursorC
282 // Step 1: walk over any trailing whitespace to the left of the cursor.
283 for i > 0 && isWordSep(r[i-1]) {
284 i--
285 }
286 // Step 2: walk over the word itself (non-separator runes).
287 for i > 0 && !isWordSep(r[i-1]) {
288 i--
289 }
290 e.Lines[e.CursorR] = string(r[:i]) + string(r[e.CursorC:])
291 e.CursorC = i
292}
293
294// isWordSep reports whether r is a word separator. Anything that isn't
295// a letter, digit, or underscore counts as a separator so delete-word
296// feels natural on paths, code, and prose.
297func isWordSep(r rune) bool {
298 switch {
299 case r == '_':
300 return false
301 case r >= '0' && r <= '9':
302 return false
303 case r >= 'a' && r <= 'z':
304 return false
305 case r >= 'A' && r <= 'Z':
306 return false
307 case r >= 0x80:
308 // Treat all non-ASCII as word chars so CJK/äöü don't get chopped.
309 return false
310 }
311 return true
312}
313
314// ---- history ----
315
316// PushHistory saves s to the history ring.
317func (e *Editor) PushHistory(s string) {
318 s = strings.TrimRight(s, "\n")
319 if s == "" {
320 return
321 }
322 if n := len(e.History); n > 0 && e.History[n-1] == s {
323 return
324 }
325 e.History = append(e.History, s)
326 if len(e.History) > 200 {
327 e.History = e.History[len(e.History)-200:]
328 }
329 e.histIdx = -1
330}
331
332func (e *Editor) historyPrev() {
333 if len(e.History) == 0 {
334 return
335 }
336 if e.histIdx == -1 {
337 e.savedDraft = e.Value()
338 e.histIdx = len(e.History) - 1
339 } else if e.histIdx > 0 {
340 e.histIdx--
341 }
342 e.SetValue(e.History[e.histIdx])
343 // SetValue resets histIdx; restore.
344 e.histIdx = e.findInHistory()
345}
346
347func (e *Editor) historyNext() {
348 if e.histIdx == -1 {
349 return
350 }
351 if e.histIdx == len(e.History)-1 {
352 e.histIdx = -1
353 e.SetValue(e.savedDraft)
354 e.histIdx = -1
355 return
356 }
357 e.histIdx++
358 e.SetValue(e.History[e.histIdx])
359 e.histIdx = e.findInHistory()
360}
361
362func (e *Editor) findInHistory() int {
363 v := e.Value()
364 for i, h := range e.History {
365 if h == v {
366 return i
367 }
368 }
369 return -1
370}
371
372// ---- rendering ----
373
374// Render returns the editor's visible lines (wrapped to width).
375// visualRow/visualCol describe where the cursor lands within the returned lines.
376func (e *Editor) Render(width int) (lines []string, visualRow, visualCol int) {
377 promptLen := visibleWidth(e.Prompt)
378 indent := strings.Repeat(" ", promptLen)
379
380 for r, line := range e.Lines {
381 var prefix string
382 if r == 0 {
383 prefix = e.Prompt
384 } else {
385 prefix = indent
386 }
387 wrapped := wrapLine(prefix+line, width, indent)
388 if r == e.CursorR {
389 // Compute where CursorC lands inside the wrapped rows by
390 // walking the wrapped output character-by-character and
391 // tracking which wrapped row + column corresponds to the
392 // cursor's rune index in `line`. This is the only reliable
393 // answer under word-wrap, where a simple (promptLen+col)/width
394 // formula overshoots when wrapLine broke on a space.
395 targetRunes := e.CursorC
396 row, col := locateCursor(wrapped, prefix, line, targetRunes, indent)
397 visualRow = len(lines) + row
398 visualCol = col
399 }
400 lines = append(lines, wrapped...)
401 }
402 return lines, visualRow, visualCol
403}
404
405// locateCursor finds the wrapped row + visible column corresponding to
406// `targetRunes` rune positions into the logical `line`, given that the
407// wrapped output `wrapped` started with `prefix` on its first row and
408// uses `cont` as the continuation indent on subsequent rows.
409func locateCursor(wrapped []string, prefix, line string, targetRunes int, cont string) (int, int) {
410 prefixW := visibleWidth(prefix)
411 contW := visibleWidth(cont)
412 lineRunes := []rune(line)
413 if targetRunes > len(lineRunes) {
414 targetRunes = len(lineRunes)
415 }
416
417 seenRunes := 0
418 for row, w := range wrapped {
419 // strip leading prefix / continuation indent before counting runes
420 body := w
421 var leadW int
422 if row == 0 {
423 if strings.HasPrefix(body, prefix) {
424 body = body[len(prefix):]
425 }
426 leadW = prefixW
427 } else {
428 if strings.HasPrefix(body, cont) {
429 body = body[len(cont):]
430 }
431 leadW = contW
432 }
433 bodyRunes := []rune(body)
434 // Could this wrapped row contain the cursor?
435 if targetRunes <= seenRunes+len(bodyRunes) {
436 // Column inside body.
437 inner := targetRunes - seenRunes
438 if inner < 0 {
439 inner = 0
440 }
441 col := leadW + runewidth.StringWidth(string(bodyRunes[:inner]))
442 return row, col
443 }
444 seenRunes += len(bodyRunes)
445 // Word-wrap may have dropped whitespace at the boundary; skip the
446 // corresponding runes in `line` so counts stay aligned.
447 for seenRunes < len(lineRunes) && lineRunes[seenRunes] == ' ' {
448 seenRunes++
449 if seenRunes >= targetRunes {
450 // Cursor landed in the skipped whitespace — place it at
451 // the start of the next wrapped row.
452 nextRow := row + 1
453 if nextRow >= len(wrapped) {
454 nextRow = row
455 }
456 return nextRow, contW
457 }
458 }
459 }
460 // Fallback: end of the last wrapped row.
461 if len(wrapped) == 0 {
462 return 0, prefixW
463 }
464 last := wrapped[len(wrapped)-1]
465 return len(wrapped) - 1, visibleWidth(last)
466}
467
468// ---- helpers ----
469
470func substringBefore(s string, col int) string {
471 r := []rune(s)
472 if col > len(r) {
473 col = len(r)
474 }
475 return string(r[:col])
476}
477
478func substringAfter(s string, col int) string {
479 r := []rune(s)
480 if col > len(r) {
481 col = len(r)
482 }
483 return string(r[col:])
484}
485
486func runeLen(s string) int { return len([]rune(s)) }
487
488func visualColumn(s string, runeCol int) int {
489 r := []rune(s)
490 if runeCol > len(r) {
491 runeCol = len(r)
492 }
493 return runewidth.StringWidth(string(r[:runeCol]))
494}
495
496func visibleWidth(s string) int {
497 return runewidth.StringWidth(stripANSI(s))
498}
499
500func stripANSI(s string) string {
501 // Minimal ANSI stripper; handles CSI sequences (ESC [ ... final).
502 var out []rune
503 i := 0
504 runes := []rune(s)
505 for i < len(runes) {
506 if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
507 i += 2
508 for i < len(runes) {
509 c := runes[i]
510 i++
511 if c >= 0x40 && c <= 0x7e {
512 break
513 }
514 }
515 continue
516 }
517 out = append(out, runes[i])
518 i++
519 }
520 return string(out)
521}
522
523func wrapLine(s string, width int, cont string) []string {
524 if width <= 0 {
525 return []string{s}
526 }
527
528 // Tokenize on spaces while preserving their widths. Runs of spaces
529 // stay attached to the preceding word so trailing whitespace doesn't
530 // create empty wrapped lines.
531 type token struct {
532 text string
533 width int
534 space bool // true if this token is whitespace only
535 }
536 var tokens []token
537 {
538 var buf strings.Builder
539 var bufW int
540 inSpace := false
541 flush := func() {
542 if buf.Len() == 0 {
543 return
544 }
545 tokens = append(tokens, token{text: buf.String(), width: bufW, space: inSpace})
546 buf.Reset()
547 bufW = 0
548 }
549 for _, r := range s {
550 rSpace := r == ' ' || r == '\t'
551 if buf.Len() == 0 {
552 inSpace = rSpace
553 } else if rSpace != inSpace {
554 flush()
555 inSpace = rSpace
556 }
557 buf.WriteRune(r)
558 bufW += runewidth.RuneWidth(r)
559 }
560 flush()
561 }
562
563 var out []string
564 var cur strings.Builder
565 curW := 0
566 firstLine := true
567 contW := visibleWidth(cont)
568
569 newLine := func() {
570 out = append(out, cur.String())
571 cur.Reset()
572 curW = 0
573 if !firstLine {
574 cur.WriteString(cont)
575 curW = contW
576 }
577 firstLine = false
578 }
579
580 for i := 0; i < len(tokens); i++ {
581 tk := tokens[i]
582
583 // Drop leading whitespace after a wrap.
584 if tk.space && curW == contW && !firstLine {
585 continue
586 }
587
588 if curW+tk.width <= width {
589 cur.WriteString(tk.text)
590 curW += tk.width
591 continue
592 }
593
594 // Token overflows. If current line has content, break first.
595 if cur.Len() > 0 && !(firstLine && curW == 0) {
596 // Drop trailing whitespace on the line we're about to break.
597 trimmed := strings.TrimRight(cur.String(), " \t")
598 cur.Reset()
599 cur.WriteString(trimmed)
600 curW = visibleWidth(trimmed)
601 newLine()
602 // Re-process this token on the fresh line.
603 if tk.space {
604 continue
605 }
606 }
607
608 // Token is longer than the available width on an empty line.
609 // Split the token itself rune-by-rune.
610 if tk.width > width-curW {
611 for _, r := range tk.text {
612 rw := runewidth.RuneWidth(r)
613 if curW+rw > width {
614 newLine()
615 }
616 cur.WriteRune(r)
617 curW += rw
618 }
619 } else {
620 cur.WriteString(tk.text)
621 curW += tk.width
622 }
623 }
624
625 if cur.Len() > 0 || len(out) == 0 {
626 out = append(out, cur.String())
627 }
628 return out
629}
630
631func max(a, b int) int {
632 if a > b {
633 return a
634 }
635 return b
636}