v0.5.1 8e4df5c

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// Columns returns the visible grid width.
128func (s *Screen) Columns() int {
129	if s == nil || s.buffer == nil {
130		return 0
131	}
132	return s.buffer.frame.Width
133}
134
135// Rows returns the visible grid height.
136func (s *Screen) Rows() int {
137	if s == nil || s.buffer == nil {
138		return 0
139	}
140	return s.buffer.frame.Height
141}
142
143// Cell returns the semantic cell at x, y. Coordinates must be inside the
144// visible grid.
145func (s *Screen) Cell(x, y int) renderer.Cell {
146	return s.buffer.frame.At(x, y)
147}
148
149// RowCells returns an owned copy of visible row y, or nil when y is out of range.
150func (s *Screen) RowCells(y int) []renderer.Cell {
151	if s == nil || s.buffer == nil || y < 0 || y >= s.buffer.frame.Height {
152		return nil
153	}
154	return s.buffer.frame.Row(y)
155}
156
157// LineBounds returns an owned copy of the live grid's per-row logical extents,
158// indexed like Frame rows. It returns nil when the screen has no buffer.
159func (s *Screen) LineBounds() []LineBound {
160	if s == nil || s.buffer == nil {
161		return nil
162	}
163	return append([]LineBound(nil), s.buffer.boundaries...)
164}
165
166func (s *Screen) Write(data []byte) {
167	if len(s.escapeBuf) > 0 && isKittyEscapeContinuation(s.escapeBuf, data) {
168		prefix := s.escapeBuf
169		s.escapeBuf = nil
170		data = s.continueKittyEscape(prefix, data)
171		if len(data) == 0 {
172			return
173		}
174	}
175	if len(s.escapeBuf) > 0 {
176		combined := make([]byte, 0, len(s.escapeBuf)+len(data))
177		combined = append(combined, s.escapeBuf...)
178		combined = append(combined, data...)
179		data = combined
180		s.escapeBuf = nil
181	}
182	if s.kittyDiscard {
183		consumed := s.consumeKittyDiscard(data)
184		data = data[consumed:]
185		if s.kittyDiscard {
186			return
187		}
188	}
189
190	for len(data) > 0 {
191		if data[0] == 0x1b {
192			consumed, partial := s.consumeEscape(data)
193			if consumed > 0 {
194				data = data[consumed:]
195				continue
196			}
197			if partial {
198				limit := maxEscapeBufferLen
199				if isKittyEscapePrefix(data) {
200					limit = maxKittyEscapeBufferLen
201				}
202				if len(data) <= limit {
203					s.escapeBuf = append(s.escapeBuf[:0], data...)
204				} else if isKittyEscapePrefix(data) {
205					s.abortKittyPendingDisplay()
206					s.kittyDiscard = true
207					s.kittyDiscardEscaped = false
208				}
209				return
210			}
211		}
212		r, size := utf8.DecodeRune(data)
213		if r == utf8.RuneError && size == 1 {
214			data = data[1:]
215			continue
216		}
217		s.putRune(r)
218		data = data[size:]
219	}
220}
221
222func isKittyEscapePrefix(data []byte) bool {
223	if len(data) == 0 || data[0] != 0x1b {
224		return false
225	}
226	if len(data) > 1 && data[1] != '_' {
227		return false
228	}
229	return len(data) < 3 || data[2] == 'G'
230}
231
232func isKittyEscapeContinuation(prefix, data []byte) bool {
233	if !isKittyEscapePrefix(prefix) {
234		return false
235	}
236	if len(data) == 0 {
237		return true
238	}
239	if len(prefix) == 1 {
240		return data[0] == '_'
241	}
242	if len(prefix) == 2 {
243		return data[0] == 'G'
244	}
245	return true
246}
247
248func (s *Screen) continueKittyEscape(prefix, data []byte) []byte {
249	if len(prefix) != 0 && prefix[len(prefix)-1] == 0x1b && len(data) != 0 && data[0] == '\\' {
250		if len(prefix)+1 <= maxKittyEscapeBufferLen {
251			apc := append(prefix, data[:1]...)
252			s.dispatchKittyGraphics(apc)
253		} else {
254			s.abortKittyPendingDisplay()
255		}
256		return data[1:]
257	}
258	for i := 0; i < len(data); i++ {
259		if data[i] == 0x9c {
260			end := i + 1
261			if len(prefix)+end <= maxKittyEscapeBufferLen {
262				apc := append(prefix, data[:end]...)
263				s.dispatchKittyGraphics(apc)
264			} else {
265				s.abortKittyPendingDisplay()
266			}
267			return data[end:]
268		}
269		if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
270			end := i + 2
271			if len(prefix)+end <= maxKittyEscapeBufferLen {
272				apc := append(prefix, data[:end]...)
273				s.dispatchKittyGraphics(apc)
274			} else {
275				s.abortKittyPendingDisplay()
276			}
277			return data[end:]
278		}
279	}
280	remaining := maxKittyEscapeBufferLen - len(prefix)
281	if len(data) <= remaining {
282		returnData := append(prefix, data...)
283		s.escapeBuf = returnData
284		return nil
285	}
286	s.abortKittyPendingDisplay()
287	s.kittyDiscard = true
288	escaped := len(prefix) != 0 && prefix[len(prefix)-1] == 0x1b
289	if remaining > 0 {
290		escaped = data[remaining-1] == 0x1b
291	}
292	s.kittyDiscardEscaped = escaped
293	tail := data[remaining:]
294	return tail[s.consumeKittyDiscard(tail):]
295}
296
297func (s *Screen) consumeKittyDiscard(data []byte) int {
298	for i, b := range data {
299		if s.kittyDiscardEscaped {
300			s.kittyDiscardEscaped = false
301			if b == '\\' {
302				s.kittyDiscard = false
303				return i + 1
304			}
305		}
306		if b == 0x9c {
307			s.kittyDiscard = false
308			return i + 1
309		}
310		if b == 0x1b {
311			if i+1 < len(data) && data[i+1] == '\\' {
312				s.kittyDiscard = false
313				return i + 2
314			}
315			s.kittyDiscardEscaped = true
316		}
317	}
318	return len(data)
319}
320
321func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
322	if len(data) < 2 {
323		return 0, true
324	}
325	switch data[1] {
326	case ']':
327		return s.consumeOSC(data)
328	case 'P':
329		return consumeSTString(data)
330	case '_':
331		if len(data) >= 3 && data[2] == 'G' {
332			return s.consumeKittyGraphics(data)
333		}
334		return consumeSTString(data)
335	case '^', 'X':
336		return consumeSTString(data)
337	case '[':
338		return s.consumeCSI(data)
339	case '=':
340		return 2, false
341	case '>':
342		return 2, false
343	case '7':
344		s.saveCursor()
345		return 2, false
346	case '8':
347		s.restoreCursor()
348		return 2, false
349	case 'D':
350		s.buffer.hard(s.Row)
351		s.index()
352		return 2, false
353	case 'E':
354		s.nextLine()
355		return 2, false
356	case 'M':
357		s.reverseIndex()
358		return 2, false
359	case 'c':
360		s.reset()
361		return 2, false
362	case 'H':
363		return 2, false
364	case '(', ')', '*', '+', '-', '.', '/':
365		if len(data) < 3 {
366			return 0, true
367		}
368		return 3, false
369	default:
370		if data[1] >= 0x30 && data[1] <= 0x7e {
371			return 2, false
372		}
373		return 0, false
374	}
375}
376
377func consumeSTString(data []byte) (consumed int, partial bool) {
378	for i := 2; i < len(data); i++ {
379		if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
380			return i + 2, false
381		}
382	}
383	return 0, true
384}
385
386// consumeKittyGraphics frames one exact ESC _ G APC and dispatches only its
387// complete bytes to the protocol adapter. The ordinary parser never sees the
388// APC body as text, and the adapter is allocated only on the first complete
389// graphics command.
390func (s *Screen) consumeKittyGraphics(data []byte) (consumed int, partial bool) {
391	for i := 3; i < len(data); i++ {
392		switch data[i] {
393		case 0x9c: // C1 string terminator.
394			end := i + 1
395			if end > maxKittyEscapeBufferLen {
396				s.abortKittyPendingDisplay()
397				return end, false
398			}
399			s.dispatchKittyGraphics(data[:end])
400			return end, false
401		case 0x1b:
402			if i+1 < len(data) && data[i+1] == '\\' {
403				end := i + 2
404				if end > maxKittyEscapeBufferLen {
405					s.abortKittyPendingDisplay()
406					return end, false
407				}
408				s.dispatchKittyGraphics(data[:end])
409				return end, false
410			}
411		}
412	}
413	return 0, true
414}
415
416func (s *Screen) dispatchKittyGraphics(apc []byte) {
417	command, err := kittygraphics.ParseAPC(apc)
418	if err != nil {
419		s.abortKittyPendingDisplay()
420		return
421	}
422	if s.graphics == nil {
423		s.graphics = newScreenGraphicsState()
424	}
425	controls := command.Controls
426	pending := s.kittyPendingDisplay
427	action := controls.Action
428	if !controls.HasAction {
429		action = kittygraphics.ActionTransmit
430		if pending != nil {
431			action = pending.Action
432		}
433	}
434	display := action == kittygraphics.ActionTransmitDisplay || action == kittygraphics.ActionPut
435	movementControls := controls
436	if pending != nil {
437		movementControls = *pending
438	}
439	if display {
440		s.graphics.kitty.SetPendingPlacement(s.kittyCursorX(), s.kittyCursorY())
441	}
442	if display && controls.HasMore && controls.More == 1 {
443		copy := controls
444		s.kittyPendingDisplay = &copy
445	}
446	result, processErr := s.graphics.kitty.Process(command)
447	if processErr != nil {
448		s.abortKittyPendingDisplay()
449	}
450	for _, response := range result.Responses {
451		s.respond(response)
452	}
453	if len(result.Mutations) != 0 {
454		if display {
455			mutation := result.Mutations[len(result.Mutations)-1]
456			s.applyKittyCursorMovement(movementControls, mutation)
457			s.kittyPendingDisplay = nil
458		}
459		// Graphics are rendered independently from Frame. A graphics mutation
460		// still needs to wake consumers which only redraw on screen damage.
461		s.fullRedraw()
462	}
463}
464
465func (s *Screen) kittyCursorX() uint64 {
466	if cellWidth, _, ok := s.kittyCellPixels(); ok {
467		return uint64(s.kittyCursorColumn() * cellWidth)
468	}
469	return 0
470}
471
472func (s *Screen) kittyCursorColumn() int {
473	if s.frame.Width <= 0 {
474		return 0
475	}
476	return clamp(s.Col, 0, s.frame.Width-1)
477}
478
479func (s *Screen) kittyCursorY() uint64 {
480	_, cellHeight, ok := s.kittyCellPixels()
481	if ok {
482		return uint64(s.Row * cellHeight)
483	}
484	return 0
485}
486
487func (s *Screen) kittyCellPixels() (width, height int, ok bool) {
488	if !s.geometry.PixelsKnown() || s.geometry.Cols <= 0 || s.geometry.Rows <= 0 {
489		return 0, 0, false
490	}
491	width = s.geometry.PixelWidth / s.geometry.Cols
492	height = s.geometry.PixelHeight / s.geometry.Rows
493	if width <= 0 || height <= 0 {
494		return 0, 0, false
495	}
496	return width, height, true
497}
498
499func (s *Screen) applyKittyCursorMovement(c kittygraphics.Controls, mutation kittygraphics.Mutation) {
500	if c.HasCursor && c.Cursor == 1 || s.frame.Width <= 0 || s.frame.Height <= 0 {
501		return
502	}
503	columns, rows := s.kittyPlacementGridSize(c, mutation)
504	targetColumn := s.kittyCursorColumn() + columns
505	wraps := targetColumn >= s.frame.Width
506	rowMoves := max(rows-1, 0)
507	if wraps {
508		rowMoves++
509	}
510	s.advanceKittyCursorRows(rowMoves)
511	if wraps {
512		s.Col = 0
513	} else {
514		s.Col = targetColumn
515	}
516}
517
518func (s *Screen) advanceKittyCursorRows(rows int) {
519	if rows <= 0 || s.frame.Height <= 0 {
520		return
521	}
522	if s.Row >= s.scrollTop && s.Row <= s.scrollBottom {
523		beforeScroll := s.scrollBottom - s.Row
524		if rows <= beforeScroll {
525			s.Row += rows
526			return
527		}
528		s.Row = s.scrollBottom
529		s.scrollUpRegion(s.scrollTop, s.scrollBottom, rows-beforeScroll)
530		return
531	}
532	s.Row = min(s.Row+rows, s.frame.Height-1)
533}
534
535func (s *Screen) kittyPlacementGridSize(c kittygraphics.Controls, mutation kittygraphics.Mutation) (columns, rows int) {
536	columns, rows = 1, 1
537	if c.HasColumns {
538		columns = max(kittyCursorAdvance(c.Columns), 1)
539	}
540	if c.HasRows {
541		rows = max(kittyCursorAdvance(c.Rows), 1)
542	}
543	if c.HasColumns && c.HasRows || s.graphics == nil || s.graphics.scene == nil {
544		return columns, rows
545	}
546	placement, ok := s.graphics.scene.Snapshot().Placement(mutation.ScenePlacementID)
547	if !ok {
548		return columns, rows
549	}
550	cellWidth, cellHeight, pixelsKnown := s.kittyCellPixels()
551	if !pixelsKnown {
552		return columns, rows
553	}
554	destination := placement.Destination()
555	if !c.HasColumns {
556		offset := destination.X % int64(cellWidth)
557		columns = kittyPixelCellSpan(offset, destination.Width, cellWidth)
558	}
559	if !c.HasRows {
560		offset := destination.Y % int64(cellHeight)
561		rows = kittyPixelCellSpan(offset, destination.Height, cellHeight)
562	}
563	return columns, rows
564}
565
566func kittyPixelCellSpan(offset, size int64, cellSize int) int {
567	if offset < 0 || size <= 0 || cellSize <= 0 {
568		return 1
569	}
570	span := (offset + size + int64(cellSize) - 1) / int64(cellSize)
571	if span <= 0 {
572		return 1
573	}
574	if span > int64(^uint(0)>>1) {
575		return int(^uint(0) >> 1)
576	}
577	return int(span)
578}
579
580func kittyCursorAdvance(value uint64) int {
581	if value > uint64(^uint(0)>>1) {
582		return int(^uint(0) >> 1)
583	}
584	return int(value)
585}

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