feat: add scrollback ring foundation
- commit
- 9548cb54a62cabc621cb6c84cc867abe0514b797
- author
- brice · · 2026-07-02 15:50 UTC
- parent
- 8f5354b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +369 -23
A internal/usecase/copy/scrollback.go +51 -0
| @@ -0,0 +1,51 @@ | |||
| 1 | + | package copy | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/vev/pkg/renderer" | |
| 4 | + | ||
| 5 | + | // Scrollback stores evicted terminal rows in a fixed-size ring buffer. | |
| 6 | + | type Scrollback struct { | |
| 7 | + | rows [][]renderer.Cell | |
| 8 | + | head int | |
| 9 | + | len int | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | // NewScrollback constructs a scrollback ring capped at capacity rows. | |
| 13 | + | func NewScrollback(capacity int) *Scrollback { | |
| 14 | + | if capacity < 0 { | |
| 15 | + | capacity = 0 | |
| 16 | + | } | |
| 17 | + | return &Scrollback{rows: make([][]renderer.Cell, capacity)} | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // Cap returns the maximum number of rows retained. | |
| 21 | + | func (s *Scrollback) Cap() int { return len(s.rows) } | |
| 22 | + | ||
| 23 | + | // Len returns the number of retained rows. | |
| 24 | + | func (s *Scrollback) Len() int { return s.len } | |
| 25 | + | ||
| 26 | + | // Head returns the physical ring slot holding the oldest retained row. | |
| 27 | + | func (s *Scrollback) Head() int { return s.head } | |
| 28 | + | ||
| 29 | + | // Append stores a copy of row, evicting the oldest row when the ring is full. | |
| 30 | + | func (s *Scrollback) Append(row []renderer.Cell) { | |
| 31 | + | if len(s.rows) == 0 { | |
| 32 | + | return | |
| 33 | + | } | |
| 34 | + | cp := append([]renderer.Cell(nil), row...) | |
| 35 | + | if s.len < len(s.rows) { | |
| 36 | + | s.rows[(s.head+s.len)%len(s.rows)] = cp | |
| 37 | + | s.len++ | |
| 38 | + | return | |
| 39 | + | } | |
| 40 | + | s.rows[s.head] = cp | |
| 41 | + | s.head = (s.head + 1) % len(s.rows) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // Row returns a copy of the retained row at logical index i, oldest first. | |
| 45 | + | func (s *Scrollback) Row(i int) []renderer.Cell { | |
| 46 | + | if i < 0 || i >= s.len || len(s.rows) == 0 { | |
| 47 | + | return nil | |
| 48 | + | } | |
| 49 | + | row := s.rows[(s.head+i)%len(s.rows)] | |
| 50 | + | return append([]renderer.Cell(nil), row...) | |
| 51 | + | } | |
| @@ -0,0 +1,51 @@ | |||||
| 1 | + | package copy | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/vev/pkg/renderer" | |||
| 4 | + | ||||
| 5 | + | // Scrollback stores evicted terminal rows in a fixed-size ring buffer. | |||
| 6 | + | type Scrollback struct { | |||
| 7 | + | rows [][]renderer.Cell | |||
| 8 | + | head int | |||
| 9 | + | len int | |||
| 10 | + | } | |||
| 11 | + | ||||
| 12 | + | // NewScrollback constructs a scrollback ring capped at capacity rows. | |||
| 13 | + | func NewScrollback(capacity int) *Scrollback { | |||
| 14 | + | if capacity < 0 { | |||
| 15 | + | capacity = 0 | |||
| 16 | + | } | |||
| 17 | + | return &Scrollback{rows: make([][]renderer.Cell, capacity)} | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // Cap returns the maximum number of rows retained. | |||
| 21 | + | func (s *Scrollback) Cap() int { return len(s.rows) } | |||
| 22 | + | ||||
| 23 | + | // Len returns the number of retained rows. | |||
| 24 | + | func (s *Scrollback) Len() int { return s.len } | |||
| 25 | + | ||||
| 26 | + | // Head returns the physical ring slot holding the oldest retained row. | |||
| 27 | + | func (s *Scrollback) Head() int { return s.head } | |||
| 28 | + | ||||
| 29 | + | // Append stores a copy of row, evicting the oldest row when the ring is full. | |||
| 30 | + | func (s *Scrollback) Append(row []renderer.Cell) { | |||
| 31 | + | if len(s.rows) == 0 { | |||
| 32 | + | return | |||
| 33 | + | } | |||
| 34 | + | cp := append([]renderer.Cell(nil), row...) | |||
| 35 | + | if s.len < len(s.rows) { | |||
| 36 | + | s.rows[(s.head+s.len)%len(s.rows)] = cp | |||
| 37 | + | s.len++ | |||
| 38 | + | return | |||
| 39 | + | } | |||
| 40 | + | s.rows[s.head] = cp | |||
| 41 | + | s.head = (s.head + 1) % len(s.rows) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // Row returns a copy of the retained row at logical index i, oldest first. | |||
| 45 | + | func (s *Scrollback) Row(i int) []renderer.Cell { | |||
| 46 | + | if i < 0 || i >= s.len || len(s.rows) == 0 { | |||
| 47 | + | return nil | |||
| 48 | + | } | |||
| 49 | + | row := s.rows[(s.head+i)%len(s.rows)] | |||
| 50 | + | return append([]renderer.Cell(nil), row...) | |||
| 51 | + | } | |||
A internal/usecase/copy/scrollback_test.go +112 -0
| @@ -0,0 +1,112 @@ | |||
| 1 | + | package copy | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func row(text string) []renderer.Cell { | |
| 10 | + | cells := make([]renderer.Cell, len(text)) | |
| 11 | + | for i, r := range text { | |
| 12 | + | cells[i] = renderer.Cell{Rune: r, Style: renderer.DefaultStyle()} | |
| 13 | + | } | |
| 14 | + | return cells | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func rowText(cells []renderer.Cell) string { | |
| 18 | + | runes := make([]rune, len(cells)) | |
| 19 | + | for i, c := range cells { | |
| 20 | + | runes[i] = c.Rune | |
| 21 | + | } | |
| 22 | + | return string(runes) | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func TestScrollbackRing(t *testing.T) { | |
| 26 | + | tests := []struct { | |
| 27 | + | name string | |
| 28 | + | cap int | |
| 29 | + | append []string | |
| 30 | + | wantHead int | |
| 31 | + | wantLen int | |
| 32 | + | wantRows []string | |
| 33 | + | }{ | |
| 34 | + | { | |
| 35 | + | name: "empty ring drops rows", | |
| 36 | + | cap: 0, | |
| 37 | + | append: []string{"aa", "bb"}, | |
| 38 | + | wantHead: 0, | |
| 39 | + | wantLen: 0, | |
| 40 | + | }, | |
| 41 | + | { | |
| 42 | + | name: "below capacity keeps head at zero", | |
| 43 | + | cap: 3, | |
| 44 | + | append: []string{"aa", "bb"}, | |
| 45 | + | wantHead: 0, | |
| 46 | + | wantLen: 2, | |
| 47 | + | wantRows: []string{"aa", "bb"}, | |
| 48 | + | }, | |
| 49 | + | { | |
| 50 | + | name: "at capacity fills without rotating head", | |
| 51 | + | cap: 3, | |
| 52 | + | append: []string{"aa", "bb", "cc"}, | |
| 53 | + | wantHead: 0, | |
| 54 | + | wantLen: 3, | |
| 55 | + | wantRows: []string{"aa", "bb", "cc"}, | |
| 56 | + | }, | |
| 57 | + | { | |
| 58 | + | name: "over capacity evicts oldest and rotates head", | |
| 59 | + | cap: 3, | |
| 60 | + | append: []string{"aa", "bb", "cc", "dd", "ee"}, | |
| 61 | + | wantHead: 2, | |
| 62 | + | wantLen: 3, | |
| 63 | + | wantRows: []string{"cc", "dd", "ee"}, | |
| 64 | + | }, | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | for _, tt := range tests { | |
| 68 | + | t.Run(tt.name, func(t *testing.T) { | |
| 69 | + | sb := NewScrollback(tt.cap) | |
| 70 | + | for _, r := range tt.append { | |
| 71 | + | sb.Append(row(r)) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | if got := sb.Cap(); got != tt.cap { | |
| 75 | + | t.Fatalf("Cap() = %d, want %d", got, tt.cap) | |
| 76 | + | } | |
| 77 | + | if got := sb.Head(); got != tt.wantHead { | |
| 78 | + | t.Fatalf("Head() = %d, want %d", got, tt.wantHead) | |
| 79 | + | } | |
| 80 | + | if got := sb.Len(); got != tt.wantLen { | |
| 81 | + | t.Fatalf("Len() = %d, want %d", got, tt.wantLen) | |
| 82 | + | } | |
| 83 | + | for i, want := range tt.wantRows { | |
| 84 | + | if got := rowText(sb.Row(i)); got != want { | |
| 85 | + | t.Fatalf("Row(%d) = %q, want %q", i, got, want) | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | }) | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func TestScrollbackCopiesRows(t *testing.T) { | |
| 93 | + | tests := []struct { | |
| 94 | + | name string | |
| 95 | + | }{ | |
| 96 | + | {name: "append copies caller row"}, | |
| 97 | + | } | |
| 98 | + | for _, tt := range tests { | |
| 99 | + | t.Run(tt.name, func(t *testing.T) { | |
| 100 | + | sb := NewScrollback(1) | |
| 101 | + | r := row("ab") | |
| 102 | + | sb.Append(r) | |
| 103 | + | r[0].Rune = 'z' | |
| 104 | + | ||
| 105 | + | got := sb.Row(0) | |
| 106 | + | got[1].Rune = 'y' | |
| 107 | + | if text := rowText(sb.Row(0)); text != "ab" { | |
| 108 | + | t.Fatalf("stored row = %q, want copy unaffected by caller/read mutation", text) | |
| 109 | + | } | |
| 110 | + | }) | |
| 111 | + | } | |
| 112 | + | } | |
| @@ -0,0 +1,112 @@ | |||||
| 1 | + | package copy | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func row(text string) []renderer.Cell { | |||
| 10 | + | cells := make([]renderer.Cell, len(text)) | |||
| 11 | + | for i, r := range text { | |||
| 12 | + | cells[i] = renderer.Cell{Rune: r, Style: renderer.DefaultStyle()} | |||
| 13 | + | } | |||
| 14 | + | return cells | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func rowText(cells []renderer.Cell) string { | |||
| 18 | + | runes := make([]rune, len(cells)) | |||
| 19 | + | for i, c := range cells { | |||
| 20 | + | runes[i] = c.Rune | |||
| 21 | + | } | |||
| 22 | + | return string(runes) | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func TestScrollbackRing(t *testing.T) { | |||
| 26 | + | tests := []struct { | |||
| 27 | + | name string | |||
| 28 | + | cap int | |||
| 29 | + | append []string | |||
| 30 | + | wantHead int | |||
| 31 | + | wantLen int | |||
| 32 | + | wantRows []string | |||
| 33 | + | }{ | |||
| 34 | + | { | |||
| 35 | + | name: "empty ring drops rows", | |||
| 36 | + | cap: 0, | |||
| 37 | + | append: []string{"aa", "bb"}, | |||
| 38 | + | wantHead: 0, | |||
| 39 | + | wantLen: 0, | |||
| 40 | + | }, | |||
| 41 | + | { | |||
| 42 | + | name: "below capacity keeps head at zero", | |||
| 43 | + | cap: 3, | |||
| 44 | + | append: []string{"aa", "bb"}, | |||
| 45 | + | wantHead: 0, | |||
| 46 | + | wantLen: 2, | |||
| 47 | + | wantRows: []string{"aa", "bb"}, | |||
| 48 | + | }, | |||
| 49 | + | { | |||
| 50 | + | name: "at capacity fills without rotating head", | |||
| 51 | + | cap: 3, | |||
| 52 | + | append: []string{"aa", "bb", "cc"}, | |||
| 53 | + | wantHead: 0, | |||
| 54 | + | wantLen: 3, | |||
| 55 | + | wantRows: []string{"aa", "bb", "cc"}, | |||
| 56 | + | }, | |||
| 57 | + | { | |||
| 58 | + | name: "over capacity evicts oldest and rotates head", | |||
| 59 | + | cap: 3, | |||
| 60 | + | append: []string{"aa", "bb", "cc", "dd", "ee"}, | |||
| 61 | + | wantHead: 2, | |||
| 62 | + | wantLen: 3, | |||
| 63 | + | wantRows: []string{"cc", "dd", "ee"}, | |||
| 64 | + | }, | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | for _, tt := range tests { | |||
| 68 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 69 | + | sb := NewScrollback(tt.cap) | |||
| 70 | + | for _, r := range tt.append { | |||
| 71 | + | sb.Append(row(r)) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | if got := sb.Cap(); got != tt.cap { | |||
| 75 | + | t.Fatalf("Cap() = %d, want %d", got, tt.cap) | |||
| 76 | + | } | |||
| 77 | + | if got := sb.Head(); got != tt.wantHead { | |||
| 78 | + | t.Fatalf("Head() = %d, want %d", got, tt.wantHead) | |||
| 79 | + | } | |||
| 80 | + | if got := sb.Len(); got != tt.wantLen { | |||
| 81 | + | t.Fatalf("Len() = %d, want %d", got, tt.wantLen) | |||
| 82 | + | } | |||
| 83 | + | for i, want := range tt.wantRows { | |||
| 84 | + | if got := rowText(sb.Row(i)); got != want { | |||
| 85 | + | t.Fatalf("Row(%d) = %q, want %q", i, got, want) | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | }) | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func TestScrollbackCopiesRows(t *testing.T) { | |||
| 93 | + | tests := []struct { | |||
| 94 | + | name string | |||
| 95 | + | }{ | |||
| 96 | + | {name: "append copies caller row"}, | |||
| 97 | + | } | |||
| 98 | + | for _, tt := range tests { | |||
| 99 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 100 | + | sb := NewScrollback(1) | |||
| 101 | + | r := row("ab") | |||
| 102 | + | sb.Append(r) | |||
| 103 | + | r[0].Rune = 'z' | |||
| 104 | + | ||||
| 105 | + | got := sb.Row(0) | |||
| 106 | + | got[1].Rune = 'y' | |||
| 107 | + | if text := rowText(sb.Row(0)); text != "ab" { | |||
| 108 | + | t.Fatalf("stored row = %q, want copy unaffected by caller/read mutation", text) | |||
| 109 | + | } | |||
| 110 | + | }) | |||
| 111 | + | } | |||
| 112 | + | } | |||
M internal/usecase/daemon/daemon.go +27 -19
| @@ -39,6 +39,7 @@ import ( | |||
| 39 | 39 | ||
| 40 | 40 | "github.com/bnema/vev/internal/domain" | |
| 41 | 41 | "github.com/bnema/vev/internal/ports" | |
| 42 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 42 | 43 | "github.com/bnema/vev/internal/usecase/keys" | |
| 43 | 44 | "github.com/bnema/vev/pkg/renderer" | |
| 44 | 45 | "github.com/bnema/vev/pkg/vt" | |
| @@ -52,6 +53,9 @@ const debounceInterval = 10 * time.Millisecond | |||
| 52 | 53 | // ptyReadBufSize is the PTY reader's read buffer. | |
| 53 | 54 | const ptyReadBufSize = 32 * 1024 | |
| 54 | 55 | ||
| 56 | + | // defaultScrollbackRows is the per-window retained history size. | |
| 57 | + | const defaultScrollbackRows = 10_000 | |
| 58 | + | ||
| 55 | 59 | // detachNotifyTimeout bounds the best-effort Detached notification on the | |
| 56 | 60 | // detach/kill/shutdown paths: if a wedged client (full kernel send buffer) | |
| 57 | 61 | // blocks the write for this long, its transport is force-closed, which fails | |
| @@ -123,13 +127,14 @@ type session struct { | |||
| 123 | 127 | // applies back-pressure by collapsing (a full buffer means "a render is | |
| 124 | 128 | // already pending"), never by blocking the reader. | |
| 125 | 129 | type window struct { | |
| 126 | - | pty ports.PTY | |
| 127 | - | mu sync.Mutex // guards screen and every attached renderer's shadow | |
| 128 | - | screen *vt.Screen | |
| 129 | - | dirty chan struct{} | |
| 130 | - | size domain.Size | |
| 131 | - | ctx context.Context | |
| 132 | - | cancel context.CancelFunc | |
| 130 | + | pty ports.PTY | |
| 131 | + | mu sync.Mutex // guards screen and every attached renderer's shadow | |
| 132 | + | screen *vt.Screen | |
| 133 | + | scrollback *scopy.Scrollback | |
| 134 | + | dirty chan struct{} | |
| 135 | + | size domain.Size | |
| 136 | + | ctx context.Context | |
| 137 | + | cancel context.CancelFunc | |
| 133 | 138 | } | |
| 134 | 139 | ||
| 135 | 140 | // attachedClient is a client currently attached to a session's window. rend is | |
| @@ -501,12 +506,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size | |||
| 501 | 506 | id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) | |
| 502 | 507 | d.nextID++ | |
| 503 | 508 | ||
| 504 | - | win := &window{ | |
| 505 | - | pty: pty, | |
| 506 | - | screen: vt.NewScreen(winSize.Cols, winSize.Rows), | |
| 507 | - | dirty: make(chan struct{}, 1), | |
| 508 | - | size: winSize, | |
| 509 | - | } | |
| 509 | + | win := newWindow(pty, winSize) | |
| 510 | 510 | sctx, cancel := context.WithCancel(d.serveCtx) | |
| 511 | 511 | win.ctx, win.cancel = context.WithCancel(sctx) | |
| 512 | 512 | sess := &session{ | |
| @@ -528,12 +528,7 @@ func (d *Daemon) createWindow(sess *session, sz domain.Size) error { | |||
| 528 | 528 | if err != nil { | |
| 529 | 529 | return fmt.Errorf("daemon: spawning window for session %q: %w", sess.name, err) | |
| 530 | 530 | } | |
| 531 | - | win := &window{ | |
| 532 | - | pty: pty, | |
| 533 | - | screen: vt.NewScreen(winSize.Cols, winSize.Rows), | |
| 534 | - | dirty: make(chan struct{}, 1), | |
| 535 | - | size: winSize, | |
| 536 | - | } | |
| 531 | + | win := newWindow(pty, winSize) | |
| 537 | 532 | win.ctx, win.cancel = context.WithCancel(sess.ctx) | |
| 538 | 533 | sess.mu.Lock() | |
| 539 | 534 | sess.windows = append(sess.windows, win) | |
| @@ -543,6 +538,19 @@ func (d *Daemon) createWindow(sess *session, sz domain.Size) error { | |||
| 543 | 538 | return nil | |
| 544 | 539 | } | |
| 545 | 540 | ||
| 541 | + | func newWindow(pty ports.PTY, sz domain.Size) *window { | |
| 542 | + | sb := scopy.NewScrollback(defaultScrollbackRows) | |
| 543 | + | screen := vt.NewScreen(sz.Cols, sz.Rows) | |
| 544 | + | screen.OnLineEvicted = sb.Append | |
| 545 | + | return &window{ | |
| 546 | + | pty: pty, | |
| 547 | + | screen: screen, | |
| 548 | + | scrollback: sb, | |
| 549 | + | dirty: make(chan struct{}, 1), | |
| 550 | + | size: sz, | |
| 551 | + | } | |
| 552 | + | } | |
| 553 | + | ||
| 546 | 554 | func windowSize(clientSize domain.Size) domain.Size { | |
| 547 | 555 | if !clientSize.Valid() { | |
| 548 | 556 | clientSize = defaultSize | |
| @@ -39,6 +39,7 @@ import ( | |||||
| 39 | 39 | ||||
| 40 | "github.com/bnema/vev/internal/domain" | 40 | "github.com/bnema/vev/internal/domain" | ||
| 41 | "github.com/bnema/vev/internal/ports" | 41 | "github.com/bnema/vev/internal/ports" | ||
| 42 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 42 | "github.com/bnema/vev/internal/usecase/keys" | 43 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 43 | "github.com/bnema/vev/pkg/renderer" | 44 | "github.com/bnema/vev/pkg/renderer" | ||
| 44 | "github.com/bnema/vev/pkg/vt" | 45 | "github.com/bnema/vev/pkg/vt" | ||
| @@ -52,6 +53,9 @@ const debounceInterval = 10 * time.Millisecond | |||||
| 52 | // ptyReadBufSize is the PTY reader's read buffer. | 53 | // ptyReadBufSize is the PTY reader's read buffer. | ||
| 53 | const ptyReadBufSize = 32 * 1024 | 54 | const ptyReadBufSize = 32 * 1024 | ||
| 54 | 55 | ||||
| 56 | + | // defaultScrollbackRows is the per-window retained history size. | |||
| 57 | + | const defaultScrollbackRows = 10_000 | |||
| 58 | + | ||||
| 55 | // detachNotifyTimeout bounds the best-effort Detached notification on the | 59 | // detachNotifyTimeout bounds the best-effort Detached notification on the | ||
| 56 | // detach/kill/shutdown paths: if a wedged client (full kernel send buffer) | 60 | // detach/kill/shutdown paths: if a wedged client (full kernel send buffer) | ||
| 57 | // blocks the write for this long, its transport is force-closed, which fails | 61 | // blocks the write for this long, its transport is force-closed, which fails | ||
| @@ -123,13 +127,14 @@ type session struct { | |||||
| 123 | // applies back-pressure by collapsing (a full buffer means "a render is | 127 | // applies back-pressure by collapsing (a full buffer means "a render is | ||
| 124 | // already pending"), never by blocking the reader. | 128 | // already pending"), never by blocking the reader. | ||
| 125 | type window struct { | 129 | type window struct { | ||
| 126 | - | pty ports.PTY | 130 | + | pty ports.PTY |
| 127 | - | mu sync.Mutex // guards screen and every attached renderer's shadow | 131 | + | mu sync.Mutex // guards screen and every attached renderer's shadow |
| 128 | - | screen *vt.Screen | 132 | + | screen *vt.Screen |
| 129 | - | dirty chan struct{} | 133 | + | scrollback *scopy.Scrollback |
| 130 | - | size domain.Size | 134 | + | dirty chan struct{} |
| 131 | - | ctx context.Context | 135 | + | size domain.Size |
| 132 | - | cancel context.CancelFunc | 136 | + | ctx context.Context |
| 137 | + | cancel context.CancelFunc | |||
| 133 | } | 138 | } | ||
| 134 | 139 | ||||
| 135 | // attachedClient is a client currently attached to a session's window. rend is | 140 | // attachedClient is a client currently attached to a session's window. rend is | ||
| @@ -501,12 +506,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size | |||||
| 501 | id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) | 506 | id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) | ||
| 502 | d.nextID++ | 507 | d.nextID++ | ||
| 503 | 508 | ||||
| 504 | - | win := &window{ | 509 | + | win := newWindow(pty, winSize) |
| 505 | - | pty: pty, | |||
| 506 | - | screen: vt.NewScreen(winSize.Cols, winSize.Rows), | |||
| 507 | - | dirty: make(chan struct{}, 1), | |||
| 508 | - | size: winSize, | |||
| 509 | - | } | |||
| 510 | sctx, cancel := context.WithCancel(d.serveCtx) | 510 | sctx, cancel := context.WithCancel(d.serveCtx) | ||
| 511 | win.ctx, win.cancel = context.WithCancel(sctx) | 511 | win.ctx, win.cancel = context.WithCancel(sctx) | ||
| 512 | sess := &session{ | 512 | sess := &session{ | ||
| @@ -528,12 +528,7 @@ func (d *Daemon) createWindow(sess *session, sz domain.Size) error { | |||||
| 528 | if err != nil { | 528 | if err != nil { | ||
| 529 | return fmt.Errorf("daemon: spawning window for session %q: %w", sess.name, err) | 529 | return fmt.Errorf("daemon: spawning window for session %q: %w", sess.name, err) | ||
| 530 | } | 530 | } | ||
| 531 | - | win := &window{ | 531 | + | win := newWindow(pty, winSize) |
| 532 | - | pty: pty, | |||
| 533 | - | screen: vt.NewScreen(winSize.Cols, winSize.Rows), | |||
| 534 | - | dirty: make(chan struct{}, 1), | |||
| 535 | - | size: winSize, | |||
| 536 | - | } | |||
| 537 | win.ctx, win.cancel = context.WithCancel(sess.ctx) | 532 | win.ctx, win.cancel = context.WithCancel(sess.ctx) | ||
| 538 | sess.mu.Lock() | 533 | sess.mu.Lock() | ||
| 539 | sess.windows = append(sess.windows, win) | 534 | sess.windows = append(sess.windows, win) | ||
| @@ -543,6 +538,19 @@ func (d *Daemon) createWindow(sess *session, sz domain.Size) error { | |||||
| 543 | return nil | 538 | return nil | ||
| 544 | } | 539 | } | ||
| 545 | 540 | ||||
| 541 | + | func newWindow(pty ports.PTY, sz domain.Size) *window { | |||
| 542 | + | sb := scopy.NewScrollback(defaultScrollbackRows) | |||
| 543 | + | screen := vt.NewScreen(sz.Cols, sz.Rows) | |||
| 544 | + | screen.OnLineEvicted = sb.Append | |||
| 545 | + | return &window{ | |||
| 546 | + | pty: pty, | |||
| 547 | + | screen: screen, | |||
| 548 | + | scrollback: sb, | |||
| 549 | + | dirty: make(chan struct{}, 1), | |||
| 550 | + | size: sz, | |||
| 551 | + | } | |||
| 552 | + | } | |||
| 553 | + | ||||
| 546 | func windowSize(clientSize domain.Size) domain.Size { | 554 | func windowSize(clientSize domain.Size) domain.Size { | ||
| 547 | if !clientSize.Valid() { | 555 | if !clientSize.Valid() { | ||
| 548 | clientSize = defaultSize | 556 | clientSize = defaultSize | ||
A internal/usecase/daemon/scrollback_test.go +49 -0
| @@ -0,0 +1,49 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestNewWindowInitializesScrollback(t *testing.T) { | |
| 10 | + | tests := []struct { | |
| 11 | + | name string | |
| 12 | + | write []byte | |
| 13 | + | wantLen int | |
| 14 | + | wantRow string | |
| 15 | + | }{ | |
| 16 | + | { | |
| 17 | + | name: "window owns scrollback wired to vt eviction callback", | |
| 18 | + | write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"), | |
| 19 | + | wantLen: 1, | |
| 20 | + | wantRow: "AAAA", | |
| 21 | + | }, | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | for _, tt := range tests { | |
| 25 | + | t.Run(tt.name, func(t *testing.T) { | |
| 26 | + | win := newWindow(nil, domain.Size{Cols: 4, Rows: 3}) | |
| 27 | + | if win.scrollback == nil { | |
| 28 | + | t.Fatal("scrollback is nil") | |
| 29 | + | } | |
| 30 | + | if win.scrollback.Cap() != defaultScrollbackRows { | |
| 31 | + | t.Fatalf("scrollback cap = %d, want %d", win.scrollback.Cap(), defaultScrollbackRows) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | win.screen.Write(tt.write) | |
| 35 | + | ||
| 36 | + | if got := win.scrollback.Len(); got != tt.wantLen { | |
| 37 | + | t.Fatalf("scrollback len = %d, want %d", got, tt.wantLen) | |
| 38 | + | } | |
| 39 | + | row := win.scrollback.Row(0) | |
| 40 | + | runes := make([]rune, len(row)) | |
| 41 | + | for i, c := range row { | |
| 42 | + | runes[i] = c.Rune | |
| 43 | + | } | |
| 44 | + | if got := string(runes); got != tt.wantRow { | |
| 45 | + | t.Fatalf("scrollback row = %q, want %q", got, tt.wantRow) | |
| 46 | + | } | |
| 47 | + | }) | |
| 48 | + | } | |
| 49 | + | } | |
| @@ -0,0 +1,49 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestNewWindowInitializesScrollback(t *testing.T) { | |||
| 10 | + | tests := []struct { | |||
| 11 | + | name string | |||
| 12 | + | write []byte | |||
| 13 | + | wantLen int | |||
| 14 | + | wantRow string | |||
| 15 | + | }{ | |||
| 16 | + | { | |||
| 17 | + | name: "window owns scrollback wired to vt eviction callback", | |||
| 18 | + | write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"), | |||
| 19 | + | wantLen: 1, | |||
| 20 | + | wantRow: "AAAA", | |||
| 21 | + | }, | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | for _, tt := range tests { | |||
| 25 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 26 | + | win := newWindow(nil, domain.Size{Cols: 4, Rows: 3}) | |||
| 27 | + | if win.scrollback == nil { | |||
| 28 | + | t.Fatal("scrollback is nil") | |||
| 29 | + | } | |||
| 30 | + | if win.scrollback.Cap() != defaultScrollbackRows { | |||
| 31 | + | t.Fatalf("scrollback cap = %d, want %d", win.scrollback.Cap(), defaultScrollbackRows) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | win.screen.Write(tt.write) | |||
| 35 | + | ||||
| 36 | + | if got := win.scrollback.Len(); got != tt.wantLen { | |||
| 37 | + | t.Fatalf("scrollback len = %d, want %d", got, tt.wantLen) | |||
| 38 | + | } | |||
| 39 | + | row := win.scrollback.Row(0) | |||
| 40 | + | runes := make([]rune, len(row)) | |||
| 41 | + | for i, c := range row { | |||
| 42 | + | runes[i] = c.Rune | |||
| 43 | + | } | |||
| 44 | + | if got := string(runes); got != tt.wantRow { | |||
| 45 | + | t.Fatalf("scrollback row = %q, want %q", got, tt.wantRow) | |||
| 46 | + | } | |||
| 47 | + | }) | |||
| 48 | + | } | |||
| 49 | + | } | |||
M pkg/vt/screen.go +18 -4
| @@ -29,10 +29,14 @@ type screenState struct { | |||
| 29 | 29 | const maxEscapeBufferLen = 4096 | |
| 30 | 30 | ||
| 31 | 31 | type Screen struct { | |
| 32 | - | Frame renderer.Frame | |
| 33 | - | Row int | |
| 34 | - | Col int | |
| 35 | - | Style renderer.Style | |
| 32 | + | Frame renderer.Frame | |
| 33 | + | Row int | |
| 34 | + | Col int | |
| 35 | + | Style renderer.Style | |
| 36 | + | // OnLineEvicted is called just before a full-width upward scroll recycles | |
| 37 | + | // and blanks rows. The callback receives a stable copy of each evicted row. | |
| 38 | + | OnLineEvicted func([]renderer.Cell) | |
| 39 | + | ||
| 36 | 40 | damage []renderer.Damage | |
| 37 | 41 | escapeBuf []byte | |
| 38 | 42 | ||
| @@ -463,11 +467,21 @@ func (s *Screen) scrollUpRegion(top, bottom, n int) { | |||
| 463 | 467 | // VT scroll regions always span the full frame width, so we rotate the | |
| 464 | 468 | // frame's line offsets (recycling and blanking the evicted rows in place) | |
| 465 | 469 | // instead of copying cells. See renderer.Frame.ScrollUp. | |
| 470 | + | s.emitLineEvicted(top, n) | |
| 466 | 471 | s.Frame.ScrollUp(top, bottom, n) | |
| 467 | 472 | s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n}) | |
| 468 | 473 | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1}) | |
| 469 | 474 | } | |
| 470 | 475 | ||
| 476 | + | func (s *Screen) emitLineEvicted(top, n int) { | |
| 477 | + | if s.OnLineEvicted == nil || s.alternate != nil { | |
| 478 | + | return | |
| 479 | + | } | |
| 480 | + | for y := top; y < top+n; y++ { | |
| 481 | + | s.OnLineEvicted(append([]renderer.Cell(nil), s.Frame.Row(y)...)) | |
| 482 | + | } | |
| 483 | + | } | |
| 484 | + | ||
| 471 | 485 | func (s *Screen) scrollDownRegion(top, bottom, n int) { | |
| 472 | 486 | if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 { | |
| 473 | 487 | return | |
| @@ -29,10 +29,14 @@ type screenState struct { | |||||
| 29 | const maxEscapeBufferLen = 4096 | 29 | const maxEscapeBufferLen = 4096 | ||
| 30 | 30 | ||||
| 31 | type Screen struct { | 31 | type Screen struct { | ||
| 32 | - | Frame renderer.Frame | 32 | + | Frame renderer.Frame |
| 33 | - | Row int | 33 | + | Row int |
| 34 | - | Col int | 34 | + | Col int |
| 35 | - | Style renderer.Style | 35 | + | Style renderer.Style |
| 36 | + | // OnLineEvicted is called just before a full-width upward scroll recycles | |||
| 37 | + | // and blanks rows. The callback receives a stable copy of each evicted row. | |||
| 38 | + | OnLineEvicted func([]renderer.Cell) | |||
| 39 | + | ||||
| 36 | damage []renderer.Damage | 40 | damage []renderer.Damage | ||
| 37 | escapeBuf []byte | 41 | escapeBuf []byte | ||
| 38 | 42 | ||||
| @@ -463,11 +467,21 @@ func (s *Screen) scrollUpRegion(top, bottom, n int) { | |||||
| 463 | // VT scroll regions always span the full frame width, so we rotate the | 467 | // VT scroll regions always span the full frame width, so we rotate the | ||
| 464 | // frame's line offsets (recycling and blanking the evicted rows in place) | 468 | // frame's line offsets (recycling and blanking the evicted rows in place) | ||
| 465 | // instead of copying cells. See renderer.Frame.ScrollUp. | 469 | // instead of copying cells. See renderer.Frame.ScrollUp. | ||
| 470 | + | s.emitLineEvicted(top, n) | |||
| 466 | s.Frame.ScrollUp(top, bottom, n) | 471 | s.Frame.ScrollUp(top, bottom, n) | ||
| 467 | s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n}) | 472 | s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n}) | ||
| 468 | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1}) | 473 | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1}) | ||
| 469 | } | 474 | } | ||
| 470 | 475 | ||||
| 476 | + | func (s *Screen) emitLineEvicted(top, n int) { | |||
| 477 | + | if s.OnLineEvicted == nil || s.alternate != nil { | |||
| 478 | + | return | |||
| 479 | + | } | |||
| 480 | + | for y := top; y < top+n; y++ { | |||
| 481 | + | s.OnLineEvicted(append([]renderer.Cell(nil), s.Frame.Row(y)...)) | |||
| 482 | + | } | |||
| 483 | + | } | |||
| 484 | + | ||||
| 471 | func (s *Screen) scrollDownRegion(top, bottom, n int) { | 485 | func (s *Screen) scrollDownRegion(top, bottom, n int) { | ||
| 472 | if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 { | 486 | if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 { | ||
| 473 | return | 487 | return | ||
A pkg/vt/scrollback_test.go +112 -0
| @@ -0,0 +1,112 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func evictedTexts(rows [][]renderer.Cell) []string { | |
| 10 | + | out := make([]string, len(rows)) | |
| 11 | + | for i, row := range rows { | |
| 12 | + | runes := make([]rune, len(row)) | |
| 13 | + | for x, c := range row { | |
| 14 | + | runes[x] = c.Rune | |
| 15 | + | } | |
| 16 | + | out[i] = string(runes) | |
| 17 | + | } | |
| 18 | + | return out | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func TestOnLineEvicted(t *testing.T) { | |
| 22 | + | tests := []struct { | |
| 23 | + | name string | |
| 24 | + | write []byte | |
| 25 | + | wantRows []string | |
| 26 | + | wantFrame []string | |
| 27 | + | }{ | |
| 28 | + | { | |
| 29 | + | name: "newline scroll evicts top row", | |
| 30 | + | write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"), | |
| 31 | + | wantRows: []string{"AAAA"}, | |
| 32 | + | wantFrame: []string{"BBBB", "CCCC", " "}, | |
| 33 | + | }, | |
| 34 | + | { | |
| 35 | + | name: "CSI S count evicts each top row in order", | |
| 36 | + | write: []byte("AAAA\r\nBBBB\r\nCCCC\x1b[2S"), | |
| 37 | + | wantRows: []string{"AAAA", "BBBB"}, | |
| 38 | + | wantFrame: []string{"CCCC", " ", " "}, | |
| 39 | + | }, | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | for _, tt := range tests { | |
| 43 | + | t.Run(tt.name, func(t *testing.T) { | |
| 44 | + | s := NewScreen(4, 3) | |
| 45 | + | var evicted [][]renderer.Cell | |
| 46 | + | s.OnLineEvicted = func(row []renderer.Cell) { | |
| 47 | + | evicted = append(evicted, append([]renderer.Cell(nil), row...)) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | s.Write(tt.write) | |
| 51 | + | ||
| 52 | + | if got := evictedTexts(evicted); !equalStrings(got, tt.wantRows) { | |
| 53 | + | t.Fatalf("evicted rows = %#v, want %#v", got, tt.wantRows) | |
| 54 | + | } | |
| 55 | + | for y, want := range tt.wantFrame { | |
| 56 | + | if got := lineText(s, y); got != want { | |
| 57 | + | t.Fatalf("line %d = %q, want %q", y, got, want) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | }) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func TestOnLineEvictedAltScreenAndRotation(t *testing.T) { | |
| 65 | + | tests := []struct { | |
| 66 | + | name string | |
| 67 | + | write []byte | |
| 68 | + | wantRows []string | |
| 69 | + | }{ | |
| 70 | + | { | |
| 71 | + | name: "alternate screen scroll does not evict", | |
| 72 | + | write: []byte("main\x1b[?1049hAAAA\r\nBBBB\r\nCCCC\r\nDDDD\x1b[?1049lEEEE\r\n"), | |
| 73 | + | wantRows: nil, | |
| 74 | + | }, | |
| 75 | + | { | |
| 76 | + | name: "evicted row follows logical top after prior lineOffset rotation", | |
| 77 | + | write: []byte("AAAA\r\nBBBB\r\nCCCC\r\nDDDD\r\n"), | |
| 78 | + | wantRows: []string{"AAAA", "BBBB"}, | |
| 79 | + | }, | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | for _, tt := range tests { | |
| 83 | + | t.Run(tt.name, func(t *testing.T) { | |
| 84 | + | s := NewScreen(4, 3) | |
| 85 | + | var evicted [][]renderer.Cell | |
| 86 | + | s.OnLineEvicted = func(row []renderer.Cell) { | |
| 87 | + | evicted = append(evicted, append([]renderer.Cell(nil), row...)) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | s.Write(tt.write) | |
| 91 | + | ||
| 92 | + | if got := evictedTexts(evicted); !equalStrings(got, tt.wantRows) { | |
| 93 | + | t.Fatalf("evicted rows = %#v, want %#v", got, tt.wantRows) | |
| 94 | + | } | |
| 95 | + | if err := s.Frame.CheckInvariants(); err != nil { | |
| 96 | + | t.Fatalf("frame invariants after scrollback callback: %v", err) | |
| 97 | + | } | |
| 98 | + | }) | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func equalStrings(a, b []string) bool { | |
| 103 | + | if len(a) != len(b) { | |
| 104 | + | return false | |
| 105 | + | } | |
| 106 | + | for i := range a { | |
| 107 | + | if a[i] != b[i] { | |
| 108 | + | return false | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | return true | |
| 112 | + | } | |
| @@ -0,0 +1,112 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func evictedTexts(rows [][]renderer.Cell) []string { | |||
| 10 | + | out := make([]string, len(rows)) | |||
| 11 | + | for i, row := range rows { | |||
| 12 | + | runes := make([]rune, len(row)) | |||
| 13 | + | for x, c := range row { | |||
| 14 | + | runes[x] = c.Rune | |||
| 15 | + | } | |||
| 16 | + | out[i] = string(runes) | |||
| 17 | + | } | |||
| 18 | + | return out | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func TestOnLineEvicted(t *testing.T) { | |||
| 22 | + | tests := []struct { | |||
| 23 | + | name string | |||
| 24 | + | write []byte | |||
| 25 | + | wantRows []string | |||
| 26 | + | wantFrame []string | |||
| 27 | + | }{ | |||
| 28 | + | { | |||
| 29 | + | name: "newline scroll evicts top row", | |||
| 30 | + | write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"), | |||
| 31 | + | wantRows: []string{"AAAA"}, | |||
| 32 | + | wantFrame: []string{"BBBB", "CCCC", " "}, | |||
| 33 | + | }, | |||
| 34 | + | { | |||
| 35 | + | name: "CSI S count evicts each top row in order", | |||
| 36 | + | write: []byte("AAAA\r\nBBBB\r\nCCCC\x1b[2S"), | |||
| 37 | + | wantRows: []string{"AAAA", "BBBB"}, | |||
| 38 | + | wantFrame: []string{"CCCC", " ", " "}, | |||
| 39 | + | }, | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | for _, tt := range tests { | |||
| 43 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 44 | + | s := NewScreen(4, 3) | |||
| 45 | + | var evicted [][]renderer.Cell | |||
| 46 | + | s.OnLineEvicted = func(row []renderer.Cell) { | |||
| 47 | + | evicted = append(evicted, append([]renderer.Cell(nil), row...)) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | s.Write(tt.write) | |||
| 51 | + | ||||
| 52 | + | if got := evictedTexts(evicted); !equalStrings(got, tt.wantRows) { | |||
| 53 | + | t.Fatalf("evicted rows = %#v, want %#v", got, tt.wantRows) | |||
| 54 | + | } | |||
| 55 | + | for y, want := range tt.wantFrame { | |||
| 56 | + | if got := lineText(s, y); got != want { | |||
| 57 | + | t.Fatalf("line %d = %q, want %q", y, got, want) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | }) | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func TestOnLineEvictedAltScreenAndRotation(t *testing.T) { | |||
| 65 | + | tests := []struct { | |||
| 66 | + | name string | |||
| 67 | + | write []byte | |||
| 68 | + | wantRows []string | |||
| 69 | + | }{ | |||
| 70 | + | { | |||
| 71 | + | name: "alternate screen scroll does not evict", | |||
| 72 | + | write: []byte("main\x1b[?1049hAAAA\r\nBBBB\r\nCCCC\r\nDDDD\x1b[?1049lEEEE\r\n"), | |||
| 73 | + | wantRows: nil, | |||
| 74 | + | }, | |||
| 75 | + | { | |||
| 76 | + | name: "evicted row follows logical top after prior lineOffset rotation", | |||
| 77 | + | write: []byte("AAAA\r\nBBBB\r\nCCCC\r\nDDDD\r\n"), | |||
| 78 | + | wantRows: []string{"AAAA", "BBBB"}, | |||
| 79 | + | }, | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | for _, tt := range tests { | |||
| 83 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 84 | + | s := NewScreen(4, 3) | |||
| 85 | + | var evicted [][]renderer.Cell | |||
| 86 | + | s.OnLineEvicted = func(row []renderer.Cell) { | |||
| 87 | + | evicted = append(evicted, append([]renderer.Cell(nil), row...)) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | s.Write(tt.write) | |||
| 91 | + | ||||
| 92 | + | if got := evictedTexts(evicted); !equalStrings(got, tt.wantRows) { | |||
| 93 | + | t.Fatalf("evicted rows = %#v, want %#v", got, tt.wantRows) | |||
| 94 | + | } | |||
| 95 | + | if err := s.Frame.CheckInvariants(); err != nil { | |||
| 96 | + | t.Fatalf("frame invariants after scrollback callback: %v", err) | |||
| 97 | + | } | |||
| 98 | + | }) | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func equalStrings(a, b []string) bool { | |||
| 103 | + | if len(a) != len(b) { | |||
| 104 | + | return false | |||
| 105 | + | } | |||
| 106 | + | for i := range a { | |||
| 107 | + | if a[i] != b[i] { | |||
| 108 | + | return false | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | return true | |||
| 112 | + | } | |||