perf/html-runs 1e6ecdb

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
21// maxRetainedEscapeBufferCap bounds the capacity of the escape scratch buffers
22// kept between writes so that a single huge APC does not pin its memory, while
23// ordinary fragmented uploads still reuse their buffer.
24const maxRetainedEscapeBufferCap = 1 << 20
25
26const (
27	// ColorSchemeReportDark is the DEC 2031 dark-scheme report.
28	ColorSchemeReportDark = "\x1b[?997;1n"
29	// ColorSchemeReportLight is the DEC 2031 light-scheme report.
30	ColorSchemeReportLight = "\x1b[?997;2n"
31)
32
33type Screen struct {
34	frame renderer.Frame
35	Row   int
36	Col   int
37	Style renderer.Style
38	// OnLineEvicted is called just before a full-width upward scroll recycles
39	// and blanks rows. The callback receives a stable copy of each evicted row.
40	OnLineEvicted func([]renderer.Cell)
41	// OnResponse is called synchronously from Write with reply bytes that the
42	// emulator must send back to the child process (DA, DSR, and ANSI/DEC mode
43	// query reports). The host wires it to the PTY input. Nil disables responses.
44	OnResponse func([]byte)
45	// OnBell is called synchronously from Write for each lone BEL (0x07)
46	// outside escape sequences. BELs that terminate an OSC never fire it.
47	// Nil disables bell reporting.
48	OnBell func()
49	// OnNotify is called synchronously from Write for explicit terminal
50	// notifications: OSC 9 (body only) and OSC 777 "notify" (title;body).
51	// Other non-clipboard OSC payloads remain discarded. Nil disables it.
52	OnNotify func(title, body string)
53	// OnProgress is called synchronously from Write for OSC 9;4 progress
54	// transitions that request attention: active progress cleared or first entry
55	// into error state. Nil disables progress reporting.
56	OnProgress func(errored bool)
57	// OnClipboard is called synchronously from Write for a complete OSC 52
58	// clipboard set request from the child. The OSC 52 selection field is
59	// accepted but ignored; the callback receives only the raw base64 payload.
60	// Clipboard queries (data == "?") and malformed payloads are ignored and
61	// never invoke it. Nil disables it.
62	OnClipboard func(b64 string)
63
64	history *History
65	buffer  *buffer
66
67	nextRowID RowID
68	geometry  Geometry
69
70	defaultFG          renderer.RGB
71	defaultBG          renderer.RGB
72	defaultColorsKnown bool
73	terminalTitle      string
74
75	damage                 []renderer.Damage
76	damageGeneration       uint64
77	damageSaturated        bool
78	damageFullRedrawSticky bool
79	escapeBuf              []byte
80	combineBuf             []byte
81	evictScratch           []renderer.Cell
82	kittyDiscard           bool
83	kittyDiscardEscaped    bool
84	kittyPendingDisplay    *kittygraphics.Controls
85	csiScratch             []int
86	sgrScratch             []int
87
88	scrollTop             int
89	scrollBottom          int
90	savedCursor           cursorState
91	alternate             *screenState
92	graphics              *screenGraphicsState
93	syncUpdateActive      bool
94	progressState         int
95	cursorVisible         bool
96	cursorStyle           int
97	cursorStyleSet        bool
98	mouseMode             int
99	mouseSGR              bool
100	bracketedPaste        bool
101	kittyKeyboard         kittyKeyboardStack
102	originMode            bool
103	insertMode            bool
104	autoWrapMode          bool
105	applicationCursorMode bool
106	colorSchemeMode       bool
107	colorSchemeLight      bool
108	colorSchemeSet        bool
109}
110
111func NewScreen(width, height int) *Screen {
112	s := &Screen{
113		Style:            renderer.DefaultStyle(),
114		damage:           []renderer.Damage{renderer.FullRedraw()},
115		damageGeneration: 1,
116		cursorVisible:    true,
117		autoWrapMode:     true,
118		geometry:         Geometry{Cols: width, Rows: height},
119	}
120	s.buffer = s.newBuffer(width, height)
121	s.frame = s.buffer.frame
122	s.resetScrollRegion()
123	return s
124}
125
126// NewScreenWithHistory creates a screen that records rows evicted from its
127// primary screen into bounded immutable terminal history.
128func NewScreenWithHistory(width, height int, config HistoryConfig) *Screen {
129	s := NewScreen(width, height)
130	s.history = NewHistory(config)
131	return s
132}
133
134// History returns this screen's terminal history, or nil when history was not
135// configured with NewScreenWithHistory.
136func (s *Screen) History() *History { return s.history }
137
138// Columns returns the visible grid width.
139func (s *Screen) Columns() int {
140	if s == nil || s.buffer == nil {
141		return 0
142	}
143	return s.buffer.frame.Width
144}
145
146// Rows returns the visible grid height.
147func (s *Screen) Rows() int {
148	if s == nil || s.buffer == nil {
149		return 0
150	}
151	return s.buffer.frame.Height
152}
153
154// Cell returns the semantic cell at x, y. Coordinates must be inside the
155// visible grid.
156func (s *Screen) Cell(x, y int) renderer.Cell {
157	return s.buffer.frame.At(x, y)
158}
159
160// RowCells returns an owned copy of visible row y, or nil when y is out of range.
161func (s *Screen) RowCells(y int) []renderer.Cell {
162	if s == nil || s.buffer == nil || y < 0 || y >= s.buffer.frame.Height {
163		return nil
164	}
165	return s.buffer.frame.Row(y)
166}
167
168// LineBounds returns an owned copy of the live grid's per-row logical extents,
169// indexed like Frame rows. It returns nil when the screen has no buffer.
170func (s *Screen) LineBounds() []LineBound {
171	if s == nil || s.buffer == nil {
172		return nil
173	}
174	return append([]LineBound(nil), s.buffer.boundaries...)
175}
176
177func (s *Screen) Write(data []byte) {
178	if len(s.escapeBuf) == 0 {
179		s.trimEscapeBuffers()
180	} else if isKittyEscapeContinuation(s.escapeBuf, data) {
181		prefix := s.escapeBuf
182		s.escapeBuf = nil
183		data = s.continueKittyEscape(prefix, data)
184		if len(data) == 0 {
185			return
186		}
187	}
188	if len(s.escapeBuf) > 0 {
189		// Reuse a bounded scratch buffer: data is consumed before Write returns
190		// and nothing retains slices of it (the partial-escape path copies).
191		// Detach it while in use: callbacks invoked below may call Write
192		// re-entrantly, and that inner Write must not overwrite bytes this
193		// Write is still reading.
194		need := len(s.escapeBuf) + len(data)
195		combined := s.combineBuf[:0]
196		s.combineBuf = nil
197		if cap(combined) < need {
198			combined = make([]byte, 0, need)
199		}
200		combined = append(combined, s.escapeBuf...)
201		combined = append(combined, data...)
202		defer func() {
203			if s.combineBuf == nil && cap(combined) <= maxRetainedEscapeBufferCap {
204				s.combineBuf = combined[:0]
205			}
206		}()
207		data = combined
208		if cap(s.escapeBuf) > maxRetainedEscapeBufferCap {
209			s.escapeBuf = nil
210		} else {
211			s.escapeBuf = s.escapeBuf[:0]
212		}
213	}
214	if s.kittyDiscard {
215		consumed := s.consumeKittyDiscard(data)
216		data = data[consumed:]
217		if s.kittyDiscard {
218			return
219		}
220	}
221
222	for len(data) > 0 {
223		if data[0] == 0x1b {
224			consumed, partial := s.consumeEscape(data)
225			if consumed > 0 {
226				data = data[consumed:]
227				continue
228			}
229			if partial {
230				limit := maxEscapeBufferLen
231				if isKittyEscapePrefix(data) {
232					limit = maxKittyEscapeBufferLen
233				}
234				if len(data) <= limit {
235					s.escapeBuf = append(s.escapeBuf[:0], data...)
236				} else if isKittyEscapePrefix(data) {
237					s.abortKittyPendingDisplay()
238					s.kittyDiscard = true
239					s.kittyDiscardEscaped = false
240				}
241				return
242			}
243		}
244		r, size := utf8.DecodeRune(data)
245		if r == utf8.RuneError && size == 1 {
246			data = data[1:]
247			continue
248		}
249		s.putRune(r)
250		data = data[size:]
251	}
252}
253
254func isKittyEscapePrefix(data []byte) bool {
255	if len(data) == 0 || data[0] != 0x1b {
256		return false
257	}
258	if len(data) > 1 && data[1] != '_' {
259		return false
260	}
261	return len(data) < 3 || data[2] == 'G'
262}
263
264func isKittyEscapeContinuation(prefix, data []byte) bool {
265	if !isKittyEscapePrefix(prefix) {
266		return false
267	}
268	if len(data) == 0 {
269		return true
270	}
271	if len(prefix) == 1 {
272		return data[0] == '_'
273	}
274	if len(prefix) == 2 {
275		return data[0] == 'G'
276	}
277	return true
278}
279
280// trimEscapeBuffers drops oversized idle escape scratch buffers.
281func (s *Screen) trimEscapeBuffers() {
282	if cap(s.escapeBuf) > maxRetainedEscapeBufferCap {
283		s.escapeBuf = nil
284	}
285	if cap(s.combineBuf) > maxRetainedEscapeBufferCap {
286		s.combineBuf = nil
287	}
288}
289
290// recycleEscapeBuf keeps buf's capacity for the next fragmented escape when no
291// other buffer has been installed meanwhile. Callers must be done with buf.
292func (s *Screen) recycleEscapeBuf(buf []byte) {
293	if s.escapeBuf == nil && cap(buf) <= maxRetainedEscapeBufferCap {
294		s.escapeBuf = buf[:0]
295	}
296}
297
298// continueKittyEscape completes or extends the pending Kitty APC in prefix,
299// which is the Screen's former escapeBuf (s.escapeBuf is nil on entry). The
300// protocol adapter copies whatever it retains from a dispatched APC, so prefix
301// is recycled afterwards instead of being reallocated for every APC.
302func (s *Screen) continueKittyEscape(prefix, data []byte) []byte {
303	if len(prefix) != 0 && prefix[len(prefix)-1] == 0x1b && len(data) != 0 && data[0] == '\\' {
304		if len(prefix)+1 <= maxKittyEscapeBufferLen {
305			apc := append(prefix, data[:1]...)
306			s.dispatchKittyGraphics(apc)
307			s.recycleEscapeBuf(apc)
308		} else {
309			s.abortKittyPendingDisplay()
310			s.recycleEscapeBuf(prefix)
311		}
312		return data[1:]
313	}
314	for i := 0; i < len(data); i++ {
315		if data[i] == 0x9c {
316			end := i + 1
317			s.finishKittyEscape(prefix, data[:end])
318			return data[end:]
319		}
320		if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
321			end := i + 2
322			s.finishKittyEscape(prefix, data[:end])
323			return data[end:]
324		}
325	}
326	remaining := maxKittyEscapeBufferLen - len(prefix)
327	if len(data) <= remaining {
328		s.escapeBuf = append(prefix, data...)
329		return nil
330	}
331	s.abortKittyPendingDisplay()
332	s.kittyDiscard = true
333	escaped := len(prefix) != 0 && prefix[len(prefix)-1] == 0x1b
334	if remaining > 0 {
335		escaped = data[remaining-1] == 0x1b
336	}
337	s.kittyDiscardEscaped = escaped
338	s.recycleEscapeBuf(prefix)
339	tail := data[remaining:]
340	return tail[s.consumeKittyDiscard(tail):]
341}
342
343// finishKittyEscape dispatches prefix+terminated, or aborts when the framed
344// APC would exceed the Kitty escape bound, then recycles prefix.
345func (s *Screen) finishKittyEscape(prefix, terminated []byte) {
346	if len(prefix)+len(terminated) > maxKittyEscapeBufferLen {
347		s.abortKittyPendingDisplay()
348		s.recycleEscapeBuf(prefix)
349		return
350	}
351	apc := append(prefix, terminated...)
352	s.dispatchKittyGraphics(apc)
353	s.recycleEscapeBuf(apc)
354}
355
356func (s *Screen) consumeKittyDiscard(data []byte) int {
357	for i, b := range data {
358		if s.kittyDiscardEscaped {
359			s.kittyDiscardEscaped = false
360			if b == '\\' {
361				s.kittyDiscard = false
362				return i + 1
363			}
364		}
365		if b == 0x9c {
366			s.kittyDiscard = false
367			return i + 1
368		}
369		if b == 0x1b {
370			if i+1 < len(data) && data[i+1] == '\\' {
371				s.kittyDiscard = false
372				return i + 2
373			}
374			s.kittyDiscardEscaped = true
375		}
376	}
377	return len(data)
378}
379
380func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
381	if len(data) < 2 {
382		return 0, true
383	}
384	switch data[1] {
385	case ']':
386		return s.consumeOSC(data)
387	case 'P':
388		return consumeSTString(data)
389	case '_':
390		if len(data) >= 3 && data[2] == 'G' {
391			return s.consumeKittyGraphics(data)
392		}
393		return consumeSTString(data)
394	case '^', 'X':
395		return consumeSTString(data)
396	case '[':
397		return s.consumeCSI(data)
398	case '=':
399		return 2, false
400	case '>':
401		return 2, false
402	case '7':
403		s.saveCursor()
404		return 2, false
405	case '8':
406		s.restoreCursor()
407		return 2, false
408	case 'D':
409		s.buffer.hard(s.Row)
410		s.index()
411		return 2, false
412	case 'E':
413		s.nextLine()
414		return 2, false
415	case 'M':
416		s.reverseIndex()
417		return 2, false
418	case 'c':
419		s.reset()
420		return 2, false
421	case 'H':
422		return 2, false
423	case '(', ')', '*', '+', '-', '.', '/':
424		if len(data) < 3 {
425			return 0, true
426		}
427		return 3, false
428	default:
429		if data[1] >= 0x30 && data[1] <= 0x7e {
430			return 2, false
431		}
432		return 0, false
433	}
434}
435
436func consumeSTString(data []byte) (consumed int, partial bool) {
437	for i := 2; i < len(data); i++ {
438		if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
439			return i + 2, false
440		}
441	}
442	return 0, true
443}
444
445// consumeKittyGraphics frames one exact ESC _ G APC and dispatches only its
446// complete bytes to the protocol adapter. The ordinary parser never sees the
447// APC body as text, and the adapter is allocated only on the first complete
448// graphics command.
449func (s *Screen) consumeKittyGraphics(data []byte) (consumed int, partial bool) {
450	for i := 3; i < len(data); i++ {
451		switch data[i] {
452		case 0x9c: // C1 string terminator.
453			end := i + 1
454			if end > maxKittyEscapeBufferLen {
455				s.abortKittyPendingDisplay()
456				return end, false
457			}
458			s.dispatchKittyGraphics(data[:end])
459			return end, false
460		case 0x1b:
461			if i+1 < len(data) && data[i+1] == '\\' {
462				end := i + 2
463				if end > maxKittyEscapeBufferLen {
464					s.abortKittyPendingDisplay()
465					return end, false
466				}
467				s.dispatchKittyGraphics(data[:end])
468				return end, false
469			}
470		}
471	}
472	return 0, true
473}
474
475func (s *Screen) dispatchKittyGraphics(apc []byte) {
476	// The session copies any payload it retains, so the APC bytes may be
477	// borrowed for the duration of this call only.
478	command, err := kittygraphics.ParseAPCBorrowed(apc)
479	if err != nil {
480		s.abortKittyPendingDisplay()
481		return
482	}
483	if s.graphics == nil {
484		s.graphics = newScreenGraphicsState()
485	}
486	controls := command.Controls
487	pending := s.kittyPendingDisplay
488	action := controls.Action
489	if !controls.HasAction {
490		action = kittygraphics.ActionTransmit
491		if pending != nil {
492			action = pending.Action
493		}
494	}
495	display := action == kittygraphics.ActionTransmitDisplay || action == kittygraphics.ActionPut
496	movementControls := controls
497	if pending != nil {
498		movementControls = *pending
499	}
500	if display {
501		s.graphics.kitty.SetPendingPlacement(s.kittyCursorX(), s.kittyCursorY())
502	}
503	if display && controls.HasMore && controls.More == 1 {
504		copy := controls
505		s.kittyPendingDisplay = &copy
506	}
507	result, processErr := s.graphics.kitty.Process(command)
508	if processErr != nil {
509		s.abortKittyPendingDisplay()
510	}
511	for _, response := range result.Responses {
512		s.respond(response)
513	}
514	if len(result.Mutations) != 0 {
515		if display {
516			mutation := result.Mutations[len(result.Mutations)-1]
517			s.applyKittyCursorMovement(movementControls, mutation)
518			s.kittyPendingDisplay = nil
519		}
520		// Graphics are rendered independently from Frame. A graphics mutation
521		// still needs to wake consumers which only redraw on screen damage.
522		s.fullRedraw()
523	}
524}
525
526func (s *Screen) kittyCursorX() uint64 {
527	if cellWidth, _, ok := s.kittyCellPixels(); ok {
528		return uint64(s.kittyCursorColumn() * cellWidth)
529	}
530	return 0
531}
532
533func (s *Screen) kittyCursorColumn() int {
534	if s.frame.Width <= 0 {
535		return 0
536	}
537	return clamp(s.Col, 0, s.frame.Width-1)
538}
539
540func (s *Screen) kittyCursorY() uint64 {
541	_, cellHeight, ok := s.kittyCellPixels()
542	if ok {
543		return uint64(s.Row * cellHeight)
544	}
545	return 0
546}
547
548func (s *Screen) kittyCellPixels() (width, height int, ok bool) {
549	if !s.geometry.PixelsKnown() || s.geometry.Cols <= 0 || s.geometry.Rows <= 0 {
550		return 0, 0, false
551	}
552	width = s.geometry.PixelWidth / s.geometry.Cols
553	height = s.geometry.PixelHeight / s.geometry.Rows
554	if width <= 0 || height <= 0 {
555		return 0, 0, false
556	}
557	return width, height, true
558}
559
560func (s *Screen) applyKittyCursorMovement(c kittygraphics.Controls, mutation kittygraphics.Mutation) {
561	if c.HasCursor && c.Cursor == 1 || s.frame.Width <= 0 || s.frame.Height <= 0 {
562		return
563	}
564	columns, rows := s.kittyPlacementGridSize(c, mutation)
565	targetColumn := s.kittyCursorColumn() + columns
566	wraps := targetColumn >= s.frame.Width
567	rowMoves := max(rows-1, 0)
568	if wraps {
569		rowMoves++
570	}
571	s.advanceKittyCursorRows(rowMoves)
572	if wraps {
573		s.Col = 0
574	} else {
575		s.Col = targetColumn
576	}
577}
578
579func (s *Screen) advanceKittyCursorRows(rows int) {
580	if rows <= 0 || s.frame.Height <= 0 {
581		return
582	}
583	if s.Row >= s.scrollTop && s.Row <= s.scrollBottom {
584		beforeScroll := s.scrollBottom - s.Row
585		if rows <= beforeScroll {
586			s.Row += rows
587			return
588		}
589		s.Row = s.scrollBottom
590		s.scrollUpRegion(s.scrollTop, s.scrollBottom, rows-beforeScroll)
591		return
592	}
593	s.Row = min(s.Row+rows, s.frame.Height-1)
594}
595
596func (s *Screen) kittyPlacementGridSize(c kittygraphics.Controls, mutation kittygraphics.Mutation) (columns, rows int) {
597	columns, rows = 1, 1
598	if c.HasColumns {
599		columns = max(kittyCursorAdvance(c.Columns), 1)
600	}
601	if c.HasRows {
602		rows = max(kittyCursorAdvance(c.Rows), 1)
603	}
604	if c.HasColumns && c.HasRows || s.graphics == nil || s.graphics.scene == nil {
605		return columns, rows
606	}
607	placement, ok := s.graphics.scene.Snapshot().Placement(mutation.ScenePlacementID)
608	if !ok {
609		return columns, rows
610	}
611	cellWidth, cellHeight, pixelsKnown := s.kittyCellPixels()
612	if !pixelsKnown {
613		return columns, rows
614	}
615	destination := placement.Destination()
616	if !c.HasColumns {
617		offset := destination.X % int64(cellWidth)
618		columns = kittyPixelCellSpan(offset, destination.Width, cellWidth)
619	}
620	if !c.HasRows {
621		offset := destination.Y % int64(cellHeight)
622		rows = kittyPixelCellSpan(offset, destination.Height, cellHeight)
623	}
624	return columns, rows
625}
626
627func kittyPixelCellSpan(offset, size int64, cellSize int) int {
628	if offset < 0 || size <= 0 || cellSize <= 0 {
629		return 1
630	}
631	span := (offset + size + int64(cellSize) - 1) / int64(cellSize)
632	if span <= 0 {
633		return 1
634	}
635	if span > int64(^uint(0)>>1) {
636		return int(^uint(0) >> 1)
637	}
638	return int(span)
639}
640
641func kittyCursorAdvance(value uint64) int {
642	if value > uint64(^uint(0)>>1) {
643		return int(^uint(0) >> 1)
644	}
645	return int(value)
646}

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