v0.0.15 3ff6d9e

editor.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close