v0.1.0 7deb448

screen.go RawHistoryFolder

1package vt
2
3import (
4	"unicode/utf8"
5
6	"github.com/bnema/vev/pkg/renderer"
7)
8
9// maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads
10// forwarded by internal/usecase/copy.OSC52MaxPayloadBytes after base64
11// expansion plus the OSC wrapper. pkg/vt cannot import internal/usecase/copy,
12// so keep this value in sync if that payload cap changes.
13const maxEscapeBufferLen = 128 * 1024
14
15const (
16	// ColorSchemeReportDark is the DEC 2031 dark-scheme report.
17	ColorSchemeReportDark = "\x1b[?997;1n"
18	// ColorSchemeReportLight is the DEC 2031 light-scheme report.
19	ColorSchemeReportLight = "\x1b[?997;2n"
20)
21
22type Screen struct {
23	Frame renderer.Frame
24	Row   int
25	Col   int
26	Style renderer.Style
27	// OnLineEvicted is called just before a full-width upward scroll recycles
28	// and blanks rows. The callback receives a stable copy of each evicted row.
29	OnLineEvicted func([]renderer.Cell)
30	// OnResponse is called synchronously from Write with reply bytes that the
31	// emulator must send back to the child process (DA, DSR, and ANSI/DEC mode
32	// query reports). The host wires it to the PTY input. Nil disables responses.
33	OnResponse func([]byte)
34	// OnBell is called synchronously from Write for each lone BEL (0x07)
35	// outside escape sequences. BELs that terminate an OSC never fire it.
36	// Nil disables bell reporting.
37	OnBell func()
38	// OnNotify is called synchronously from Write for explicit terminal
39	// notifications: OSC 9 (body only) and OSC 777 "notify" (title;body).
40	// Other non-clipboard OSC payloads remain discarded. Nil disables it.
41	OnNotify func(title, body string)
42	// OnProgress is called synchronously from Write for OSC 9;4 progress
43	// transitions that request attention: active progress cleared or first entry
44	// into error state. Nil disables progress reporting.
45	OnProgress func(errored bool)
46	// OnClipboard is called synchronously from Write for a complete OSC 52
47	// clipboard set request from the child. The OSC 52 selection field is
48	// accepted but ignored; the callback receives only the raw base64 payload.
49	// Clipboard queries (data == "?") and malformed payloads are ignored and
50	// never invoke it. Nil disables it.
51	OnClipboard func(b64 string)
52
53	history *History
54	buffer  *buffer
55
56	defaultFG          renderer.RGB
57	defaultBG          renderer.RGB
58	defaultColorsKnown bool
59	terminalTitle      string
60
61	damage                 []renderer.Damage
62	damageGeneration       uint64
63	damageSaturated        bool
64	damageFullRedrawSticky bool
65	escapeBuf              []byte
66	csiScratch             []int
67	sgrScratch             []int
68
69	scrollTop        int
70	scrollBottom     int
71	savedCursor      cursorState
72	alternate        *screenState
73	syncUpdateActive bool
74	progressState    int
75	cursorVisible    bool
76	cursorStyle      int
77	cursorStyleSet   bool
78	mouseMode        int
79	mouseSGR         bool
80	bracketedPaste   bool
81	originMode       bool
82	insertMode       bool
83	colorSchemeMode  bool
84	colorSchemeLight bool
85	colorSchemeSet   bool
86}
87
88func NewScreen(width, height int) *Screen {
89	b := newBuffer(width, height)
90	s := &Screen{
91		Frame:            b.frame,
92		buffer:           b,
93		Style:            renderer.DefaultStyle(),
94		damage:           []renderer.Damage{renderer.FullRedraw()},
95		damageGeneration: 1,
96		cursorVisible:    true,
97	}
98	s.resetScrollRegion()
99	return s
100}
101
102// NewScreenWithHistory creates a screen that records rows evicted from its
103// primary screen into bounded immutable terminal history.
104func NewScreenWithHistory(width, height int, config HistoryConfig) *Screen {
105	s := NewScreen(width, height)
106	s.history = NewHistory(config)
107	return s
108}
109
110// History returns this screen's terminal history, or nil when history was not
111// configured with NewScreenWithHistory.
112func (s *Screen) History() *History { return s.history }
113
114// LineBounds returns an owned copy of the live grid's per-row logical extents,
115// indexed like Frame rows. It returns nil when the screen has no buffer.
116func (s *Screen) LineBounds() []LineBound {
117	if s == nil || s.buffer == nil {
118		return nil
119	}
120	return append([]LineBound(nil), s.buffer.boundaries...)
121}
122
123func (s *Screen) Write(data []byte) {
124	if len(s.escapeBuf) > 0 {
125		combined := make([]byte, 0, len(s.escapeBuf)+len(data))
126		combined = append(combined, s.escapeBuf...)
127		combined = append(combined, data...)
128		data = combined
129		s.escapeBuf = s.escapeBuf[:0]
130	}
131
132	for len(data) > 0 {
133		if data[0] == 0x1b {
134			consumed, partial := s.consumeEscape(data)
135			if consumed > 0 {
136				data = data[consumed:]
137				continue
138			}
139			if partial {
140				if len(data) <= maxEscapeBufferLen {
141					s.escapeBuf = append(s.escapeBuf[:0], data...)
142				}
143				return
144			}
145		}
146		r, size := utf8.DecodeRune(data)
147		if r == utf8.RuneError && size == 1 {
148			data = data[1:]
149			continue
150		}
151		s.putRune(r)
152		data = data[size:]
153	}
154}
155
156func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
157	if len(data) < 2 {
158		return 0, true
159	}
160	switch data[1] {
161	case ']':
162		return s.consumeOSC(data)
163	case 'P':
164		return consumeSTString(data)
165	case '_', '^', 'X':
166		return consumeSTString(data)
167	case '[':
168		return s.consumeCSI(data)
169	case '=':
170		return 2, false
171	case '>':
172		return 2, false
173	case '7':
174		s.saveCursor()
175		return 2, false
176	case '8':
177		s.restoreCursor()
178		return 2, false
179	case 'D':
180		s.buffer.hard(s.Row)
181		s.index()
182		return 2, false
183	case 'E':
184		s.nextLine()
185		return 2, false
186	case 'M':
187		s.reverseIndex()
188		return 2, false
189	case 'c':
190		s.reset()
191		return 2, false
192	case 'H':
193		return 2, false
194	case '(', ')', '*', '+', '-', '.', '/':
195		if len(data) < 3 {
196			return 0, true
197		}
198		return 3, false
199	default:
200		if data[1] >= 0x30 && data[1] <= 0x7e {
201			return 2, false
202		}
203		return 0, false
204	}
205}
206
207func consumeSTString(data []byte) (consumed int, partial bool) {
208	for i := 2; i < len(data); i++ {
209		if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
210			return i + 2, false
211		}
212	}
213	return 0, true
214}

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 12

Tags 11

↑↓ moveenter openesc close