v0.4.0 0b9cfda

screen.go RawHistoryFolder

1package vt
2
3import (
4	"unicode/utf8"
5
6	renderer "github.com/bnema/vev-vt/core"
7	"github.com/bnema/vev-vt/protocol/kittygraphics"
8)
9
10// maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads
11// forwarded by internal/usecase/copy.OSC52MaxPayloadBytes after base64
12// expansion plus the OSC wrapper. pkg/vt cannot import internal/usecase/copy,
13// so keep this value in sync if that payload cap changes.
14const maxEscapeBufferLen = 128 * 1024
15
16// Kitty graphics APCs have their own protocol limit. Keep this separate from
17// the ordinary escape-string limit so a fragmented image does not get fed
18// through the text path or truncated at the OSC clipboard bound.
19const maxKittyEscapeBufferLen = int(kittygraphics.DefaultMaxAPCBytes) + 5
20
21const (
22	// ColorSchemeReportDark is the DEC 2031 dark-scheme report.
23	ColorSchemeReportDark = "\x1b[?997;1n"
24	// ColorSchemeReportLight is the DEC 2031 light-scheme report.
25	ColorSchemeReportLight = "\x1b[?997;2n"
26)
27
28type Screen struct {
29	Frame renderer.Frame
30	Row   int
31	Col   int
32	Style renderer.Style
33	// OnLineEvicted is called just before a full-width upward scroll recycles
34	// and blanks rows. The callback receives a stable copy of each evicted row.
35	OnLineEvicted func([]renderer.Cell)
36	// OnResponse is called synchronously from Write with reply bytes that the
37	// emulator must send back to the child process (DA, DSR, and ANSI/DEC mode
38	// query reports). The host wires it to the PTY input. Nil disables responses.
39	OnResponse func([]byte)
40	// OnBell is called synchronously from Write for each lone BEL (0x07)
41	// outside escape sequences. BELs that terminate an OSC never fire it.
42	// Nil disables bell reporting.
43	OnBell func()
44	// OnNotify is called synchronously from Write for explicit terminal
45	// notifications: OSC 9 (body only) and OSC 777 "notify" (title;body).
46	// Other non-clipboard OSC payloads remain discarded. Nil disables it.
47	OnNotify func(title, body string)
48	// OnProgress is called synchronously from Write for OSC 9;4 progress
49	// transitions that request attention: active progress cleared or first entry
50	// into error state. Nil disables progress reporting.
51	OnProgress func(errored bool)
52	// OnClipboard is called synchronously from Write for a complete OSC 52
53	// clipboard set request from the child. The OSC 52 selection field is
54	// accepted but ignored; the callback receives only the raw base64 payload.
55	// Clipboard queries (data == "?") and malformed payloads are ignored and
56	// never invoke it. Nil disables it.
57	OnClipboard func(b64 string)
58
59	history *History
60	buffer  *buffer
61
62	nextRowID RowID
63	geometry  Geometry
64
65	defaultFG          renderer.RGB
66	defaultBG          renderer.RGB
67	defaultColorsKnown bool
68	terminalTitle      string
69
70	damage                 []renderer.Damage
71	damageGeneration       uint64
72	damageSaturated        bool
73	damageFullRedrawSticky bool
74	escapeBuf              []byte
75	kittyDiscard           bool
76	kittyDiscardEscaped    bool
77	kittyPendingDisplay    *kittygraphics.Controls
78	csiScratch             []int
79	sgrScratch             []int
80
81	scrollTop        int
82	scrollBottom     int
83	savedCursor      cursorState
84	alternate        *screenState
85	graphics         *screenGraphicsState
86	syncUpdateActive bool
87	progressState    int
88	cursorVisible    bool
89	cursorStyle      int
90	cursorStyleSet   bool
91	mouseMode        int
92	mouseSGR         bool
93	bracketedPaste   bool
94	originMode       bool
95	insertMode       bool
96	colorSchemeMode  bool
97	colorSchemeLight bool
98	colorSchemeSet   bool
99}
100
101func NewScreen(width, height int) *Screen {
102	s := &Screen{
103		Style:            renderer.DefaultStyle(),
104		damage:           []renderer.Damage{renderer.FullRedraw()},
105		damageGeneration: 1,
106		cursorVisible:    true,
107		geometry:         Geometry{Cols: width, Rows: height},
108	}
109	s.buffer = s.newBuffer(width, height)
110	s.Frame = s.buffer.frame
111	s.resetScrollRegion()
112	return s
113}
114
115// NewScreenWithHistory creates a screen that records rows evicted from its
116// primary screen into bounded immutable terminal history.
117func NewScreenWithHistory(width, height int, config HistoryConfig) *Screen {
118	s := NewScreen(width, height)
119	s.history = NewHistory(config)
120	return s
121}
122
123// History returns this screen's terminal history, or nil when history was not
124// configured with NewScreenWithHistory.
125func (s *Screen) History() *History { return s.history }
126
127// LineBounds returns an owned copy of the live grid's per-row logical extents,
128// indexed like Frame rows. It returns nil when the screen has no buffer.
129func (s *Screen) LineBounds() []LineBound {
130	if s == nil || s.buffer == nil {
131		return nil
132	}
133	return append([]LineBound(nil), s.buffer.boundaries...)
134}
135
136func (s *Screen) Write(data []byte) {
137	if len(s.escapeBuf) > 0 && isKittyEscapeContinuation(s.escapeBuf, data) {
138		prefix := s.escapeBuf
139		s.escapeBuf = nil
140		data = s.continueKittyEscape(prefix, data)
141		if len(data) == 0 {
142			return
143		}
144	}
145	if len(s.escapeBuf) > 0 {
146		combined := make([]byte, 0, len(s.escapeBuf)+len(data))
147		combined = append(combined, s.escapeBuf...)
148		combined = append(combined, data...)
149		data = combined
150		s.escapeBuf = nil
151	}
152	if s.kittyDiscard {
153		consumed := s.consumeKittyDiscard(data)
154		data = data[consumed:]
155		if s.kittyDiscard {
156			return
157		}
158	}
159
160	for len(data) > 0 {
161		if data[0] == 0x1b {
162			consumed, partial := s.consumeEscape(data)
163			if consumed > 0 {
164				data = data[consumed:]
165				continue
166			}
167			if partial {
168				limit := maxEscapeBufferLen
169				if isKittyEscapePrefix(data) {
170					limit = maxKittyEscapeBufferLen
171				}
172				if len(data) <= limit {
173					s.escapeBuf = append(s.escapeBuf[:0], data...)
174				} else if isKittyEscapePrefix(data) {
175					s.abortKittyPendingDisplay()
176					s.kittyDiscard = true
177					s.kittyDiscardEscaped = false
178				}
179				return
180			}
181		}
182		r, size := utf8.DecodeRune(data)
183		if r == utf8.RuneError && size == 1 {
184			data = data[1:]
185			continue
186		}
187		s.putRune(r)
188		data = data[size:]
189	}
190}
191
192func isKittyEscapePrefix(data []byte) bool {
193	if len(data) == 0 || data[0] != 0x1b {
194		return false
195	}
196	if len(data) > 1 && data[1] != '_' {
197		return false
198	}
199	return len(data) < 3 || data[2] == 'G'
200}
201
202func isKittyEscapeContinuation(prefix, data []byte) bool {
203	if !isKittyEscapePrefix(prefix) {
204		return false
205	}
206	if len(data) == 0 {
207		return true
208	}
209	if len(prefix) == 1 {
210		return data[0] == '_'
211	}
212	if len(prefix) == 2 {
213		return data[0] == 'G'
214	}
215	return true
216}
217
218func (s *Screen) continueKittyEscape(prefix, data []byte) []byte {
219	if len(prefix) != 0 && prefix[len(prefix)-1] == 0x1b && len(data) != 0 && data[0] == '\\' {
220		if len(prefix)+1 <= maxKittyEscapeBufferLen {
221			apc := append(prefix, data[:1]...)
222			s.dispatchKittyGraphics(apc)
223		} else {
224			s.abortKittyPendingDisplay()
225		}
226		return data[1:]
227	}
228	for i := 0; i < len(data); i++ {
229		if data[i] == 0x9c {
230			end := i + 1
231			if len(prefix)+end <= maxKittyEscapeBufferLen {
232				apc := append(prefix, data[:end]...)
233				s.dispatchKittyGraphics(apc)
234			} else {
235				s.abortKittyPendingDisplay()
236			}
237			return data[end:]
238		}
239		if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
240			end := i + 2
241			if len(prefix)+end <= maxKittyEscapeBufferLen {
242				apc := append(prefix, data[:end]...)
243				s.dispatchKittyGraphics(apc)
244			} else {
245				s.abortKittyPendingDisplay()
246			}
247			return data[end:]
248		}
249	}
250	remaining := maxKittyEscapeBufferLen - len(prefix)
251	if len(data) <= remaining {
252		returnData := append(prefix, data...)
253		s.escapeBuf = returnData
254		return nil
255	}
256	s.abortKittyPendingDisplay()
257	s.kittyDiscard = true
258	escaped := len(prefix) != 0 && prefix[len(prefix)-1] == 0x1b
259	if remaining > 0 {
260		escaped = data[remaining-1] == 0x1b
261	}
262	s.kittyDiscardEscaped = escaped
263	tail := data[remaining:]
264	return tail[s.consumeKittyDiscard(tail):]
265}
266
267func (s *Screen) consumeKittyDiscard(data []byte) int {
268	for i, b := range data {
269		if s.kittyDiscardEscaped {
270			s.kittyDiscardEscaped = false
271			if b == '\\' {
272				s.kittyDiscard = false
273				return i + 1
274			}
275		}
276		if b == 0x9c {
277			s.kittyDiscard = false
278			return i + 1
279		}
280		if b == 0x1b {
281			if i+1 < len(data) && data[i+1] == '\\' {
282				s.kittyDiscard = false
283				return i + 2
284			}
285			s.kittyDiscardEscaped = true
286		}
287	}
288	return len(data)
289}
290
291func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
292	if len(data) < 2 {
293		return 0, true
294	}
295	switch data[1] {
296	case ']':
297		return s.consumeOSC(data)
298	case 'P':
299		return consumeSTString(data)
300	case '_':
301		if len(data) >= 3 && data[2] == 'G' {
302			return s.consumeKittyGraphics(data)
303		}
304		return consumeSTString(data)
305	case '^', 'X':
306		return consumeSTString(data)
307	case '[':
308		return s.consumeCSI(data)
309	case '=':
310		return 2, false
311	case '>':
312		return 2, false
313	case '7':
314		s.saveCursor()
315		return 2, false
316	case '8':
317		s.restoreCursor()
318		return 2, false
319	case 'D':
320		s.buffer.hard(s.Row)
321		s.index()
322		return 2, false
323	case 'E':
324		s.nextLine()
325		return 2, false
326	case 'M':
327		s.reverseIndex()
328		return 2, false
329	case 'c':
330		s.reset()
331		return 2, false
332	case 'H':
333		return 2, false
334	case '(', ')', '*', '+', '-', '.', '/':
335		if len(data) < 3 {
336			return 0, true
337		}
338		return 3, false
339	default:
340		if data[1] >= 0x30 && data[1] <= 0x7e {
341			return 2, false
342		}
343		return 0, false
344	}
345}
346
347func consumeSTString(data []byte) (consumed int, partial bool) {
348	for i := 2; i < len(data); i++ {
349		if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
350			return i + 2, false
351		}
352	}
353	return 0, true
354}
355
356// consumeKittyGraphics frames one exact ESC _ G APC and dispatches only its
357// complete bytes to the protocol adapter. The ordinary parser never sees the
358// APC body as text, and the adapter is allocated only on the first complete
359// graphics command.
360func (s *Screen) consumeKittyGraphics(data []byte) (consumed int, partial bool) {
361	for i := 3; i < len(data); i++ {
362		switch data[i] {
363		case 0x9c: // C1 string terminator.
364			end := i + 1
365			if end > maxKittyEscapeBufferLen {
366				s.abortKittyPendingDisplay()
367				return end, false
368			}
369			s.dispatchKittyGraphics(data[:end])
370			return end, false
371		case 0x1b:
372			if i+1 < len(data) && data[i+1] == '\\' {
373				end := i + 2
374				if end > maxKittyEscapeBufferLen {
375					s.abortKittyPendingDisplay()
376					return end, false
377				}
378				s.dispatchKittyGraphics(data[:end])
379				return end, false
380			}
381		}
382	}
383	return 0, true
384}
385
386func (s *Screen) dispatchKittyGraphics(apc []byte) {
387	command, err := kittygraphics.ParseAPC(apc)
388	if err != nil {
389		s.abortKittyPendingDisplay()
390		return
391	}
392	if s.graphics == nil {
393		s.graphics = newScreenGraphicsState()
394	}
395	controls := command.Controls
396	pending := s.kittyPendingDisplay
397	action := controls.Action
398	if !controls.HasAction {
399		action = kittygraphics.ActionTransmit
400		if pending != nil {
401			action = pending.Action
402		}
403	}
404	display := action == kittygraphics.ActionTransmitDisplay || action == kittygraphics.ActionPut
405	movementControls := controls
406	if pending != nil {
407		movementControls = *pending
408	}
409	if display {
410		s.graphics.kitty.SetPendingPlacement(s.kittyCursorX(), s.kittyCursorY())
411	}
412	if display && controls.HasMore && controls.More == 1 {
413		copy := controls
414		s.kittyPendingDisplay = &copy
415	}
416	result, processErr := s.graphics.kitty.Process(command)
417	if processErr != nil {
418		s.abortKittyPendingDisplay()
419	}
420	for _, response := range result.Responses {
421		s.respond(response)
422	}
423	if len(result.Mutations) != 0 {
424		if display {
425			s.applyKittyCursorMovement(movementControls)
426			s.kittyPendingDisplay = nil
427		}
428		// Graphics are rendered independently from Frame. A graphics mutation
429		// still needs to wake consumers which only redraw on screen damage.
430		s.fullRedraw()
431	}
432}
433
434func (s *Screen) kittyCursorX() uint64 {
435	if cellWidth, _, ok := s.kittyCellPixels(); ok {
436		return uint64(s.Col * cellWidth)
437	}
438	return 0
439}
440
441func (s *Screen) kittyCursorY() uint64 {
442	_, cellHeight, ok := s.kittyCellPixels()
443	if ok {
444		return uint64(s.Row * cellHeight)
445	}
446	return 0
447}
448
449func (s *Screen) kittyCellPixels() (width, height int, ok bool) {
450	if !s.geometry.PixelsKnown() || s.geometry.Cols <= 0 || s.geometry.Rows <= 0 {
451		return 0, 0, false
452	}
453	width = s.geometry.PixelWidth / s.geometry.Cols
454	height = s.geometry.PixelHeight / s.geometry.Rows
455	if width <= 0 || height <= 0 {
456		return 0, 0, false
457	}
458	return width, height, true
459}
460
461func (s *Screen) applyKittyCursorMovement(c kittygraphics.Controls) {
462	if c.HasCursor && c.Cursor == 1 {
463		return
464	}
465	columns, rows := 1, 1
466	if c.HasColumns {
467		columns = kittyCursorAdvance(c.Columns)
468	}
469	if c.HasRows {
470		rows = kittyCursorAdvance(c.Rows)
471	}
472	s.Col = advanceKittyCursor(s.Col, columns, 0, s.Frame.Width-1)
473	s.Row = advanceKittyCursor(s.Row, rows, s.cursorMinRow(), s.cursorMaxRow())
474}
475
476func advanceKittyCursor(current, amount, minimum, maximum int) int {
477	if maximum < minimum {
478		return minimum
479	}
480	if current < minimum {
481		current = minimum
482	}
483	if current > maximum {
484		current = maximum
485	}
486	if amount > maximum-current {
487		return maximum
488	}
489	return current + amount
490}
491
492func kittyCursorAdvance(value uint64) int {
493	if value > uint64(^uint(0)>>1) {
494		return int(^uint(0) >> 1)
495	}
496	return int(value)
497}

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