v0.4.2 baf7aea

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			mutation := result.Mutations[len(result.Mutations)-1]
426			s.applyKittyCursorMovement(movementControls, mutation)
427			s.kittyPendingDisplay = nil
428		}
429		// Graphics are rendered independently from Frame. A graphics mutation
430		// still needs to wake consumers which only redraw on screen damage.
431		s.fullRedraw()
432	}
433}
434
435func (s *Screen) kittyCursorX() uint64 {
436	if cellWidth, _, ok := s.kittyCellPixels(); ok {
437		return uint64(s.kittyCursorColumn() * cellWidth)
438	}
439	return 0
440}
441
442func (s *Screen) kittyCursorColumn() int {
443	if s.Frame.Width <= 0 {
444		return 0
445	}
446	return clamp(s.Col, 0, s.Frame.Width-1)
447}
448
449func (s *Screen) kittyCursorY() uint64 {
450	_, cellHeight, ok := s.kittyCellPixels()
451	if ok {
452		return uint64(s.Row * cellHeight)
453	}
454	return 0
455}
456
457func (s *Screen) kittyCellPixels() (width, height int, ok bool) {
458	if !s.geometry.PixelsKnown() || s.geometry.Cols <= 0 || s.geometry.Rows <= 0 {
459		return 0, 0, false
460	}
461	width = s.geometry.PixelWidth / s.geometry.Cols
462	height = s.geometry.PixelHeight / s.geometry.Rows
463	if width <= 0 || height <= 0 {
464		return 0, 0, false
465	}
466	return width, height, true
467}
468
469func (s *Screen) applyKittyCursorMovement(c kittygraphics.Controls, mutation kittygraphics.Mutation) {
470	if c.HasCursor && c.Cursor == 1 || s.Frame.Width <= 0 || s.Frame.Height <= 0 {
471		return
472	}
473	columns, rows := s.kittyPlacementGridSize(c, mutation)
474	targetColumn := s.kittyCursorColumn() + columns
475	wraps := targetColumn >= s.Frame.Width
476	rowMoves := max(rows-1, 0)
477	if wraps {
478		rowMoves++
479	}
480	s.advanceKittyCursorRows(rowMoves)
481	if wraps {
482		s.Col = 0
483	} else {
484		s.Col = targetColumn
485	}
486}
487
488func (s *Screen) advanceKittyCursorRows(rows int) {
489	if rows <= 0 || s.Frame.Height <= 0 {
490		return
491	}
492	if s.Row >= s.scrollTop && s.Row <= s.scrollBottom {
493		beforeScroll := s.scrollBottom - s.Row
494		if rows <= beforeScroll {
495			s.Row += rows
496			return
497		}
498		s.Row = s.scrollBottom
499		s.scrollUpRegion(s.scrollTop, s.scrollBottom, rows-beforeScroll)
500		return
501	}
502	s.Row = min(s.Row+rows, s.Frame.Height-1)
503}
504
505func (s *Screen) kittyPlacementGridSize(c kittygraphics.Controls, mutation kittygraphics.Mutation) (columns, rows int) {
506	columns, rows = 1, 1
507	if c.HasColumns {
508		columns = max(kittyCursorAdvance(c.Columns), 1)
509	}
510	if c.HasRows {
511		rows = max(kittyCursorAdvance(c.Rows), 1)
512	}
513	if c.HasColumns && c.HasRows || s.graphics == nil || s.graphics.scene == nil {
514		return columns, rows
515	}
516	placement, ok := s.graphics.scene.Snapshot().Placement(mutation.ScenePlacementID)
517	if !ok {
518		return columns, rows
519	}
520	cellWidth, cellHeight, pixelsKnown := s.kittyCellPixels()
521	if !pixelsKnown {
522		return columns, rows
523	}
524	destination := placement.Destination()
525	if !c.HasColumns {
526		offset := destination.X % int64(cellWidth)
527		columns = kittyPixelCellSpan(offset, destination.Width, cellWidth)
528	}
529	if !c.HasRows {
530		offset := destination.Y % int64(cellHeight)
531		rows = kittyPixelCellSpan(offset, destination.Height, cellHeight)
532	}
533	return columns, rows
534}
535
536func kittyPixelCellSpan(offset, size int64, cellSize int) int {
537	if offset < 0 || size <= 0 || cellSize <= 0 {
538		return 1
539	}
540	span := (offset + size + int64(cellSize) - 1) / int64(cellSize)
541	if span <= 0 {
542		return 1
543	}
544	if span > int64(^uint(0)>>1) {
545		return int(^uint(0) >> 1)
546	}
547	return int(span)
548}
549
550func kittyCursorAdvance(value uint64) int {
551	if value > uint64(^uint(0)>>1) {
552		return int(^uint(0) >> 1)
553	}
554	return int(value)
555}

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