main ab6686c

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	// charsets holds the final byte designated to G0 and G1 (ESC ( F and
110	// ESC ) F); shiftOut selects G1 into GL (SO) instead of G0 (SI).
111	charsets [2]byte
112	shiftOut bool
113	// lastPrinted is the last graphic rune written, repeated by REP.
114	lastPrinted rune
115}
116
117func NewScreen(width, height int) *Screen {
118	s := &Screen{
119		Style:            renderer.DefaultStyle(),
120		damage:           []renderer.Damage{renderer.FullRedraw()},
121		damageGeneration: 1,
122		cursorVisible:    true,
123		autoWrapMode:     true,
124		geometry:         Geometry{Cols: width, Rows: height},
125	}
126	s.buffer = s.newBuffer(width, height)
127	s.frame = s.buffer.frame
128	s.resetScrollRegion()
129	return s
130}
131
132// NewScreenWithHistory creates a screen that records rows evicted from its
133// primary screen into bounded immutable terminal history.
134func NewScreenWithHistory(width, height int, config HistoryConfig) *Screen {
135	s := NewScreen(width, height)
136	s.history = NewHistory(config)
137	return s
138}
139
140// History returns this screen's terminal history, or nil when history was not
141// configured with NewScreenWithHistory.
142func (s *Screen) History() *History { return s.history }
143
144// Columns returns the visible grid width.
145func (s *Screen) Columns() int {
146	if s == nil || s.buffer == nil {
147		return 0
148	}
149	return s.buffer.frame.Width
150}
151
152// Rows returns the visible grid height.
153func (s *Screen) Rows() int {
154	if s == nil || s.buffer == nil {
155		return 0
156	}
157	return s.buffer.frame.Height
158}
159
160// Cell returns the semantic cell at x, y. Coordinates must be inside the
161// visible grid.
162func (s *Screen) Cell(x, y int) renderer.Cell {
163	return s.buffer.frame.At(x, y)
164}
165
166// RowCells returns an owned copy of visible row y, or nil when y is out of range.
167func (s *Screen) RowCells(y int) []renderer.Cell {
168	if s == nil || s.buffer == nil || y < 0 || y >= s.buffer.frame.Height {
169		return nil
170	}
171	return s.buffer.frame.Row(y)
172}
173
174// LineBounds returns an owned copy of the live grid's per-row logical extents,
175// indexed like Frame rows. It returns nil when the screen has no buffer.
176func (s *Screen) LineBounds() []LineBound {
177	if s == nil || s.buffer == nil {
178		return nil
179	}
180	return append([]LineBound(nil), s.buffer.boundaries...)
181}
182
183func (s *Screen) Write(data []byte) {
184	if len(s.escapeBuf) == 0 {
185		s.trimEscapeBuffers()
186	} else if isKittyEscapeContinuation(s.escapeBuf, data) {
187		// A Kitty APC sits between any earlier graphic and what follows. An
188		// empty write has not identified the buffered escape yet.
189		if len(data) > 0 {
190			s.lastPrinted = 0
191		}
192		prefix := s.escapeBuf
193		s.escapeBuf = nil
194		data = s.continueKittyEscape(prefix, data)
195		if len(data) == 0 {
196			return
197		}
198	}
199	if len(s.escapeBuf) > 0 {
200		// Reuse a bounded scratch buffer: data is consumed before Write returns
201		// and nothing retains slices of it (the partial-escape path copies).
202		// Detach it while in use: callbacks invoked below may call Write
203		// re-entrantly, and that inner Write must not overwrite bytes this
204		// Write is still reading.
205		need := len(s.escapeBuf) + len(data)
206		combined := s.combineBuf[:0]
207		s.combineBuf = nil
208		if cap(combined) < need {
209			combined = make([]byte, 0, need)
210		}
211		combined = append(combined, s.escapeBuf...)
212		combined = append(combined, data...)
213		defer func() {
214			if s.combineBuf == nil && cap(combined) <= maxRetainedEscapeBufferCap {
215				s.combineBuf = combined[:0]
216			}
217		}()
218		data = combined
219		if cap(s.escapeBuf) > maxRetainedEscapeBufferCap {
220			s.escapeBuf = nil
221		} else {
222			s.escapeBuf = s.escapeBuf[:0]
223		}
224	}
225	if s.kittyDiscard {
226		s.lastPrinted = 0
227		consumed := s.consumeKittyDiscard(data)
228		data = data[consumed:]
229		if s.kittyDiscard {
230			return
231		}
232	}
233
234	for len(data) > 0 {
235		if data[0] == 0x1b {
236			consumed, partial := s.consumeEscape(data)
237			if consumed > 0 {
238				// A completed sequence (including REP itself) ends REP eligibility.
239				s.lastPrinted = 0
240				data = data[consumed:]
241				continue
242			}
243			if partial {
244				limit := maxEscapeBufferLen
245				if isKittyEscapePrefix(data) {
246					limit = maxKittyEscapeBufferLen
247				}
248				if len(data) <= limit {
249					s.escapeBuf = append(s.escapeBuf[:0], data...)
250				} else if isKittyEscapePrefix(data) {
251					s.abortKittyPendingDisplay()
252					s.kittyDiscard = true
253					s.kittyDiscardEscaped = false
254				}
255				return
256			}
257		}
258		r, size := utf8.DecodeRune(data)
259		if r == utf8.RuneError && size == 1 {
260			data = data[1:]
261			continue
262		}
263		s.putRune(r)
264		data = data[size:]
265	}
266}
267
268func isKittyEscapePrefix(data []byte) bool {
269	if len(data) == 0 || data[0] != 0x1b {
270		return false
271	}
272	if len(data) > 1 && data[1] != '_' {
273		return false
274	}
275	return len(data) < 3 || data[2] == 'G'
276}
277
278func isKittyEscapeContinuation(prefix, data []byte) bool {
279	if !isKittyEscapePrefix(prefix) {
280		return false
281	}
282	if len(data) == 0 {
283		return true
284	}
285	if len(prefix) == 1 {
286		return data[0] == '_'
287	}
288	if len(prefix) == 2 {
289		return data[0] == 'G'
290	}
291	return true
292}
293
294// trimEscapeBuffers drops oversized idle escape scratch buffers.
295func (s *Screen) trimEscapeBuffers() {
296	if cap(s.escapeBuf) > maxRetainedEscapeBufferCap {
297		s.escapeBuf = nil
298	}
299	if cap(s.combineBuf) > maxRetainedEscapeBufferCap {
300		s.combineBuf = nil
301	}
302}
303
304// recycleEscapeBuf keeps buf's capacity for the next fragmented escape when no
305// other buffer has been installed meanwhile. Callers must be done with buf.
306func (s *Screen) recycleEscapeBuf(buf []byte) {
307	if s.escapeBuf == nil && cap(buf) <= maxRetainedEscapeBufferCap {
308		s.escapeBuf = buf[:0]
309	}
310}
311
312// continueKittyEscape completes or extends the pending Kitty APC in prefix,
313// which is the Screen's former escapeBuf (s.escapeBuf is nil on entry). The
314// protocol adapter copies whatever it retains from a dispatched APC, so prefix
315// is recycled afterwards instead of being reallocated for every APC.
316func (s *Screen) continueKittyEscape(prefix, data []byte) []byte {
317	if len(prefix) != 0 && prefix[len(prefix)-1] == 0x1b && len(data) != 0 && data[0] == '\\' {
318		if len(prefix)+1 <= maxKittyEscapeBufferLen {
319			apc := append(prefix, data[:1]...)
320			s.dispatchKittyGraphics(apc)
321			s.recycleEscapeBuf(apc)
322		} else {
323			s.abortKittyPendingDisplay()
324			s.recycleEscapeBuf(prefix)
325		}
326		return data[1:]
327	}
328	for i := 0; i < len(data); i++ {
329		if data[i] == 0x9c {
330			end := i + 1
331			s.finishKittyEscape(prefix, data[:end])
332			return data[end:]
333		}
334		if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
335			end := i + 2
336			s.finishKittyEscape(prefix, data[:end])
337			return data[end:]
338		}
339	}
340	remaining := maxKittyEscapeBufferLen - len(prefix)
341	if len(data) <= remaining {
342		s.escapeBuf = append(prefix, data...)
343		return nil
344	}
345	s.abortKittyPendingDisplay()
346	s.kittyDiscard = true
347	escaped := len(prefix) != 0 && prefix[len(prefix)-1] == 0x1b
348	if remaining > 0 {
349		escaped = data[remaining-1] == 0x1b
350	}
351	s.kittyDiscardEscaped = escaped
352	s.recycleEscapeBuf(prefix)
353	tail := data[remaining:]
354	return tail[s.consumeKittyDiscard(tail):]
355}
356
357// finishKittyEscape dispatches prefix+terminated, or aborts when the framed
358// APC would exceed the Kitty escape bound, then recycles prefix.
359func (s *Screen) finishKittyEscape(prefix, terminated []byte) {
360	if len(prefix)+len(terminated) > maxKittyEscapeBufferLen {
361		s.abortKittyPendingDisplay()
362		s.recycleEscapeBuf(prefix)
363		return
364	}
365	apc := append(prefix, terminated...)
366	s.dispatchKittyGraphics(apc)
367	s.recycleEscapeBuf(apc)
368}
369
370func (s *Screen) consumeKittyDiscard(data []byte) int {
371	for i, b := range data {
372		if s.kittyDiscardEscaped {
373			s.kittyDiscardEscaped = false
374			if b == '\\' {
375				s.kittyDiscard = false
376				return i + 1
377			}
378		}
379		if b == 0x9c {
380			s.kittyDiscard = false
381			return i + 1
382		}
383		if b == 0x1b {
384			if i+1 < len(data) && data[i+1] == '\\' {
385				s.kittyDiscard = false
386				return i + 2
387			}
388			s.kittyDiscardEscaped = true
389		}
390	}
391	return len(data)
392}
393
394func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
395	if len(data) < 2 {
396		return 0, true
397	}
398	switch data[1] {
399	case ']':
400		return s.consumeOSC(data)
401	case 'P':
402		return consumeSTString(data)
403	case '_':
404		if len(data) >= 3 && data[2] == 'G' {
405			return s.consumeKittyGraphics(data)
406		}
407		return consumeSTString(data)
408	case '^', 'X':
409		return consumeSTString(data)
410	case '[':
411		return s.consumeCSI(data)
412	case '=':
413		return 2, false
414	case '>':
415		return 2, false
416	case '7':
417		s.saveCursor()
418		return 2, false
419	case '8':
420		s.restoreCursor()
421		return 2, false
422	case 'D':
423		s.buffer.hard(s.Row)
424		s.index()
425		return 2, false
426	case 'E':
427		s.nextLine()
428		return 2, false
429	case 'M':
430		s.reverseIndex()
431		return 2, false
432	case 'c':
433		s.reset()
434		return 2, false
435	case 'H':
436		return 2, false
437	case '(', ')', '*', '+', '-', '.', '/':
438		if len(data) < 3 {
439			return 0, true
440		}
441		switch data[1] {
442		case '(':
443			s.charsets[0] = data[2]
444		case ')':
445			s.charsets[1] = data[2]
446		}
447		return 3, false
448	default:
449		if data[1] >= 0x30 && data[1] <= 0x7e {
450			return 2, false
451		}
452		return 0, false
453	}
454}
455
456func consumeSTString(data []byte) (consumed int, partial bool) {
457	for i := 2; i < len(data); i++ {
458		if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
459			return i + 2, false
460		}
461	}
462	return 0, true
463}
464
465// consumeKittyGraphics frames one exact ESC _ G APC and dispatches only its
466// complete bytes to the protocol adapter. The ordinary parser never sees the
467// APC body as text, and the adapter is allocated only on the first complete
468// graphics command.
469func (s *Screen) consumeKittyGraphics(data []byte) (consumed int, partial bool) {
470	for i := 3; i < len(data); i++ {
471		switch data[i] {
472		case 0x9c: // C1 string terminator.
473			end := i + 1
474			if end > maxKittyEscapeBufferLen {
475				s.abortKittyPendingDisplay()
476				return end, false
477			}
478			s.dispatchKittyGraphics(data[:end])
479			return end, false
480		case 0x1b:
481			if i+1 < len(data) && data[i+1] == '\\' {
482				end := i + 2
483				if end > maxKittyEscapeBufferLen {
484					s.abortKittyPendingDisplay()
485					return end, false
486				}
487				s.dispatchKittyGraphics(data[:end])
488				return end, false
489			}
490		}
491	}
492	return 0, true
493}
494
495func (s *Screen) dispatchKittyGraphics(apc []byte) {
496	// The session copies any payload it retains, so the APC bytes may be
497	// borrowed for the duration of this call only.
498	command, err := kittygraphics.ParseAPCBorrowed(apc)
499	if err != nil {
500		s.abortKittyPendingDisplay()
501		return
502	}
503	if s.graphics == nil {
504		s.graphics = newScreenGraphicsState()
505	}
506	controls := command.Controls
507	pending := s.kittyPendingDisplay
508	action := controls.Action
509	if !controls.HasAction {
510		action = kittygraphics.ActionTransmit
511		if pending != nil {
512			action = pending.Action
513		}
514	}
515	display := action == kittygraphics.ActionTransmitDisplay || action == kittygraphics.ActionPut
516	movementControls := controls
517	if pending != nil {
518		movementControls = *pending
519	}
520	if display {
521		s.graphics.kitty.SetPendingPlacement(s.kittyCursorX(), s.kittyCursorY())
522		cellWidth, cellHeight, _ := s.kittyCellPixels()
523		s.graphics.kitty.SetCellPixels(cellWidth, cellHeight)
524	}
525	if display && controls.HasMore && controls.More == 1 {
526		copy := controls
527		s.kittyPendingDisplay = &copy
528	}
529	result, processErr := s.graphics.kitty.Process(command)
530	if processErr != nil {
531		s.abortKittyPendingDisplay()
532	}
533	for _, response := range result.Responses {
534		s.respond(response)
535	}
536	if len(result.Mutations) != 0 {
537		if display {
538			mutation := result.Mutations[len(result.Mutations)-1]
539			s.applyKittyCursorMovement(movementControls, mutation)
540			s.kittyPendingDisplay = nil
541		}
542		// Graphics are rendered independently from Frame. A graphics mutation
543		// still needs to wake consumers which only redraw on screen damage.
544		s.fullRedraw()
545	}
546}
547
548func (s *Screen) kittyCursorX() uint64 {
549	if cellWidth, _, ok := s.kittyCellPixels(); ok {
550		return uint64(s.kittyCursorColumn() * cellWidth)
551	}
552	return 0
553}
554
555func (s *Screen) kittyCursorColumn() int {
556	if s.frame.Width <= 0 {
557		return 0
558	}
559	return clamp(s.Col, 0, s.frame.Width-1)
560}
561
562func (s *Screen) kittyCursorY() uint64 {
563	_, cellHeight, ok := s.kittyCellPixels()
564	if ok {
565		return uint64(s.Row * cellHeight)
566	}
567	return 0
568}
569
570func (s *Screen) kittyCellPixels() (width, height int, ok bool) {
571	if !s.geometry.PixelsKnown() || s.geometry.Cols <= 0 || s.geometry.Rows <= 0 {
572		return 0, 0, false
573	}
574	width = s.geometry.PixelWidth / s.geometry.Cols
575	height = s.geometry.PixelHeight / s.geometry.Rows
576	if width <= 0 || height <= 0 {
577		return 0, 0, false
578	}
579	return width, height, true
580}
581
582func (s *Screen) applyKittyCursorMovement(c kittygraphics.Controls, mutation kittygraphics.Mutation) {
583	if c.HasCursor && c.Cursor == 1 || s.frame.Width <= 0 || s.frame.Height <= 0 {
584		return
585	}
586	columns, rows := s.kittyPlacementGridSize(c, mutation)
587	targetColumn := s.kittyCursorColumn() + columns
588	wraps := targetColumn >= s.frame.Width
589	rowMoves := max(rows-1, 0)
590	if wraps {
591		rowMoves++
592	}
593	s.advanceKittyCursorRows(rowMoves)
594	if wraps {
595		s.Col = 0
596	} else {
597		s.Col = targetColumn
598	}
599}
600
601func (s *Screen) advanceKittyCursorRows(rows int) {
602	if rows <= 0 || s.frame.Height <= 0 {
603		return
604	}
605	if s.Row >= s.scrollTop && s.Row <= s.scrollBottom {
606		beforeScroll := s.scrollBottom - s.Row
607		if rows <= beforeScroll {
608			s.Row += rows
609			return
610		}
611		s.Row = s.scrollBottom
612		s.scrollUpRegion(s.scrollTop, s.scrollBottom, rows-beforeScroll)
613		return
614	}
615	s.Row = min(s.Row+rows, s.frame.Height-1)
616}
617
618func (s *Screen) kittyPlacementGridSize(c kittygraphics.Controls, mutation kittygraphics.Mutation) (columns, rows int) {
619	// c=0 and r=0 mean unset, as in Kitty.
620	hasColumns := c.HasColumns && c.Columns != 0
621	hasRows := c.HasRows && c.Rows != 0
622	columns, rows = 1, 1
623	if hasColumns {
624		columns = max(kittyCursorAdvance(c.Columns), 1)
625	}
626	if hasRows {
627		rows = max(kittyCursorAdvance(c.Rows), 1)
628	}
629	if hasColumns && hasRows || s.graphics == nil || s.graphics.scene == nil {
630		return columns, rows
631	}
632	placement, ok := s.graphics.scene.Snapshot().Placement(mutation.ScenePlacementID)
633	if !ok {
634		return columns, rows
635	}
636	// Cell counts derived from a single c/r are already stored on the placement.
637	if cells := placement.Cells(); !cells.Empty() {
638		return max(kittyCursorAdvance(uint64(cells.Width)), 1), max(kittyCursorAdvance(uint64(cells.Height)), 1)
639	}
640	cellWidth, cellHeight, pixelsKnown := s.kittyCellPixels()
641	if !pixelsKnown {
642		return columns, rows
643	}
644	destination := placement.Destination()
645	if !hasColumns {
646		offset := destination.X % int64(cellWidth)
647		columns = kittyPixelCellSpan(offset, destination.Width, cellWidth)
648	}
649	if !hasRows {
650		offset := destination.Y % int64(cellHeight)
651		rows = kittyPixelCellSpan(offset, destination.Height, cellHeight)
652	}
653	return columns, rows
654}
655
656func kittyPixelCellSpan(offset, size int64, cellSize int) int {
657	if offset < 0 || size <= 0 || cellSize <= 0 {
658		return 1
659	}
660	span := (offset + size + int64(cellSize) - 1) / int64(cellSize)
661	if span <= 0 {
662		return 1
663	}
664	if span > int64(^uint(0)>>1) {
665		return int(^uint(0) >> 1)
666	}
667	return int(span)
668}
669
670func kittyCursorAdvance(value uint64) int {
671	if value > uint64(^uint(0)>>1) {
672		return int(^uint(0) >> 1)
673	}
674	return int(value)
675}

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