v0.6.0 b250c5e

screen_csi.go RawHistoryFolder

1package vt
2
3import (
4	"strconv"
5	"strings"
6
7	renderer "github.com/bnema/vev-vt/core"
8)
9
10func (s *Screen) consumeCSI(data []byte) (consumed int, partial bool) {
11	end := -1
12	for i := 2; i < len(data); i++ {
13		if data[i] >= '@' && data[i] <= '~' {
14			end = i
15			break
16		}
17	}
18	if end == -1 {
19		return 0, true
20	}
21	params := string(data[2:end])
22	cmd := data[end]
23	s.applyCSI(params, cmd)
24	return end + 1, false
25}
26
27func (s *Screen) applyCSI(params string, cmd byte) {
28	private := strings.HasPrefix(params, "?")
29	parts := s.parseCSIInts(params)
30	switch cmd {
31	case 'c':
32		switch params {
33		case "", "0":
34			// Identify as a VT220-compatible terminal with ANSI color. Modern
35			// capability probes require a DA1 response with a conformance level;
36			// the shorter VT102 response is ignored by kitten icat.
37			s.respond([]byte("\x1b[?62;22c"))
38		case ">", ">0":
39			s.respond([]byte("\x1b[>0;0;0c"))
40		}
41	case 'n':
42		switch firstPositive(parts, 0) {
43		case 5:
44			if !private {
45				s.respond([]byte("\x1b[0n"))
46			}
47		case 996:
48			if private {
49				if report := s.colorSchemeReport(); report != nil {
50					s.respond(report)
51				}
52			}
53		case 6:
54			resp := make([]byte, 0, 16)
55			resp = append(resp, "\x1b["...)
56			if private {
57				resp = append(resp, '?')
58			}
59			resp = strconv.AppendInt(resp, int64(s.cursorReportRow()), 10)
60			resp = append(resp, ';')
61			resp = strconv.AppendInt(resp, int64(s.Col+1), 10)
62			resp = append(resp, 'R')
63			s.respond(resp)
64		}
65	case 'p':
66		if modeText, ok := strings.CutSuffix(params, "$"); ok {
67			if private {
68				modeText = strings.TrimPrefix(modeText, "?")
69			}
70			mode, err := strconv.Atoi(modeText)
71			if err != nil {
72				return
73			}
74			state := s.modeReportState(private, mode)
75			resp := make([]byte, 0, 16)
76			resp = append(resp, "\x1b["...)
77			if private {
78				resp = append(resp, '?')
79			}
80			resp = strconv.AppendInt(resp, int64(mode), 10)
81			resp = append(resp, ';')
82			resp = strconv.AppendInt(resp, int64(state), 10)
83			resp = append(resp, "$y"...)
84			s.respond(resp)
85		}
86	case 'm':
87		if strings.HasPrefix(params, ">") {
88			return
89		}
90		s.applySGR(params)
91	case 'q':
92		s.applyCursorStyle(params)
93	case 'J':
94		mode := 0
95		if len(parts) > 0 {
96			mode = parts[0]
97		}
98		s.clearScreenMode(mode)
99	case 'K':
100		mode := 0
101		if len(parts) > 0 {
102			mode = parts[0]
103		}
104		s.clearLineMode(mode)
105	case 'X':
106		s.eraseChars(firstPositive(parts, 1))
107	case 'S':
108		s.scrollUpBy(firstPositive(parts, 1))
109	case 'T':
110		s.scrollDownBy(firstPositive(parts, 1))
111	case 'H', 'f':
112		row, col := 1, 1
113		if len(parts) > 0 && parts[0] > 0 {
114			row = parts[0]
115		}
116		if len(parts) > 1 && parts[1] > 0 {
117			col = parts[1]
118		}
119		s.addressCursor(row, col)
120	case 'A':
121		s.Row = clamp(s.Row-firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
122	case 'B':
123		s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
124	case 'C':
125		s.Col = clamp(s.Col+firstPositive(parts, 1), 0, s.frame.Width-1)
126	case 'D':
127		s.Col = clamp(s.Col-firstPositive(parts, 1), 0, s.frame.Width-1)
128	case 'E':
129		s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
130		s.Col = 0
131	case 'F':
132		s.Row = clamp(s.Row-firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
133		s.Col = 0
134	case 'G':
135		col := firstPositive(parts, 1)
136		s.Col = clamp(col-1, 0, s.frame.Width-1)
137	case 'd':
138		row := firstPositive(parts, 1)
139		s.Row = s.addressedRow(row)
140	case '@':
141		s.insertChars(firstPositive(parts, 1))
142	case 'P':
143		s.deleteChars(firstPositive(parts, 1))
144	case 'L':
145		s.insertLines(firstPositive(parts, 1))
146	case 'M':
147		s.deleteLines(firstPositive(parts, 1))
148	case 'r':
149		s.setScrollRegion(parts)
150	case 't':
151		if !private {
152			s.reportWindowSize(parts)
153		}
154	case 's':
155		s.saveCursor()
156	case 'u':
157		if params == "" {
158			s.restoreCursor()
159			return
160		}
161		s.applyKittyKeyboard(params)
162	case 'h':
163		s.setMode(private, parts, true)
164	case 'l':
165		s.setMode(private, parts, false)
166	}
167}
168
169func firstPositive(parts []int, fallback int) int {
170	if len(parts) == 0 || parts[0] <= 0 {
171		return fallback
172	}
173	return parts[0]
174}
175
176func (s *Screen) modeReportState(private bool, mode int) int {
177	if private {
178		switch mode {
179		case 1:
180			return boolModeReportState(s.applicationCursorMode)
181		case 6:
182			return boolModeReportState(s.originMode)
183		case 7:
184			return boolModeReportState(s.autoWrapMode)
185		case 2026:
186			return boolModeReportState(s.syncUpdateActive)
187		case 2031:
188			return boolModeReportState(s.colorSchemeMode)
189		}
190		return 0
191	}
192	if mode == 4 {
193		return boolModeReportState(s.insertMode)
194	}
195	return 0
196}
197
198func boolModeReportState(enabled bool) int {
199	if enabled {
200		return 1
201	}
202	return 2
203}
204
205const sgrUnderlineStyleMarker = -1000
206
207func (s *Screen) applySGR(params string) {
208	if params == "" {
209		s.Style = renderer.DefaultStyle()
210		return
211	}
212	parts := s.parseSGRInts(params)
213	for i := 0; i < len(parts); i++ {
214		switch parts[i] {
215		case 0:
216			s.Style = renderer.DefaultStyle()
217		case 1:
218			s.Style.Bold = true
219		case 2:
220			s.Style.Attrs |= renderer.AttrDim
221		case 3:
222			s.Style.Italic = true
223		case 4:
224			s.Style.Attrs |= renderer.AttrUnderline
225			s.Style.UnderlineStyle = renderer.UnderlineSingle
226			if i+2 < len(parts) && parts[i+1] == sgrUnderlineStyleMarker {
227				underlineStyle := sgrUnderlineStyle(parts[i+2])
228				if underlineStyle == renderer.UnderlineNone {
229					s.Style.Attrs &^= renderer.AttrUnderline
230				} else {
231					s.Style.Attrs |= renderer.AttrUnderline
232				}
233				s.Style.UnderlineStyle = underlineStyle
234				i += 2
235			}
236		case 5, 6:
237			s.Style.Attrs |= renderer.AttrBlink
238		case 7:
239			s.Style.Inverse = true
240		case 9:
241			s.Style.Attrs |= renderer.AttrStrikethrough
242		case 21:
243			s.Style.Attrs |= renderer.AttrUnderline
244			s.Style.UnderlineStyle = renderer.UnderlineDouble
245		case 22:
246			s.Style.Bold = false
247			s.Style.Attrs &^= renderer.AttrDim
248		case 23:
249			s.Style.Italic = false
250		case 24:
251			s.Style.Attrs &^= renderer.AttrUnderline
252			s.Style.UnderlineStyle = renderer.UnderlineNone
253		case 25:
254			s.Style.Attrs &^= renderer.AttrBlink
255		case 27:
256			s.Style.Inverse = false
257		case 29:
258			s.Style.Attrs &^= renderer.AttrStrikethrough
259		case 30, 31, 32, 33, 34, 35, 36, 37:
260			s.Style.Foreground = parts[i] - 30
261			s.Style.HasForegroundRGB = false
262		case 39:
263			s.Style.Foreground = -1
264			s.Style.HasForegroundRGB = false
265		case 40, 41, 42, 43, 44, 45, 46, 47:
266			s.Style.Background = parts[i] - 40
267			s.Style.HasBackgroundRGB = false
268		case 49:
269			s.Style.Background = -1
270			s.Style.HasBackgroundRGB = false
271		case 90, 91, 92, 93, 94, 95, 96, 97:
272			s.Style.Foreground = parts[i] - 90 + 8
273			s.Style.HasForegroundRGB = false
274		case 100, 101, 102, 103, 104, 105, 106, 107:
275			s.Style.Background = parts[i] - 100 + 8
276			s.Style.HasBackgroundRGB = false
277		case 38:
278			if i+2 < len(parts) && parts[i+1] == 5 {
279				s.Style.Foreground = parts[i+2]
280				s.Style.HasForegroundRGB = false
281				i += 2
282			} else if i+4 < len(parts) && parts[i+1] == 2 {
283				s.Style.Foreground = -1
284				s.Style.HasForegroundRGB = true
285				s.Style.ForegroundRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4])
286				i += 4
287			}
288		case 48:
289			if i+2 < len(parts) && parts[i+1] == 5 {
290				s.Style.Background = parts[i+2]
291				s.Style.HasBackgroundRGB = false
292				i += 2
293			} else if i+4 < len(parts) && parts[i+1] == 2 {
294				s.Style.Background = -1
295				s.Style.HasBackgroundRGB = true
296				s.Style.BackgroundRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4])
297				i += 4
298			}
299		case 58:
300			if i+2 < len(parts) && parts[i+1] == 5 {
301				s.Style.UnderlineColor = parts[i+2]
302				s.Style.HasUnderlineColor = true
303				s.Style.HasUnderlineColorRGB = false
304				i += 2
305			} else if i+4 < len(parts) && parts[i+1] == 2 {
306				s.Style.HasUnderlineColor = false
307				s.Style.HasUnderlineColorRGB = true
308				s.Style.UnderlineColorRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4])
309				i += 4
310			}
311		case 59:
312			s.Style.HasUnderlineColor = false
313			s.Style.HasUnderlineColorRGB = false
314		}
315	}
316}
317
318func sgrRGB(r, g, b int) renderer.RGB {
319	return renderer.RGB{
320		R: uint8(clamp(r, 0, 255)),
321		G: uint8(clamp(g, 0, 255)),
322		B: uint8(clamp(b, 0, 255)),
323	}
324}
325
326func sgrUnderlineStyle(param int) renderer.UnderlineStyle {
327	switch param {
328	case 0:
329		return renderer.UnderlineNone
330	case 2:
331		return renderer.UnderlineDouble
332	case 3:
333		return renderer.UnderlineCurly
334	case 4:
335		return renderer.UnderlineDotted
336	case 5:
337		return renderer.UnderlineDashed
338	default:
339		return renderer.UnderlineSingle
340	}
341}
342
343func (s *Screen) insertChars(n int) {
344	if s.Row < 0 || s.Row >= s.frame.Height || s.Col >= s.frame.Width || n <= 0 {
345		return
346	}
347	w := s.frame.Width
348	if n > w-s.Col {
349		n = w - s.Col
350	}
351	// A wide left half at Col-1 whose continuation sits at Col will be orphaned
352	// by the shift; its repair falls outside the default damage rect.
353	leftSplit := s.Col > 0 && s.frame.At(s.Col, s.Row).Continuation
354	s.frame.CopyRow(s.Row, s.Col+n, s.Col, w-s.Col-n)
355	s.frame.FillRow(s.Row, s.Col, s.Col+n, renderer.BlankCell())
356	s.repairRow(s.Row)
357	dmgX := s.Col
358	if leftSplit {
359		dmgX = s.Col - 1
360	}
361	s.record(renderer.Damage{Kind: renderer.DamageText, X: dmgX, Y: s.Row, Width: w - dmgX, Height: 1, Count: 1})
362}
363
364func (s *Screen) deleteChars(n int) {
365	if s.Row < 0 || s.Row >= s.frame.Height || s.Col >= s.frame.Width || n <= 0 {
366		return
367	}
368	w := s.frame.Width
369	if n > w-s.Col {
370		n = w - s.Col
371	}
372	// A wide left half at Col-1 whose continuation sits at Col will be orphaned
373	// by the shift; its repair falls outside the default damage rect.
374	leftSplit := s.Col > 0 && s.frame.At(s.Col, s.Row).Continuation
375	s.frame.CopyRow(s.Row, s.Col, s.Col+n, w-s.Col-n)
376	s.frame.FillRow(s.Row, w-n, w, renderer.BlankCell())
377	s.repairRow(s.Row)
378	dmgX := s.Col
379	if leftSplit {
380		dmgX = s.Col - 1
381	}
382	s.record(renderer.Damage{Kind: renderer.DamageText, X: dmgX, Y: s.Row, Width: w - dmgX, Height: 1, Count: 1})
383}
384
385func (s *Screen) insertLines(n int) {
386	if s.Row < s.scrollTop || s.Row > s.scrollBottom || n <= 0 {
387		return
388	}
389	s.scrollDownRegion(s.Row, s.scrollBottom, n)
390}
391
392func (s *Screen) deleteLines(n int) {
393	if s.Row < s.scrollTop || s.Row > s.scrollBottom || n <= 0 {
394		return
395	}
396	s.scrollUpRegion(s.Row, s.scrollBottom, n)
397}
398
399func (s *Screen) clearScreenMode(mode int) {
400	s.clampCursor()
401	switch mode {
402	case 1:
403		for y := range min(s.Row+1, s.frame.Height) {
404			end := s.frame.Width
405			if y == s.Row {
406				end = min(s.Col+1, s.frame.Width)
407			}
408			s.clearRow(y, 0, end)
409		}
410		s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.frame.Width, Height: min(s.Row+1, s.frame.Height), Count: 1})
411	case 2, 3:
412		blank := s.eraseCell()
413		for y := range s.frame.Height {
414			s.frame.FillRow(y, 0, s.frame.Width, blank)
415		}
416		for y := range s.buffer.boundaries {
417			s.buffer.boundaries[y] = LineBound{Soft: s.buffer.boundaries[y].Soft}
418			s.buffer.rowIDs[y] = s.nextRowIDValue()
419		}
420		s.graphicsClearPlacements()
421		s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.frame.Width, Height: s.frame.Height, Count: 1})
422	default:
423		for y := s.Row; y < s.frame.Height; y++ {
424			start := 0
425			if y == s.Row {
426				start = s.Col
427			}
428			s.clearRow(y, start, s.frame.Width)
429		}
430		s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: s.Row, Width: s.frame.Width, Height: s.frame.Height - s.Row, Count: 1})
431	}
432}
433
434func (s *Screen) clearLineMode(mode int) {
435	s.clampCursor()
436	switch mode {
437	case 1:
438		start, width := s.clearRow(s.Row, 0, min(s.Col+1, s.frame.Width))
439		s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
440	case 2:
441		start, width := s.clearRow(s.Row, 0, s.frame.Width)
442		s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
443	default:
444		start, width := s.clearRow(s.Row, s.Col, s.frame.Width)
445		s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
446	}
447}
448
449func (s *Screen) eraseChars(n int) {
450	if n <= 0 {
451		return
452	}
453	s.clampCursor()
454	if remaining := s.frame.Width - s.Col; n > remaining {
455		n = remaining
456	}
457	end := s.Col + n
458	start, width := s.clearRow(s.Row, s.Col, end)
459	if width > 0 {
460		s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
461	}
462}
463
464func (s *Screen) clampCursor() {
465	s.Row = clamp(s.Row, 0, s.frame.Height-1)
466	s.Col = clamp(s.Col, 0, s.frame.Width-1)
467}
468
469func (s *Screen) parseCSIInts(params string) []int {
470	s.csiScratch = parseCSIIntsInto(s.csiScratch[:0], params)
471	return s.csiScratch
472}
473
474func parseCSIInts(params string) []int {
475	return parseCSIIntsInto(nil, params)
476}
477
478func parseCSIIntsInto(out []int, params string) []int {
479	if params == "" {
480		return out
481	}
482	params = strings.TrimPrefix(params, "?")
483	params = strings.TrimPrefix(params, ">")
484	start := 0
485	for i := 0; i <= len(params); i++ {
486		if i == len(params) || params[i] == ';' {
487			out = append(out, parseCSIInt(params[start:i]))
488			start = i + 1
489		}
490	}
491	return out
492}
493
494func (s *Screen) parseSGRInts(params string) []int {
495	s.sgrScratch = parseSGRIntsInto(s.sgrScratch[:0], params)
496	return s.sgrScratch
497}
498
499func parseSGRInts(params string) []int {
500	return parseSGRIntsInto(nil, params)
501}
502
503func parseSGRIntsInto(out []int, params string) []int {
504	if params == "" {
505		return out
506	}
507	start := 0
508	for i := 0; i <= len(params); i++ {
509		if i == len(params) || params[i] == ';' {
510			out = appendSGRGroup(out, params[start:i])
511			start = i + 1
512		}
513	}
514	return out
515}
516
517func appendSGRGroup(out []int, group string) []int {
518	colon := false
519	for i := 0; i < len(group); i++ {
520		if group[i] == ':' {
521			colon = true
522			break
523		}
524	}
525	if !colon {
526		return append(out, parseCSIInt(group))
527	}
528
529	parts := countSGRColonFields(group)
530	code := parseCSIInt(sgrColonField(group, 0))
531	if code == 4 {
532		if parts < 2 {
533			return append(out, code)
534		}
535		return append(out, code, sgrUnderlineStyleMarker, parseCSIInt(sgrColonField(group, 1)))
536	}
537	if code != 38 && code != 48 && code != 58 {
538		return append(out, code)
539	}
540	mode := 0
541	if parts > 1 {
542		mode = parseCSIInt(sgrColonField(group, 1))
543	}
544	switch mode {
545	case 5:
546		if parts < 3 {
547			return out
548		}
549		out = append(out, code, mode, parseCSIInt(sgrColonField(group, 2)))
550	case 2:
551		// code:mode::R:G:B and code:mode:cs:R:G:B both put RGB after
552		// the colorspace slot; code:mode:R:G:B omits that slot.
553		start := 2
554		if parts > 2 && (sgrColonField(group, 2) == "" || parts >= 6) {
555			start = 3
556		}
557		if parts < start+3 {
558			return out
559		}
560		out = append(out, code, mode)
561		for i := range 3 {
562			out = append(out, parseCSIInt(sgrColonField(group, start+i)))
563		}
564	}
565	return out
566}
567
568func countSGRColonFields(group string) int {
569	count := 1
570	for i := 0; i < len(group); i++ {
571		if group[i] == ':' {
572			count++
573		}
574	}
575	return count
576}
577
578func sgrColonField(group string, index int) string {
579	start := 0
580	field := 0
581	for i := 0; i <= len(group); i++ {
582		if i == len(group) || group[i] == ':' {
583			if field == index {
584				return group[start:i]
585			}
586			field++
587			start = i + 1
588		}
589	}
590	return ""
591}
592
593func parseCSIInt(param string) int {
594	if param == "" {
595		return 0
596	}
597	v, err := strconv.Atoi(param)
598	if err != nil {
599		return 0
600	}
601	return v
602}

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 2

Tags 13

↑↓ moveenter openesc close