detached 58fcde1

refactor(vt): split screen state operations

commit
58fcde1e64de233eb1189926045014ab0fc06d84
author
brice · · 2026-07-14 20:23 UTC
parent
86b7f27
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +512 -500
M pkg/vt/screen.go +0 -500
@@ -6,28 +6,12 @@ import (
66 "github.com/bnema/vev/pkg/renderer"
77 )
88
9-type screenState struct {
10- frame renderer.Frame
11- row int
12- col int
13- style renderer.Style
14- scrollTop int
15- scrollBottom int
16- savedCursor cursorState
17- originMode bool
18- insertMode bool
19-}
20-
219 // maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads
2210 // forwarded by internal/usecase/copy.OSC52MaxPayloadBytes after base64
2311 // expansion plus the OSC wrapper. pkg/vt cannot import internal/usecase/copy,
2412 // so keep this value in sync if that payload cap changes.
2513 const maxEscapeBufferLen = 128 * 1024
2614
27-// maxPendingDamage bounds metadata retained while no render transaction can
28-// acknowledge a screen. Saturation falls back to one exact full redraw.
29-const maxPendingDamage = 1024
30-
3115 const (
3216 // ColorSchemeReportDark is the DEC 2031 dark-scheme report.
3317 ColorSchemeReportDark = "\x1b[?997;1n"
@@ -124,130 +108,6 @@ func NewScreenWithHistory(width, height int, config HistoryConfig) *Screen {
124108 // configured with NewScreenWithHistory.
125109 func (s *Screen) History() *History { return s.history }
126110
127-func (s *Screen) Resize(width, height int) {
128- if width == s.Frame.Width && height == s.Frame.Height {
129- return
130- }
131- evict := s.recordEvicted
132-
133- if s.alternate != nil {
134- var shift int
135- s.Frame, shift = resizeFrame(s.Frame, width, height, s.Row, nil)
136- s.Row = clamp(s.Row-shift, 0, height-1)
137- s.Col = clamp(s.Col, 0, width-1)
138- s.savedCursor = resizedCursor(s.savedCursor, shift, width, height)
139- s.alternate.frame, shift = resizeFrame(s.alternate.frame, width, height, s.alternate.row, evict)
140- s.alternate.row = clamp(s.alternate.row-shift, 0, height-1)
141- s.alternate.col = clamp(s.alternate.col, 0, width-1)
142- s.alternate.savedCursor = resizedCursor(s.alternate.savedCursor, shift, width, height)
143- s.alternate.scrollTop = clamp(s.alternate.scrollTop-shift, 0, height-1)
144- s.alternate.scrollBottom = clamp(s.alternate.scrollBottom-shift, 0, height-1)
145- if s.alternate.scrollTop >= s.alternate.scrollBottom {
146- s.alternate.scrollTop = 0
147- s.alternate.scrollBottom = max(height-1, 0)
148- }
149- } else {
150- var shift int
151- s.Frame, shift = resizeFrame(s.Frame, width, height, s.Row, evict)
152- s.Row = clamp(s.Row-shift, 0, height-1)
153- s.Col = clamp(s.Col, 0, width-1)
154- s.savedCursor = resizedCursor(s.savedCursor, shift, width, height)
155- }
156-
157- // A resize can split an in-flight escape sequence from the terminal state it
158- // was meant to mutate; keep the durable child state but discard partial bytes.
159- s.escapeBuf = s.escapeBuf[:0]
160- s.resetScrollRegion()
161- s.fullRedraw()
162-}
163-
164-// DamageCapture is an immutable copy of pending damage at one screen generation.
165-type DamageCapture struct {
166- Damage []renderer.Damage
167- Generation uint64
168-}
169-
170-// Damage returns the current damage list. The caller must not modify the
171-// returned slice; ClearDamage must be called after the damage is consumed.
172-func (s *Screen) Damage() []renderer.Damage { return s.damage }
173-func (s *Screen) ClearDamage() {
174- s.damage = s.damage[:0]
175- s.damageSaturated = false
176- s.damageFullRedrawSticky = false
177-}
178-
179-// CaptureDamage snapshots pending damage without consuming it.
180-func (s *Screen) CaptureDamage() DamageCapture {
181- return DamageCapture{Damage: append([]renderer.Damage(nil), s.damage...), Generation: s.damageGeneration}
182-}
183-
184-// AcknowledgeDamage consumes a capture only if no screen mutation occurred
185-// since it was taken. A stale acknowledgement conservatively requests a full
186-// redraw, ensuring intervening writes remain visible to the next capture.
187-func (s *Screen) AcknowledgeDamage(generation uint64) bool {
188- if generation != s.damageGeneration {
189- s.fullRedraw()
190- return false
191- }
192- s.ClearDamage()
193- return true
194-}
195-
196-// SyncUpdateActive reports whether DEC private mode 2026 (synchronized update)
197-// is currently enabled by the child process.
198-func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive }
199-
200-// BracketedPasteMode reports whether DEC private mode 2004 is currently enabled
201-// by the child process.
202-func (s *Screen) BracketedPasteMode() bool { return s.bracketedPaste }
203-
204-// ColorSchemeMode reports whether DEC private mode 2031 is currently enabled.
205-func (s *Screen) ColorSchemeMode() bool { return s.colorSchemeMode }
206-
207-// SetColorScheme updates the host color scheme and notifies subscribed child apps.
208-func (s *Screen) SetColorScheme(light bool) {
209- if s.colorSchemeSet && s.colorSchemeLight == light {
210- return
211- }
212- s.colorSchemeSet = true
213- s.colorSchemeLight = light
214- if s.colorSchemeMode {
215- s.respond(s.colorSchemeReport())
216- }
217-}
218-
219-// ClearColorScheme marks the host color scheme as unknown. Future child color
220-// scheme queries are silent until SetColorScheme supplies a known value again.
221-func (s *Screen) ClearColorScheme() {
222- s.colorSchemeSet = false
223-}
224-
225-// ForceSyncEnd forcibly leaves DEC private mode 2026 (synchronized update).
226-// Hosts use this as a safety valve if a child enters synchronized update mode
227-// and never sends the matching end sequence.
228-func (s *Screen) ForceSyncEnd() { s.syncUpdateActive = false }
229-
230-// SetDefaultColors sets the terminal default foreground/background colors used
231-// to answer child OSC 10/11 color queries. Passing ok=false makes color queries
232-// silent until known colors are supplied again.
233-func (s *Screen) SetDefaultColors(fg, bg renderer.RGB, ok bool) {
234- s.defaultFG = fg
235- s.defaultBG = bg
236- s.defaultColorsKnown = ok
237-}
238-
239-func (s *Screen) CursorRow() int { return s.Row }
240-func (s *Screen) CursorCol() int { return s.Col }
241-func (s *Screen) CursorVisible() bool {
242- return s.cursorVisible
243-}
244-func (s *Screen) CursorStyle() (int, bool) { return s.cursorStyle, s.cursorStyleSet }
245-func (s *Screen) MouseMode() (int, bool) { return s.mouseMode, s.mouseSGR }
246-func (s *Screen) AltScreenActive() bool { return s.alternate != nil }
247-
248-// TerminalTitle returns the latest title set by OSC 0 or OSC 2.
249-func (s *Screen) TerminalTitle() string { return s.terminalTitle }
250-
251111 func (s *Screen) Write(data []byte) {
252112 if len(s.escapeBuf) > 0 {
253113 combined := make([]byte, 0, len(s.escapeBuf)+len(data))
@@ -281,173 +141,6 @@ func (s *Screen) Write(data []byte) {
281141 }
282142 }
283143
284-func (s *Screen) index() {
285- if s.Frame.Height == 0 {
286- return
287- }
288- if s.Row == s.scrollBottom && s.Row >= s.scrollTop {
289- s.scrollUpRegion(s.scrollTop, s.scrollBottom, 1)
290- return
291- }
292- if s.Row+1 < s.Frame.Height {
293- s.Row++
294- return
295- }
296- s.Row = s.Frame.Height - 1
297-}
298-
299-func (s *Screen) nextLine() {
300- s.Col = 0
301- s.index()
302-}
303-
304-func (s *Screen) reverseIndex() {
305- if s.Frame.Height == 0 {
306- return
307- }
308- if s.Row == s.scrollTop && s.Row <= s.scrollBottom {
309- s.scrollDownRegion(s.scrollTop, s.scrollBottom, 1)
310- return
311- }
312- if s.Row > 0 {
313- s.Row--
314- }
315-}
316-
317-// scrollUpBy scrolls the current scroll region up by n lines (CSI S / SU).
318-// The cursor position is preserved.
319-func (s *Screen) scrollUpBy(n int) {
320- if n <= 0 {
321- return
322- }
323- s.scrollUpRegion(s.scrollTop, s.scrollBottom, n)
324-}
325-
326-func (s *Screen) scrollDownBy(n int) {
327- if n <= 0 {
328- return
329- }
330- s.scrollDownRegion(s.scrollTop, s.scrollBottom, n)
331-}
332-
333-func (s *Screen) scrollUpRegion(top, bottom, n int) {
334- if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 {
335- return
336- }
337- top, bottom, ok := s.normalizedRegion(top, bottom)
338- if !ok {
339- return
340- }
341- w := s.Frame.Width
342- height := bottom - top + 1
343- if n > height {
344- n = height
345- }
346- // VT scroll regions always span the full frame width, so we rotate the
347- // frame's line offsets (recycling and blanking the evicted rows in place)
348- // instead of copying cells. See renderer.Frame.ScrollUp.
349- s.emitLineEvicted(top, n)
350- s.Frame.ScrollUp(top, bottom, n)
351- s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n})
352- s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1})
353-}
354-
355-func (s *Screen) emitLineEvicted(top, n int) {
356- if s.alternate != nil {
357- return
358- }
359- for y := top; y < top+n; y++ {
360- s.recordEvicted(s.Frame.Row(y))
361- }
362-}
363-
364-func (s *Screen) recordEvicted(row []renderer.Cell) {
365- if s.history != nil {
366- s.history.Append(row)
367- }
368- if s.OnLineEvicted != nil {
369- s.OnLineEvicted(append([]renderer.Cell(nil), row...))
370- }
371-}
372-
373-func (s *Screen) respond(b []byte) {
374- if s.OnResponse != nil {
375- s.OnResponse(b)
376- }
377-}
378-
379-func (s *Screen) colorSchemeReport() []byte {
380- if !s.colorSchemeSet {
381- return nil
382- }
383- if s.colorSchemeLight {
384- return []byte(ColorSchemeReportLight)
385- }
386- return []byte(ColorSchemeReportDark)
387-}
388-
389-func (s *Screen) scrollDownRegion(top, bottom, n int) {
390- if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 {
391- return
392- }
393- top, bottom, ok := s.normalizedRegion(top, bottom)
394- if !ok {
395- return
396- }
397- w := s.Frame.Width
398- height := bottom - top + 1
399- if n > height {
400- n = height
401- }
402- // Full-width region: rotate line offsets instead of copying cells.
403- s.Frame.ScrollDown(top, bottom, n)
404- s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1})
405-}
406-
407-func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) {
408- if s.Frame.Height == 0 || top > bottom {
409- return 0, 0, false
410- }
411- top = clamp(top, 0, s.Frame.Height-1)
412- bottom = clamp(bottom, 0, s.Frame.Height-1)
413- return top, bottom, top <= bottom
414-}
415-
416-func (s *Screen) record(d renderer.Damage) {
417- s.damageGeneration++
418- if s.damageSaturated || s.damageFullRedrawSticky {
419- return
420- }
421- if len(s.damage) >= maxPendingDamage {
422- s.damage = []renderer.Damage{renderer.FullRedraw()}
423- s.damageSaturated = true
424- return
425- }
426- // Replace FullRedraw with the first concrete damage item.
427- if len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw {
428- s.damage[0] = d
429- return
430- }
431- // Coalesce adjacent single-cell text damage on the same line.
432- if d.Kind == renderer.DamageText && d.Width == 1 && d.Height == 1 && len(s.damage) > 0 {
433- last := &s.damage[len(s.damage)-1]
434- if last.Kind == renderer.DamageText && last.Y == d.Y && last.X+last.Width == d.X && last.Height == 1 {
435- last.Width++
436- return
437- }
438- }
439- s.damage = append(s.damage, d)
440-}
441-
442-func (s *Screen) fullRedraw() {
443- s.damageGeneration++
444- s.damage = []renderer.Damage{renderer.FullRedraw()}
445- // A structural frame replacement (such as DEC 1049 exit) cannot be
446- // represented by the following text damage alone. Retain the redraw until
447- // the render owner acknowledges this screen generation.
448- s.damageFullRedrawSticky = true
449-}
450-
451144 func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
452145 if len(data) < 2 {
453146 return 0, true
@@ -506,196 +199,3 @@ func consumeSTString(data []byte) (consumed int, partial bool) {
506199 }
507200 return 0, true
508201 }
509-
510-func (s *Screen) reset() {
511- s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height)
512- s.Row, s.Col = 0, 0
513- s.Style = renderer.DefaultStyle()
514- s.escapeBuf = s.escapeBuf[:0]
515- s.savedCursor = cursorState{}
516- s.alternate = nil
517- s.originMode = false
518- s.insertMode = false
519- s.cursorVisible = true
520- s.cursorStyle = 0
521- s.cursorStyleSet = false
522- s.mouseMode = 0
523- s.mouseSGR = false
524- s.bracketedPaste = false
525- s.colorSchemeMode = false
526- s.resetScrollRegion()
527- s.fullRedraw()
528-}
529-
530-func (s *Screen) setScrollRegion(parts []int) {
531- if s.Frame.Height == 0 {
532- return
533- }
534- top, bottom := 1, s.Frame.Height
535- if len(parts) > 0 && parts[0] > 0 {
536- top = parts[0]
537- }
538- if len(parts) > 1 && parts[1] > 0 {
539- bottom = parts[1]
540- }
541- if top >= bottom {
542- s.resetScrollRegion()
543- } else {
544- s.scrollTop = clamp(top-1, 0, s.Frame.Height-1)
545- s.scrollBottom = clamp(bottom-1, 0, s.Frame.Height-1)
546- if s.scrollTop >= s.scrollBottom {
547- s.resetScrollRegion()
548- }
549- }
550- s.homeCursor()
551-}
552-
553-func (s *Screen) resetScrollRegion() {
554- s.scrollTop = 0
555- if s.Frame.Height > 0 {
556- s.scrollBottom = s.Frame.Height - 1
557- } else {
558- s.scrollBottom = 0
559- }
560-}
561-
562-func (s *Screen) setMode(private bool, parts []int, enabled bool) {
563- if !private {
564- for _, mode := range parts {
565- switch mode {
566- case 4:
567- s.insertMode = enabled
568- default:
569- // Other ANSI modes (for example LNM) are intentionally ignored
570- // until the screen model implements their observable behavior.
571- continue
572- }
573- }
574- return
575- }
576- for _, mode := range parts {
577- switch mode {
578- case 6:
579- s.originMode = enabled
580- s.homeCursor()
581- case 47, 1047, 1049:
582- if enabled {
583- s.enterAlternateScreen()
584- } else {
585- s.exitAlternateScreen()
586- }
587- case 2026:
588- s.syncUpdateActive = enabled
589- case 2031:
590- s.colorSchemeMode = enabled
591- case 25:
592- s.cursorVisible = enabled
593- case 1000, 1002, 1003:
594- if enabled {
595- s.mouseMode = mode
596- } else if s.mouseMode == mode {
597- s.mouseMode = 0
598- }
599- case 1006:
600- s.mouseSGR = enabled
601- case 2004:
602- s.bracketedPaste = enabled
603- case 1, 1004, 1005:
604- // Trackable terminal modes that do not directly affect the current
605- // cell model yet. Consuming them prevents mode bytes from leaking.
606- continue
607- }
608- }
609-}
610-
611-func (s *Screen) enterAlternateScreen() {
612- if s.alternate == nil {
613- s.alternate = &screenState{
614- frame: cloneFrame(s.Frame),
615- row: s.Row,
616- col: s.Col,
617- style: s.Style,
618- scrollTop: s.scrollTop,
619- scrollBottom: s.scrollBottom,
620- savedCursor: s.savedCursor,
621- originMode: s.originMode,
622- insertMode: s.insertMode,
623- }
624- }
625- s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height)
626- s.Row, s.Col = 0, 0
627- s.Style = renderer.DefaultStyle()
628- s.savedCursor = cursorState{}
629- s.resetScrollRegion()
630- s.fullRedraw()
631-}
632-
633-func (s *Screen) exitAlternateScreen() {
634- if s.alternate == nil {
635- return
636- }
637- state := s.alternate
638- s.Frame = cloneFrame(state.frame)
639- s.Row = clamp(state.row, 0, s.Frame.Height-1)
640- s.Col = clamp(state.col, 0, s.Frame.Width-1)
641- s.Style = state.style
642- s.scrollTop = state.scrollTop
643- s.scrollBottom = state.scrollBottom
644- s.savedCursor = state.savedCursor
645- s.originMode = state.originMode
646- s.insertMode = state.insertMode
647- s.alternate = nil
648- s.fullRedraw()
649-}
650-
651-func resizeFrame(old renderer.Frame, newW, newH, cursorRow int, evict func([]renderer.Cell)) (renderer.Frame, int) {
652- next := renderer.NewFrame(newW, newH)
653- shift := clamp(cursorRow-(newH-1), 0, max(old.Height-newH, 0))
654- if evict != nil {
655- for y := range shift {
656- evict(old.Row(y))
657- }
658- }
659- for dy := range newH {
660- sy := dy + shift
661- if sy >= old.Height {
662- break
663- }
664- copy(next.Row(dy), old.Row(sy))
665- repairFrameRow(next, dy)
666- }
667- return next, shift
668-}
669-
670-func resizedCursor(cur cursorState, shift, width, height int) cursorState {
671- if !cur.saved {
672- return cur
673- }
674- cur.row = clamp(cur.row-shift, 0, height-1)
675- cur.col = clamp(cur.col, 0, width-1)
676- return cur
677-}
678-
679-// cloneFrame produces an independent copy in canonical layout: it copies the
680-// source's logical rows (via Row) into a fresh frame whose offsets are already
681-// canonical, so a rotated source is normalized in the clone.
682-func cloneFrame(frame renderer.Frame) renderer.Frame {
683- out := renderer.NewFrame(frame.Width, frame.Height)
684- for y := range frame.Height {
685- copy(out.Row(y), frame.Row(y))
686- }
687- return out
688-}
689-
690-func clamp(v, lo, hi int) int {
691- if hi < lo {
692- return lo
693- }
694- if v < lo {
695- return lo
696- }
697- if v > hi {
698- return hi
699- }
700- return v
701-}
@@ -6,28 +6,12 @@ import (
6 "github.com/bnema/vev/pkg/renderer"6 "github.com/bnema/vev/pkg/renderer"
7 )7 )
8 8
9-type screenState struct {
10- frame renderer.Frame
11- row int
12- col int
13- style renderer.Style
14- scrollTop int
15- scrollBottom int
16- savedCursor cursorState
17- originMode bool
18- insertMode bool
19-}
20-
21 // maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads9 // maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads
22 // forwarded by internal/usecase/copy.OSC52MaxPayloadBytes after base6410 // forwarded by internal/usecase/copy.OSC52MaxPayloadBytes after base64
23 // expansion plus the OSC wrapper. pkg/vt cannot import internal/usecase/copy,11 // expansion plus the OSC wrapper. pkg/vt cannot import internal/usecase/copy,
24 // so keep this value in sync if that payload cap changes.12 // so keep this value in sync if that payload cap changes.
25 const maxEscapeBufferLen = 128 * 102413 const maxEscapeBufferLen = 128 * 1024
26 14
27-// maxPendingDamage bounds metadata retained while no render transaction can
28-// acknowledge a screen. Saturation falls back to one exact full redraw.
29-const maxPendingDamage = 1024
30-
31 const (15 const (
32 // ColorSchemeReportDark is the DEC 2031 dark-scheme report.16 // ColorSchemeReportDark is the DEC 2031 dark-scheme report.
33 ColorSchemeReportDark = "\x1b[?997;1n"17 ColorSchemeReportDark = "\x1b[?997;1n"
@@ -124,130 +108,6 @@ func NewScreenWithHistory(width, height int, config HistoryConfig) *Screen {
124 // configured with NewScreenWithHistory.108 // configured with NewScreenWithHistory.
125 func (s *Screen) History() *History { return s.history }109 func (s *Screen) History() *History { return s.history }
126 110
127-func (s *Screen) Resize(width, height int) {
128- if width == s.Frame.Width && height == s.Frame.Height {
129- return
130- }
131- evict := s.recordEvicted
132-
133- if s.alternate != nil {
134- var shift int
135- s.Frame, shift = resizeFrame(s.Frame, width, height, s.Row, nil)
136- s.Row = clamp(s.Row-shift, 0, height-1)
137- s.Col = clamp(s.Col, 0, width-1)
138- s.savedCursor = resizedCursor(s.savedCursor, shift, width, height)
139- s.alternate.frame, shift = resizeFrame(s.alternate.frame, width, height, s.alternate.row, evict)
140- s.alternate.row = clamp(s.alternate.row-shift, 0, height-1)
141- s.alternate.col = clamp(s.alternate.col, 0, width-1)
142- s.alternate.savedCursor = resizedCursor(s.alternate.savedCursor, shift, width, height)
143- s.alternate.scrollTop = clamp(s.alternate.scrollTop-shift, 0, height-1)
144- s.alternate.scrollBottom = clamp(s.alternate.scrollBottom-shift, 0, height-1)
145- if s.alternate.scrollTop >= s.alternate.scrollBottom {
146- s.alternate.scrollTop = 0
147- s.alternate.scrollBottom = max(height-1, 0)
148- }
149- } else {
150- var shift int
151- s.Frame, shift = resizeFrame(s.Frame, width, height, s.Row, evict)
152- s.Row = clamp(s.Row-shift, 0, height-1)
153- s.Col = clamp(s.Col, 0, width-1)
154- s.savedCursor = resizedCursor(s.savedCursor, shift, width, height)
155- }
156-
157- // A resize can split an in-flight escape sequence from the terminal state it
158- // was meant to mutate; keep the durable child state but discard partial bytes.
159- s.escapeBuf = s.escapeBuf[:0]
160- s.resetScrollRegion()
161- s.fullRedraw()
162-}
163-
164-// DamageCapture is an immutable copy of pending damage at one screen generation.
165-type DamageCapture struct {
166- Damage []renderer.Damage
167- Generation uint64
168-}
169-
170-// Damage returns the current damage list. The caller must not modify the
171-// returned slice; ClearDamage must be called after the damage is consumed.
172-func (s *Screen) Damage() []renderer.Damage { return s.damage }
173-func (s *Screen) ClearDamage() {
174- s.damage = s.damage[:0]
175- s.damageSaturated = false
176- s.damageFullRedrawSticky = false
177-}
178-
179-// CaptureDamage snapshots pending damage without consuming it.
180-func (s *Screen) CaptureDamage() DamageCapture {
181- return DamageCapture{Damage: append([]renderer.Damage(nil), s.damage...), Generation: s.damageGeneration}
182-}
183-
184-// AcknowledgeDamage consumes a capture only if no screen mutation occurred
185-// since it was taken. A stale acknowledgement conservatively requests a full
186-// redraw, ensuring intervening writes remain visible to the next capture.
187-func (s *Screen) AcknowledgeDamage(generation uint64) bool {
188- if generation != s.damageGeneration {
189- s.fullRedraw()
190- return false
191- }
192- s.ClearDamage()
193- return true
194-}
195-
196-// SyncUpdateActive reports whether DEC private mode 2026 (synchronized update)
197-// is currently enabled by the child process.
198-func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive }
199-
200-// BracketedPasteMode reports whether DEC private mode 2004 is currently enabled
201-// by the child process.
202-func (s *Screen) BracketedPasteMode() bool { return s.bracketedPaste }
203-
204-// ColorSchemeMode reports whether DEC private mode 2031 is currently enabled.
205-func (s *Screen) ColorSchemeMode() bool { return s.colorSchemeMode }
206-
207-// SetColorScheme updates the host color scheme and notifies subscribed child apps.
208-func (s *Screen) SetColorScheme(light bool) {
209- if s.colorSchemeSet && s.colorSchemeLight == light {
210- return
211- }
212- s.colorSchemeSet = true
213- s.colorSchemeLight = light
214- if s.colorSchemeMode {
215- s.respond(s.colorSchemeReport())
216- }
217-}
218-
219-// ClearColorScheme marks the host color scheme as unknown. Future child color
220-// scheme queries are silent until SetColorScheme supplies a known value again.
221-func (s *Screen) ClearColorScheme() {
222- s.colorSchemeSet = false
223-}
224-
225-// ForceSyncEnd forcibly leaves DEC private mode 2026 (synchronized update).
226-// Hosts use this as a safety valve if a child enters synchronized update mode
227-// and never sends the matching end sequence.
228-func (s *Screen) ForceSyncEnd() { s.syncUpdateActive = false }
229-
230-// SetDefaultColors sets the terminal default foreground/background colors used
231-// to answer child OSC 10/11 color queries. Passing ok=false makes color queries
232-// silent until known colors are supplied again.
233-func (s *Screen) SetDefaultColors(fg, bg renderer.RGB, ok bool) {
234- s.defaultFG = fg
235- s.defaultBG = bg
236- s.defaultColorsKnown = ok
237-}
238-
239-func (s *Screen) CursorRow() int { return s.Row }
240-func (s *Screen) CursorCol() int { return s.Col }
241-func (s *Screen) CursorVisible() bool {
242- return s.cursorVisible
243-}
244-func (s *Screen) CursorStyle() (int, bool) { return s.cursorStyle, s.cursorStyleSet }
245-func (s *Screen) MouseMode() (int, bool) { return s.mouseMode, s.mouseSGR }
246-func (s *Screen) AltScreenActive() bool { return s.alternate != nil }
247-
248-// TerminalTitle returns the latest title set by OSC 0 or OSC 2.
249-func (s *Screen) TerminalTitle() string { return s.terminalTitle }
250-
251 func (s *Screen) Write(data []byte) {111 func (s *Screen) Write(data []byte) {
252 if len(s.escapeBuf) > 0 {112 if len(s.escapeBuf) > 0 {
253 combined := make([]byte, 0, len(s.escapeBuf)+len(data))113 combined := make([]byte, 0, len(s.escapeBuf)+len(data))
@@ -281,173 +141,6 @@ func (s *Screen) Write(data []byte) {
281 }141 }
282 }142 }
283 143
284-func (s *Screen) index() {
285- if s.Frame.Height == 0 {
286- return
287- }
288- if s.Row == s.scrollBottom && s.Row >= s.scrollTop {
289- s.scrollUpRegion(s.scrollTop, s.scrollBottom, 1)
290- return
291- }
292- if s.Row+1 < s.Frame.Height {
293- s.Row++
294- return
295- }
296- s.Row = s.Frame.Height - 1
297-}
298-
299-func (s *Screen) nextLine() {
300- s.Col = 0
301- s.index()
302-}
303-
304-func (s *Screen) reverseIndex() {
305- if s.Frame.Height == 0 {
306- return
307- }
308- if s.Row == s.scrollTop && s.Row <= s.scrollBottom {
309- s.scrollDownRegion(s.scrollTop, s.scrollBottom, 1)
310- return
311- }
312- if s.Row > 0 {
313- s.Row--
314- }
315-}
316-
317-// scrollUpBy scrolls the current scroll region up by n lines (CSI S / SU).
318-// The cursor position is preserved.
319-func (s *Screen) scrollUpBy(n int) {
320- if n <= 0 {
321- return
322- }
323- s.scrollUpRegion(s.scrollTop, s.scrollBottom, n)
324-}
325-
326-func (s *Screen) scrollDownBy(n int) {
327- if n <= 0 {
328- return
329- }
330- s.scrollDownRegion(s.scrollTop, s.scrollBottom, n)
331-}
332-
333-func (s *Screen) scrollUpRegion(top, bottom, n int) {
334- if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 {
335- return
336- }
337- top, bottom, ok := s.normalizedRegion(top, bottom)
338- if !ok {
339- return
340- }
341- w := s.Frame.Width
342- height := bottom - top + 1
343- if n > height {
344- n = height
345- }
346- // VT scroll regions always span the full frame width, so we rotate the
347- // frame's line offsets (recycling and blanking the evicted rows in place)
348- // instead of copying cells. See renderer.Frame.ScrollUp.
349- s.emitLineEvicted(top, n)
350- s.Frame.ScrollUp(top, bottom, n)
351- s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n})
352- s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1})
353-}
354-
355-func (s *Screen) emitLineEvicted(top, n int) {
356- if s.alternate != nil {
357- return
358- }
359- for y := top; y < top+n; y++ {
360- s.recordEvicted(s.Frame.Row(y))
361- }
362-}
363-
364-func (s *Screen) recordEvicted(row []renderer.Cell) {
365- if s.history != nil {
366- s.history.Append(row)
367- }
368- if s.OnLineEvicted != nil {
369- s.OnLineEvicted(append([]renderer.Cell(nil), row...))
370- }
371-}
372-
373-func (s *Screen) respond(b []byte) {
374- if s.OnResponse != nil {
375- s.OnResponse(b)
376- }
377-}
378-
379-func (s *Screen) colorSchemeReport() []byte {
380- if !s.colorSchemeSet {
381- return nil
382- }
383- if s.colorSchemeLight {
384- return []byte(ColorSchemeReportLight)
385- }
386- return []byte(ColorSchemeReportDark)
387-}
388-
389-func (s *Screen) scrollDownRegion(top, bottom, n int) {
390- if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 {
391- return
392- }
393- top, bottom, ok := s.normalizedRegion(top, bottom)
394- if !ok {
395- return
396- }
397- w := s.Frame.Width
398- height := bottom - top + 1
399- if n > height {
400- n = height
401- }
402- // Full-width region: rotate line offsets instead of copying cells.
403- s.Frame.ScrollDown(top, bottom, n)
404- s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1})
405-}
406-
407-func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) {
408- if s.Frame.Height == 0 || top > bottom {
409- return 0, 0, false
410- }
411- top = clamp(top, 0, s.Frame.Height-1)
412- bottom = clamp(bottom, 0, s.Frame.Height-1)
413- return top, bottom, top <= bottom
414-}
415-
416-func (s *Screen) record(d renderer.Damage) {
417- s.damageGeneration++
418- if s.damageSaturated || s.damageFullRedrawSticky {
419- return
420- }
421- if len(s.damage) >= maxPendingDamage {
422- s.damage = []renderer.Damage{renderer.FullRedraw()}
423- s.damageSaturated = true
424- return
425- }
426- // Replace FullRedraw with the first concrete damage item.
427- if len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw {
428- s.damage[0] = d
429- return
430- }
431- // Coalesce adjacent single-cell text damage on the same line.
432- if d.Kind == renderer.DamageText && d.Width == 1 && d.Height == 1 && len(s.damage) > 0 {
433- last := &s.damage[len(s.damage)-1]
434- if last.Kind == renderer.DamageText && last.Y == d.Y && last.X+last.Width == d.X && last.Height == 1 {
435- last.Width++
436- return
437- }
438- }
439- s.damage = append(s.damage, d)
440-}
441-
442-func (s *Screen) fullRedraw() {
443- s.damageGeneration++
444- s.damage = []renderer.Damage{renderer.FullRedraw()}
445- // A structural frame replacement (such as DEC 1049 exit) cannot be
446- // represented by the following text damage alone. Retain the redraw until
447- // the render owner acknowledges this screen generation.
448- s.damageFullRedrawSticky = true
449-}
450-
451 func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {144 func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
452 if len(data) < 2 {145 if len(data) < 2 {
453 return 0, true146 return 0, true
@@ -506,196 +199,3 @@ func consumeSTString(data []byte) (consumed int, partial bool) {
506 }199 }
507 return 0, true200 return 0, true
508 }201 }
509-
510-func (s *Screen) reset() {
511- s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height)
512- s.Row, s.Col = 0, 0
513- s.Style = renderer.DefaultStyle()
514- s.escapeBuf = s.escapeBuf[:0]
515- s.savedCursor = cursorState{}
516- s.alternate = nil
517- s.originMode = false
518- s.insertMode = false
519- s.cursorVisible = true
520- s.cursorStyle = 0
521- s.cursorStyleSet = false
522- s.mouseMode = 0
523- s.mouseSGR = false
524- s.bracketedPaste = false
525- s.colorSchemeMode = false
526- s.resetScrollRegion()
527- s.fullRedraw()
528-}
529-
530-func (s *Screen) setScrollRegion(parts []int) {
531- if s.Frame.Height == 0 {
532- return
533- }
534- top, bottom := 1, s.Frame.Height
535- if len(parts) > 0 && parts[0] > 0 {
536- top = parts[0]
537- }
538- if len(parts) > 1 && parts[1] > 0 {
539- bottom = parts[1]
540- }
541- if top >= bottom {
542- s.resetScrollRegion()
543- } else {
544- s.scrollTop = clamp(top-1, 0, s.Frame.Height-1)
545- s.scrollBottom = clamp(bottom-1, 0, s.Frame.Height-1)
546- if s.scrollTop >= s.scrollBottom {
547- s.resetScrollRegion()
548- }
549- }
550- s.homeCursor()
551-}
552-
553-func (s *Screen) resetScrollRegion() {
554- s.scrollTop = 0
555- if s.Frame.Height > 0 {
556- s.scrollBottom = s.Frame.Height - 1
557- } else {
558- s.scrollBottom = 0
559- }
560-}
561-
562-func (s *Screen) setMode(private bool, parts []int, enabled bool) {
563- if !private {
564- for _, mode := range parts {
565- switch mode {
566- case 4:
567- s.insertMode = enabled
568- default:
569- // Other ANSI modes (for example LNM) are intentionally ignored
570- // until the screen model implements their observable behavior.
571- continue
572- }
573- }
574- return
575- }
576- for _, mode := range parts {
577- switch mode {
578- case 6:
579- s.originMode = enabled
580- s.homeCursor()
581- case 47, 1047, 1049:
582- if enabled {
583- s.enterAlternateScreen()
584- } else {
585- s.exitAlternateScreen()
586- }
587- case 2026:
588- s.syncUpdateActive = enabled
589- case 2031:
590- s.colorSchemeMode = enabled
591- case 25:
592- s.cursorVisible = enabled
593- case 1000, 1002, 1003:
594- if enabled {
595- s.mouseMode = mode
596- } else if s.mouseMode == mode {
597- s.mouseMode = 0
598- }
599- case 1006:
600- s.mouseSGR = enabled
601- case 2004:
602- s.bracketedPaste = enabled
603- case 1, 1004, 1005:
604- // Trackable terminal modes that do not directly affect the current
605- // cell model yet. Consuming them prevents mode bytes from leaking.
606- continue
607- }
608- }
609-}
610-
611-func (s *Screen) enterAlternateScreen() {
612- if s.alternate == nil {
613- s.alternate = &screenState{
614- frame: cloneFrame(s.Frame),
615- row: s.Row,
616- col: s.Col,
617- style: s.Style,
618- scrollTop: s.scrollTop,
619- scrollBottom: s.scrollBottom,
620- savedCursor: s.savedCursor,
621- originMode: s.originMode,
622- insertMode: s.insertMode,
623- }
624- }
625- s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height)
626- s.Row, s.Col = 0, 0
627- s.Style = renderer.DefaultStyle()
628- s.savedCursor = cursorState{}
629- s.resetScrollRegion()
630- s.fullRedraw()
631-}
632-
633-func (s *Screen) exitAlternateScreen() {
634- if s.alternate == nil {
635- return
636- }
637- state := s.alternate
638- s.Frame = cloneFrame(state.frame)
639- s.Row = clamp(state.row, 0, s.Frame.Height-1)
640- s.Col = clamp(state.col, 0, s.Frame.Width-1)
641- s.Style = state.style
642- s.scrollTop = state.scrollTop
643- s.scrollBottom = state.scrollBottom
644- s.savedCursor = state.savedCursor
645- s.originMode = state.originMode
646- s.insertMode = state.insertMode
647- s.alternate = nil
648- s.fullRedraw()
649-}
650-
651-func resizeFrame(old renderer.Frame, newW, newH, cursorRow int, evict func([]renderer.Cell)) (renderer.Frame, int) {
652- next := renderer.NewFrame(newW, newH)
653- shift := clamp(cursorRow-(newH-1), 0, max(old.Height-newH, 0))
654- if evict != nil {
655- for y := range shift {
656- evict(old.Row(y))
657- }
658- }
659- for dy := range newH {
660- sy := dy + shift
661- if sy >= old.Height {
662- break
663- }
664- copy(next.Row(dy), old.Row(sy))
665- repairFrameRow(next, dy)
666- }
667- return next, shift
668-}
669-
670-func resizedCursor(cur cursorState, shift, width, height int) cursorState {
671- if !cur.saved {
672- return cur
673- }
674- cur.row = clamp(cur.row-shift, 0, height-1)
675- cur.col = clamp(cur.col, 0, width-1)
676- return cur
677-}
678-
679-// cloneFrame produces an independent copy in canonical layout: it copies the
680-// source's logical rows (via Row) into a fresh frame whose offsets are already
681-// canonical, so a rotated source is normalized in the clone.
682-func cloneFrame(frame renderer.Frame) renderer.Frame {
683- out := renderer.NewFrame(frame.Width, frame.Height)
684- for y := range frame.Height {
685- copy(out.Row(y), frame.Row(y))
686- }
687- return out
688-}
689-
690-func clamp(v, lo, hi int) int {
691- if hi < lo {
692- return lo
693- }
694- if v < lo {
695- return lo
696- }
697- if v > hi {
698- return hi
699- }
700- return v
701-}
A pkg/vt/screen_damage.go +74 -0
@@ -0,0 +1,74 @@
1+package vt
2+
3+import "github.com/bnema/vev/pkg/renderer"
4+
5+// maxPendingDamage bounds metadata retained while no render transaction can
6+// acknowledge a screen. Saturation falls back to one exact full redraw.
7+const maxPendingDamage = 1024
8+
9+// DamageCapture is an immutable copy of pending damage at one screen generation.
10+type DamageCapture struct {
11+ Damage []renderer.Damage
12+ Generation uint64
13+}
14+
15+// Damage returns the current damage list. The caller must not modify the
16+// returned slice; ClearDamage must be called after the damage is consumed.
17+func (s *Screen) Damage() []renderer.Damage { return s.damage }
18+func (s *Screen) ClearDamage() {
19+ s.damage = s.damage[:0]
20+ s.damageSaturated = false
21+ s.damageFullRedrawSticky = false
22+}
23+
24+// CaptureDamage snapshots pending damage without consuming it.
25+func (s *Screen) CaptureDamage() DamageCapture {
26+ return DamageCapture{Damage: append([]renderer.Damage(nil), s.damage...), Generation: s.damageGeneration}
27+}
28+
29+// AcknowledgeDamage consumes a capture only if no screen mutation occurred
30+// since it was taken. A stale acknowledgement conservatively requests a full
31+// redraw, ensuring intervening writes remain visible to the next capture.
32+func (s *Screen) AcknowledgeDamage(generation uint64) bool {
33+ if generation != s.damageGeneration {
34+ s.fullRedraw()
35+ return false
36+ }
37+ s.ClearDamage()
38+ return true
39+}
40+
41+func (s *Screen) record(d renderer.Damage) {
42+ s.damageGeneration++
43+ if s.damageSaturated || s.damageFullRedrawSticky {
44+ return
45+ }
46+ if len(s.damage) >= maxPendingDamage {
47+ s.damage = []renderer.Damage{renderer.FullRedraw()}
48+ s.damageSaturated = true
49+ return
50+ }
51+ // Replace FullRedraw with the first concrete damage item.
52+ if len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw {
53+ s.damage[0] = d
54+ return
55+ }
56+ // Coalesce adjacent single-cell text damage on the same line.
57+ if d.Kind == renderer.DamageText && d.Width == 1 && d.Height == 1 && len(s.damage) > 0 {
58+ last := &s.damage[len(s.damage)-1]
59+ if last.Kind == renderer.DamageText && last.Y == d.Y && last.X+last.Width == d.X && last.Height == 1 {
60+ last.Width++
61+ return
62+ }
63+ }
64+ s.damage = append(s.damage, d)
65+}
66+
67+func (s *Screen) fullRedraw() {
68+ s.damageGeneration++
69+ s.damage = []renderer.Damage{renderer.FullRedraw()}
70+ // A structural frame replacement (such as DEC 1049 exit) cannot be
71+ // represented by the following text damage alone. Retain the redraw until
72+ // the render owner acknowledges this screen generation.
73+ s.damageFullRedrawSticky = true
74+}
@@ -0,0 +1,74 @@
1+package vt
2+
3+import "github.com/bnema/vev/pkg/renderer"
4+
5+// maxPendingDamage bounds metadata retained while no render transaction can
6+// acknowledge a screen. Saturation falls back to one exact full redraw.
7+const maxPendingDamage = 1024
8+
9+// DamageCapture is an immutable copy of pending damage at one screen generation.
10+type DamageCapture struct {
11+ Damage []renderer.Damage
12+ Generation uint64
13+}
14+
15+// Damage returns the current damage list. The caller must not modify the
16+// returned slice; ClearDamage must be called after the damage is consumed.
17+func (s *Screen) Damage() []renderer.Damage { return s.damage }
18+func (s *Screen) ClearDamage() {
19+ s.damage = s.damage[:0]
20+ s.damageSaturated = false
21+ s.damageFullRedrawSticky = false
22+}
23+
24+// CaptureDamage snapshots pending damage without consuming it.
25+func (s *Screen) CaptureDamage() DamageCapture {
26+ return DamageCapture{Damage: append([]renderer.Damage(nil), s.damage...), Generation: s.damageGeneration}
27+}
28+
29+// AcknowledgeDamage consumes a capture only if no screen mutation occurred
30+// since it was taken. A stale acknowledgement conservatively requests a full
31+// redraw, ensuring intervening writes remain visible to the next capture.
32+func (s *Screen) AcknowledgeDamage(generation uint64) bool {
33+ if generation != s.damageGeneration {
34+ s.fullRedraw()
35+ return false
36+ }
37+ s.ClearDamage()
38+ return true
39+}
40+
41+func (s *Screen) record(d renderer.Damage) {
42+ s.damageGeneration++
43+ if s.damageSaturated || s.damageFullRedrawSticky {
44+ return
45+ }
46+ if len(s.damage) >= maxPendingDamage {
47+ s.damage = []renderer.Damage{renderer.FullRedraw()}
48+ s.damageSaturated = true
49+ return
50+ }
51+ // Replace FullRedraw with the first concrete damage item.
52+ if len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw {
53+ s.damage[0] = d
54+ return
55+ }
56+ // Coalesce adjacent single-cell text damage on the same line.
57+ if d.Kind == renderer.DamageText && d.Width == 1 && d.Height == 1 && len(s.damage) > 0 {
58+ last := &s.damage[len(s.damage)-1]
59+ if last.Kind == renderer.DamageText && last.Y == d.Y && last.X+last.Width == d.X && last.Height == 1 {
60+ last.Width++
61+ return
62+ }
63+ }
64+ s.damage = append(s.damage, d)
65+}
66+
67+func (s *Screen) fullRedraw() {
68+ s.damageGeneration++
69+ s.damage = []renderer.Damage{renderer.FullRedraw()}
70+ // A structural frame replacement (such as DEC 1049 exit) cannot be
71+ // represented by the following text damage alone. Retain the redraw until
72+ // the render owner acknowledges this screen generation.
73+ s.damageFullRedrawSticky = true
74+}
A pkg/vt/screen_modes.go +227 -0
@@ -0,0 +1,227 @@
1+package vt
2+
3+import "github.com/bnema/vev/pkg/renderer"
4+
5+type screenState struct {
6+ frame renderer.Frame
7+ row int
8+ col int
9+ style renderer.Style
10+ scrollTop int
11+ scrollBottom int
12+ savedCursor cursorState
13+ originMode bool
14+ insertMode bool
15+}
16+
17+// SyncUpdateActive reports whether DEC private mode 2026 (synchronized update)
18+// is currently enabled by the child process.
19+func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive }
20+
21+// BracketedPasteMode reports whether DEC private mode 2004 is currently enabled
22+// by the child process.
23+func (s *Screen) BracketedPasteMode() bool { return s.bracketedPaste }
24+
25+// ColorSchemeMode reports whether DEC private mode 2031 is currently enabled.
26+func (s *Screen) ColorSchemeMode() bool { return s.colorSchemeMode }
27+
28+// SetColorScheme updates the host color scheme and notifies subscribed child apps.
29+func (s *Screen) SetColorScheme(light bool) {
30+ if s.colorSchemeSet && s.colorSchemeLight == light {
31+ return
32+ }
33+ s.colorSchemeSet = true
34+ s.colorSchemeLight = light
35+ if s.colorSchemeMode {
36+ s.respond(s.colorSchemeReport())
37+ }
38+}
39+
40+// ClearColorScheme marks the host color scheme as unknown. Future child color
41+// scheme queries are silent until SetColorScheme supplies a known value again.
42+func (s *Screen) ClearColorScheme() {
43+ s.colorSchemeSet = false
44+}
45+
46+// ForceSyncEnd forcibly leaves DEC private mode 2026 (synchronized update).
47+// Hosts use this as a safety valve if a child enters synchronized update mode
48+// and never sends the matching end sequence.
49+func (s *Screen) ForceSyncEnd() { s.syncUpdateActive = false }
50+
51+// SetDefaultColors sets the terminal default foreground/background colors used
52+// to answer child OSC 10/11 color queries. Passing ok=false makes color queries
53+// silent until known colors are supplied again.
54+func (s *Screen) SetDefaultColors(fg, bg renderer.RGB, ok bool) {
55+ s.defaultFG = fg
56+ s.defaultBG = bg
57+ s.defaultColorsKnown = ok
58+}
59+
60+func (s *Screen) CursorRow() int { return s.Row }
61+func (s *Screen) CursorCol() int { return s.Col }
62+func (s *Screen) CursorVisible() bool {
63+ return s.cursorVisible
64+}
65+func (s *Screen) CursorStyle() (int, bool) { return s.cursorStyle, s.cursorStyleSet }
66+func (s *Screen) MouseMode() (int, bool) { return s.mouseMode, s.mouseSGR }
67+func (s *Screen) AltScreenActive() bool { return s.alternate != nil }
68+
69+// TerminalTitle returns the latest title set by OSC 0 or OSC 2.
70+func (s *Screen) TerminalTitle() string { return s.terminalTitle }
71+
72+func (s *Screen) respond(b []byte) {
73+ if s.OnResponse != nil {
74+ s.OnResponse(b)
75+ }
76+}
77+
78+func (s *Screen) colorSchemeReport() []byte {
79+ if !s.colorSchemeSet {
80+ return nil
81+ }
82+ if s.colorSchemeLight {
83+ return []byte(ColorSchemeReportLight)
84+ }
85+ return []byte(ColorSchemeReportDark)
86+}
87+
88+func (s *Screen) reset() {
89+ s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height)
90+ s.Row, s.Col = 0, 0
91+ s.Style = renderer.DefaultStyle()
92+ s.escapeBuf = s.escapeBuf[:0]
93+ s.savedCursor = cursorState{}
94+ s.alternate = nil
95+ s.originMode = false
96+ s.insertMode = false
97+ s.cursorVisible = true
98+ s.cursorStyle = 0
99+ s.cursorStyleSet = false
100+ s.mouseMode = 0
101+ s.mouseSGR = false
102+ s.bracketedPaste = false
103+ s.colorSchemeMode = false
104+ s.resetScrollRegion()
105+ s.fullRedraw()
106+}
107+
108+func (s *Screen) setScrollRegion(parts []int) {
109+ if s.Frame.Height == 0 {
110+ return
111+ }
112+ top, bottom := 1, s.Frame.Height
113+ if len(parts) > 0 && parts[0] > 0 {
114+ top = parts[0]
115+ }
116+ if len(parts) > 1 && parts[1] > 0 {
117+ bottom = parts[1]
118+ }
119+ if top >= bottom {
120+ s.resetScrollRegion()
121+ } else {
122+ s.scrollTop = clamp(top-1, 0, s.Frame.Height-1)
123+ s.scrollBottom = clamp(bottom-1, 0, s.Frame.Height-1)
124+ if s.scrollTop >= s.scrollBottom {
125+ s.resetScrollRegion()
126+ }
127+ }
128+ s.homeCursor()
129+}
130+
131+func (s *Screen) resetScrollRegion() {
132+ s.scrollTop = 0
133+ if s.Frame.Height > 0 {
134+ s.scrollBottom = s.Frame.Height - 1
135+ } else {
136+ s.scrollBottom = 0
137+ }
138+}
139+
140+func (s *Screen) setMode(private bool, parts []int, enabled bool) {
141+ if !private {
142+ for _, mode := range parts {
143+ switch mode {
144+ case 4:
145+ s.insertMode = enabled
146+ default:
147+ // Other ANSI modes (for example LNM) are intentionally ignored
148+ // until the screen model implements their observable behavior.
149+ continue
150+ }
151+ }
152+ return
153+ }
154+ for _, mode := range parts {
155+ switch mode {
156+ case 6:
157+ s.originMode = enabled
158+ s.homeCursor()
159+ case 47, 1047, 1049:
160+ if enabled {
161+ s.enterAlternateScreen()
162+ } else {
163+ s.exitAlternateScreen()
164+ }
165+ case 2026:
166+ s.syncUpdateActive = enabled
167+ case 2031:
168+ s.colorSchemeMode = enabled
169+ case 25:
170+ s.cursorVisible = enabled
171+ case 1000, 1002, 1003:
172+ if enabled {
173+ s.mouseMode = mode
174+ } else if s.mouseMode == mode {
175+ s.mouseMode = 0
176+ }
177+ case 1006:
178+ s.mouseSGR = enabled
179+ case 2004:
180+ s.bracketedPaste = enabled
181+ case 1, 1004, 1005:
182+ // Trackable terminal modes that do not directly affect the current
183+ // cell model yet. Consuming them prevents mode bytes from leaking.
184+ continue
185+ }
186+ }
187+}
188+
189+func (s *Screen) enterAlternateScreen() {
190+ if s.alternate == nil {
191+ s.alternate = &screenState{
192+ frame: cloneFrame(s.Frame),
193+ row: s.Row,
194+ col: s.Col,
195+ style: s.Style,
196+ scrollTop: s.scrollTop,
197+ scrollBottom: s.scrollBottom,
198+ savedCursor: s.savedCursor,
199+ originMode: s.originMode,
200+ insertMode: s.insertMode,
201+ }
202+ }
203+ s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height)
204+ s.Row, s.Col = 0, 0
205+ s.Style = renderer.DefaultStyle()
206+ s.savedCursor = cursorState{}
207+ s.resetScrollRegion()
208+ s.fullRedraw()
209+}
210+
211+func (s *Screen) exitAlternateScreen() {
212+ if s.alternate == nil {
213+ return
214+ }
215+ state := s.alternate
216+ s.Frame = cloneFrame(state.frame)
217+ s.Row = clamp(state.row, 0, s.Frame.Height-1)
218+ s.Col = clamp(state.col, 0, s.Frame.Width-1)
219+ s.Style = state.style
220+ s.scrollTop = state.scrollTop
221+ s.scrollBottom = state.scrollBottom
222+ s.savedCursor = state.savedCursor
223+ s.originMode = state.originMode
224+ s.insertMode = state.insertMode
225+ s.alternate = nil
226+ s.fullRedraw()
227+}
@@ -0,0 +1,227 @@
1+package vt
2+
3+import "github.com/bnema/vev/pkg/renderer"
4+
5+type screenState struct {
6+ frame renderer.Frame
7+ row int
8+ col int
9+ style renderer.Style
10+ scrollTop int
11+ scrollBottom int
12+ savedCursor cursorState
13+ originMode bool
14+ insertMode bool
15+}
16+
17+// SyncUpdateActive reports whether DEC private mode 2026 (synchronized update)
18+// is currently enabled by the child process.
19+func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive }
20+
21+// BracketedPasteMode reports whether DEC private mode 2004 is currently enabled
22+// by the child process.
23+func (s *Screen) BracketedPasteMode() bool { return s.bracketedPaste }
24+
25+// ColorSchemeMode reports whether DEC private mode 2031 is currently enabled.
26+func (s *Screen) ColorSchemeMode() bool { return s.colorSchemeMode }
27+
28+// SetColorScheme updates the host color scheme and notifies subscribed child apps.
29+func (s *Screen) SetColorScheme(light bool) {
30+ if s.colorSchemeSet && s.colorSchemeLight == light {
31+ return
32+ }
33+ s.colorSchemeSet = true
34+ s.colorSchemeLight = light
35+ if s.colorSchemeMode {
36+ s.respond(s.colorSchemeReport())
37+ }
38+}
39+
40+// ClearColorScheme marks the host color scheme as unknown. Future child color
41+// scheme queries are silent until SetColorScheme supplies a known value again.
42+func (s *Screen) ClearColorScheme() {
43+ s.colorSchemeSet = false
44+}
45+
46+// ForceSyncEnd forcibly leaves DEC private mode 2026 (synchronized update).
47+// Hosts use this as a safety valve if a child enters synchronized update mode
48+// and never sends the matching end sequence.
49+func (s *Screen) ForceSyncEnd() { s.syncUpdateActive = false }
50+
51+// SetDefaultColors sets the terminal default foreground/background colors used
52+// to answer child OSC 10/11 color queries. Passing ok=false makes color queries
53+// silent until known colors are supplied again.
54+func (s *Screen) SetDefaultColors(fg, bg renderer.RGB, ok bool) {
55+ s.defaultFG = fg
56+ s.defaultBG = bg
57+ s.defaultColorsKnown = ok
58+}
59+
60+func (s *Screen) CursorRow() int { return s.Row }
61+func (s *Screen) CursorCol() int { return s.Col }
62+func (s *Screen) CursorVisible() bool {
63+ return s.cursorVisible
64+}
65+func (s *Screen) CursorStyle() (int, bool) { return s.cursorStyle, s.cursorStyleSet }
66+func (s *Screen) MouseMode() (int, bool) { return s.mouseMode, s.mouseSGR }
67+func (s *Screen) AltScreenActive() bool { return s.alternate != nil }
68+
69+// TerminalTitle returns the latest title set by OSC 0 or OSC 2.
70+func (s *Screen) TerminalTitle() string { return s.terminalTitle }
71+
72+func (s *Screen) respond(b []byte) {
73+ if s.OnResponse != nil {
74+ s.OnResponse(b)
75+ }
76+}
77+
78+func (s *Screen) colorSchemeReport() []byte {
79+ if !s.colorSchemeSet {
80+ return nil
81+ }
82+ if s.colorSchemeLight {
83+ return []byte(ColorSchemeReportLight)
84+ }
85+ return []byte(ColorSchemeReportDark)
86+}
87+
88+func (s *Screen) reset() {
89+ s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height)
90+ s.Row, s.Col = 0, 0
91+ s.Style = renderer.DefaultStyle()
92+ s.escapeBuf = s.escapeBuf[:0]
93+ s.savedCursor = cursorState{}
94+ s.alternate = nil
95+ s.originMode = false
96+ s.insertMode = false
97+ s.cursorVisible = true
98+ s.cursorStyle = 0
99+ s.cursorStyleSet = false
100+ s.mouseMode = 0
101+ s.mouseSGR = false
102+ s.bracketedPaste = false
103+ s.colorSchemeMode = false
104+ s.resetScrollRegion()
105+ s.fullRedraw()
106+}
107+
108+func (s *Screen) setScrollRegion(parts []int) {
109+ if s.Frame.Height == 0 {
110+ return
111+ }
112+ top, bottom := 1, s.Frame.Height
113+ if len(parts) > 0 && parts[0] > 0 {
114+ top = parts[0]
115+ }
116+ if len(parts) > 1 && parts[1] > 0 {
117+ bottom = parts[1]
118+ }
119+ if top >= bottom {
120+ s.resetScrollRegion()
121+ } else {
122+ s.scrollTop = clamp(top-1, 0, s.Frame.Height-1)
123+ s.scrollBottom = clamp(bottom-1, 0, s.Frame.Height-1)
124+ if s.scrollTop >= s.scrollBottom {
125+ s.resetScrollRegion()
126+ }
127+ }
128+ s.homeCursor()
129+}
130+
131+func (s *Screen) resetScrollRegion() {
132+ s.scrollTop = 0
133+ if s.Frame.Height > 0 {
134+ s.scrollBottom = s.Frame.Height - 1
135+ } else {
136+ s.scrollBottom = 0
137+ }
138+}
139+
140+func (s *Screen) setMode(private bool, parts []int, enabled bool) {
141+ if !private {
142+ for _, mode := range parts {
143+ switch mode {
144+ case 4:
145+ s.insertMode = enabled
146+ default:
147+ // Other ANSI modes (for example LNM) are intentionally ignored
148+ // until the screen model implements their observable behavior.
149+ continue
150+ }
151+ }
152+ return
153+ }
154+ for _, mode := range parts {
155+ switch mode {
156+ case 6:
157+ s.originMode = enabled
158+ s.homeCursor()
159+ case 47, 1047, 1049:
160+ if enabled {
161+ s.enterAlternateScreen()
162+ } else {
163+ s.exitAlternateScreen()
164+ }
165+ case 2026:
166+ s.syncUpdateActive = enabled
167+ case 2031:
168+ s.colorSchemeMode = enabled
169+ case 25:
170+ s.cursorVisible = enabled
171+ case 1000, 1002, 1003:
172+ if enabled {
173+ s.mouseMode = mode
174+ } else if s.mouseMode == mode {
175+ s.mouseMode = 0
176+ }
177+ case 1006:
178+ s.mouseSGR = enabled
179+ case 2004:
180+ s.bracketedPaste = enabled
181+ case 1, 1004, 1005:
182+ // Trackable terminal modes that do not directly affect the current
183+ // cell model yet. Consuming them prevents mode bytes from leaking.
184+ continue
185+ }
186+ }
187+}
188+
189+func (s *Screen) enterAlternateScreen() {
190+ if s.alternate == nil {
191+ s.alternate = &screenState{
192+ frame: cloneFrame(s.Frame),
193+ row: s.Row,
194+ col: s.Col,
195+ style: s.Style,
196+ scrollTop: s.scrollTop,
197+ scrollBottom: s.scrollBottom,
198+ savedCursor: s.savedCursor,
199+ originMode: s.originMode,
200+ insertMode: s.insertMode,
201+ }
202+ }
203+ s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height)
204+ s.Row, s.Col = 0, 0
205+ s.Style = renderer.DefaultStyle()
206+ s.savedCursor = cursorState{}
207+ s.resetScrollRegion()
208+ s.fullRedraw()
209+}
210+
211+func (s *Screen) exitAlternateScreen() {
212+ if s.alternate == nil {
213+ return
214+ }
215+ state := s.alternate
216+ s.Frame = cloneFrame(state.frame)
217+ s.Row = clamp(state.row, 0, s.Frame.Height-1)
218+ s.Col = clamp(state.col, 0, s.Frame.Width-1)
219+ s.Style = state.style
220+ s.scrollTop = state.scrollTop
221+ s.scrollBottom = state.scrollBottom
222+ s.savedCursor = state.savedCursor
223+ s.originMode = state.originMode
224+ s.insertMode = state.insertMode
225+ s.alternate = nil
226+ s.fullRedraw()
227+}
A pkg/vt/screen_resize.go +92 -0
@@ -0,0 +1,92 @@
1+package vt
2+
3+import "github.com/bnema/vev/pkg/renderer"
4+
5+func (s *Screen) Resize(width, height int) {
6+ if width == s.Frame.Width && height == s.Frame.Height {
7+ return
8+ }
9+ evict := s.recordEvicted
10+
11+ if s.alternate != nil {
12+ var shift int
13+ s.Frame, shift = resizeFrame(s.Frame, width, height, s.Row, nil)
14+ s.Row = clamp(s.Row-shift, 0, height-1)
15+ s.Col = clamp(s.Col, 0, width-1)
16+ s.savedCursor = resizedCursor(s.savedCursor, shift, width, height)
17+ s.alternate.frame, shift = resizeFrame(s.alternate.frame, width, height, s.alternate.row, evict)
18+ s.alternate.row = clamp(s.alternate.row-shift, 0, height-1)
19+ s.alternate.col = clamp(s.alternate.col, 0, width-1)
20+ s.alternate.savedCursor = resizedCursor(s.alternate.savedCursor, shift, width, height)
21+ s.alternate.scrollTop = clamp(s.alternate.scrollTop-shift, 0, height-1)
22+ s.alternate.scrollBottom = clamp(s.alternate.scrollBottom-shift, 0, height-1)
23+ if s.alternate.scrollTop >= s.alternate.scrollBottom {
24+ s.alternate.scrollTop = 0
25+ s.alternate.scrollBottom = max(height-1, 0)
26+ }
27+ } else {
28+ var shift int
29+ s.Frame, shift = resizeFrame(s.Frame, width, height, s.Row, evict)
30+ s.Row = clamp(s.Row-shift, 0, height-1)
31+ s.Col = clamp(s.Col, 0, width-1)
32+ s.savedCursor = resizedCursor(s.savedCursor, shift, width, height)
33+ }
34+
35+ // A resize can split an in-flight escape sequence from the terminal state it
36+ // was meant to mutate; keep the durable child state but discard partial bytes.
37+ s.escapeBuf = s.escapeBuf[:0]
38+ s.resetScrollRegion()
39+ s.fullRedraw()
40+}
41+
42+func resizeFrame(old renderer.Frame, newW, newH, cursorRow int, evict func([]renderer.Cell)) (renderer.Frame, int) {
43+ next := renderer.NewFrame(newW, newH)
44+ shift := clamp(cursorRow-(newH-1), 0, max(old.Height-newH, 0))
45+ if evict != nil {
46+ for y := range shift {
47+ evict(old.Row(y))
48+ }
49+ }
50+ for dy := range newH {
51+ sy := dy + shift
52+ if sy >= old.Height {
53+ break
54+ }
55+ copy(next.Row(dy), old.Row(sy))
56+ repairFrameRow(next, dy)
57+ }
58+ return next, shift
59+}
60+
61+func resizedCursor(cur cursorState, shift, width, height int) cursorState {
62+ if !cur.saved {
63+ return cur
64+ }
65+ cur.row = clamp(cur.row-shift, 0, height-1)
66+ cur.col = clamp(cur.col, 0, width-1)
67+ return cur
68+}
69+
70+// cloneFrame produces an independent copy in canonical layout: it copies the
71+// source's logical rows (via Row) into a fresh frame whose offsets are already
72+// canonical, so a rotated source is normalized in the clone.
73+func cloneFrame(frame renderer.Frame) renderer.Frame {
74+ out := renderer.NewFrame(frame.Width, frame.Height)
75+ for y := range frame.Height {
76+ copy(out.Row(y), frame.Row(y))
77+ }
78+ return out
79+}
80+
81+func clamp(v, lo, hi int) int {
82+ if hi < lo {
83+ return lo
84+ }
85+ if v < lo {
86+ return lo
87+ }
88+ if v > hi {
89+ return hi
90+ }
91+ return v
92+}
@@ -0,0 +1,92 @@
1+package vt
2+
3+import "github.com/bnema/vev/pkg/renderer"
4+
5+func (s *Screen) Resize(width, height int) {
6+ if width == s.Frame.Width && height == s.Frame.Height {
7+ return
8+ }
9+ evict := s.recordEvicted
10+
11+ if s.alternate != nil {
12+ var shift int
13+ s.Frame, shift = resizeFrame(s.Frame, width, height, s.Row, nil)
14+ s.Row = clamp(s.Row-shift, 0, height-1)
15+ s.Col = clamp(s.Col, 0, width-1)
16+ s.savedCursor = resizedCursor(s.savedCursor, shift, width, height)
17+ s.alternate.frame, shift = resizeFrame(s.alternate.frame, width, height, s.alternate.row, evict)
18+ s.alternate.row = clamp(s.alternate.row-shift, 0, height-1)
19+ s.alternate.col = clamp(s.alternate.col, 0, width-1)
20+ s.alternate.savedCursor = resizedCursor(s.alternate.savedCursor, shift, width, height)
21+ s.alternate.scrollTop = clamp(s.alternate.scrollTop-shift, 0, height-1)
22+ s.alternate.scrollBottom = clamp(s.alternate.scrollBottom-shift, 0, height-1)
23+ if s.alternate.scrollTop >= s.alternate.scrollBottom {
24+ s.alternate.scrollTop = 0
25+ s.alternate.scrollBottom = max(height-1, 0)
26+ }
27+ } else {
28+ var shift int
29+ s.Frame, shift = resizeFrame(s.Frame, width, height, s.Row, evict)
30+ s.Row = clamp(s.Row-shift, 0, height-1)
31+ s.Col = clamp(s.Col, 0, width-1)
32+ s.savedCursor = resizedCursor(s.savedCursor, shift, width, height)
33+ }
34+
35+ // A resize can split an in-flight escape sequence from the terminal state it
36+ // was meant to mutate; keep the durable child state but discard partial bytes.
37+ s.escapeBuf = s.escapeBuf[:0]
38+ s.resetScrollRegion()
39+ s.fullRedraw()
40+}
41+
42+func resizeFrame(old renderer.Frame, newW, newH, cursorRow int, evict func([]renderer.Cell)) (renderer.Frame, int) {
43+ next := renderer.NewFrame(newW, newH)
44+ shift := clamp(cursorRow-(newH-1), 0, max(old.Height-newH, 0))
45+ if evict != nil {
46+ for y := range shift {
47+ evict(old.Row(y))
48+ }
49+ }
50+ for dy := range newH {
51+ sy := dy + shift
52+ if sy >= old.Height {
53+ break
54+ }
55+ copy(next.Row(dy), old.Row(sy))
56+ repairFrameRow(next, dy)
57+ }
58+ return next, shift
59+}
60+
61+func resizedCursor(cur cursorState, shift, width, height int) cursorState {
62+ if !cur.saved {
63+ return cur
64+ }
65+ cur.row = clamp(cur.row-shift, 0, height-1)
66+ cur.col = clamp(cur.col, 0, width-1)
67+ return cur
68+}
69+
70+// cloneFrame produces an independent copy in canonical layout: it copies the
71+// source's logical rows (via Row) into a fresh frame whose offsets are already
72+// canonical, so a rotated source is normalized in the clone.
73+func cloneFrame(frame renderer.Frame) renderer.Frame {
74+ out := renderer.NewFrame(frame.Width, frame.Height)
75+ for y := range frame.Height {
76+ copy(out.Row(y), frame.Row(y))
77+ }
78+ return out
79+}
80+
81+func clamp(v, lo, hi int) int {
82+ if hi < lo {
83+ return lo
84+ }
85+ if v < lo {
86+ return lo
87+ }
88+ if v > hi {
89+ return hi
90+ }
91+ return v
92+}
A pkg/vt/screen_scroll.go +119 -0
@@ -0,0 +1,119 @@
1+package vt
2+
3+import "github.com/bnema/vev/pkg/renderer"
4+
5+func (s *Screen) index() {
6+ if s.Frame.Height == 0 {
7+ return
8+ }
9+ if s.Row == s.scrollBottom && s.Row >= s.scrollTop {
10+ s.scrollUpRegion(s.scrollTop, s.scrollBottom, 1)
11+ return
12+ }
13+ if s.Row+1 < s.Frame.Height {
14+ s.Row++
15+ return
16+ }
17+ s.Row = s.Frame.Height - 1
18+}
19+
20+func (s *Screen) nextLine() {
21+ s.Col = 0
22+ s.index()
23+}
24+
25+func (s *Screen) reverseIndex() {
26+ if s.Frame.Height == 0 {
27+ return
28+ }
29+ if s.Row == s.scrollTop && s.Row <= s.scrollBottom {
30+ s.scrollDownRegion(s.scrollTop, s.scrollBottom, 1)
31+ return
32+ }
33+ if s.Row > 0 {
34+ s.Row--
35+ }
36+}
37+
38+// scrollUpBy scrolls the current scroll region up by n lines (CSI S / SU).
39+// The cursor position is preserved.
40+func (s *Screen) scrollUpBy(n int) {
41+ if n <= 0 {
42+ return
43+ }
44+ s.scrollUpRegion(s.scrollTop, s.scrollBottom, n)
45+}
46+
47+func (s *Screen) scrollDownBy(n int) {
48+ if n <= 0 {
49+ return
50+ }
51+ s.scrollDownRegion(s.scrollTop, s.scrollBottom, n)
52+}
53+
54+func (s *Screen) scrollUpRegion(top, bottom, n int) {
55+ if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 {
56+ return
57+ }
58+ top, bottom, ok := s.normalizedRegion(top, bottom)
59+ if !ok {
60+ return
61+ }
62+ w := s.Frame.Width
63+ height := bottom - top + 1
64+ if n > height {
65+ n = height
66+ }
67+ // VT scroll regions always span the full frame width, so we rotate the
68+ // frame's line offsets (recycling and blanking the evicted rows in place)
69+ // instead of copying cells. See renderer.Frame.ScrollUp.
70+ s.emitLineEvicted(top, n)
71+ s.Frame.ScrollUp(top, bottom, n)
72+ s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n})
73+ s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1})
74+}
75+
76+func (s *Screen) emitLineEvicted(top, n int) {
77+ if s.alternate != nil {
78+ return
79+ }
80+ for y := top; y < top+n; y++ {
81+ s.recordEvicted(s.Frame.Row(y))
82+ }
83+}
84+
85+func (s *Screen) recordEvicted(row []renderer.Cell) {
86+ if s.history != nil {
87+ s.history.Append(row)
88+ }
89+ if s.OnLineEvicted != nil {
90+ s.OnLineEvicted(append([]renderer.Cell(nil), row...))
91+ }
92+}
93+
94+func (s *Screen) scrollDownRegion(top, bottom, n int) {
95+ if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 {
96+ return
97+ }
98+ top, bottom, ok := s.normalizedRegion(top, bottom)
99+ if !ok {
100+ return
101+ }
102+ w := s.Frame.Width
103+ height := bottom - top + 1
104+ if n > height {
105+ n = height
106+ }
107+ // Full-width region: rotate line offsets instead of copying cells.
108+ s.Frame.ScrollDown(top, bottom, n)
109+ s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1})
110+}
111+
112+func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) {
113+ if s.Frame.Height == 0 || top > bottom {
114+ return 0, 0, false
115+ }
116+ top = clamp(top, 0, s.Frame.Height-1)
117+ bottom = clamp(bottom, 0, s.Frame.Height-1)
118+ return top, bottom, top <= bottom
119+}
@@ -0,0 +1,119 @@
1+package vt
2+
3+import "github.com/bnema/vev/pkg/renderer"
4+
5+func (s *Screen) index() {
6+ if s.Frame.Height == 0 {
7+ return
8+ }
9+ if s.Row == s.scrollBottom && s.Row >= s.scrollTop {
10+ s.scrollUpRegion(s.scrollTop, s.scrollBottom, 1)
11+ return
12+ }
13+ if s.Row+1 < s.Frame.Height {
14+ s.Row++
15+ return
16+ }
17+ s.Row = s.Frame.Height - 1
18+}
19+
20+func (s *Screen) nextLine() {
21+ s.Col = 0
22+ s.index()
23+}
24+
25+func (s *Screen) reverseIndex() {
26+ if s.Frame.Height == 0 {
27+ return
28+ }
29+ if s.Row == s.scrollTop && s.Row <= s.scrollBottom {
30+ s.scrollDownRegion(s.scrollTop, s.scrollBottom, 1)
31+ return
32+ }
33+ if s.Row > 0 {
34+ s.Row--
35+ }
36+}
37+
38+// scrollUpBy scrolls the current scroll region up by n lines (CSI S / SU).
39+// The cursor position is preserved.
40+func (s *Screen) scrollUpBy(n int) {
41+ if n <= 0 {
42+ return
43+ }
44+ s.scrollUpRegion(s.scrollTop, s.scrollBottom, n)
45+}
46+
47+func (s *Screen) scrollDownBy(n int) {
48+ if n <= 0 {
49+ return
50+ }
51+ s.scrollDownRegion(s.scrollTop, s.scrollBottom, n)
52+}
53+
54+func (s *Screen) scrollUpRegion(top, bottom, n int) {
55+ if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 {
56+ return
57+ }
58+ top, bottom, ok := s.normalizedRegion(top, bottom)
59+ if !ok {
60+ return
61+ }
62+ w := s.Frame.Width
63+ height := bottom - top + 1
64+ if n > height {
65+ n = height
66+ }
67+ // VT scroll regions always span the full frame width, so we rotate the
68+ // frame's line offsets (recycling and blanking the evicted rows in place)
69+ // instead of copying cells. See renderer.Frame.ScrollUp.
70+ s.emitLineEvicted(top, n)
71+ s.Frame.ScrollUp(top, bottom, n)
72+ s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n})
73+ s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1})
74+}
75+
76+func (s *Screen) emitLineEvicted(top, n int) {
77+ if s.alternate != nil {
78+ return
79+ }
80+ for y := top; y < top+n; y++ {
81+ s.recordEvicted(s.Frame.Row(y))
82+ }
83+}
84+
85+func (s *Screen) recordEvicted(row []renderer.Cell) {
86+ if s.history != nil {
87+ s.history.Append(row)
88+ }
89+ if s.OnLineEvicted != nil {
90+ s.OnLineEvicted(append([]renderer.Cell(nil), row...))
91+ }
92+}
93+
94+func (s *Screen) scrollDownRegion(top, bottom, n int) {
95+ if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 {
96+ return
97+ }
98+ top, bottom, ok := s.normalizedRegion(top, bottom)
99+ if !ok {
100+ return
101+ }
102+ w := s.Frame.Width
103+ height := bottom - top + 1
104+ if n > height {
105+ n = height
106+ }
107+ // Full-width region: rotate line offsets instead of copying cells.
108+ s.Frame.ScrollDown(top, bottom, n)
109+ s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1})
110+}
111+
112+func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) {
113+ if s.Frame.Height == 0 || top > bottom {
114+ return 0, 0, false
115+ }
116+ top = clamp(top, 0, s.Frame.Height-1)
117+ bottom = clamp(bottom, 0, s.Frame.Height-1)
118+ return top, bottom, top <= bottom
119+}

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 12

Tags 11

↑↓ moveenter openesc close