feat(vt): carry per-row logical extents into history
- commit
- 74eecd7cbe12d7c6cd6fd1ac1160a8e3b014ad70
- author
- brice · · 2026-07-25 05:59 UTC
- parent
- acd4dbe
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
23 files · +300 -86
M internal/usecase/copy/mode.go +1 -1
| @@ -34,7 +34,7 @@ func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot { | |||
| 34 | 34 | ChunkRows: 256, | |
| 35 | 35 | }) | |
| 36 | 36 | for _, row := range rows { | |
| 37 | - | if err := history.Append(row); err != nil { | |
| 37 | + | if err := history.Append(row, vt.LineBound{End: len(row)}); err != nil { | |
| 38 | 38 | panic("copy snapshot: configured history rejected supplied row") | |
| 39 | 39 | } | |
| 40 | 40 | } | |
| @@ -34,7 +34,7 @@ func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot { | |||||
| 34 | ChunkRows: 256, | 34 | ChunkRows: 256, | ||
| 35 | }) | 35 | }) | ||
| 36 | for _, row := range rows { | 36 | for _, row := range rows { | ||
| 37 | - | if err := history.Append(row); err != nil { | 37 | + | if err := history.Append(row, vt.LineBound{End: len(row)}); err != nil { |
| 38 | panic("copy snapshot: configured history rejected supplied row") | 38 | panic("copy snapshot: configured history rejected supplied row") | ||
| 39 | } | 39 | } | ||
| 40 | } | 40 | } | ||
M internal/usecase/copy/mode_test.go +3 -2
| @@ -189,10 +189,11 @@ func TestFindMatchesRepeatedUnicodeDisplayCells(t *testing.T) { | |||
| 189 | 189 | func TestFindMatchesUsesSealedScrollbackWithoutGlobalCopy(t *testing.T) { | |
| 190 | 190 | const rows = 10_000 | |
| 191 | 191 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: rows + 1, ChunkRows: 256}) | |
| 192 | + | unmatched, target := row("unmatched"), row("target") | |
| 192 | 193 | for range rows { | |
| 193 | - | require.NoError(t, history.Append(row("unmatched"))) | |
| 194 | + | require.NoError(t, history.Append(unmatched, vt.LineBound{End: len(unmatched)})) | |
| 194 | 195 | } | |
| 195 | - | require.NoError(t, history.Append(row("target"))) | |
| 196 | + | require.NoError(t, history.Append(target, vt.LineBound{End: len(target)})) | |
| 196 | 197 | snapshot := NewSnapshot(history, renderer.NewFrame(16, 1)) | |
| 197 | 198 | view := history.SealAndView() | |
| 198 | 199 | require.Same(t, view.Chunk(0), snapshot.history.Chunk(0)) | |
| @@ -189,10 +189,11 @@ func TestFindMatchesRepeatedUnicodeDisplayCells(t *testing.T) { | |||||
| 189 | func TestFindMatchesUsesSealedScrollbackWithoutGlobalCopy(t *testing.T) { | 189 | func TestFindMatchesUsesSealedScrollbackWithoutGlobalCopy(t *testing.T) { | ||
| 190 | const rows = 10_000 | 190 | const rows = 10_000 | ||
| 191 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: rows + 1, ChunkRows: 256}) | 191 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: rows + 1, ChunkRows: 256}) | ||
| 192 | + | unmatched, target := row("unmatched"), row("target") | |||
| 192 | for range rows { | 193 | for range rows { | ||
| 193 | - | require.NoError(t, history.Append(row("unmatched"))) | 194 | + | require.NoError(t, history.Append(unmatched, vt.LineBound{End: len(unmatched)})) |
| 194 | } | 195 | } | ||
| 195 | - | require.NoError(t, history.Append(row("target"))) | 196 | + | require.NoError(t, history.Append(target, vt.LineBound{End: len(target)})) |
| 196 | snapshot := NewSnapshot(history, renderer.NewFrame(16, 1)) | 197 | snapshot := NewSnapshot(history, renderer.NewFrame(16, 1)) | ||
| 197 | view := history.SealAndView() | 198 | view := history.SealAndView() | ||
| 198 | require.Same(t, view.Chunk(0), snapshot.history.Chunk(0)) | 199 | require.Same(t, view.Chunk(0), snapshot.history.Chunk(0)) | ||
M internal/usecase/daemon/copymode_test.go +9 -9
| @@ -301,7 +301,7 @@ func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) { | |||
| 301 | 301 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 302 | 302 | pane := sess.tabs[0].focusedPane() | |
| 303 | 303 | installTestHistory(pane, vt.HistoryConfig{MaxRows: 4}) | |
| 304 | - | require.NoError(t, pane.history.Append(testRow("old alpha"))) | |
| 304 | + | appendHistoryRow(t, pane.history, testRow("old alpha")) | |
| 305 | 305 | copy(pane.screen.Frame.Row(0), testRow("live alpha")) | |
| 306 | 306 | ||
| 307 | 307 | d.enterCopyMode(sess, ac) | |
| @@ -330,8 +330,8 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) { | |||
| 330 | 330 | p, _ := newBlockingPTYWithWrites(t, writes) | |
| 331 | 331 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 332 | 332 | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4}) | |
| 333 | - | require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old1 "))) | |
| 334 | - | require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old2 "))) | |
| 333 | + | appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("old1 ")) | |
| 334 | + | appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("old2 ")) | |
| 335 | 335 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |
| 336 | 336 | ||
| 337 | 337 | d.enterCopyMode(sess, ac) | |
| @@ -505,8 +505,8 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) { | |||
| 505 | 505 | p, _ := newBlockingPTY(t) | |
| 506 | 506 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 507 | 507 | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4}) | |
| 508 | - | require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old1 "))) | |
| 509 | - | require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old2 "))) | |
| 508 | + | appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("old1 ")) | |
| 509 | + | appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("old2 ")) | |
| 510 | 510 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |
| 511 | 511 | ||
| 512 | 512 | d.enterCopyMode(sess, ac) | |
| @@ -739,11 +739,11 @@ func TestCopyModeCapturesSourceAndRetainsItAcrossFocusMove(t *testing.T) { | |||
| 739 | 739 | var want *pane | |
| 740 | 740 | if tc.useFloating { | |
| 741 | 741 | want = installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3}) | |
| 742 | - | require.NoError(t, want.history.Append(testRow("flt-old"))) | |
| 742 | + | appendHistoryRow(t, want.history, testRow("flt-old")) | |
| 743 | 743 | want.screen.Write([]byte("flt-live")) | |
| 744 | 744 | } else { | |
| 745 | 745 | want = main | |
| 746 | - | require.NoError(t, want.history.Append(testRow("one-old"))) | |
| 746 | + | appendHistoryRow(t, want.history, testRow("one-old")) | |
| 747 | 747 | want.screen.Write([]byte("one-live")) | |
| 748 | 748 | } | |
| 749 | 749 | ||
| @@ -780,7 +780,7 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) { | |||
| 780 | 780 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | |
| 781 | 781 | fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3}) | |
| 782 | 782 | for i := range 30 { | |
| 783 | - | require.NoError(t, fp.history.Append(testRow(fmt.Sprintf("old-%02d", i)))) | |
| 783 | + | appendHistoryRow(t, fp.history, testRow(fmt.Sprintf("old-%02d", i))) | |
| 784 | 784 | } | |
| 785 | 785 | fp.screen.Write([]byte("live")) | |
| 786 | 786 | fp.mu.Lock() | |
| @@ -806,7 +806,7 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) { | |||
| 806 | 806 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | |
| 807 | 807 | fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3}) | |
| 808 | 808 | for i := range 5 { | |
| 809 | - | require.NoError(t, fp.history.Append(testRow(fmt.Sprintf("old-%d", i)))) | |
| 809 | + | appendHistoryRow(t, fp.history, testRow(fmt.Sprintf("old-%d", i))) | |
| 810 | 810 | } | |
| 811 | 811 | fp.screen.Write([]byte("live")) | |
| 812 | 812 | ||
| @@ -301,7 +301,7 @@ func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) { | |||||
| 301 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 301 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 302 | pane := sess.tabs[0].focusedPane() | 302 | pane := sess.tabs[0].focusedPane() | ||
| 303 | installTestHistory(pane, vt.HistoryConfig{MaxRows: 4}) | 303 | installTestHistory(pane, vt.HistoryConfig{MaxRows: 4}) | ||
| 304 | - | require.NoError(t, pane.history.Append(testRow("old alpha"))) | 304 | + | appendHistoryRow(t, pane.history, testRow("old alpha")) |
| 305 | copy(pane.screen.Frame.Row(0), testRow("live alpha")) | 305 | copy(pane.screen.Frame.Row(0), testRow("live alpha")) | ||
| 306 | 306 | ||||
| 307 | d.enterCopyMode(sess, ac) | 307 | d.enterCopyMode(sess, ac) | ||
| @@ -330,8 +330,8 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) { | |||||
| 330 | p, _ := newBlockingPTYWithWrites(t, writes) | 330 | p, _ := newBlockingPTYWithWrites(t, writes) | ||
| 331 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 331 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 332 | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4}) | 332 | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4}) | ||
| 333 | - | require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old1 "))) | 333 | + | appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("old1 ")) |
| 334 | - | require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old2 "))) | 334 | + | appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("old2 ")) |
| 335 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | 335 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | ||
| 336 | 336 | ||||
| 337 | d.enterCopyMode(sess, ac) | 337 | d.enterCopyMode(sess, ac) | ||
| @@ -505,8 +505,8 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) { | |||||
| 505 | p, _ := newBlockingPTY(t) | 505 | p, _ := newBlockingPTY(t) | ||
| 506 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 506 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 507 | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4}) | 507 | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4}) | ||
| 508 | - | require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old1 "))) | 508 | + | appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("old1 ")) |
| 509 | - | require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old2 "))) | 509 | + | appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("old2 ")) |
| 510 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | 510 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | ||
| 511 | 511 | ||||
| 512 | d.enterCopyMode(sess, ac) | 512 | d.enterCopyMode(sess, ac) | ||
| @@ -739,11 +739,11 @@ func TestCopyModeCapturesSourceAndRetainsItAcrossFocusMove(t *testing.T) { | |||||
| 739 | var want *pane | 739 | var want *pane | ||
| 740 | if tc.useFloating { | 740 | if tc.useFloating { | ||
| 741 | want = installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3}) | 741 | want = installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3}) | ||
| 742 | - | require.NoError(t, want.history.Append(testRow("flt-old"))) | 742 | + | appendHistoryRow(t, want.history, testRow("flt-old")) |
| 743 | want.screen.Write([]byte("flt-live")) | 743 | want.screen.Write([]byte("flt-live")) | ||
| 744 | } else { | 744 | } else { | ||
| 745 | want = main | 745 | want = main | ||
| 746 | - | require.NoError(t, want.history.Append(testRow("one-old"))) | 746 | + | appendHistoryRow(t, want.history, testRow("one-old")) |
| 747 | want.screen.Write([]byte("one-live")) | 747 | want.screen.Write([]byte("one-live")) | ||
| 748 | } | 748 | } | ||
| 749 | 749 | ||||
| @@ -780,7 +780,7 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) { | |||||
| 780 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | 780 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | ||
| 781 | fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3}) | 781 | fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3}) | ||
| 782 | for i := range 30 { | 782 | for i := range 30 { | ||
| 783 | - | require.NoError(t, fp.history.Append(testRow(fmt.Sprintf("old-%02d", i)))) | 783 | + | appendHistoryRow(t, fp.history, testRow(fmt.Sprintf("old-%02d", i))) |
| 784 | } | 784 | } | ||
| 785 | fp.screen.Write([]byte("live")) | 785 | fp.screen.Write([]byte("live")) | ||
| 786 | fp.mu.Lock() | 786 | fp.mu.Lock() | ||
| @@ -806,7 +806,7 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) { | |||||
| 806 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | 806 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | ||
| 807 | fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3}) | 807 | fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3}) | ||
| 808 | for i := range 5 { | 808 | for i := range 5 { | ||
| 809 | - | require.NoError(t, fp.history.Append(testRow(fmt.Sprintf("old-%d", i)))) | 809 | + | appendHistoryRow(t, fp.history, testRow(fmt.Sprintf("old-%d", i))) |
| 810 | } | 810 | } | ||
| 811 | fp.screen.Write([]byte("live")) | 811 | fp.screen.Write([]byte("live")) | ||
| 812 | 812 | ||||
M internal/usecase/daemon/daemon_test.go +8 -0
| @@ -432,6 +432,14 @@ func testRow(text string) []renderer.Cell { | |||
| 432 | 432 | return cells | |
| 433 | 433 | } | |
| 434 | 434 | ||
| 435 | + | // appendHistoryRow records a row whose logical extent covers it entirely and | |
| 436 | + | // that ends its logical line — the shape of a line scrolled off a grid it never | |
| 437 | + | // wrapped out of. Tests that care about wrapped extents build the bound instead. | |
| 438 | + | func appendHistoryRow(tb testing.TB, history *vt.History, row []renderer.Cell) { | |
| 439 | + | tb.Helper() | |
| 440 | + | require.NoError(tb, history.Append(row, vt.LineBound{End: len(row)})) | |
| 441 | + | } | |
| 442 | + | ||
| 435 | 443 | func tabCount(sess *session) int { | |
| 436 | 444 | sess.mu.Lock() | |
| 437 | 445 | defer sess.mu.Unlock() | |
| @@ -432,6 +432,14 @@ func testRow(text string) []renderer.Cell { | |||||
| 432 | return cells | 432 | return cells | ||
| 433 | } | 433 | } | ||
| 434 | 434 | ||||
| 435 | + | // appendHistoryRow records a row whose logical extent covers it entirely and | |||
| 436 | + | // that ends its logical line — the shape of a line scrolled off a grid it never | |||
| 437 | + | // wrapped out of. Tests that care about wrapped extents build the bound instead. | |||
| 438 | + | func appendHistoryRow(tb testing.TB, history *vt.History, row []renderer.Cell) { | |||
| 439 | + | tb.Helper() | |||
| 440 | + | require.NoError(tb, history.Append(row, vt.LineBound{End: len(row)})) | |||
| 441 | + | } | |||
| 442 | + | ||||
| 435 | func tabCount(sess *session) int { | 443 | func tabCount(sess *session) int { | ||
| 436 | sess.mu.Lock() | 444 | sess.mu.Lock() | ||
| 437 | defer sess.mu.Unlock() | 445 | defer sess.mu.Unlock() | ||
M internal/usecase/daemon/input_test.go +2 -2
| @@ -1237,7 +1237,7 @@ func TestMouseNormalScreenDragUsesPressOwnedDocumentAfterOutputEviction(t *testi | |||
| 1237 | 1237 | ||
| 1238 | 1238 | // Output evicts five lines after Press; the Document remains immutable. | |
| 1239 | 1239 | for range 5 { | |
| 1240 | - | require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("evicted"))) | |
| 1240 | + | appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("evicted")) | |
| 1241 | 1241 | } | |
| 1242 | 1242 | ||
| 1243 | 1243 | // Motion maps the next endpoint through the press-owned Document. | |
| @@ -1772,7 +1772,7 @@ func TestActiveCopyMouseRejectsViewportChangeAfterMappingSnapshot(t *testing.T) | |||
| 1772 | 1772 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 1773 | 1773 | pane := sess.activeTab().focusedPane() | |
| 1774 | 1774 | for range 8 { | |
| 1775 | - | require.NoError(t, pane.history.Append(testRow("history"))) | |
| 1775 | + | appendHistoryRow(t, pane.history, testRow("history")) | |
| 1776 | 1776 | } | |
| 1777 | 1777 | d.enterCopyMode(sess, ac) | |
| 1778 | 1778 | mustOutputData(t, sends) | |
| @@ -1237,7 +1237,7 @@ func TestMouseNormalScreenDragUsesPressOwnedDocumentAfterOutputEviction(t *testi | |||||
| 1237 | 1237 | ||||
| 1238 | // Output evicts five lines after Press; the Document remains immutable. | 1238 | // Output evicts five lines after Press; the Document remains immutable. | ||
| 1239 | for range 5 { | 1239 | for range 5 { | ||
| 1240 | - | require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("evicted"))) | 1240 | + | appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("evicted")) |
| 1241 | } | 1241 | } | ||
| 1242 | 1242 | ||||
| 1243 | // Motion maps the next endpoint through the press-owned Document. | 1243 | // Motion maps the next endpoint through the press-owned Document. | ||
| @@ -1772,7 +1772,7 @@ func TestActiveCopyMouseRejectsViewportChangeAfterMappingSnapshot(t *testing.T) | |||||
| 1772 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 1772 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 1773 | pane := sess.activeTab().focusedPane() | 1773 | pane := sess.activeTab().focusedPane() | ||
| 1774 | for range 8 { | 1774 | for range 8 { | ||
| 1775 | - | require.NoError(t, pane.history.Append(testRow("history"))) | 1775 | + | appendHistoryRow(t, pane.history, testRow("history")) |
| 1776 | } | 1776 | } | ||
| 1777 | d.enterCopyMode(sess, ac) | 1777 | d.enterCopyMode(sess, ac) | ||
| 1778 | mustOutputData(t, sends) | 1778 | mustOutputData(t, sends) | ||
M internal/usecase/daemon/overlay_runtime_test.go +2 -2
| @@ -199,7 +199,7 @@ func TestOverlayRuntimeCopyModeDocumentSurvivesRingOverwrite(t *testing.T) { | |||
| 199 | 199 | ac := &attachedClient{} | |
| 200 | 200 | ac.initOverlays() | |
| 201 | 201 | history := newTestHistory(1) | |
| 202 | - | require.NoError(t, history.Append(testRow("before"))) | |
| 202 | + | appendHistoryRow(t, history, testRow("before")) | |
| 203 | 203 | document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1)), domain.DefaultWordSeparators) | |
| 204 | 204 | ||
| 205 | 205 | ac.overlays.copyMu.Lock() | |
| @@ -207,7 +207,7 @@ func TestOverlayRuntimeCopyModeDocumentSurvivesRingOverwrite(t *testing.T) { | |||
| 207 | 207 | ac.overlays.copyDocument = document | |
| 208 | 208 | ac.overlays.copyMu.Unlock() | |
| 209 | 209 | ||
| 210 | - | require.NoError(t, history.Append(testRow("after"))) | |
| 210 | + | appendHistoryRow(t, history, testRow("after")) | |
| 211 | 211 | renderSnap := ac.overlays.SnapshotForRender() | |
| 212 | 212 | defer renderSnap.Unlock() | |
| 213 | 213 | require.NotNil(t, renderSnap.copyMode) | |
| @@ -199,7 +199,7 @@ func TestOverlayRuntimeCopyModeDocumentSurvivesRingOverwrite(t *testing.T) { | |||||
| 199 | ac := &attachedClient{} | 199 | ac := &attachedClient{} | ||
| 200 | ac.initOverlays() | 200 | ac.initOverlays() | ||
| 201 | history := newTestHistory(1) | 201 | history := newTestHistory(1) | ||
| 202 | - | require.NoError(t, history.Append(testRow("before"))) | 202 | + | appendHistoryRow(t, history, testRow("before")) |
| 203 | document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1)), domain.DefaultWordSeparators) | 203 | document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1)), domain.DefaultWordSeparators) | ||
| 204 | 204 | ||||
| 205 | ac.overlays.copyMu.Lock() | 205 | ac.overlays.copyMu.Lock() | ||
| @@ -207,7 +207,7 @@ func TestOverlayRuntimeCopyModeDocumentSurvivesRingOverwrite(t *testing.T) { | |||||
| 207 | ac.overlays.copyDocument = document | 207 | ac.overlays.copyDocument = document | ||
| 208 | ac.overlays.copyMu.Unlock() | 208 | ac.overlays.copyMu.Unlock() | ||
| 209 | 209 | ||||
| 210 | - | require.NoError(t, history.Append(testRow("after"))) | 210 | + | appendHistoryRow(t, history, testRow("after")) |
| 211 | renderSnap := ac.overlays.SnapshotForRender() | 211 | renderSnap := ac.overlays.SnapshotForRender() | ||
| 212 | defer renderSnap.Unlock() | 212 | defer renderSnap.Unlock() | ||
| 213 | require.NotNil(t, renderSnap.copyMode) | 213 | require.NotNil(t, renderSnap.copyMode) | ||
M internal/usecase/daemon/performance_benchmark_test.go +1 -3
| @@ -643,9 +643,7 @@ func mutateBenchmarkTail(fixture *performanceFixture, operation int) { | |||
| 643 | 643 | for column := range row { | |
| 644 | 644 | row[column] = renderer.Cell{Rune: rune('a' + (operation+column)%26)} | |
| 645 | 645 | } | |
| 646 | - | if err := fixture.activePane.history.Append(row); err != nil { | |
| 647 | - | fixture.t.Fatal(err) | |
| 648 | - | } | |
| 646 | + | appendHistoryRow(fixture.t, fixture.activePane.history, row) | |
| 649 | 647 | } | |
| 650 | 648 | ||
| 651 | 649 | func mutateBenchmarkSealedChunk(fixture *performanceFixture, operation int) { | |
| @@ -643,9 +643,7 @@ func mutateBenchmarkTail(fixture *performanceFixture, operation int) { | |||||
| 643 | for column := range row { | 643 | for column := range row { | ||
| 644 | row[column] = renderer.Cell{Rune: rune('a' + (operation+column)%26)} | 644 | row[column] = renderer.Cell{Rune: rune('a' + (operation+column)%26)} | ||
| 645 | } | 645 | } | ||
| 646 | - | if err := fixture.activePane.history.Append(row); err != nil { | 646 | + | appendHistoryRow(fixture.t, fixture.activePane.history, row) |
| 647 | - | fixture.t.Fatal(err) | |||
| 648 | - | } | |||
| 649 | } | 647 | } | ||
| 650 | 648 | ||||
| 651 | func mutateBenchmarkSealedChunk(fixture *performanceFixture, operation int) { | 649 | func mutateBenchmarkSealedChunk(fixture *performanceFixture, operation int) { | ||
M internal/usecase/daemon/picker_test.go +1 -1
| @@ -835,7 +835,7 @@ func newPickerPreviewTabWithHistoryRows(t testing.TB, historyRows int) *tab { | |||
| 835 | 835 | tb := newTab(nil, domain.Size{Cols: 10, Rows: 3}) | |
| 836 | 836 | p := tb.focusedPane() | |
| 837 | 837 | for range historyRows { | |
| 838 | - | require.NoError(t, p.screen.History().Append(testRow("history-only-marker"))) | |
| 838 | + | appendHistoryRow(t, p.screen.History(), testRow("history-only-marker")) | |
| 839 | 839 | } | |
| 840 | 840 | p.screen.Write([]byte("NOW")) | |
| 841 | 841 | return tb | |
| @@ -835,7 +835,7 @@ func newPickerPreviewTabWithHistoryRows(t testing.TB, historyRows int) *tab { | |||||
| 835 | tb := newTab(nil, domain.Size{Cols: 10, Rows: 3}) | 835 | tb := newTab(nil, domain.Size{Cols: 10, Rows: 3}) | ||
| 836 | p := tb.focusedPane() | 836 | p := tb.focusedPane() | ||
| 837 | for range historyRows { | 837 | for range historyRows { | ||
| 838 | - | require.NoError(t, p.screen.History().Append(testRow("history-only-marker"))) | 838 | + | appendHistoryRow(t, p.screen.History(), testRow("history-only-marker")) |
| 839 | } | 839 | } | ||
| 840 | p.screen.Write([]byte("NOW")) | 840 | p.screen.Write([]byte("NOW")) | ||
| 841 | return tb | 841 | return tb | ||
M internal/usecase/daemon/render_test.go +1 -1
| @@ -1289,7 +1289,7 @@ func TestPaintComposesCopyBodyAboveFloating(t *testing.T) { | |||
| 1289 | 1289 | defer releaseFloating() | |
| 1290 | 1290 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | |
| 1291 | 1291 | fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3}) | |
| 1292 | - | require.NoError(t, fp.history.Append(testRow("flt-old"))) | |
| 1292 | + | appendHistoryRow(t, fp.history, testRow("flt-old")) | |
| 1293 | 1293 | fp.screen.Write([]byte("flt-live")) | |
| 1294 | 1294 | installTestFloating(sess.activeTab(), fp, true) | |
| 1295 | 1295 | ||
| @@ -1289,7 +1289,7 @@ func TestPaintComposesCopyBodyAboveFloating(t *testing.T) { | |||||
| 1289 | defer releaseFloating() | 1289 | defer releaseFloating() | ||
| 1290 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | 1290 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | ||
| 1291 | fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3}) | 1291 | fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3}) | ||
| 1292 | - | require.NoError(t, fp.history.Append(testRow("flt-old"))) | 1292 | + | appendHistoryRow(t, fp.history, testRow("flt-old")) |
| 1293 | fp.screen.Write([]byte("flt-live")) | 1293 | fp.screen.Write([]byte("flt-live")) | ||
| 1294 | installTestFloating(sess.activeTab(), fp, true) | 1294 | installTestFloating(sess.activeTab(), fp, true) | ||
| 1295 | 1295 | ||||
M internal/usecase/daemon/snapshot_acceptance_test.go +1 -1
| @@ -56,7 +56,7 @@ func snapshotCacheHistory(t *testing.T, chunks int, first rune) vt.HistorySnapsh | |||
| 56 | 56 | for col := range cells { | |
| 57 | 57 | cells[col] = renderer.Cell{Rune: first + rune((row+col)%26)} | |
| 58 | 58 | } | |
| 59 | - | require.NoError(t, history.Append(cells)) | |
| 59 | + | appendHistoryRow(t, history, cells) | |
| 60 | 60 | } | |
| 61 | 61 | return history.SnapshotView() | |
| 62 | 62 | } | |
| @@ -56,7 +56,7 @@ func snapshotCacheHistory(t *testing.T, chunks int, first rune) vt.HistorySnapsh | |||||
| 56 | for col := range cells { | 56 | for col := range cells { | ||
| 57 | cells[col] = renderer.Cell{Rune: first + rune((row+col)%26)} | 57 | cells[col] = renderer.Cell{Rune: first + rune((row+col)%26)} | ||
| 58 | } | 58 | } | ||
| 59 | - | require.NoError(t, history.Append(cells)) | 59 | + | appendHistoryRow(t, history, cells) |
| 60 | } | 60 | } | ||
| 61 | return history.SnapshotView() | 61 | return history.SnapshotView() | ||
| 62 | } | 62 | } | ||
M internal/usecase/daemon/snapshot_incremental_test.go +3 -3
| @@ -22,7 +22,7 @@ func TestMarshalSnapshotTailSelectsCanonicalEncoder(t *testing.T) { | |||
| 22 | 22 | t.Run(tt.name, func(t *testing.T) { | |
| 23 | 23 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 2}) | |
| 24 | 24 | if tt.fill { | |
| 25 | - | require.NoError(t, history.Append([]renderer.Cell{{Rune: 'x'}})) | |
| 25 | + | appendHistoryRow(t, history, []renderer.Cell{{Rune: 'x'}}) | |
| 26 | 26 | } | |
| 27 | 27 | tail := history.SnapshotView().Tail() | |
| 28 | 28 | ||
| @@ -71,7 +71,7 @@ func TestIncrementalPublicationEncodesAfterPaneUnlock(t *testing.T) { | |||
| 71 | 71 | func TestSnapshotChunkCacheResistsWarmedOverCapacityHistoryScans(t *testing.T) { | |
| 72 | 72 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 6, ChunkRows: 1}) | |
| 73 | 73 | for i := range 6 { | |
| 74 | - | require.NoError(t, history.Append([]renderer.Cell{{Rune: rune('a' + i)}})) | |
| 74 | + | appendHistoryRow(t, history, []renderer.Cell{{Rune: rune('a' + i)}}) | |
| 75 | 75 | } | |
| 76 | 76 | view := history.SnapshotView() | |
| 77 | 77 | require.Equal(t, 6, view.ChunkCount()) | |
| @@ -119,7 +119,7 @@ func TestSnapshotChunkCacheResistsWarmedOverCapacityHistoryScans(t *testing.T) { | |||
| 119 | 119 | ||
| 120 | 120 | func TestIncrementalPublicationReusesSealedChunkObject(t *testing.T) { | |
| 121 | 121 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1}) | |
| 122 | - | require.NoError(t, history.Append([]renderer.Cell{renderer.BlankCell()})) | |
| 122 | + | appendHistoryRow(t, history, []renderer.Cell{renderer.BlankCell()}) | |
| 123 | 123 | view := history.SnapshotView() | |
| 124 | 124 | visible := vt.NewScreen(1, 1).PrimaryVisibleSnapshot() | |
| 125 | 125 | d := New(nil, nil, nil) | |
| @@ -22,7 +22,7 @@ func TestMarshalSnapshotTailSelectsCanonicalEncoder(t *testing.T) { | |||||
| 22 | t.Run(tt.name, func(t *testing.T) { | 22 | t.Run(tt.name, func(t *testing.T) { | ||
| 23 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 2}) | 23 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 2}) | ||
| 24 | if tt.fill { | 24 | if tt.fill { | ||
| 25 | - | require.NoError(t, history.Append([]renderer.Cell{{Rune: 'x'}})) | 25 | + | appendHistoryRow(t, history, []renderer.Cell{{Rune: 'x'}}) |
| 26 | } | 26 | } | ||
| 27 | tail := history.SnapshotView().Tail() | 27 | tail := history.SnapshotView().Tail() | ||
| 28 | 28 | ||||
| @@ -71,7 +71,7 @@ func TestIncrementalPublicationEncodesAfterPaneUnlock(t *testing.T) { | |||||
| 71 | func TestSnapshotChunkCacheResistsWarmedOverCapacityHistoryScans(t *testing.T) { | 71 | func TestSnapshotChunkCacheResistsWarmedOverCapacityHistoryScans(t *testing.T) { | ||
| 72 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 6, ChunkRows: 1}) | 72 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 6, ChunkRows: 1}) | ||
| 73 | for i := range 6 { | 73 | for i := range 6 { | ||
| 74 | - | require.NoError(t, history.Append([]renderer.Cell{{Rune: rune('a' + i)}})) | 74 | + | appendHistoryRow(t, history, []renderer.Cell{{Rune: rune('a' + i)}}) |
| 75 | } | 75 | } | ||
| 76 | view := history.SnapshotView() | 76 | view := history.SnapshotView() | ||
| 77 | require.Equal(t, 6, view.ChunkCount()) | 77 | require.Equal(t, 6, view.ChunkCount()) | ||
| @@ -119,7 +119,7 @@ func TestSnapshotChunkCacheResistsWarmedOverCapacityHistoryScans(t *testing.T) { | |||||
| 119 | 119 | ||||
| 120 | func TestIncrementalPublicationReusesSealedChunkObject(t *testing.T) { | 120 | func TestIncrementalPublicationReusesSealedChunkObject(t *testing.T) { | ||
| 121 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1}) | 121 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1}) | ||
| 122 | - | require.NoError(t, history.Append([]renderer.Cell{renderer.BlankCell()})) | 122 | + | appendHistoryRow(t, history, []renderer.Cell{renderer.BlankCell()}) |
| 123 | view := history.SnapshotView() | 123 | view := history.SnapshotView() | ||
| 124 | visible := vt.NewScreen(1, 1).PrimaryVisibleSnapshot() | 124 | visible := vt.NewScreen(1, 1).PrimaryVisibleSnapshot() | ||
| 125 | d := New(nil, nil, nil) | 125 | d := New(nil, nil, nil) | ||
M internal/usecase/daemon/snapshot_restore_test.go +1 -1
| @@ -132,7 +132,7 @@ func restoreAcceptanceSession(t *testing.T, name string) snapcodec.Session { | |||
| 132 | 132 | for i := range row { | |
| 133 | 133 | row[i] = renderer.Cell{Rune: ch} | |
| 134 | 134 | } | |
| 135 | - | require.NoError(t, history.Append(row)) | |
| 135 | + | appendHistoryRow(t, history, row) | |
| 136 | 136 | } | |
| 137 | 137 | view := history.SnapshotView() | |
| 138 | 138 | sealed := make([][]byte, view.ChunkCount()) | |
| @@ -132,7 +132,7 @@ func restoreAcceptanceSession(t *testing.T, name string) snapcodec.Session { | |||||
| 132 | for i := range row { | 132 | for i := range row { | ||
| 133 | row[i] = renderer.Cell{Rune: ch} | 133 | row[i] = renderer.Cell{Rune: ch} | ||
| 134 | } | 134 | } | ||
| 135 | - | require.NoError(t, history.Append(row)) | 135 | + | appendHistoryRow(t, history, row) |
| 136 | } | 136 | } | ||
| 137 | view := history.SnapshotView() | 137 | view := history.SnapshotView() | ||
| 138 | sealed := make([][]byte, view.ChunkCount()) | 138 | sealed := make([][]byte, view.ChunkCount()) | ||
M internal/usecase/daemon/snapshot_test.go +1 -1
| @@ -44,7 +44,7 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin | |||
| 44 | 44 | tb := newTab(pty, domain.Size{Cols: 8, Rows: 3}) | |
| 45 | 45 | p := tb.panes["pane-1"] | |
| 46 | 46 | p.screen.Write([]byte("hello")) | |
| 47 | - | require.NoError(t, p.history.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})) | |
| 47 | + | appendHistoryRow(t, p.history, []renderer.Cell{{Rune: 'h'}, {Rune: 'i'}}) | |
| 48 | 48 | sess := &session{id: domain.SessionID("sess-" + name), name: name, ephemeral: ephemeral, ctx: context.Background(), cancel: func() {}, tabs: []*tab{tb}, active: 0, cwd: cwd, createdAt: 42} | |
| 49 | 49 | sess.snapEligible.Store(!ephemeral && name != "") | |
| 50 | 50 | return sess | |
| @@ -44,7 +44,7 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin | |||||
| 44 | tb := newTab(pty, domain.Size{Cols: 8, Rows: 3}) | 44 | tb := newTab(pty, domain.Size{Cols: 8, Rows: 3}) | ||
| 45 | p := tb.panes["pane-1"] | 45 | p := tb.panes["pane-1"] | ||
| 46 | p.screen.Write([]byte("hello")) | 46 | p.screen.Write([]byte("hello")) | ||
| 47 | - | require.NoError(t, p.history.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})) | 47 | + | appendHistoryRow(t, p.history, []renderer.Cell{{Rune: 'h'}, {Rune: 'i'}}) |
| 48 | sess := &session{id: domain.SessionID("sess-" + name), name: name, ephemeral: ephemeral, ctx: context.Background(), cancel: func() {}, tabs: []*tab{tb}, active: 0, cwd: cwd, createdAt: 42} | 48 | sess := &session{id: domain.SessionID("sess-" + name), name: name, ephemeral: ephemeral, ctx: context.Background(), cancel: func() {}, tabs: []*tab{tb}, active: 0, cwd: cwd, createdAt: 42} | ||
| 49 | sess.snapEligible.Store(!ephemeral && name != "") | 49 | sess.snapEligible.Store(!ephemeral && name != "") | ||
| 50 | return sess | 50 | return sess | ||
M internal/usecase/snapshot/codec_test.go +3 -3
| @@ -210,7 +210,7 @@ func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) { | |||
| 210 | 210 | // must remain a valid v3 import payload. | |
| 211 | 211 | mutableHistory := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2}) | |
| 212 | 212 | for _, row := range [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}} { | |
| 213 | - | requireNoError(t, mutableHistory.Append(row)) | |
| 213 | + | requireNoError(t, mutableHistory.Append(row, vt.LineBound{End: len(row)})) | |
| 214 | 214 | } | |
| 215 | 215 | mutableTail, err := vt.MarshalHistoryTail(mutableHistory.SnapshotView()) | |
| 216 | 216 | requireNoError(t, err) | |
| @@ -343,7 +343,7 @@ func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) { | |||
| 343 | 343 | func TestUnmarshalGlobalBudgetRejectionDoesNotAllocatePerPane(t *testing.T) { | |
| 344 | 344 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 256, ChunkRows: 256}) | |
| 345 | 345 | for range 256 { | |
| 346 | - | requireNoError(t, history.Append([]renderer.Cell{{Rune: 'x'}})) | |
| 346 | + | requireNoError(t, history.Append([]renderer.Cell{{Rune: 'x'}}, vt.LineBound{End: 1})) | |
| 347 | 347 | } | |
| 348 | 348 | sealed, tail, err := vt.MarshalSealedHistory(history.SealAndView()) | |
| 349 | 349 | requireNoError(t, err) | |
| @@ -406,7 +406,7 @@ func historyBlobs(t *testing.T, rows [][]renderer.Cell) ([][]byte, []byte) { | |||
| 406 | 406 | t.Helper() | |
| 407 | 407 | h := vt.NewHistory(vt.HistoryConfig{MaxRows: 128, ChunkRows: 2}) | |
| 408 | 408 | for _, row := range rows { | |
| 409 | - | requireNoError(t, h.Append(row)) | |
| 409 | + | requireNoError(t, h.Append(row, vt.LineBound{End: len(row)})) | |
| 410 | 410 | } | |
| 411 | 411 | sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView()) | |
| 412 | 412 | if err != nil { | |
| @@ -210,7 +210,7 @@ func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) { | |||||
| 210 | // must remain a valid v3 import payload. | 210 | // must remain a valid v3 import payload. | ||
| 211 | mutableHistory := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2}) | 211 | mutableHistory := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2}) | ||
| 212 | for _, row := range [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}} { | 212 | for _, row := range [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}} { | ||
| 213 | - | requireNoError(t, mutableHistory.Append(row)) | 213 | + | requireNoError(t, mutableHistory.Append(row, vt.LineBound{End: len(row)})) |
| 214 | } | 214 | } | ||
| 215 | mutableTail, err := vt.MarshalHistoryTail(mutableHistory.SnapshotView()) | 215 | mutableTail, err := vt.MarshalHistoryTail(mutableHistory.SnapshotView()) | ||
| 216 | requireNoError(t, err) | 216 | requireNoError(t, err) | ||
| @@ -343,7 +343,7 @@ func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) { | |||||
| 343 | func TestUnmarshalGlobalBudgetRejectionDoesNotAllocatePerPane(t *testing.T) { | 343 | func TestUnmarshalGlobalBudgetRejectionDoesNotAllocatePerPane(t *testing.T) { | ||
| 344 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 256, ChunkRows: 256}) | 344 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 256, ChunkRows: 256}) | ||
| 345 | for range 256 { | 345 | for range 256 { | ||
| 346 | - | requireNoError(t, history.Append([]renderer.Cell{{Rune: 'x'}})) | 346 | + | requireNoError(t, history.Append([]renderer.Cell{{Rune: 'x'}}, vt.LineBound{End: 1})) |
| 347 | } | 347 | } | ||
| 348 | sealed, tail, err := vt.MarshalSealedHistory(history.SealAndView()) | 348 | sealed, tail, err := vt.MarshalSealedHistory(history.SealAndView()) | ||
| 349 | requireNoError(t, err) | 349 | requireNoError(t, err) | ||
| @@ -406,7 +406,7 @@ func historyBlobs(t *testing.T, rows [][]renderer.Cell) ([][]byte, []byte) { | |||||
| 406 | t.Helper() | 406 | t.Helper() | ||
| 407 | h := vt.NewHistory(vt.HistoryConfig{MaxRows: 128, ChunkRows: 2}) | 407 | h := vt.NewHistory(vt.HistoryConfig{MaxRows: 128, ChunkRows: 2}) | ||
| 408 | for _, row := range rows { | 408 | for _, row := range rows { | ||
| 409 | - | requireNoError(t, h.Append(row)) | 409 | + | requireNoError(t, h.Append(row, vt.LineBound{End: len(row)})) |
| 410 | } | 410 | } | ||
| 411 | sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView()) | 411 | sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView()) | ||
| 412 | if err != nil { | 412 | if err != nil { | ||
M pkg/vt/benchmark_test.go +6 -1
| @@ -28,9 +28,14 @@ func TestHistoryViewPartialTailAllocationBounded(t *testing.T) { | |||
| 28 | 28 | partial := benchmarkHistory(t, chunkRows*4+tailRows, width, chunkRows) | |
| 29 | 29 | partialBefore := partial.View() | |
| 30 | 30 | ||
| 31 | + | // Copying a partial tail costs one allocation per row plus a fixed set of | |
| 32 | + | // headers: the chunk slice, its growth for the tail chunk, the chunk itself, | |
| 33 | + | // the row slice, and the bounds slice parallel to it. | |
| 34 | + | const tailHeaders = 4 | |
| 35 | + | ||
| 31 | 36 | sealedAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = sealed.View() }) | |
| 32 | 37 | partialAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = partial.View() }) | |
| 33 | - | if partialAllocs > sealedAllocs+tailRows+3 { | |
| 38 | + | if partialAllocs > sealedAllocs+tailRows+tailHeaders { | |
| 34 | 39 | t.Fatalf("partial-tail View allocations = %v, sealed=%v, tail rows=%d; sealed history was copied", partialAllocs, sealedAllocs, tailRows) | |
| 35 | 40 | } | |
| 36 | 41 | if partial.View().Chunk(0) != partialBefore.Chunk(0) { | |
| @@ -28,9 +28,14 @@ func TestHistoryViewPartialTailAllocationBounded(t *testing.T) { | |||||
| 28 | partial := benchmarkHistory(t, chunkRows*4+tailRows, width, chunkRows) | 28 | partial := benchmarkHistory(t, chunkRows*4+tailRows, width, chunkRows) | ||
| 29 | partialBefore := partial.View() | 29 | partialBefore := partial.View() | ||
| 30 | 30 | ||||
| 31 | + | // Copying a partial tail costs one allocation per row plus a fixed set of | |||
| 32 | + | // headers: the chunk slice, its growth for the tail chunk, the chunk itself, | |||
| 33 | + | // the row slice, and the bounds slice parallel to it. | |||
| 34 | + | const tailHeaders = 4 | |||
| 35 | + | ||||
| 31 | sealedAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = sealed.View() }) | 36 | sealedAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = sealed.View() }) | ||
| 32 | partialAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = partial.View() }) | 37 | partialAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = partial.View() }) | ||
| 33 | - | if partialAllocs > sealedAllocs+tailRows+3 { | 38 | + | if partialAllocs > sealedAllocs+tailRows+tailHeaders { |
| 34 | t.Fatalf("partial-tail View allocations = %v, sealed=%v, tail rows=%d; sealed history was copied", partialAllocs, sealedAllocs, tailRows) | 39 | t.Fatalf("partial-tail View allocations = %v, sealed=%v, tail rows=%d; sealed history was copied", partialAllocs, sealedAllocs, tailRows) | ||
| 35 | } | 40 | } | ||
| 36 | if partial.View().Chunk(0) != partialBefore.Chunk(0) { | 41 | if partial.View().Chunk(0) != partialBefore.Chunk(0) { | ||
M pkg/vt/buffer.go +32 -9
| @@ -39,6 +39,16 @@ func (b *buffer) content(y, end int) { | |||
| 39 | 39 | b.boundaries[y].End = max(b.boundaries[y].End, clamp(end, 0, b.frame.Width)) | |
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | + | // bound reports the logical extent of row y. Callers on eviction paths may run | |
| 43 | + | // against a nil or shorter buffer than the frame they are walking, so an | |
| 44 | + | // unknown row reads as a hard row of no extent rather than panicking. | |
| 45 | + | func (b *buffer) bound(y int) LineBound { | |
| 46 | + | if b == nil || y < 0 || y >= len(b.boundaries) { | |
| 47 | + | return LineBound{} | |
| 48 | + | } | |
| 49 | + | return b.boundaries[y] | |
| 50 | + | } | |
| 51 | + | ||
| 42 | 52 | func (b *buffer) truncate(y, end int) { | |
| 43 | 53 | if y < 0 || y >= len(b.boundaries) { | |
| 44 | 54 | return | |
| @@ -140,15 +150,18 @@ func (b *buffer) hasSoft() bool { | |||
| 140 | 150 | ||
| 141 | 151 | // resizeFixed keeps hard physical lines independent. It is the common shell | |
| 142 | 152 | // path and avoids constructing logical-line scratch state when nothing wraps. | |
| 143 | - | func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) [][]renderer.Cell { | |
| 153 | + | func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound) { | |
| 144 | 154 | anchor := 0 | |
| 145 | 155 | if active != nil { | |
| 146 | 156 | anchor = active.row | |
| 147 | 157 | } | |
| 148 | 158 | shift := clamp(anchor-(height-1), 0, max(b.frame.Height-height, 0)) | |
| 149 | 159 | evicted := make([][]renderer.Cell, 0, shift) | |
| 160 | + | evictedBounds := make([]LineBound, 0, shift) | |
| 150 | 161 | for y := range shift { | |
| 151 | 162 | evicted = append(evicted, append([]renderer.Cell(nil), b.frame.Row(y)...)) | |
| 163 | + | // The evicted row keeps its source width, so its extent needs no clamping. | |
| 164 | + | evictedBounds = append(evictedBounds, b.boundaries[y]) | |
| 152 | 165 | } | |
| 153 | 166 | // Fixed-line resizes can retain the boundary backing store. In particular, | |
| 154 | 167 | // keep its capacity through a short viewport so the common shrink/grow | |
| @@ -180,7 +193,7 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) [][ | |||
| 180 | 193 | } | |
| 181 | 194 | } | |
| 182 | 195 | *b = next | |
| 183 | - | return evicted | |
| 196 | + | return evicted, evictedBounds | |
| 184 | 197 | } | |
| 185 | 198 | ||
| 186 | 199 | type reflowPoint struct { | |
| @@ -221,7 +234,7 @@ func (b *buffer) cursorReflowPoints(active, saved *bufferCursor) [2]reflowPoint | |||
| 221 | 234 | // the first maps both cursors and counts rows; the second writes only the | |
| 222 | 235 | // retained viewport (and the rows genuinely evicted to history). It never | |
| 223 | 236 | // materializes logical lines, per-cell position maps, or temporary output rows. | |
| 224 | - | func (b *buffer) resize(width, height int, active, saved *bufferCursor) [][]renderer.Cell { | |
| 237 | + | func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound) { | |
| 225 | 238 | if !b.hasSoft() { | |
| 226 | 239 | return b.resizeFixed(width, height, active, saved) | |
| 227 | 240 | } | |
| @@ -235,7 +248,7 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) [][]rend | |||
| 235 | 248 | } | |
| 236 | 249 | ||
| 237 | 250 | points := b.cursorReflowPoints(active, saved) | |
| 238 | - | rows := b.layoutReflow(width, &points, nil, 0, nil) | |
| 251 | + | rows := b.layoutReflow(width, &points, nil, 0, nil, nil) | |
| 239 | 252 | anchor := 0 | |
| 240 | 253 | if active != nil { | |
| 241 | 254 | anchor = points[0].row | |
| @@ -245,16 +258,18 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) [][]rend | |||
| 245 | 258 | next := newBuffer(width, height) | |
| 246 | 259 | var evicted [][]renderer.Cell | |
| 247 | 260 | var evictedCells []renderer.Cell | |
| 261 | + | var evictedBounds []LineBound | |
| 248 | 262 | if shift > 0 { | |
| 249 | 263 | // History needs owned rows. One flat backing store replaces a temporary | |
| 250 | 264 | // allocation per evicted output row. | |
| 251 | 265 | evicted = make([][]renderer.Cell, shift) | |
| 252 | 266 | evictedCells = make([]renderer.Cell, shift*width) | |
| 267 | + | evictedBounds = make([]LineBound, shift) | |
| 253 | 268 | for y := range evicted { | |
| 254 | 269 | evicted[y] = evictedCells[y*width : (y+1)*width] | |
| 255 | 270 | } | |
| 256 | 271 | } | |
| 257 | - | b.layoutReflow(width, &points, next, shift, evictedCells) | |
| 272 | + | b.layoutReflow(width, &points, next, shift, evictedCells, evictedBounds) | |
| 258 | 273 | for i, cur := range [2]*bufferCursor{active, saved} { | |
| 259 | 274 | if cur != nil { | |
| 260 | 275 | cur.row = clamp(points[i].row-shift, 0, height-1) | |
| @@ -262,13 +277,13 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) [][]rend | |||
| 262 | 277 | } | |
| 263 | 278 | } | |
| 264 | 279 | *b = *next | |
| 265 | - | return evicted | |
| 280 | + | return evicted, evictedBounds | |
| 266 | 281 | } | |
| 267 | 282 | ||
| 268 | 283 | // layoutReflow maps source offsets and, when dst is non-nil, emits only rows in | |
| 269 | 284 | // [shift, shift+dst.height). Rows before shift are written straight into the | |
| 270 | 285 | // contiguous eviction backing store. The return is the logical output height. | |
| 271 | - | func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, shift int, evicted []renderer.Cell) int { | |
| 286 | + | func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, shift int, evicted []renderer.Cell, evictedBounds []LineBound) int { | |
| 272 | 287 | row, col := 0, 0 | |
| 273 | 288 | blankEvicted := func(outputRow int) []renderer.Cell { | |
| 274 | 289 | if outputRow < shift { | |
| @@ -288,8 +303,16 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh | |||
| 288 | 303 | output = blankEvicted(0) | |
| 289 | 304 | } | |
| 290 | 305 | finishRow := func(soft bool) { | |
| 291 | - | if dst != nil && row >= shift && row < shift+dst.frame.Height { | |
| 292 | - | dst.boundaries[row-shift] = LineBound{End: col, Soft: soft} | |
| 306 | + | // An output row belongs either to history or to the retained viewport, and | |
| 307 | + | // only this pass knows where it ended, so both destinations are written here. | |
| 308 | + | bound := LineBound{End: col, Soft: soft} | |
| 309 | + | switch { | |
| 310 | + | case row < shift: | |
| 311 | + | if evictedBounds != nil { | |
| 312 | + | evictedBounds[row] = bound | |
| 313 | + | } | |
| 314 | + | case dst != nil && row < shift+dst.frame.Height: | |
| 315 | + | dst.boundaries[row-shift] = bound | |
| 293 | 316 | } | |
| 294 | 317 | row++ | |
| 295 | 318 | col = 0 | |
| @@ -39,6 +39,16 @@ func (b *buffer) content(y, end int) { | |||||
| 39 | b.boundaries[y].End = max(b.boundaries[y].End, clamp(end, 0, b.frame.Width)) | 39 | b.boundaries[y].End = max(b.boundaries[y].End, clamp(end, 0, b.frame.Width)) | ||
| 40 | } | 40 | } | ||
| 41 | 41 | ||||
| 42 | + | // bound reports the logical extent of row y. Callers on eviction paths may run | |||
| 43 | + | // against a nil or shorter buffer than the frame they are walking, so an | |||
| 44 | + | // unknown row reads as a hard row of no extent rather than panicking. | |||
| 45 | + | func (b *buffer) bound(y int) LineBound { | |||
| 46 | + | if b == nil || y < 0 || y >= len(b.boundaries) { | |||
| 47 | + | return LineBound{} | |||
| 48 | + | } | |||
| 49 | + | return b.boundaries[y] | |||
| 50 | + | } | |||
| 51 | + | ||||
| 42 | func (b *buffer) truncate(y, end int) { | 52 | func (b *buffer) truncate(y, end int) { | ||
| 43 | if y < 0 || y >= len(b.boundaries) { | 53 | if y < 0 || y >= len(b.boundaries) { | ||
| 44 | return | 54 | return | ||
| @@ -140,15 +150,18 @@ func (b *buffer) hasSoft() bool { | |||||
| 140 | 150 | ||||
| 141 | // resizeFixed keeps hard physical lines independent. It is the common shell | 151 | // resizeFixed keeps hard physical lines independent. It is the common shell | ||
| 142 | // path and avoids constructing logical-line scratch state when nothing wraps. | 152 | // path and avoids constructing logical-line scratch state when nothing wraps. | ||
| 143 | - | func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) [][]renderer.Cell { | 153 | + | func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound) { |
| 144 | anchor := 0 | 154 | anchor := 0 | ||
| 145 | if active != nil { | 155 | if active != nil { | ||
| 146 | anchor = active.row | 156 | anchor = active.row | ||
| 147 | } | 157 | } | ||
| 148 | shift := clamp(anchor-(height-1), 0, max(b.frame.Height-height, 0)) | 158 | shift := clamp(anchor-(height-1), 0, max(b.frame.Height-height, 0)) | ||
| 149 | evicted := make([][]renderer.Cell, 0, shift) | 159 | evicted := make([][]renderer.Cell, 0, shift) | ||
| 160 | + | evictedBounds := make([]LineBound, 0, shift) | |||
| 150 | for y := range shift { | 161 | for y := range shift { | ||
| 151 | evicted = append(evicted, append([]renderer.Cell(nil), b.frame.Row(y)...)) | 162 | evicted = append(evicted, append([]renderer.Cell(nil), b.frame.Row(y)...)) | ||
| 163 | + | // The evicted row keeps its source width, so its extent needs no clamping. | |||
| 164 | + | evictedBounds = append(evictedBounds, b.boundaries[y]) | |||
| 152 | } | 165 | } | ||
| 153 | // Fixed-line resizes can retain the boundary backing store. In particular, | 166 | // Fixed-line resizes can retain the boundary backing store. In particular, | ||
| 154 | // keep its capacity through a short viewport so the common shrink/grow | 167 | // keep its capacity through a short viewport so the common shrink/grow | ||
| @@ -180,7 +193,7 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) [][ | |||||
| 180 | } | 193 | } | ||
| 181 | } | 194 | } | ||
| 182 | *b = next | 195 | *b = next | ||
| 183 | - | return evicted | 196 | + | return evicted, evictedBounds |
| 184 | } | 197 | } | ||
| 185 | 198 | ||||
| 186 | type reflowPoint struct { | 199 | type reflowPoint struct { | ||
| @@ -221,7 +234,7 @@ func (b *buffer) cursorReflowPoints(active, saved *bufferCursor) [2]reflowPoint | |||||
| 221 | // the first maps both cursors and counts rows; the second writes only the | 234 | // the first maps both cursors and counts rows; the second writes only the | ||
| 222 | // retained viewport (and the rows genuinely evicted to history). It never | 235 | // retained viewport (and the rows genuinely evicted to history). It never | ||
| 223 | // materializes logical lines, per-cell position maps, or temporary output rows. | 236 | // materializes logical lines, per-cell position maps, or temporary output rows. | ||
| 224 | - | func (b *buffer) resize(width, height int, active, saved *bufferCursor) [][]renderer.Cell { | 237 | + | func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound) { |
| 225 | if !b.hasSoft() { | 238 | if !b.hasSoft() { | ||
| 226 | return b.resizeFixed(width, height, active, saved) | 239 | return b.resizeFixed(width, height, active, saved) | ||
| 227 | } | 240 | } | ||
| @@ -235,7 +248,7 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) [][]rend | |||||
| 235 | } | 248 | } | ||
| 236 | 249 | ||||
| 237 | points := b.cursorReflowPoints(active, saved) | 250 | points := b.cursorReflowPoints(active, saved) | ||
| 238 | - | rows := b.layoutReflow(width, &points, nil, 0, nil) | 251 | + | rows := b.layoutReflow(width, &points, nil, 0, nil, nil) |
| 239 | anchor := 0 | 252 | anchor := 0 | ||
| 240 | if active != nil { | 253 | if active != nil { | ||
| 241 | anchor = points[0].row | 254 | anchor = points[0].row | ||
| @@ -245,16 +258,18 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) [][]rend | |||||
| 245 | next := newBuffer(width, height) | 258 | next := newBuffer(width, height) | ||
| 246 | var evicted [][]renderer.Cell | 259 | var evicted [][]renderer.Cell | ||
| 247 | var evictedCells []renderer.Cell | 260 | var evictedCells []renderer.Cell | ||
| 261 | + | var evictedBounds []LineBound | |||
| 248 | if shift > 0 { | 262 | if shift > 0 { | ||
| 249 | // History needs owned rows. One flat backing store replaces a temporary | 263 | // History needs owned rows. One flat backing store replaces a temporary | ||
| 250 | // allocation per evicted output row. | 264 | // allocation per evicted output row. | ||
| 251 | evicted = make([][]renderer.Cell, shift) | 265 | evicted = make([][]renderer.Cell, shift) | ||
| 252 | evictedCells = make([]renderer.Cell, shift*width) | 266 | evictedCells = make([]renderer.Cell, shift*width) | ||
| 267 | + | evictedBounds = make([]LineBound, shift) | |||
| 253 | for y := range evicted { | 268 | for y := range evicted { | ||
| 254 | evicted[y] = evictedCells[y*width : (y+1)*width] | 269 | evicted[y] = evictedCells[y*width : (y+1)*width] | ||
| 255 | } | 270 | } | ||
| 256 | } | 271 | } | ||
| 257 | - | b.layoutReflow(width, &points, next, shift, evictedCells) | 272 | + | b.layoutReflow(width, &points, next, shift, evictedCells, evictedBounds) |
| 258 | for i, cur := range [2]*bufferCursor{active, saved} { | 273 | for i, cur := range [2]*bufferCursor{active, saved} { | ||
| 259 | if cur != nil { | 274 | if cur != nil { | ||
| 260 | cur.row = clamp(points[i].row-shift, 0, height-1) | 275 | cur.row = clamp(points[i].row-shift, 0, height-1) | ||
| @@ -262,13 +277,13 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) [][]rend | |||||
| 262 | } | 277 | } | ||
| 263 | } | 278 | } | ||
| 264 | *b = *next | 279 | *b = *next | ||
| 265 | - | return evicted | 280 | + | return evicted, evictedBounds |
| 266 | } | 281 | } | ||
| 267 | 282 | ||||
| 268 | // layoutReflow maps source offsets and, when dst is non-nil, emits only rows in | 283 | // layoutReflow maps source offsets and, when dst is non-nil, emits only rows in | ||
| 269 | // [shift, shift+dst.height). Rows before shift are written straight into the | 284 | // [shift, shift+dst.height). Rows before shift are written straight into the | ||
| 270 | // contiguous eviction backing store. The return is the logical output height. | 285 | // contiguous eviction backing store. The return is the logical output height. | ||
| 271 | - | func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, shift int, evicted []renderer.Cell) int { | 286 | + | func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, shift int, evicted []renderer.Cell, evictedBounds []LineBound) int { |
| 272 | row, col := 0, 0 | 287 | row, col := 0, 0 | ||
| 273 | blankEvicted := func(outputRow int) []renderer.Cell { | 288 | blankEvicted := func(outputRow int) []renderer.Cell { | ||
| 274 | if outputRow < shift { | 289 | if outputRow < shift { | ||
| @@ -288,8 +303,16 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh | |||||
| 288 | output = blankEvicted(0) | 303 | output = blankEvicted(0) | ||
| 289 | } | 304 | } | ||
| 290 | finishRow := func(soft bool) { | 305 | finishRow := func(soft bool) { | ||
| 291 | - | if dst != nil && row >= shift && row < shift+dst.frame.Height { | 306 | + | // An output row belongs either to history or to the retained viewport, and |
| 292 | - | dst.boundaries[row-shift] = LineBound{End: col, Soft: soft} | 307 | + | // only this pass knows where it ended, so both destinations are written here. |
| 308 | + | bound := LineBound{End: col, Soft: soft} | |||
| 309 | + | switch { | |||
| 310 | + | case row < shift: | |||
| 311 | + | if evictedBounds != nil { | |||
| 312 | + | evictedBounds[row] = bound | |||
| 313 | + | } | |||
| 314 | + | case dst != nil && row < shift+dst.frame.Height: | |||
| 315 | + | dst.boundaries[row-shift] = bound | |||
| 293 | } | 316 | } | ||
| 294 | row++ | 317 | row++ | ||
| 295 | col = 0 | 318 | col = 0 | ||
M pkg/vt/history.go +80 -24
| @@ -22,19 +22,22 @@ type HistoryConfig struct { | |||
| 22 | 22 | // mutated by the owner of a Screen; views are safe to retain after later | |
| 23 | 23 | // appends. | |
| 24 | 24 | type History struct { | |
| 25 | - | maxRows int | |
| 26 | - | maxCells int | |
| 27 | - | chunkRows int | |
| 28 | - | chunks []*HistoryChunk | |
| 29 | - | tail [][]renderer.Cell | |
| 30 | - | rows int | |
| 31 | - | cells int | |
| 25 | + | maxRows int | |
| 26 | + | maxCells int | |
| 27 | + | chunkRows int | |
| 28 | + | chunks []*HistoryChunk | |
| 29 | + | tail [][]renderer.Cell | |
| 30 | + | tailBounds []LineBound | |
| 31 | + | rows int | |
| 32 | + | cells int | |
| 32 | 33 | } | |
| 33 | 34 | ||
| 34 | 35 | // HistoryChunk is an immutable group of history rows. Its identity is stable | |
| 35 | 36 | // and can be used by consumers to avoid copying unchanged sealed chunks. | |
| 37 | + | // bounds is parallel to rows and always has the same length. | |
| 36 | 38 | type HistoryChunk struct { | |
| 37 | - | rows [][]renderer.Cell | |
| 39 | + | rows [][]renderer.Cell | |
| 40 | + | bounds []LineBound | |
| 38 | 41 | } | |
| 39 | 42 | ||
| 40 | 43 | // HistoryView is an immutable snapshot of history. Row returns a copy so the | |
| @@ -49,10 +52,11 @@ type HistoryView struct { | |||
| 49 | 52 | // independently. Sealed chunks are shared by identity; Tail is owned by the | |
| 50 | 53 | // view and can be serialized without rotating the live tail into a chunk. | |
| 51 | 54 | type HistorySnapshotView struct { | |
| 52 | - | chunks []*HistoryChunk | |
| 53 | - | tail [][]renderer.Cell | |
| 54 | - | rows int | |
| 55 | - | cells int | |
| 55 | + | chunks []*HistoryChunk | |
| 56 | + | tail [][]renderer.Cell | |
| 57 | + | tailBounds []LineBound | |
| 58 | + | rows int | |
| 59 | + | cells int | |
| 56 | 60 | } | |
| 57 | 61 | ||
| 58 | 62 | func NewHistory(config HistoryConfig) *History { | |
| @@ -79,9 +83,10 @@ func defaultHistoryMaxCells(maxRows int) int { | |||
| 79 | 83 | return maxRows * 160 | |
| 80 | 84 | } | |
| 81 | 85 | ||
| 82 | - | // Append records a copy of row. Once a chunk is full it is sealed forever. | |
| 83 | - | // Rows wider than the total cell capacity are rejected without mutation. | |
| 84 | - | func (h *History) Append(row []renderer.Cell) error { | |
| 86 | + | // Append records a copy of row along with its logical extent. Once a chunk is | |
| 87 | + | // full it is sealed forever. Rows wider than the total cell capacity are | |
| 88 | + | // rejected without mutation. | |
| 89 | + | func (h *History) Append(row []renderer.Cell, bound LineBound) error { | |
| 85 | 90 | if h == nil || h.maxRows == 0 { | |
| 86 | 91 | return nil | |
| 87 | 92 | } | |
| @@ -92,6 +97,7 @@ func (h *History) Append(row []renderer.Cell) error { | |||
| 92 | 97 | // capacity is MaxInt. | |
| 93 | 98 | h.evictFor(len(row)) | |
| 94 | 99 | h.tail = append(h.tail, append([]renderer.Cell(nil), row...)) | |
| 100 | + | h.tailBounds = append(h.tailBounds, clampBound(bound, len(row))) | |
| 95 | 101 | h.rows++ | |
| 96 | 102 | h.cells += len(row) | |
| 97 | 103 | if len(h.tail) == h.chunkRows { | |
| @@ -100,12 +106,31 @@ func (h *History) Append(row []renderer.Cell) error { | |||
| 100 | 106 | return nil | |
| 101 | 107 | } | |
| 102 | 108 | ||
| 109 | + | // clampBound keeps End inside the row it describes so a persisted bound can be | |
| 110 | + | // validated without consulting its row again. | |
| 111 | + | func clampBound(b LineBound, cells int) LineBound { | |
| 112 | + | b.End = min(max(b.End, 0), cells) | |
| 113 | + | return b | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // growBounds returns bounds sized to rows, padding with zero values. It keeps | |
| 117 | + | // the parallel-slice invariant when a chunk arrives with fewer bounds than rows. | |
| 118 | + | func growBounds(bounds []LineBound, rows int) []LineBound { | |
| 119 | + | if len(bounds) == rows { | |
| 120 | + | return bounds | |
| 121 | + | } | |
| 122 | + | out := make([]LineBound, rows) | |
| 123 | + | copy(out, bounds) | |
| 124 | + | return out | |
| 125 | + | } | |
| 126 | + | ||
| 103 | 127 | func (h *History) sealTail() { | |
| 104 | 128 | if len(h.tail) == 0 { | |
| 105 | 129 | return | |
| 106 | 130 | } | |
| 107 | - | h.chunks = append(h.chunks, &HistoryChunk{rows: h.tail}) | |
| 131 | + | h.chunks = append(h.chunks, &HistoryChunk{rows: h.tail, bounds: growBounds(h.tailBounds, len(h.tail))}) | |
| 108 | 132 | h.tail = nil | |
| 133 | + | h.tailBounds = nil | |
| 109 | 134 | } | |
| 110 | 135 | ||
| 111 | 136 | // normalizeTail seals complete chunks so the mutable tail remains shorter than | |
| @@ -113,8 +138,12 @@ func (h *History) sealTail() { | |||
| 113 | 138 | // size than the current history configuration. | |
| 114 | 139 | func (h *History) normalizeTail() { | |
| 115 | 140 | for h.chunkRows > 0 && len(h.tail) >= h.chunkRows { | |
| 116 | - | h.chunks = append(h.chunks, &HistoryChunk{rows: h.tail[:h.chunkRows]}) | |
| 141 | + | h.chunks = append(h.chunks, &HistoryChunk{ | |
| 142 | + | rows: h.tail[:h.chunkRows], | |
| 143 | + | bounds: growBounds(h.tailBounds, len(h.tail))[:h.chunkRows], | |
| 144 | + | }) | |
| 117 | 145 | h.tail = h.tail[h.chunkRows:] | |
| 146 | + | h.tailBounds = growBounds(h.tailBounds, len(h.tail)+h.chunkRows)[h.chunkRows:] | |
| 118 | 147 | } | |
| 119 | 148 | } | |
| 120 | 149 | ||
| @@ -143,7 +172,7 @@ func (h *History) evictUntil(rowCount, cellCount int) { | |||
| 143 | 172 | } else { | |
| 144 | 173 | // Preserve cell storage while replacing only the chunk wrapper: a | |
| 145 | 174 | // retained view may still refer to the original immutable chunk. | |
| 146 | - | h.chunks[0] = &HistoryChunk{rows: chunk.rows[1:]} | |
| 175 | + | h.chunks[0] = &HistoryChunk{rows: chunk.rows[1:], bounds: growBounds(chunk.bounds, len(chunk.rows))[1:]} | |
| 147 | 176 | } | |
| 148 | 177 | continue | |
| 149 | 178 | } | |
| @@ -155,6 +184,7 @@ func (h *History) evictUntil(rowCount, cellCount int) { | |||
| 155 | 184 | h.cells -= len(row) | |
| 156 | 185 | h.tail[0] = nil | |
| 157 | 186 | h.tail = h.tail[1:] | |
| 187 | + | h.tailBounds = growBounds(h.tailBounds, len(h.tail)+1)[1:] | |
| 158 | 188 | } | |
| 159 | 189 | } | |
| 160 | 190 | ||
| @@ -175,7 +205,10 @@ func (h *History) View() HistoryView { | |||
| 175 | 205 | } | |
| 176 | 206 | chunks := append([]*HistoryChunk(nil), h.chunks...) | |
| 177 | 207 | if len(h.tail) > 0 { | |
| 178 | - | chunks = append(chunks, &HistoryChunk{rows: cloneHistoryRows(h.tail)}) | |
| 208 | + | chunks = append(chunks, &HistoryChunk{ | |
| 209 | + | rows: cloneHistoryRows(h.tail), | |
| 210 | + | bounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...), | |
| 211 | + | }) | |
| 179 | 212 | } | |
| 180 | 213 | return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells} | |
| 181 | 214 | } | |
| @@ -187,10 +220,11 @@ func (h *History) SnapshotView() HistorySnapshotView { | |||
| 187 | 220 | return HistorySnapshotView{} | |
| 188 | 221 | } | |
| 189 | 222 | return HistorySnapshotView{ | |
| 190 | - | chunks: append([]*HistoryChunk(nil), h.chunks...), | |
| 191 | - | tail: cloneHistoryRows(h.tail), | |
| 192 | - | rows: h.rows, | |
| 193 | - | cells: h.cells, | |
| 223 | + | chunks: append([]*HistoryChunk(nil), h.chunks...), | |
| 224 | + | tail: cloneHistoryRows(h.tail), | |
| 225 | + | tailBounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...), | |
| 226 | + | rows: h.rows, | |
| 227 | + | cells: h.cells, | |
| 194 | 228 | } | |
| 195 | 229 | } | |
| 196 | 230 | ||
| @@ -282,6 +316,24 @@ func (v HistoryView) BorrowedRow(i int) []renderer.Cell { | |||
| 282 | 316 | return nil | |
| 283 | 317 | } | |
| 284 | 318 | ||
| 319 | + | // Bound returns the logical extent of the row at i, or the zero value when i is | |
| 320 | + | // out of range. A zero value describes a hard row whose End is not meaningful. | |
| 321 | + | func (v HistoryView) Bound(i int) LineBound { | |
| 322 | + | if i < 0 { | |
| 323 | + | return LineBound{} | |
| 324 | + | } | |
| 325 | + | for _, chunk := range v.chunks { | |
| 326 | + | if i < len(chunk.rows) { | |
| 327 | + | if i < len(chunk.bounds) { | |
| 328 | + | return chunk.bounds[i] | |
| 329 | + | } | |
| 330 | + | return LineBound{} | |
| 331 | + | } | |
| 332 | + | i -= len(chunk.rows) | |
| 333 | + | } | |
| 334 | + | return LineBound{} | |
| 335 | + | } | |
| 336 | + | ||
| 285 | 337 | func (v HistorySnapshotView) Len() int { return v.rows } | |
| 286 | 338 | func (v HistorySnapshotView) Cells() int { return v.cells } | |
| 287 | 339 | func (v HistorySnapshotView) ChunkCount() int { return len(v.chunks) } | |
| @@ -299,7 +351,11 @@ func (v HistorySnapshotView) Tail() HistoryView { | |||
| 299 | 351 | if len(v.tail) == 0 { | |
| 300 | 352 | return HistoryView{} | |
| 301 | 353 | } | |
| 302 | - | return HistoryView{chunks: []*HistoryChunk{{rows: v.tail}}, rows: len(v.tail), cells: historyRowsCells(v.tail)} | |
| 354 | + | return HistoryView{ | |
| 355 | + | chunks: []*HistoryChunk{{rows: v.tail, bounds: growBounds(v.tailBounds, len(v.tail))}}, | |
| 356 | + | rows: len(v.tail), | |
| 357 | + | cells: historyRowsCells(v.tail), | |
| 358 | + | } | |
| 303 | 359 | } | |
| 304 | 360 | ||
| 305 | 361 | func historyRowsCells(rows [][]renderer.Cell) int { | |
| @@ -22,19 +22,22 @@ type HistoryConfig struct { | |||||
| 22 | // mutated by the owner of a Screen; views are safe to retain after later | 22 | // mutated by the owner of a Screen; views are safe to retain after later | ||
| 23 | // appends. | 23 | // appends. | ||
| 24 | type History struct { | 24 | type History struct { | ||
| 25 | - | maxRows int | 25 | + | maxRows int |
| 26 | - | maxCells int | 26 | + | maxCells int |
| 27 | - | chunkRows int | 27 | + | chunkRows int |
| 28 | - | chunks []*HistoryChunk | 28 | + | chunks []*HistoryChunk |
| 29 | - | tail [][]renderer.Cell | 29 | + | tail [][]renderer.Cell |
| 30 | - | rows int | 30 | + | tailBounds []LineBound |
| 31 | - | cells int | 31 | + | rows int |
| 32 | + | cells int | |||
| 32 | } | 33 | } | ||
| 33 | 34 | ||||
| 34 | // HistoryChunk is an immutable group of history rows. Its identity is stable | 35 | // HistoryChunk is an immutable group of history rows. Its identity is stable | ||
| 35 | // and can be used by consumers to avoid copying unchanged sealed chunks. | 36 | // and can be used by consumers to avoid copying unchanged sealed chunks. | ||
| 37 | + | // bounds is parallel to rows and always has the same length. | |||
| 36 | type HistoryChunk struct { | 38 | type HistoryChunk struct { | ||
| 37 | - | rows [][]renderer.Cell | 39 | + | rows [][]renderer.Cell |
| 40 | + | bounds []LineBound | |||
| 38 | } | 41 | } | ||
| 39 | 42 | ||||
| 40 | // HistoryView is an immutable snapshot of history. Row returns a copy so the | 43 | // HistoryView is an immutable snapshot of history. Row returns a copy so the | ||
| @@ -49,10 +52,11 @@ type HistoryView struct { | |||||
| 49 | // independently. Sealed chunks are shared by identity; Tail is owned by the | 52 | // independently. Sealed chunks are shared by identity; Tail is owned by the | ||
| 50 | // view and can be serialized without rotating the live tail into a chunk. | 53 | // view and can be serialized without rotating the live tail into a chunk. | ||
| 51 | type HistorySnapshotView struct { | 54 | type HistorySnapshotView struct { | ||
| 52 | - | chunks []*HistoryChunk | 55 | + | chunks []*HistoryChunk |
| 53 | - | tail [][]renderer.Cell | 56 | + | tail [][]renderer.Cell |
| 54 | - | rows int | 57 | + | tailBounds []LineBound |
| 55 | - | cells int | 58 | + | rows int |
| 59 | + | cells int | |||
| 56 | } | 60 | } | ||
| 57 | 61 | ||||
| 58 | func NewHistory(config HistoryConfig) *History { | 62 | func NewHistory(config HistoryConfig) *History { | ||
| @@ -79,9 +83,10 @@ func defaultHistoryMaxCells(maxRows int) int { | |||||
| 79 | return maxRows * 160 | 83 | return maxRows * 160 | ||
| 80 | } | 84 | } | ||
| 81 | 85 | ||||
| 82 | - | // Append records a copy of row. Once a chunk is full it is sealed forever. | 86 | + | // Append records a copy of row along with its logical extent. Once a chunk is |
| 83 | - | // Rows wider than the total cell capacity are rejected without mutation. | 87 | + | // full it is sealed forever. Rows wider than the total cell capacity are |
| 84 | - | func (h *History) Append(row []renderer.Cell) error { | 88 | + | // rejected without mutation. |
| 89 | + | func (h *History) Append(row []renderer.Cell, bound LineBound) error { | |||
| 85 | if h == nil || h.maxRows == 0 { | 90 | if h == nil || h.maxRows == 0 { | ||
| 86 | return nil | 91 | return nil | ||
| 87 | } | 92 | } | ||
| @@ -92,6 +97,7 @@ func (h *History) Append(row []renderer.Cell) error { | |||||
| 92 | // capacity is MaxInt. | 97 | // capacity is MaxInt. | ||
| 93 | h.evictFor(len(row)) | 98 | h.evictFor(len(row)) | ||
| 94 | h.tail = append(h.tail, append([]renderer.Cell(nil), row...)) | 99 | h.tail = append(h.tail, append([]renderer.Cell(nil), row...)) | ||
| 100 | + | h.tailBounds = append(h.tailBounds, clampBound(bound, len(row))) | |||
| 95 | h.rows++ | 101 | h.rows++ | ||
| 96 | h.cells += len(row) | 102 | h.cells += len(row) | ||
| 97 | if len(h.tail) == h.chunkRows { | 103 | if len(h.tail) == h.chunkRows { | ||
| @@ -100,12 +106,31 @@ func (h *History) Append(row []renderer.Cell) error { | |||||
| 100 | return nil | 106 | return nil | ||
| 101 | } | 107 | } | ||
| 102 | 108 | ||||
| 109 | + | // clampBound keeps End inside the row it describes so a persisted bound can be | |||
| 110 | + | // validated without consulting its row again. | |||
| 111 | + | func clampBound(b LineBound, cells int) LineBound { | |||
| 112 | + | b.End = min(max(b.End, 0), cells) | |||
| 113 | + | return b | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // growBounds returns bounds sized to rows, padding with zero values. It keeps | |||
| 117 | + | // the parallel-slice invariant when a chunk arrives with fewer bounds than rows. | |||
| 118 | + | func growBounds(bounds []LineBound, rows int) []LineBound { | |||
| 119 | + | if len(bounds) == rows { | |||
| 120 | + | return bounds | |||
| 121 | + | } | |||
| 122 | + | out := make([]LineBound, rows) | |||
| 123 | + | copy(out, bounds) | |||
| 124 | + | return out | |||
| 125 | + | } | |||
| 126 | + | ||||
| 103 | func (h *History) sealTail() { | 127 | func (h *History) sealTail() { | ||
| 104 | if len(h.tail) == 0 { | 128 | if len(h.tail) == 0 { | ||
| 105 | return | 129 | return | ||
| 106 | } | 130 | } | ||
| 107 | - | h.chunks = append(h.chunks, &HistoryChunk{rows: h.tail}) | 131 | + | h.chunks = append(h.chunks, &HistoryChunk{rows: h.tail, bounds: growBounds(h.tailBounds, len(h.tail))}) |
| 108 | h.tail = nil | 132 | h.tail = nil | ||
| 133 | + | h.tailBounds = nil | |||
| 109 | } | 134 | } | ||
| 110 | 135 | ||||
| 111 | // normalizeTail seals complete chunks so the mutable tail remains shorter than | 136 | // normalizeTail seals complete chunks so the mutable tail remains shorter than | ||
| @@ -113,8 +138,12 @@ func (h *History) sealTail() { | |||||
| 113 | // size than the current history configuration. | 138 | // size than the current history configuration. | ||
| 114 | func (h *History) normalizeTail() { | 139 | func (h *History) normalizeTail() { | ||
| 115 | for h.chunkRows > 0 && len(h.tail) >= h.chunkRows { | 140 | for h.chunkRows > 0 && len(h.tail) >= h.chunkRows { | ||
| 116 | - | h.chunks = append(h.chunks, &HistoryChunk{rows: h.tail[:h.chunkRows]}) | 141 | + | h.chunks = append(h.chunks, &HistoryChunk{ |
| 142 | + | rows: h.tail[:h.chunkRows], | |||
| 143 | + | bounds: growBounds(h.tailBounds, len(h.tail))[:h.chunkRows], | |||
| 144 | + | }) | |||
| 117 | h.tail = h.tail[h.chunkRows:] | 145 | h.tail = h.tail[h.chunkRows:] | ||
| 146 | + | h.tailBounds = growBounds(h.tailBounds, len(h.tail)+h.chunkRows)[h.chunkRows:] | |||
| 118 | } | 147 | } | ||
| 119 | } | 148 | } | ||
| 120 | 149 | ||||
| @@ -143,7 +172,7 @@ func (h *History) evictUntil(rowCount, cellCount int) { | |||||
| 143 | } else { | 172 | } else { | ||
| 144 | // Preserve cell storage while replacing only the chunk wrapper: a | 173 | // Preserve cell storage while replacing only the chunk wrapper: a | ||
| 145 | // retained view may still refer to the original immutable chunk. | 174 | // retained view may still refer to the original immutable chunk. | ||
| 146 | - | h.chunks[0] = &HistoryChunk{rows: chunk.rows[1:]} | 175 | + | h.chunks[0] = &HistoryChunk{rows: chunk.rows[1:], bounds: growBounds(chunk.bounds, len(chunk.rows))[1:]} |
| 147 | } | 176 | } | ||
| 148 | continue | 177 | continue | ||
| 149 | } | 178 | } | ||
| @@ -155,6 +184,7 @@ func (h *History) evictUntil(rowCount, cellCount int) { | |||||
| 155 | h.cells -= len(row) | 184 | h.cells -= len(row) | ||
| 156 | h.tail[0] = nil | 185 | h.tail[0] = nil | ||
| 157 | h.tail = h.tail[1:] | 186 | h.tail = h.tail[1:] | ||
| 187 | + | h.tailBounds = growBounds(h.tailBounds, len(h.tail)+1)[1:] | |||
| 158 | } | 188 | } | ||
| 159 | } | 189 | } | ||
| 160 | 190 | ||||
| @@ -175,7 +205,10 @@ func (h *History) View() HistoryView { | |||||
| 175 | } | 205 | } | ||
| 176 | chunks := append([]*HistoryChunk(nil), h.chunks...) | 206 | chunks := append([]*HistoryChunk(nil), h.chunks...) | ||
| 177 | if len(h.tail) > 0 { | 207 | if len(h.tail) > 0 { | ||
| 178 | - | chunks = append(chunks, &HistoryChunk{rows: cloneHistoryRows(h.tail)}) | 208 | + | chunks = append(chunks, &HistoryChunk{ |
| 209 | + | rows: cloneHistoryRows(h.tail), | |||
| 210 | + | bounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...), | |||
| 211 | + | }) | |||
| 179 | } | 212 | } | ||
| 180 | return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells} | 213 | return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells} | ||
| 181 | } | 214 | } | ||
| @@ -187,10 +220,11 @@ func (h *History) SnapshotView() HistorySnapshotView { | |||||
| 187 | return HistorySnapshotView{} | 220 | return HistorySnapshotView{} | ||
| 188 | } | 221 | } | ||
| 189 | return HistorySnapshotView{ | 222 | return HistorySnapshotView{ | ||
| 190 | - | chunks: append([]*HistoryChunk(nil), h.chunks...), | 223 | + | chunks: append([]*HistoryChunk(nil), h.chunks...), |
| 191 | - | tail: cloneHistoryRows(h.tail), | 224 | + | tail: cloneHistoryRows(h.tail), |
| 192 | - | rows: h.rows, | 225 | + | tailBounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...), |
| 193 | - | cells: h.cells, | 226 | + | rows: h.rows, |
| 227 | + | cells: h.cells, | |||
| 194 | } | 228 | } | ||
| 195 | } | 229 | } | ||
| 196 | 230 | ||||
| @@ -282,6 +316,24 @@ func (v HistoryView) BorrowedRow(i int) []renderer.Cell { | |||||
| 282 | return nil | 316 | return nil | ||
| 283 | } | 317 | } | ||
| 284 | 318 | ||||
| 319 | + | // Bound returns the logical extent of the row at i, or the zero value when i is | |||
| 320 | + | // out of range. A zero value describes a hard row whose End is not meaningful. | |||
| 321 | + | func (v HistoryView) Bound(i int) LineBound { | |||
| 322 | + | if i < 0 { | |||
| 323 | + | return LineBound{} | |||
| 324 | + | } | |||
| 325 | + | for _, chunk := range v.chunks { | |||
| 326 | + | if i < len(chunk.rows) { | |||
| 327 | + | if i < len(chunk.bounds) { | |||
| 328 | + | return chunk.bounds[i] | |||
| 329 | + | } | |||
| 330 | + | return LineBound{} | |||
| 331 | + | } | |||
| 332 | + | i -= len(chunk.rows) | |||
| 333 | + | } | |||
| 334 | + | return LineBound{} | |||
| 335 | + | } | |||
| 336 | + | ||||
| 285 | func (v HistorySnapshotView) Len() int { return v.rows } | 337 | func (v HistorySnapshotView) Len() int { return v.rows } | ||
| 286 | func (v HistorySnapshotView) Cells() int { return v.cells } | 338 | func (v HistorySnapshotView) Cells() int { return v.cells } | ||
| 287 | func (v HistorySnapshotView) ChunkCount() int { return len(v.chunks) } | 339 | func (v HistorySnapshotView) ChunkCount() int { return len(v.chunks) } | ||
| @@ -299,7 +351,11 @@ func (v HistorySnapshotView) Tail() HistoryView { | |||||
| 299 | if len(v.tail) == 0 { | 351 | if len(v.tail) == 0 { | ||
| 300 | return HistoryView{} | 352 | return HistoryView{} | ||
| 301 | } | 353 | } | ||
| 302 | - | return HistoryView{chunks: []*HistoryChunk{{rows: v.tail}}, rows: len(v.tail), cells: historyRowsCells(v.tail)} | 354 | + | return HistoryView{ |
| 355 | + | chunks: []*HistoryChunk{{rows: v.tail, bounds: growBounds(v.tailBounds, len(v.tail))}}, | |||
| 356 | + | rows: len(v.tail), | |||
| 357 | + | cells: historyRowsCells(v.tail), | |||
| 358 | + | } | |||
| 303 | } | 359 | } | ||
| 304 | 360 | ||||
| 305 | func historyRowsCells(rows [][]renderer.Cell) int { | 361 | func historyRowsCells(rows [][]renderer.Cell) int { | ||
M pkg/vt/history_test.go +72 -5
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | func requireHistoryAppend(t testing.TB, history *History, row []renderer.Cell) { | |
| 12 | 12 | t.Helper() | |
| 13 | - | if err := history.Append(row); err != nil { | |
| 13 | + | if err := history.Append(row, LineBound{End: len(row)}); err != nil { | |
| 14 | 14 | t.Fatalf("append history row: %v", err) | |
| 15 | 15 | } | |
| 16 | 16 | } | |
| @@ -204,7 +204,7 @@ func TestHistoryRecordsOnlyTopEdgeScrollEvictions(t *testing.T) { | |||
| 204 | 204 | func TestHistoryBoundsRowsAndCellsWithExactRowEviction(t *testing.T) { | |
| 205 | 205 | history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 5, ChunkRows: 2}) | |
| 206 | 206 | for _, text := range []string{"aa", "bbb", "c", "dd"} { | |
| 207 | - | if err := history.Append(historyRow(text)); err != nil { | |
| 207 | + | if err := history.Append(historyRow(text), LineBound{End: len(text)}); err != nil { | |
| 208 | 208 | t.Fatalf("append %q: %v", text, err) | |
| 209 | 209 | } | |
| 210 | 210 | } | |
| @@ -235,7 +235,8 @@ func TestHistoryAppendIsNoOpForNilAndZeroValue(t *testing.T) { | |||
| 235 | 235 | ||
| 236 | 236 | for _, tt := range tests { | |
| 237 | 237 | t.Run(tt.name, func(t *testing.T) { | |
| 238 | - | if err := tt.history.Append(historyRow("row")); err != nil { | |
| 238 | + | row := historyRow("row") | |
| 239 | + | if err := tt.history.Append(row, LineBound{End: len(row)}); err != nil { | |
| 239 | 240 | t.Fatalf("append error = %v, want nil", err) | |
| 240 | 241 | } | |
| 241 | 242 | if got := tt.history.Len(); got != 0 { | |
| @@ -247,12 +248,14 @@ func TestHistoryAppendIsNoOpForNilAndZeroValue(t *testing.T) { | |||
| 247 | 248 | ||
| 248 | 249 | func TestHistoryRejectsRowWiderThanCellBudgetWithoutMutation(t *testing.T) { | |
| 249 | 250 | history := NewHistory(HistoryConfig{MaxRows: 2, MaxCells: 2, ChunkRows: 2}) | |
| 250 | - | if err := history.Append(historyRow("ok")); err != nil { | |
| 251 | + | kept := historyRow("ok") | |
| 252 | + | if err := history.Append(kept, LineBound{End: len(kept)}); err != nil { | |
| 251 | 253 | t.Fatalf("append retained row: %v", err) | |
| 252 | 254 | } | |
| 253 | 255 | before := history.View() | |
| 254 | 256 | ||
| 255 | - | err := history.Append(historyRow("wide")) | |
| 257 | + | wide := historyRow("wide") | |
| 258 | + | err := history.Append(wide, LineBound{End: len(wide)}) | |
| 256 | 259 | if !errors.Is(err, ErrHistoryRowTooWide) { | |
| 257 | 260 | t.Fatalf("append oversized row error = %v, want ErrHistoryRowTooWide", err) | |
| 258 | 261 | } | |
| @@ -283,6 +286,70 @@ func TestScreenDropsOversizedHistoryRowsWithoutInterruptingScroll(t *testing.T) | |||
| 283 | 286 | } | |
| 284 | 287 | } | |
| 285 | 288 | ||
| 289 | + | func TestHistoryAppendRetainsBounds(t *testing.T) { | |
| 290 | + | row := func(s string) []renderer.Cell { | |
| 291 | + | cells := make([]renderer.Cell, 0, len(s)) | |
| 292 | + | for _, r := range s { | |
| 293 | + | cells = append(cells, renderer.Cell{Rune: r}) | |
| 294 | + | } | |
| 295 | + | return cells | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | tests := []struct { | |
| 299 | + | name string | |
| 300 | + | chunkRows int | |
| 301 | + | bounds []LineBound | |
| 302 | + | }{ | |
| 303 | + | { | |
| 304 | + | name: "within the mutable tail", | |
| 305 | + | chunkRows: 8, | |
| 306 | + | bounds: []LineBound{{End: 3, Soft: true}, {End: 2}}, | |
| 307 | + | }, | |
| 308 | + | { | |
| 309 | + | name: "across a sealed chunk boundary", | |
| 310 | + | chunkRows: 2, | |
| 311 | + | bounds: []LineBound{{End: 3, Soft: true}, {End: 3, Soft: true}, {End: 2}}, | |
| 312 | + | }, | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | for _, tc := range tests { | |
| 316 | + | t.Run(tc.name, func(t *testing.T) { | |
| 317 | + | h := NewHistory(HistoryConfig{MaxRows: 16, MaxCells: 1024, ChunkRows: tc.chunkRows}) | |
| 318 | + | for i, b := range tc.bounds { | |
| 319 | + | if err := h.Append(row("abc"[:b.End]), b); err != nil { | |
| 320 | + | t.Fatalf("append %d: %v", i, err) | |
| 321 | + | } | |
| 322 | + | } | |
| 323 | + | view := h.SealAndView() | |
| 324 | + | for i, want := range tc.bounds { | |
| 325 | + | if got := view.Bound(i); got != want { | |
| 326 | + | t.Errorf("Bound(%d) = %+v, want %+v", i, got, want) | |
| 327 | + | } | |
| 328 | + | } | |
| 329 | + | }) | |
| 330 | + | } | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | func TestHistoryEvictionKeepsRowsAndBoundsAligned(t *testing.T) { | |
| 334 | + | h := NewHistory(HistoryConfig{MaxRows: 3, MaxCells: 1024, ChunkRows: 2}) | |
| 335 | + | for i := range 5 { | |
| 336 | + | cells := []renderer.Cell{{Rune: rune('a' + i)}} | |
| 337 | + | if err := h.Append(cells, LineBound{End: 1, Soft: i%2 == 0}); err != nil { | |
| 338 | + | t.Fatalf("append %d: %v", i, err) | |
| 339 | + | } | |
| 340 | + | } | |
| 341 | + | view := h.SealAndView() | |
| 342 | + | if view.Len() != 3 { | |
| 343 | + | t.Fatalf("Len() = %d, want 3", view.Len()) | |
| 344 | + | } | |
| 345 | + | // Rows 2, 3, 4 survived; their Soft flags were true, false, true. | |
| 346 | + | for i, want := range []bool{true, false, true} { | |
| 347 | + | if got := view.Bound(i).Soft; got != want { | |
| 348 | + | t.Errorf("Bound(%d).Soft = %v, want %v (row %q)", i, got, want, view.BorrowedRow(i)[0].Rune) | |
| 349 | + | } | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | ||
| 286 | 353 | func TestHistoryDefaultCellBudgetDoesNotOverflow(t *testing.T) { | |
| 287 | 354 | history := NewHistory(HistoryConfig{MaxRows: math.MaxInt, ChunkRows: 1}) | |
| 288 | 355 | if got, want := history.CellCap(), math.MaxInt; got != want { | |
| @@ -10,7 +10,7 @@ import ( | |||||
| 10 | 10 | ||||
| 11 | func requireHistoryAppend(t testing.TB, history *History, row []renderer.Cell) { | 11 | func requireHistoryAppend(t testing.TB, history *History, row []renderer.Cell) { | ||
| 12 | t.Helper() | 12 | t.Helper() | ||
| 13 | - | if err := history.Append(row); err != nil { | 13 | + | if err := history.Append(row, LineBound{End: len(row)}); err != nil { |
| 14 | t.Fatalf("append history row: %v", err) | 14 | t.Fatalf("append history row: %v", err) | ||
| 15 | } | 15 | } | ||
| 16 | } | 16 | } | ||
| @@ -204,7 +204,7 @@ func TestHistoryRecordsOnlyTopEdgeScrollEvictions(t *testing.T) { | |||||
| 204 | func TestHistoryBoundsRowsAndCellsWithExactRowEviction(t *testing.T) { | 204 | func TestHistoryBoundsRowsAndCellsWithExactRowEviction(t *testing.T) { | ||
| 205 | history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 5, ChunkRows: 2}) | 205 | history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 5, ChunkRows: 2}) | ||
| 206 | for _, text := range []string{"aa", "bbb", "c", "dd"} { | 206 | for _, text := range []string{"aa", "bbb", "c", "dd"} { | ||
| 207 | - | if err := history.Append(historyRow(text)); err != nil { | 207 | + | if err := history.Append(historyRow(text), LineBound{End: len(text)}); err != nil { |
| 208 | t.Fatalf("append %q: %v", text, err) | 208 | t.Fatalf("append %q: %v", text, err) | ||
| 209 | } | 209 | } | ||
| 210 | } | 210 | } | ||
| @@ -235,7 +235,8 @@ func TestHistoryAppendIsNoOpForNilAndZeroValue(t *testing.T) { | |||||
| 235 | 235 | ||||
| 236 | for _, tt := range tests { | 236 | for _, tt := range tests { | ||
| 237 | t.Run(tt.name, func(t *testing.T) { | 237 | t.Run(tt.name, func(t *testing.T) { | ||
| 238 | - | if err := tt.history.Append(historyRow("row")); err != nil { | 238 | + | row := historyRow("row") |
| 239 | + | if err := tt.history.Append(row, LineBound{End: len(row)}); err != nil { | |||
| 239 | t.Fatalf("append error = %v, want nil", err) | 240 | t.Fatalf("append error = %v, want nil", err) | ||
| 240 | } | 241 | } | ||
| 241 | if got := tt.history.Len(); got != 0 { | 242 | if got := tt.history.Len(); got != 0 { | ||
| @@ -247,12 +248,14 @@ func TestHistoryAppendIsNoOpForNilAndZeroValue(t *testing.T) { | |||||
| 247 | 248 | ||||
| 248 | func TestHistoryRejectsRowWiderThanCellBudgetWithoutMutation(t *testing.T) { | 249 | func TestHistoryRejectsRowWiderThanCellBudgetWithoutMutation(t *testing.T) { | ||
| 249 | history := NewHistory(HistoryConfig{MaxRows: 2, MaxCells: 2, ChunkRows: 2}) | 250 | history := NewHistory(HistoryConfig{MaxRows: 2, MaxCells: 2, ChunkRows: 2}) | ||
| 250 | - | if err := history.Append(historyRow("ok")); err != nil { | 251 | + | kept := historyRow("ok") |
| 252 | + | if err := history.Append(kept, LineBound{End: len(kept)}); err != nil { | |||
| 251 | t.Fatalf("append retained row: %v", err) | 253 | t.Fatalf("append retained row: %v", err) | ||
| 252 | } | 254 | } | ||
| 253 | before := history.View() | 255 | before := history.View() | ||
| 254 | 256 | ||||
| 255 | - | err := history.Append(historyRow("wide")) | 257 | + | wide := historyRow("wide") |
| 258 | + | err := history.Append(wide, LineBound{End: len(wide)}) | |||
| 256 | if !errors.Is(err, ErrHistoryRowTooWide) { | 259 | if !errors.Is(err, ErrHistoryRowTooWide) { | ||
| 257 | t.Fatalf("append oversized row error = %v, want ErrHistoryRowTooWide", err) | 260 | t.Fatalf("append oversized row error = %v, want ErrHistoryRowTooWide", err) | ||
| 258 | } | 261 | } | ||
| @@ -283,6 +286,70 @@ func TestScreenDropsOversizedHistoryRowsWithoutInterruptingScroll(t *testing.T) | |||||
| 283 | } | 286 | } | ||
| 284 | } | 287 | } | ||
| 285 | 288 | ||||
| 289 | + | func TestHistoryAppendRetainsBounds(t *testing.T) { | |||
| 290 | + | row := func(s string) []renderer.Cell { | |||
| 291 | + | cells := make([]renderer.Cell, 0, len(s)) | |||
| 292 | + | for _, r := range s { | |||
| 293 | + | cells = append(cells, renderer.Cell{Rune: r}) | |||
| 294 | + | } | |||
| 295 | + | return cells | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | tests := []struct { | |||
| 299 | + | name string | |||
| 300 | + | chunkRows int | |||
| 301 | + | bounds []LineBound | |||
| 302 | + | }{ | |||
| 303 | + | { | |||
| 304 | + | name: "within the mutable tail", | |||
| 305 | + | chunkRows: 8, | |||
| 306 | + | bounds: []LineBound{{End: 3, Soft: true}, {End: 2}}, | |||
| 307 | + | }, | |||
| 308 | + | { | |||
| 309 | + | name: "across a sealed chunk boundary", | |||
| 310 | + | chunkRows: 2, | |||
| 311 | + | bounds: []LineBound{{End: 3, Soft: true}, {End: 3, Soft: true}, {End: 2}}, | |||
| 312 | + | }, | |||
| 313 | + | } | |||
| 314 | + | ||||
| 315 | + | for _, tc := range tests { | |||
| 316 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 317 | + | h := NewHistory(HistoryConfig{MaxRows: 16, MaxCells: 1024, ChunkRows: tc.chunkRows}) | |||
| 318 | + | for i, b := range tc.bounds { | |||
| 319 | + | if err := h.Append(row("abc"[:b.End]), b); err != nil { | |||
| 320 | + | t.Fatalf("append %d: %v", i, err) | |||
| 321 | + | } | |||
| 322 | + | } | |||
| 323 | + | view := h.SealAndView() | |||
| 324 | + | for i, want := range tc.bounds { | |||
| 325 | + | if got := view.Bound(i); got != want { | |||
| 326 | + | t.Errorf("Bound(%d) = %+v, want %+v", i, got, want) | |||
| 327 | + | } | |||
| 328 | + | } | |||
| 329 | + | }) | |||
| 330 | + | } | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | func TestHistoryEvictionKeepsRowsAndBoundsAligned(t *testing.T) { | |||
| 334 | + | h := NewHistory(HistoryConfig{MaxRows: 3, MaxCells: 1024, ChunkRows: 2}) | |||
| 335 | + | for i := range 5 { | |||
| 336 | + | cells := []renderer.Cell{{Rune: rune('a' + i)}} | |||
| 337 | + | if err := h.Append(cells, LineBound{End: 1, Soft: i%2 == 0}); err != nil { | |||
| 338 | + | t.Fatalf("append %d: %v", i, err) | |||
| 339 | + | } | |||
| 340 | + | } | |||
| 341 | + | view := h.SealAndView() | |||
| 342 | + | if view.Len() != 3 { | |||
| 343 | + | t.Fatalf("Len() = %d, want 3", view.Len()) | |||
| 344 | + | } | |||
| 345 | + | // Rows 2, 3, 4 survived; their Soft flags were true, false, true. | |||
| 346 | + | for i, want := range []bool{true, false, true} { | |||
| 347 | + | if got := view.Bound(i).Soft; got != want { | |||
| 348 | + | t.Errorf("Bound(%d).Soft = %v, want %v (row %q)", i, got, want, view.BorrowedRow(i)[0].Rune) | |||
| 349 | + | } | |||
| 350 | + | } | |||
| 351 | + | } | |||
| 352 | + | ||||
| 286 | func TestHistoryDefaultCellBudgetDoesNotOverflow(t *testing.T) { | 353 | func TestHistoryDefaultCellBudgetDoesNotOverflow(t *testing.T) { | ||
| 287 | history := NewHistory(HistoryConfig{MaxRows: math.MaxInt, ChunkRows: 1}) | 354 | history := NewHistory(HistoryConfig{MaxRows: math.MaxInt, ChunkRows: 1}) | ||
| 288 | if got, want := history.CellCap(), math.MaxInt; got != want { | 355 | if got, want := history.CellCap(), math.MaxInt; got != want { | ||
M pkg/vt/screen_resize.go +3 -3
| @@ -25,10 +25,10 @@ func (s *Screen) Resize(width, height int) { | |||
| 25 | 25 | if saved != nil && saved.saved { | |
| 26 | 26 | savedPoint = &bufferCursor{row: saved.row, col: saved.col} | |
| 27 | 27 | } | |
| 28 | - | evicted := b.resize(width, height, active, savedPoint) | |
| 28 | + | evicted, evictedBounds := b.resize(width, height, active, savedPoint) | |
| 29 | 29 | if evict { | |
| 30 | - | for _, line := range evicted { | |
| 31 | - | s.recordEvicted(line) | |
| 30 | + | for i, line := range evicted { | |
| 31 | + | s.recordEvicted(line, evictedBounds[i]) | |
| 32 | 32 | } | |
| 33 | 33 | } | |
| 34 | 34 | if savedPoint != nil { | |
| @@ -25,10 +25,10 @@ func (s *Screen) Resize(width, height int) { | |||||
| 25 | if saved != nil && saved.saved { | 25 | if saved != nil && saved.saved { | ||
| 26 | savedPoint = &bufferCursor{row: saved.row, col: saved.col} | 26 | savedPoint = &bufferCursor{row: saved.row, col: saved.col} | ||
| 27 | } | 27 | } | ||
| 28 | - | evicted := b.resize(width, height, active, savedPoint) | 28 | + | evicted, evictedBounds := b.resize(width, height, active, savedPoint) |
| 29 | if evict { | 29 | if evict { | ||
| 30 | - | for _, line := range evicted { | 30 | + | for i, line := range evicted { |
| 31 | - | s.recordEvicted(line) | 31 | + | s.recordEvicted(line, evictedBounds[i]) |
| 32 | } | 32 | } | ||
| 33 | } | 33 | } | ||
| 34 | if savedPoint != nil { | 34 | if savedPoint != nil { | ||
M pkg/vt/screen_scroll.go +5 -3
| @@ -85,14 +85,16 @@ func (s *Screen) emitLineEvicted(top, n int) { | |||
| 85 | 85 | if s.alternate != nil || top != 0 { | |
| 86 | 86 | return | |
| 87 | 87 | } | |
| 88 | + | // Read boundaries before the caller rotates the frame: a soft link belongs | |
| 89 | + | // to the row it follows, and rotation reassigns row indices. | |
| 88 | 90 | for y := top; y < top+n; y++ { | |
| 89 | - | s.recordEvicted(s.Frame.Row(y)) | |
| 91 | + | s.recordEvicted(s.Frame.Row(y), s.buffer.bound(y)) | |
| 90 | 92 | } | |
| 91 | 93 | } | |
| 92 | 94 | ||
| 93 | - | func (s *Screen) recordEvicted(row []renderer.Cell) { | |
| 95 | + | func (s *Screen) recordEvicted(row []renderer.Cell, bound LineBound) { | |
| 94 | 96 | if s.history != nil { | |
| 95 | - | err := s.history.Append(row) | |
| 97 | + | err := s.history.Append(row, bound) | |
| 96 | 98 | if err != nil && !errors.Is(err, ErrHistoryRowTooWide) { | |
| 97 | 99 | panic(err) | |
| 98 | 100 | } | |
| @@ -85,14 +85,16 @@ func (s *Screen) emitLineEvicted(top, n int) { | |||||
| 85 | if s.alternate != nil || top != 0 { | 85 | if s.alternate != nil || top != 0 { | ||
| 86 | return | 86 | return | ||
| 87 | } | 87 | } | ||
| 88 | + | // Read boundaries before the caller rotates the frame: a soft link belongs | |||
| 89 | + | // to the row it follows, and rotation reassigns row indices. | |||
| 88 | for y := top; y < top+n; y++ { | 90 | for y := top; y < top+n; y++ { | ||
| 89 | - | s.recordEvicted(s.Frame.Row(y)) | 91 | + | s.recordEvicted(s.Frame.Row(y), s.buffer.bound(y)) |
| 90 | } | 92 | } | ||
| 91 | } | 93 | } | ||
| 92 | 94 | ||||
| 93 | - | func (s *Screen) recordEvicted(row []renderer.Cell) { | 95 | + | func (s *Screen) recordEvicted(row []renderer.Cell, bound LineBound) { |
| 94 | if s.history != nil { | 96 | if s.history != nil { | ||
| 95 | - | err := s.history.Append(row) | 97 | + | err := s.history.Append(row, bound) |
| 96 | if err != nil && !errors.Is(err, ErrHistoryRowTooWide) { | 98 | if err != nil && !errors.Is(err, ErrHistoryRowTooWide) { | ||
| 97 | panic(err) | 99 | panic(err) | ||
| 98 | } | 100 | } | ||
M pkg/vt/scrollback_test.go +59 -0
| @@ -99,6 +99,65 @@ func TestOnLineEvictedAltScreenAndRotation(t *testing.T) { | |||
| 99 | 99 | } | |
| 100 | 100 | } | |
| 101 | 101 | ||
| 102 | + | func TestEvictedLinesCarryTheirBound(t *testing.T) { | |
| 103 | + | tests := []struct { | |
| 104 | + | name string | |
| 105 | + | evict func(s *Screen) | |
| 106 | + | wantSoft bool | |
| 107 | + | }{ | |
| 108 | + | { | |
| 109 | + | name: "scroll evicts a soft-wrapped row", | |
| 110 | + | evict: func(s *Screen) { | |
| 111 | + | // Filling all four columns only leaves deferred wrap pending. The | |
| 112 | + | // fifth printable character triggers the soft wrap before scrolling. | |
| 113 | + | s.Write([]byte("abcde")) | |
| 114 | + | s.Write([]byte("\r\n\r\n\r\n\r\n")) | |
| 115 | + | }, | |
| 116 | + | wantSoft: true, | |
| 117 | + | }, | |
| 118 | + | { | |
| 119 | + | name: "scroll evicts a hard row", | |
| 120 | + | evict: func(s *Screen) { | |
| 121 | + | s.Write([]byte("ab\r\n\r\n\r\n\r\n")) | |
| 122 | + | }, | |
| 123 | + | wantSoft: false, | |
| 124 | + | }, | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | for _, tc := range tests { | |
| 128 | + | t.Run(tc.name, func(t *testing.T) { | |
| 129 | + | s := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 16, MaxCells: 1024}) | |
| 130 | + | tc.evict(s) | |
| 131 | + | view := s.History().SealAndView() | |
| 132 | + | if view.Len() == 0 { | |
| 133 | + | t.Fatal("nothing was evicted to history") | |
| 134 | + | } | |
| 135 | + | if got := view.Bound(0).Soft; got != tc.wantSoft { | |
| 136 | + | t.Errorf("Bound(0).Soft = %v, want %v", got, tc.wantSoft) | |
| 137 | + | } | |
| 138 | + | }) | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func TestReflowEvictedLinesCarryTheirBound(t *testing.T) { | |
| 143 | + | s := NewScreenWithHistory(8, 3, HistoryConfig{MaxRows: 16, MaxCells: 1024}) | |
| 144 | + | // Fill the grid with wrapped content, then shrink so rows are evicted by | |
| 145 | + | // the reflow path in buffer.resize rather than by scrolling. | |
| 146 | + | s.Write([]byte("aaaaaaaabbbbbbbbcccccccc")) | |
| 147 | + | s.Resize(4, 2) | |
| 148 | + | ||
| 149 | + | view := s.History().SealAndView() | |
| 150 | + | if view.Len() == 0 { | |
| 151 | + | t.Fatal("reflow evicted nothing to history") | |
| 152 | + | } | |
| 153 | + | for i := range view.Len() { | |
| 154 | + | bound := view.Bound(i) | |
| 155 | + | if bound.End == 0 { | |
| 156 | + | t.Errorf("Bound(%d).End = 0, want the reflowed row extent", i) | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | ||
| 102 | 161 | func equalStrings(a, b []string) bool { | |
| 103 | 162 | if len(a) != len(b) { | |
| 104 | 163 | return false | |
| @@ -99,6 +99,65 @@ func TestOnLineEvictedAltScreenAndRotation(t *testing.T) { | |||||
| 99 | } | 99 | } | ||
| 100 | } | 100 | } | ||
| 101 | 101 | ||||
| 102 | + | func TestEvictedLinesCarryTheirBound(t *testing.T) { | |||
| 103 | + | tests := []struct { | |||
| 104 | + | name string | |||
| 105 | + | evict func(s *Screen) | |||
| 106 | + | wantSoft bool | |||
| 107 | + | }{ | |||
| 108 | + | { | |||
| 109 | + | name: "scroll evicts a soft-wrapped row", | |||
| 110 | + | evict: func(s *Screen) { | |||
| 111 | + | // Filling all four columns only leaves deferred wrap pending. The | |||
| 112 | + | // fifth printable character triggers the soft wrap before scrolling. | |||
| 113 | + | s.Write([]byte("abcde")) | |||
| 114 | + | s.Write([]byte("\r\n\r\n\r\n\r\n")) | |||
| 115 | + | }, | |||
| 116 | + | wantSoft: true, | |||
| 117 | + | }, | |||
| 118 | + | { | |||
| 119 | + | name: "scroll evicts a hard row", | |||
| 120 | + | evict: func(s *Screen) { | |||
| 121 | + | s.Write([]byte("ab\r\n\r\n\r\n\r\n")) | |||
| 122 | + | }, | |||
| 123 | + | wantSoft: false, | |||
| 124 | + | }, | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | for _, tc := range tests { | |||
| 128 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 129 | + | s := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 16, MaxCells: 1024}) | |||
| 130 | + | tc.evict(s) | |||
| 131 | + | view := s.History().SealAndView() | |||
| 132 | + | if view.Len() == 0 { | |||
| 133 | + | t.Fatal("nothing was evicted to history") | |||
| 134 | + | } | |||
| 135 | + | if got := view.Bound(0).Soft; got != tc.wantSoft { | |||
| 136 | + | t.Errorf("Bound(0).Soft = %v, want %v", got, tc.wantSoft) | |||
| 137 | + | } | |||
| 138 | + | }) | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func TestReflowEvictedLinesCarryTheirBound(t *testing.T) { | |||
| 143 | + | s := NewScreenWithHistory(8, 3, HistoryConfig{MaxRows: 16, MaxCells: 1024}) | |||
| 144 | + | // Fill the grid with wrapped content, then shrink so rows are evicted by | |||
| 145 | + | // the reflow path in buffer.resize rather than by scrolling. | |||
| 146 | + | s.Write([]byte("aaaaaaaabbbbbbbbcccccccc")) | |||
| 147 | + | s.Resize(4, 2) | |||
| 148 | + | ||||
| 149 | + | view := s.History().SealAndView() | |||
| 150 | + | if view.Len() == 0 { | |||
| 151 | + | t.Fatal("reflow evicted nothing to history") | |||
| 152 | + | } | |||
| 153 | + | for i := range view.Len() { | |||
| 154 | + | bound := view.Bound(i) | |||
| 155 | + | if bound.End == 0 { | |||
| 156 | + | t.Errorf("Bound(%d).End = 0, want the reflowed row extent", i) | |||
| 157 | + | } | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 160 | + | ||||
| 102 | func equalStrings(a, b []string) bool { | 161 | func equalStrings(a, b []string) bool { | ||
| 103 | if len(a) != len(b) { | 162 | if len(a) != len(b) { | ||
| 104 | return false | 163 | return false | ||
M pkg/vt/snapshot.go +1 -0
| @@ -102,6 +102,7 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist | |||
| 102 | 102 | if len(view.chunks) == 1 { | |
| 103 | 103 | h.evictUntil(len(view.chunks[0].rows), view.Cells()) | |
| 104 | 104 | h.tail = view.chunks[0].rows | |
| 105 | + | h.tailBounds = growBounds(view.chunks[0].bounds, len(h.tail)) | |
| 105 | 106 | h.rows += len(h.tail) | |
| 106 | 107 | h.cells += view.Cells() | |
| 107 | 108 | } | |
| @@ -102,6 +102,7 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist | |||||
| 102 | if len(view.chunks) == 1 { | 102 | if len(view.chunks) == 1 { | ||
| 103 | h.evictUntil(len(view.chunks[0].rows), view.Cells()) | 103 | h.evictUntil(len(view.chunks[0].rows), view.Cells()) | ||
| 104 | h.tail = view.chunks[0].rows | 104 | h.tail = view.chunks[0].rows | ||
| 105 | + | h.tailBounds = growBounds(view.chunks[0].bounds, len(h.tail)) | |||
| 105 | h.rows += len(h.tail) | 106 | h.rows += len(h.tail) | ||
| 106 | h.cells += view.Cells() | 107 | h.cells += view.Cells() | ||
| 107 | } | 108 | } | ||
M pkg/vt/snapshot_test.go +5 -11
| @@ -10,15 +10,9 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | func TestHistorySnapshotViewDoesNotSealTailAndCopiesIt(t *testing.T) { | |
| 12 | 12 | history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2}) | |
| 13 | - | if err := history.Append(historyRow("AAAA")); err != nil { | |
| 14 | - | t.Fatal(err) | |
| 15 | - | } | |
| 16 | - | if err := history.Append(historyRow("BBBB")); err != nil { | |
| 17 | - | t.Fatal(err) | |
| 18 | - | } | |
| 19 | - | if err := history.Append(historyRow("CCCC")); err != nil { | |
| 20 | - | t.Fatal(err) | |
| 21 | - | } | |
| 13 | + | requireHistoryAppend(t, history, historyRow("AAAA")) | |
| 14 | + | requireHistoryAppend(t, history, historyRow("BBBB")) | |
| 15 | + | requireHistoryAppend(t, history, historyRow("CCCC")) | |
| 22 | 16 | sealed := history.View().Chunk(0) | |
| 23 | 17 | ||
| 24 | 18 | view := history.SnapshotView() | |
| @@ -58,7 +52,7 @@ func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) { | |||
| 58 | 52 | ||
| 59 | 53 | restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 4, ChunkRows: 2}, nil, fullTail) | |
| 60 | 54 | require.NoError(t, err) | |
| 61 | - | require.NoError(t, restored.Append(historyRow("CCCC"))) | |
| 55 | + | requireHistoryAppend(t, restored, historyRow("CCCC")) | |
| 62 | 56 | ||
| 63 | 57 | require.Equal(t, []string{"AAAA", "BBBB", "CCCC"}, historyViewTexts(restored.View())) | |
| 64 | 58 | require.Len(t, restored.chunks, 1, "a full restored tail must become sealed") | |
| @@ -68,7 +62,7 @@ func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) { | |||
| 68 | 62 | func TestHistoryFromBlobsRestoresCellAccountingAndEvictsToBothBudgets(t *testing.T) { | |
| 69 | 63 | history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 10, ChunkRows: 2}) | |
| 70 | 64 | for _, text := range []string{"a", "b", "cc"} { | |
| 71 | - | require.NoError(t, history.Append(historyRow(text))) | |
| 65 | + | requireHistoryAppend(t, history, historyRow(text)) | |
| 72 | 66 | } | |
| 73 | 67 | view := history.SnapshotView() | |
| 74 | 68 | sealed := make([][]byte, view.ChunkCount()) | |
| @@ -10,15 +10,9 @@ import ( | |||||
| 10 | 10 | ||||
| 11 | func TestHistorySnapshotViewDoesNotSealTailAndCopiesIt(t *testing.T) { | 11 | func TestHistorySnapshotViewDoesNotSealTailAndCopiesIt(t *testing.T) { | ||
| 12 | history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2}) | 12 | history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2}) | ||
| 13 | - | if err := history.Append(historyRow("AAAA")); err != nil { | 13 | + | requireHistoryAppend(t, history, historyRow("AAAA")) |
| 14 | - | t.Fatal(err) | 14 | + | requireHistoryAppend(t, history, historyRow("BBBB")) |
| 15 | - | } | 15 | + | requireHistoryAppend(t, history, historyRow("CCCC")) |
| 16 | - | if err := history.Append(historyRow("BBBB")); err != nil { | |||
| 17 | - | t.Fatal(err) | |||
| 18 | - | } | |||
| 19 | - | if err := history.Append(historyRow("CCCC")); err != nil { | |||
| 20 | - | t.Fatal(err) | |||
| 21 | - | } | |||
| 22 | sealed := history.View().Chunk(0) | 16 | sealed := history.View().Chunk(0) | ||
| 23 | 17 | ||||
| 24 | view := history.SnapshotView() | 18 | view := history.SnapshotView() | ||
| @@ -58,7 +52,7 @@ func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) { | |||||
| 58 | 52 | ||||
| 59 | restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 4, ChunkRows: 2}, nil, fullTail) | 53 | restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 4, ChunkRows: 2}, nil, fullTail) | ||
| 60 | require.NoError(t, err) | 54 | require.NoError(t, err) | ||
| 61 | - | require.NoError(t, restored.Append(historyRow("CCCC"))) | 55 | + | requireHistoryAppend(t, restored, historyRow("CCCC")) |
| 62 | 56 | ||||
| 63 | require.Equal(t, []string{"AAAA", "BBBB", "CCCC"}, historyViewTexts(restored.View())) | 57 | require.Equal(t, []string{"AAAA", "BBBB", "CCCC"}, historyViewTexts(restored.View())) | ||
| 64 | require.Len(t, restored.chunks, 1, "a full restored tail must become sealed") | 58 | require.Len(t, restored.chunks, 1, "a full restored tail must become sealed") | ||
| @@ -68,7 +62,7 @@ func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) { | |||||
| 68 | func TestHistoryFromBlobsRestoresCellAccountingAndEvictsToBothBudgets(t *testing.T) { | 62 | func TestHistoryFromBlobsRestoresCellAccountingAndEvictsToBothBudgets(t *testing.T) { | ||
| 69 | history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 10, ChunkRows: 2}) | 63 | history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 10, ChunkRows: 2}) | ||
| 70 | for _, text := range []string{"a", "b", "cc"} { | 64 | for _, text := range []string{"a", "b", "cc"} { | ||
| 71 | - | require.NoError(t, history.Append(historyRow(text))) | 65 | + | requireHistoryAppend(t, history, historyRow(text)) |
| 72 | } | 66 | } | ||
| 73 | view := history.SnapshotView() | 67 | view := history.SnapshotView() | ||
| 74 | sealed := make([][]byte, view.ChunkCount()) | 68 | sealed := make([][]byte, view.ChunkCount()) | ||