perf(copy,streamframe): reuse copy-mode rows and drop the receive copy (#311)
Copy-mode row rendering borrows a pooled row instead of allocating one per call, and the status line writes cells directly instead of concatenating strings. streamframe.Recv reads the length prefix into a Framer field and the body into its final buffer, removing the reused buffer plus copy. The status line now advances one cell per rune; it previously indexed by byte offset, which left gaps after non-ASCII search queries. Copy scroll: -10..-14% B/op, 176 -> 166 allocs/op. IPC receive: 2 -> 1 allocs/op; typed round trip: 90 -> 86 allocs/op.
- commit
- d0781e62dc2f1cf9ace4df70355545ba885775a0
- author
- bnema · · 2026-10-04 16:23 UTC
- committer
- GitHub · · 2026-10-04 16:23 UTC
- parent
- 5ac8017
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
3 files · +110 -40
M internal/adapters/streamframe/streamframe.go +9 -14
| @@ -74,8 +74,10 @@ type Framer struct { | |||
| 74 | 74 | queueBytes uint64 | |
| 75 | 75 | ||
| 76 | 76 | // readMu serializes Recv so concurrent receivers each get one | |
| 77 | - | // complete envelope instead of racing on the reused read buffer. | |
| 77 | + | // complete envelope instead of interleaving header and body reads. | |
| 78 | 78 | readMu sync.Mutex | |
| 79 | + | // header is the length-prefix buffer, guarded by readMu. | |
| 80 | + | header [4]byte | |
| 79 | 81 | ||
| 80 | 82 | egressMu sync.Mutex | |
| 81 | 83 | egressClosing bool | |
| @@ -87,8 +89,6 @@ type Framer struct { | |||
| 87 | 89 | writerDone chan struct{} | |
| 88 | 90 | closeOnce sync.Once | |
| 89 | 91 | closeErr error | |
| 90 | - | ||
| 91 | - | readBuf []byte | |
| 92 | 92 | } | |
| 93 | 93 | ||
| 94 | 94 | type sendRequest struct { | |
| @@ -196,28 +196,23 @@ func (f *Framer) negotiatedBound() uint64 { | |||
| 196 | 196 | ||
| 197 | 197 | // recvLocked reads and validates one frame under readMu. The length prefix is | |
| 198 | 198 | // validated against bound before any body buffer exists, and the returned slice | |
| 199 | - | // is a fresh copy the caller owns. | |
| 199 | + | // is freshly allocated and owned by the caller. | |
| 200 | 200 | func (f *Framer) recvLocked(bound uint64) ([]byte, error) { | |
| 201 | - | var header [4]byte | |
| 202 | - | if _, err := io.ReadFull(f.r, header[:]); err != nil { | |
| 201 | + | header := f.header[:] | |
| 202 | + | if _, err := io.ReadFull(f.r, header); err != nil { | |
| 203 | 203 | if f.isClosed() { | |
| 204 | 204 | return nil, io.EOF | |
| 205 | 205 | } | |
| 206 | 206 | return nil, err | |
| 207 | 207 | } | |
| 208 | - | length := binary.BigEndian.Uint32(header[:]) | |
| 208 | + | length := binary.BigEndian.Uint32(header) | |
| 209 | 209 | if length == 0 { | |
| 210 | 210 | return nil, ErrZeroLength | |
| 211 | 211 | } | |
| 212 | 212 | if uint64(length) > bound { | |
| 213 | 213 | return nil, ErrTooLarge | |
| 214 | 214 | } | |
| 215 | - | if cap(f.readBuf) < int(length) { | |
| 216 | - | f.readBuf = make([]byte, length) | |
| 217 | - | } else { | |
| 218 | - | f.readBuf = f.readBuf[:length] | |
| 219 | - | } | |
| 220 | - | payload := f.readBuf | |
| 215 | + | payload := make([]byte, length) | |
| 221 | 216 | if _, err := io.ReadFull(f.r, payload); err != nil { | |
| 222 | 217 | // Best-effort erase: a truncated frame may hold a partial secret | |
| 223 | 218 | // (for example a bounded bootstrap auth record). | |
| @@ -227,7 +222,7 @@ func (f *Framer) recvLocked(bound uint64) ([]byte, error) { | |||
| 227 | 222 | } | |
| 228 | 223 | return nil, err | |
| 229 | 224 | } | |
| 230 | - | return append([]byte(nil), payload...), nil | |
| 225 | + | return payload, nil | |
| 231 | 226 | } | |
| 232 | 227 | ||
| 233 | 228 | // Close unblocks in-flight Send and Recv, fails queued payloads, waits for | |
| @@ -74,8 +74,10 @@ type Framer struct { | |||||
| 74 | queueBytes uint64 | 74 | queueBytes uint64 | ||
| 75 | 75 | ||||
| 76 | // readMu serializes Recv so concurrent receivers each get one | 76 | // readMu serializes Recv so concurrent receivers each get one | ||
| 77 | - | // complete envelope instead of racing on the reused read buffer. | 77 | + | // complete envelope instead of interleaving header and body reads. |
| 78 | readMu sync.Mutex | 78 | readMu sync.Mutex | ||
| 79 | + | // header is the length-prefix buffer, guarded by readMu. | |||
| 80 | + | header [4]byte | |||
| 79 | 81 | ||||
| 80 | egressMu sync.Mutex | 82 | egressMu sync.Mutex | ||
| 81 | egressClosing bool | 83 | egressClosing bool | ||
| @@ -87,8 +89,6 @@ type Framer struct { | |||||
| 87 | writerDone chan struct{} | 89 | writerDone chan struct{} | ||
| 88 | closeOnce sync.Once | 90 | closeOnce sync.Once | ||
| 89 | closeErr error | 91 | closeErr error | ||
| 90 | - | ||||
| 91 | - | readBuf []byte | |||
| 92 | } | 92 | } | ||
| 93 | 93 | ||||
| 94 | type sendRequest struct { | 94 | type sendRequest struct { | ||
| @@ -196,28 +196,23 @@ func (f *Framer) negotiatedBound() uint64 { | |||||
| 196 | 196 | ||||
| 197 | // recvLocked reads and validates one frame under readMu. The length prefix is | 197 | // recvLocked reads and validates one frame under readMu. The length prefix is | ||
| 198 | // validated against bound before any body buffer exists, and the returned slice | 198 | // validated against bound before any body buffer exists, and the returned slice | ||
| 199 | - | // is a fresh copy the caller owns. | 199 | + | // is freshly allocated and owned by the caller. |
| 200 | func (f *Framer) recvLocked(bound uint64) ([]byte, error) { | 200 | func (f *Framer) recvLocked(bound uint64) ([]byte, error) { | ||
| 201 | - | var header [4]byte | 201 | + | header := f.header[:] |
| 202 | - | if _, err := io.ReadFull(f.r, header[:]); err != nil { | 202 | + | if _, err := io.ReadFull(f.r, header); err != nil { |
| 203 | if f.isClosed() { | 203 | if f.isClosed() { | ||
| 204 | return nil, io.EOF | 204 | return nil, io.EOF | ||
| 205 | } | 205 | } | ||
| 206 | return nil, err | 206 | return nil, err | ||
| 207 | } | 207 | } | ||
| 208 | - | length := binary.BigEndian.Uint32(header[:]) | 208 | + | length := binary.BigEndian.Uint32(header) |
| 209 | if length == 0 { | 209 | if length == 0 { | ||
| 210 | return nil, ErrZeroLength | 210 | return nil, ErrZeroLength | ||
| 211 | } | 211 | } | ||
| 212 | if uint64(length) > bound { | 212 | if uint64(length) > bound { | ||
| 213 | return nil, ErrTooLarge | 213 | return nil, ErrTooLarge | ||
| 214 | } | 214 | } | ||
| 215 | - | if cap(f.readBuf) < int(length) { | 215 | + | payload := make([]byte, length) |
| 216 | - | f.readBuf = make([]byte, length) | |||
| 217 | - | } else { | |||
| 218 | - | f.readBuf = f.readBuf[:length] | |||
| 219 | - | } | |||
| 220 | - | payload := f.readBuf | |||
| 221 | if _, err := io.ReadFull(f.r, payload); err != nil { | 216 | if _, err := io.ReadFull(f.r, payload); err != nil { | ||
| 222 | // Best-effort erase: a truncated frame may hold a partial secret | 217 | // Best-effort erase: a truncated frame may hold a partial secret | ||
| 223 | // (for example a bounded bootstrap auth record). | 218 | // (for example a bounded bootstrap auth record). | ||
| @@ -227,7 +222,7 @@ func (f *Framer) recvLocked(bound uint64) ([]byte, error) { | |||||
| 227 | } | 222 | } | ||
| 228 | return nil, err | 223 | return nil, err | ||
| 229 | } | 224 | } | ||
| 230 | - | return append([]byte(nil), payload...), nil | 225 | + | return payload, nil |
| 231 | } | 226 | } | ||
| 232 | 227 | ||||
| 233 | // Close unblocks in-flight Send and Recv, fails queued payloads, waits for | 228 | // Close unblocks in-flight Send and Recv, fails queued payloads, waits for | ||
M internal/usecase/copy/mode.go +48 -26
| @@ -4,7 +4,9 @@ import ( | |||
| 4 | 4 | "encoding/base64" | |
| 5 | 5 | "math" | |
| 6 | 6 | "slices" | |
| 7 | + | "strconv" | |
| 7 | 8 | "strings" | |
| 9 | + | "sync" | |
| 8 | 10 | "unicode" | |
| 9 | 11 | ||
| 10 | 12 | vt "github.com/bnema/vev-vt" | |
| @@ -247,6 +249,10 @@ type Mode struct { | |||
| 247 | 249 | SearchIndex int | |
| 248 | 250 | } | |
| 249 | 251 | ||
| 252 | + | // rowPool recycles the semantic rows lent to RenderRowsRange callbacks. | |
| 253 | + | // Renderers snapshot Mode by value, so the buffer cannot live on Mode. | |
| 254 | + | var rowPool sync.Pool | |
| 255 | + | ||
| 250 | 256 | func NewMode(doc *Document) *Mode { | |
| 251 | 257 | m := &Mode{document: doc, SearchIndex: -1} | |
| 252 | 258 | if doc != nil && doc.Len() > 0 { | |
| @@ -574,7 +580,13 @@ func (m *Mode) RenderRowsRange(start, end int, paint func(int, []renderer.Cell), | |||
| 574 | 580 | selectionBounds, hasSelectionBounds := m.selection.bounds(d) | |
| 575 | 581 | selection, hasSelection := optionalStyle(styles, 1) | |
| 576 | 582 | cursor, cursorValid := d.Normalize(m.navigator.Pos) | |
| 577 | - | row := make([]renderer.Cell, d.Width()) | |
| 583 | + | buf, _ := rowPool.Get().(*[]renderer.Cell) | |
| 584 | + | if buf == nil || cap(*buf) < d.Width() { | |
| 585 | + | row := make([]renderer.Cell, d.Width()) | |
| 586 | + | buf = &row | |
| 587 | + | } | |
| 588 | + | defer rowPool.Put(buf) | |
| 589 | + | row := (*buf)[:d.Width()] | |
| 578 | 590 | for y := max(start, 0); y < min(end, d.Height()); y++ { | |
| 579 | 591 | src := m.ViewportTop + y | |
| 580 | 592 | for x := range row { | |
| @@ -639,26 +651,49 @@ func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Styl | |||
| 639 | 651 | for i := range row { | |
| 640 | 652 | row[i] = renderer.Cell{Rune: ' ', Style: style} | |
| 641 | 653 | } | |
| 642 | - | text := " [SCROLL] " | |
| 654 | + | col := 0 | |
| 655 | + | put := func(text string) { | |
| 656 | + | for _, r := range text { | |
| 657 | + | if col >= len(row) { | |
| 658 | + | return | |
| 659 | + | } | |
| 660 | + | row[col] = renderer.Cell{Rune: r, Style: style} | |
| 661 | + | col++ | |
| 662 | + | } | |
| 663 | + | } | |
| 664 | + | var digits [20]byte | |
| 665 | + | putInt := func(n int) { | |
| 666 | + | for _, b := range strconv.AppendInt(digits[:0], int64(n), 10) { | |
| 667 | + | if col >= len(row) { | |
| 668 | + | return | |
| 669 | + | } | |
| 670 | + | row[col] = renderer.Cell{Rune: rune(b), Style: style} | |
| 671 | + | col++ | |
| 672 | + | } | |
| 673 | + | } | |
| 643 | 674 | if m.selection.Enabled { | |
| 644 | - | text = " [SELECT] " | |
| 675 | + | put(" [SELECT] ") | |
| 676 | + | } else { | |
| 677 | + | put(" [SCROLL] ") | |
| 645 | 678 | } | |
| 646 | 679 | if total > 0 { | |
| 647 | - | text += strconvItoa(m.navigator.Pos.Row+1) + "/" + strconvItoa(total) + " " | |
| 680 | + | putInt(m.navigator.Pos.Row + 1) | |
| 681 | + | put("/") | |
| 682 | + | putInt(total) | |
| 683 | + | put(" ") | |
| 648 | 684 | } else { | |
| 649 | - | text += "0/0 " | |
| 685 | + | put("0/0 ") | |
| 650 | 686 | } | |
| 651 | 687 | if m.SearchQuery != "" { | |
| 652 | 688 | if len(m.Searches) > 0 && m.SearchIndex >= 0 && m.SearchIndex < len(m.Searches) { | |
| 653 | - | text += strconvItoa(m.SearchIndex+1) + "/" + strconvItoa(len(m.Searches)) + " " | |
| 654 | - | } | |
| 655 | - | text += "/" + m.SearchQuery + " " | |
| 656 | - | } | |
| 657 | - | for i, r := range text { | |
| 658 | - | if i >= len(row) { | |
| 659 | - | break | |
| 689 | + | putInt(m.SearchIndex + 1) | |
| 690 | + | put("/") | |
| 691 | + | putInt(len(m.Searches)) | |
| 692 | + | put(" ") | |
| 660 | 693 | } | |
| 661 | - | row[i] = renderer.Cell{Rune: r, Style: style} | |
| 694 | + | put("/") | |
| 695 | + | put(m.SearchQuery) | |
| 696 | + | put(" ") | |
| 662 | 697 | } | |
| 663 | 698 | } | |
| 664 | 699 | ||
| @@ -675,19 +710,6 @@ func OSC52FromBase64(b64 string) []byte { | |||
| 675 | 710 | } | |
| 676 | 711 | return []byte("\x1b]52;c;" + b64 + "\x07") | |
| 677 | 712 | } | |
| 678 | - | func strconvItoa(n int) string { | |
| 679 | - | if n == 0 { | |
| 680 | - | return "0" | |
| 681 | - | } | |
| 682 | - | var b [20]byte | |
| 683 | - | i := len(b) | |
| 684 | - | for n > 0 { | |
| 685 | - | i-- | |
| 686 | - | b[i] = byte('0' + n%10) | |
| 687 | - | n /= 10 | |
| 688 | - | } | |
| 689 | - | return string(b[i:]) | |
| 690 | - | } | |
| 691 | 713 | func abs(n int) int { | |
| 692 | 714 | if n < 0 { | |
| 693 | 715 | return -n | |
| @@ -4,7 +4,9 @@ import ( | |||||
| 4 | "encoding/base64" | 4 | "encoding/base64" | ||
| 5 | "math" | 5 | "math" | ||
| 6 | "slices" | 6 | "slices" | ||
| 7 | + | "strconv" | |||
| 7 | "strings" | 8 | "strings" | ||
| 9 | + | "sync" | |||
| 8 | "unicode" | 10 | "unicode" | ||
| 9 | 11 | ||||
| 10 | vt "github.com/bnema/vev-vt" | 12 | vt "github.com/bnema/vev-vt" | ||
| @@ -247,6 +249,10 @@ type Mode struct { | |||||
| 247 | SearchIndex int | 249 | SearchIndex int | ||
| 248 | } | 250 | } | ||
| 249 | 251 | ||||
| 252 | + | // rowPool recycles the semantic rows lent to RenderRowsRange callbacks. | |||
| 253 | + | // Renderers snapshot Mode by value, so the buffer cannot live on Mode. | |||
| 254 | + | var rowPool sync.Pool | |||
| 255 | + | ||||
| 250 | func NewMode(doc *Document) *Mode { | 256 | func NewMode(doc *Document) *Mode { | ||
| 251 | m := &Mode{document: doc, SearchIndex: -1} | 257 | m := &Mode{document: doc, SearchIndex: -1} | ||
| 252 | if doc != nil && doc.Len() > 0 { | 258 | if doc != nil && doc.Len() > 0 { | ||
| @@ -574,7 +580,13 @@ func (m *Mode) RenderRowsRange(start, end int, paint func(int, []renderer.Cell), | |||||
| 574 | selectionBounds, hasSelectionBounds := m.selection.bounds(d) | 580 | selectionBounds, hasSelectionBounds := m.selection.bounds(d) | ||
| 575 | selection, hasSelection := optionalStyle(styles, 1) | 581 | selection, hasSelection := optionalStyle(styles, 1) | ||
| 576 | cursor, cursorValid := d.Normalize(m.navigator.Pos) | 582 | cursor, cursorValid := d.Normalize(m.navigator.Pos) | ||
| 577 | - | row := make([]renderer.Cell, d.Width()) | 583 | + | buf, _ := rowPool.Get().(*[]renderer.Cell) |
| 584 | + | if buf == nil || cap(*buf) < d.Width() { | |||
| 585 | + | row := make([]renderer.Cell, d.Width()) | |||
| 586 | + | buf = &row | |||
| 587 | + | } | |||
| 588 | + | defer rowPool.Put(buf) | |||
| 589 | + | row := (*buf)[:d.Width()] | |||
| 578 | for y := max(start, 0); y < min(end, d.Height()); y++ { | 590 | for y := max(start, 0); y < min(end, d.Height()); y++ { | ||
| 579 | src := m.ViewportTop + y | 591 | src := m.ViewportTop + y | ||
| 580 | for x := range row { | 592 | for x := range row { | ||
| @@ -639,26 +651,49 @@ func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Styl | |||||
| 639 | for i := range row { | 651 | for i := range row { | ||
| 640 | row[i] = renderer.Cell{Rune: ' ', Style: style} | 652 | row[i] = renderer.Cell{Rune: ' ', Style: style} | ||
| 641 | } | 653 | } | ||
| 642 | - | text := " [SCROLL] " | 654 | + | col := 0 |
| 655 | + | put := func(text string) { | |||
| 656 | + | for _, r := range text { | |||
| 657 | + | if col >= len(row) { | |||
| 658 | + | return | |||
| 659 | + | } | |||
| 660 | + | row[col] = renderer.Cell{Rune: r, Style: style} | |||
| 661 | + | col++ | |||
| 662 | + | } | |||
| 663 | + | } | |||
| 664 | + | var digits [20]byte | |||
| 665 | + | putInt := func(n int) { | |||
| 666 | + | for _, b := range strconv.AppendInt(digits[:0], int64(n), 10) { | |||
| 667 | + | if col >= len(row) { | |||
| 668 | + | return | |||
| 669 | + | } | |||
| 670 | + | row[col] = renderer.Cell{Rune: rune(b), Style: style} | |||
| 671 | + | col++ | |||
| 672 | + | } | |||
| 673 | + | } | |||
| 643 | if m.selection.Enabled { | 674 | if m.selection.Enabled { | ||
| 644 | - | text = " [SELECT] " | 675 | + | put(" [SELECT] ") |
| 676 | + | } else { | |||
| 677 | + | put(" [SCROLL] ") | |||
| 645 | } | 678 | } | ||
| 646 | if total > 0 { | 679 | if total > 0 { | ||
| 647 | - | text += strconvItoa(m.navigator.Pos.Row+1) + "/" + strconvItoa(total) + " " | 680 | + | putInt(m.navigator.Pos.Row + 1) |
| 681 | + | put("/") | |||
| 682 | + | putInt(total) | |||
| 683 | + | put(" ") | |||
| 648 | } else { | 684 | } else { | ||
| 649 | - | text += "0/0 " | 685 | + | put("0/0 ") |
| 650 | } | 686 | } | ||
| 651 | if m.SearchQuery != "" { | 687 | if m.SearchQuery != "" { | ||
| 652 | if len(m.Searches) > 0 && m.SearchIndex >= 0 && m.SearchIndex < len(m.Searches) { | 688 | if len(m.Searches) > 0 && m.SearchIndex >= 0 && m.SearchIndex < len(m.Searches) { | ||
| 653 | - | text += strconvItoa(m.SearchIndex+1) + "/" + strconvItoa(len(m.Searches)) + " " | 689 | + | putInt(m.SearchIndex + 1) |
| 654 | - | } | 690 | + | put("/") |
| 655 | - | text += "/" + m.SearchQuery + " " | 691 | + | putInt(len(m.Searches)) |
| 656 | - | } | 692 | + | put(" ") |
| 657 | - | for i, r := range text { | |||
| 658 | - | if i >= len(row) { | |||
| 659 | - | break | |||
| 660 | } | 693 | } | ||
| 661 | - | row[i] = renderer.Cell{Rune: r, Style: style} | 694 | + | put("/") |
| 695 | + | put(m.SearchQuery) | |||
| 696 | + | put(" ") | |||
| 662 | } | 697 | } | ||
| 663 | } | 698 | } | ||
| 664 | 699 | ||||
| @@ -675,19 +710,6 @@ func OSC52FromBase64(b64 string) []byte { | |||||
| 675 | } | 710 | } | ||
| 676 | return []byte("\x1b]52;c;" + b64 + "\x07") | 711 | return []byte("\x1b]52;c;" + b64 + "\x07") | ||
| 677 | } | 712 | } | ||
| 678 | - | func strconvItoa(n int) string { | |||
| 679 | - | if n == 0 { | |||
| 680 | - | return "0" | |||
| 681 | - | } | |||
| 682 | - | var b [20]byte | |||
| 683 | - | i := len(b) | |||
| 684 | - | for n > 0 { | |||
| 685 | - | i-- | |||
| 686 | - | b[i] = byte('0' + n%10) | |||
| 687 | - | n /= 10 | |||
| 688 | - | } | |||
| 689 | - | return string(b[i:]) | |||
| 690 | - | } | |||
| 691 | func abs(n int) int { | 713 | func abs(n int) int { | ||
| 692 | if n < 0 { | 714 | if n < 0 { | ||
| 693 | return -n | 715 | return -n | ||
A internal/usecase/copy/status_test.go +53 -0
| @@ -0,0 +1,53 @@ | |||
| 1 | + | package copy | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | renderer "github.com/bnema/vev-vt/ansi" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestDrawCopyStatus(t *testing.T) { | |
| 11 | + | tests := []struct { | |
| 12 | + | name string | |
| 13 | + | width int | |
| 14 | + | lines []string | |
| 15 | + | query string | |
| 16 | + | want string | |
| 17 | + | }{ | |
| 18 | + | {name: "empty document", width: 12, want: " [SCROLL] 0/"}, | |
| 19 | + | {name: "position", width: 16, lines: []string{"a", "b"}, want: " [SCROLL] 2/2 "}, | |
| 20 | + | {name: "truncated", width: 5, lines: []string{"a"}, want: " [SCR"}, | |
| 21 | + | {name: "zero width", width: 0, lines: []string{"a"}, want: ""}, | |
| 22 | + | {name: "multibyte query is contiguous", width: 22, lines: []string{"éa"}, query: "é", want: " [SCROLL] 1/1 1/1 /é "}, | |
| 23 | + | } | |
| 24 | + | for _, tt := range tests { | |
| 25 | + | t.Run(tt.name, func(t *testing.T) { | |
| 26 | + | m := modeFor(tt.lines, 1) | |
| 27 | + | if tt.query != "" { | |
| 28 | + | m.Search(tt.query) | |
| 29 | + | } | |
| 30 | + | row := make([]renderer.Cell, tt.width) | |
| 31 | + | drawCopyStatus(row, m, m.document.Len(), renderer.Style{}) | |
| 32 | + | var got []rune | |
| 33 | + | for _, cell := range row { | |
| 34 | + | got = append(got, cell.Rune) | |
| 35 | + | } | |
| 36 | + | require.Equal(t, tt.want, string(got)[:len(tt.want)]) | |
| 37 | + | require.Len(t, row, tt.width) | |
| 38 | + | }) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // TestRenderRowsRangeReusesRowsAcrossWidths guards the pooled row buffer: | |
| 43 | + | // a narrow render between two wide ones must not leak cells. | |
| 44 | + | func TestRenderRowsRangeReusesRowsAcrossWidths(t *testing.T) { | |
| 45 | + | wide := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{row("abcdefgh")}, 8, 1), "")) | |
| 46 | + | narrow := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{row("xy")}, 2, 1), "")) | |
| 47 | + | for _, m := range []*Mode{wide, narrow, wide} { | |
| 48 | + | full := m.Render() | |
| 49 | + | m.RenderRowsRange(0, 1, func(y int, got []renderer.Cell) { | |
| 50 | + | require.Equal(t, full.Row(y), got) | |
| 51 | + | }) | |
| 52 | + | } | |
| 53 | + | } | |
| @@ -0,0 +1,53 @@ | |||||
| 1 | + | package copy | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | renderer "github.com/bnema/vev-vt/ansi" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestDrawCopyStatus(t *testing.T) { | |||
| 11 | + | tests := []struct { | |||
| 12 | + | name string | |||
| 13 | + | width int | |||
| 14 | + | lines []string | |||
| 15 | + | query string | |||
| 16 | + | want string | |||
| 17 | + | }{ | |||
| 18 | + | {name: "empty document", width: 12, want: " [SCROLL] 0/"}, | |||
| 19 | + | {name: "position", width: 16, lines: []string{"a", "b"}, want: " [SCROLL] 2/2 "}, | |||
| 20 | + | {name: "truncated", width: 5, lines: []string{"a"}, want: " [SCR"}, | |||
| 21 | + | {name: "zero width", width: 0, lines: []string{"a"}, want: ""}, | |||
| 22 | + | {name: "multibyte query is contiguous", width: 22, lines: []string{"éa"}, query: "é", want: " [SCROLL] 1/1 1/1 /é "}, | |||
| 23 | + | } | |||
| 24 | + | for _, tt := range tests { | |||
| 25 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 26 | + | m := modeFor(tt.lines, 1) | |||
| 27 | + | if tt.query != "" { | |||
| 28 | + | m.Search(tt.query) | |||
| 29 | + | } | |||
| 30 | + | row := make([]renderer.Cell, tt.width) | |||
| 31 | + | drawCopyStatus(row, m, m.document.Len(), renderer.Style{}) | |||
| 32 | + | var got []rune | |||
| 33 | + | for _, cell := range row { | |||
| 34 | + | got = append(got, cell.Rune) | |||
| 35 | + | } | |||
| 36 | + | require.Equal(t, tt.want, string(got)[:len(tt.want)]) | |||
| 37 | + | require.Len(t, row, tt.width) | |||
| 38 | + | }) | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // TestRenderRowsRangeReusesRowsAcrossWidths guards the pooled row buffer: | |||
| 43 | + | // a narrow render between two wide ones must not leak cells. | |||
| 44 | + | func TestRenderRowsRangeReusesRowsAcrossWidths(t *testing.T) { | |||
| 45 | + | wide := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{row("abcdefgh")}, 8, 1), "")) | |||
| 46 | + | narrow := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{row("xy")}, 2, 1), "")) | |||
| 47 | + | for _, m := range []*Mode{wide, narrow, wide} { | |||
| 48 | + | full := m.Render() | |||
| 49 | + | m.RenderRowsRange(0, 1, func(y int, got []renderer.Cell) { | |||
| 50 | + | require.Equal(t, full.Row(y), got) | |||
| 51 | + | }) | |||
| 52 | + | } | |||
| 53 | + | } | |||