feat(terminal): integrate compact storage and measured history budgets (#254)
* feat(terminal): integrate compact storage and measured history budgets * fix(copy): handle checked history iteration and pin reviewed storage * fix(snapshot): reject incompatible dense VT checkpoints * fix(copy): discard failed searches without crashing daemon * perf(copy): avoid redundant frame work while scrolling * test(copy): cover row semantics and cache ownership boundaries * chore: bump vev-vt to v0.5.0
- commit
- cb489d5b4ce3e21e27fd516e65f719980aa16ee4
- author
- bnema · · 2026-09-05 10:19 UTC
- committer
- GitHub · · 2026-09-05 10:19 UTC
- parent
- 9bf0749
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
76 files · +1366 -399
A docs/compact-storage-benchmarks.md +106 -0
| @@ -0,0 +1,106 @@ | |||
| 1 | + | # Compact storage: measured budgets | |
| 2 | + | ||
| 3 | + | Measured September 5, 2026 (CEST; September 4 UTC) on Linux amd64, Ryzen 5 7535HS, Go 1.27.1, | |
| 4 | + | `GOMAXPROCS=2`. The existing manual-test binary was not replaced or restarted. | |
| 5 | + | ||
| 6 | + | ## References and method | |
| 7 | + | ||
| 8 | + | - Consumer baseline: clean vev `9bf07496` (before this integration). | |
| 9 | + | - Measured compact consumer: the integration captured in vev `b59725e3`, using vev-vt `f912b86` (before the final review fixes). | |
| 10 | + | - Current integration dependency: vev-vt `f2960699a487`. The figures below were not remeasured at this revision; they describe the earlier `f912b86` dependency state. | |
| 11 | + | - Retained-history baseline: vev-vt foundation `a0f7c5a`, before compact pages. | |
| 12 | + | - Consumer workloads: 120 × 40 terminals, 10,000 history rows per pane. | |
| 13 | + | - Main consumer comparisons: three 100 ms samples, reported as medians. | |
| 14 | + | - Additional four/eight-tab cases: one 100 ms sample; directional evidence only. | |
| 15 | + | - Retained heap: three one-iteration samples with explicit GC; not process RSS. | |
| 16 | + | ||
| 17 | + | These are local short-run measurements, not a statistical latency guarantee. | |
| 18 | + | ||
| 19 | + | ## Consumer results | |
| 20 | + | ||
| 21 | + | | Operation | Dense ms/op | Compact ms/op | Dense bytes/op | Compact bytes/op | Dense allocs/op | Compact allocs/op | | |
| 22 | + | | --- | ---: | ---: | ---: | ---: | ---: | ---: | | |
| 23 | + | | Live paint, 1 tab / 1 pane | 0.197 | 0.951 | 356,090 | 86,507 | 36 | 40 | | |
| 24 | + | | Live paint, 1 tab / 4 panes | 0.129 | 0.444 | 355,884 | 86,041 | 36 | 40 | | |
| 25 | + | | Copy entry, 1 tab / 1 pane | 0.708 | 2.180 | 2,100,051 | 449,512 | 64 | 85 | | |
| 26 | + | | Copy entry, 1 tab / 4 panes | 0.629 | 1.389 | 1,542,092 | 296,382 | 82 | 114 | | |
| 27 | + | | Copy entry, 4 tabs / 1 pane* | 1.165 | 2.144 | 2,096,593 | 433,169 | 68 | 88 | | |
| 28 | + | | Copy entry, 4 tabs / 4 panes* | 0.974 | 1.383 | 1,541,684 | 296,647 | 86 | 118 | | |
| 29 | + | | Copy entry, 8 tabs / 1 pane* | 1.113 | 2.169 | 2,098,087 | 434,861 | 76 | 96 | | |
| 30 | + | ||
| 31 | + | `*` Single-sample comparison. | |
| 32 | + | ||
| 33 | + | Main workloads emitted the same output bytes and one output frame per operation: | |
| 34 | + | 57/59 bytes for live paint and 524/971 bytes for copy entry. | |
| 35 | + | ||
| 36 | + | **The trade-off is real:** allocated bytes fall approximately 4–5×, but the | |
| 37 | + | main workloads take approximately 2.2–4.8× longer. Do not describe this change as | |
| 38 | + | a general throughput improvement. The integration's cell-by-cell grid copies | |
| 39 | + | and page-local style bookkeeping remain optimization candidates. No unsafe | |
| 40 | + | copying, pooling, or new allocator was introduced to make allocation counts pass. | |
| 41 | + | ||
| 42 | + | ## Retained history | |
| 43 | + | ||
| 44 | + | Plain ASCII, 10,000 × 120 cells: | |
| 45 | + | ||
| 46 | + | | Representation | Median retained bytes | | |
| 47 | + | | --- | ---: | | |
| 48 | + | | Dense foundation | 95,283,872 | | |
| 49 | + | | Compact, uncompressed | 22,769,712 | | |
| 50 | + | | Compact after two idle compression passes | 4,209,776 | | |
| 51 | + | ||
| 52 | + | Uncompressed storage meets issue #9's **4× retained-memory reduction** target | |
| 53 | + | (approximately 4.18×). Cold storage retains approximately 22.6× less than dense | |
| 54 | + | storage, with 38 cold pages and 173,190 compressed bytes. | |
| 55 | + | ||
| 56 | + | Building this synthetic history allocates **148.5 MB cumulatively**, versus | |
| 57 | + | 95.8 MB for the dense reference. Lower retained memory does not imply lower | |
| 58 | + | allocation traffic during construction. The mutable semantic tail contributes | |
| 59 | + | to this cost. Retained-memory sample times include measurement GCs and must not | |
| 60 | + | be treated as isolated append throughput. | |
| 61 | + | ||
| 62 | + | ## Regression gates | |
| 63 | + | ||
| 64 | + | The previous 38/66–90 allocation-count thresholds came from vev's dense-frame | |
| 65 | + | benchmarks, not from the vev-vt issues. The floating composition threshold of | |
| 66 | + | two allocations explicitly assumed two dense slices. | |
| 67 | + | ||
| 68 | + | The recalibrated tests retain count checks and add independent byte checks: | |
| 69 | + | ||
| 70 | + | - Live paint: at most **44 allocations** and **96 KiB/op**. | |
| 71 | + | - Copy entry: approximately 10% count headroom over measured compact values; | |
| 72 | + | at most **512 KiB/op** for single-pane tabs and **384 KiB/op** for four-pane tabs. | |
| 73 | + | - Cached floating composition: the measured cost of one compact clone plus one | |
| 74 | + | allocation for initial style growth, and at most **40 KiB/op**. | |
| 75 | + | - Warm title/bar composition still requires **zero allocations**. | |
| 76 | + | - Retained-history and ownership/safety checks remain separate and unchanged. | |
| 77 | + | ||
| 78 | + | Byte checks use Go benchmark allocation accounting, excluding fixture setup. | |
| 79 | + | Instrumented race runs exercise behavior but do not enforce byte budgets. | |
| 80 | + | Wall-clock timings are reported rather than asserted as machine-dependent CI | |
| 81 | + | thresholds. The CPU regression remains visible above; these gates do not claim | |
| 82 | + | that it has been removed. | |
| 83 | + | ||
| 84 | + | ## Reproduce | |
| 85 | + | ||
| 86 | + | Run the same consumer command in clean vev `9bf07496` and the integration tree. | |
| 87 | + | The current integration measures the final dependency, not the exact historical | |
| 88 | + | sample above. For the historical comparison, use a separate checkout of vev | |
| 89 | + | `b59725e3`, which pins the compatible `f912b86` dependency and pre-review consumer | |
| 90 | + | API. Do not downgrade the dependency alone in the current integration. | |
| 91 | + | ||
| 92 | + | ```sh | |
| 93 | + | GOMAXPROCS=2 go test ./internal/usecase/daemon -run '^$' \ | |
| 94 | + | -bench '^BenchmarkDaemonHistory(LivePaint|CopyEnter)$/^1tab-(1pane-control|4panes)$' \ | |
| 95 | + | -benchmem -benchtime=100ms -count=3 | |
| 96 | + | ``` | |
| 97 | + | ||
| 98 | + | Run in the dense foundation and compact vev-vt trees: | |
| 99 | + | ||
| 100 | + | ```sh | |
| 101 | + | GOMAXPROCS=2 go test -run '^$' \ | |
| 102 | + | -bench '^BenchmarkHistoryRetained10Kx120$/plain-ascii$' \ | |
| 103 | + | -benchtime=1x -count=3 -benchmem | |
| 104 | + | ``` | |
| 105 | + | ||
| 106 | + | The compact tree also provides `BenchmarkColdHistoryRetained10Kx120`. | |
| @@ -0,0 +1,106 @@ | |||||
| 1 | + | # Compact storage: measured budgets | |||
| 2 | + | ||||
| 3 | + | Measured September 5, 2026 (CEST; September 4 UTC) on Linux amd64, Ryzen 5 7535HS, Go 1.27.1, | |||
| 4 | + | `GOMAXPROCS=2`. The existing manual-test binary was not replaced or restarted. | |||
| 5 | + | ||||
| 6 | + | ## References and method | |||
| 7 | + | ||||
| 8 | + | - Consumer baseline: clean vev `9bf07496` (before this integration). | |||
| 9 | + | - Measured compact consumer: the integration captured in vev `b59725e3`, using vev-vt `f912b86` (before the final review fixes). | |||
| 10 | + | - Current integration dependency: vev-vt `f2960699a487`. The figures below were not remeasured at this revision; they describe the earlier `f912b86` dependency state. | |||
| 11 | + | - Retained-history baseline: vev-vt foundation `a0f7c5a`, before compact pages. | |||
| 12 | + | - Consumer workloads: 120 × 40 terminals, 10,000 history rows per pane. | |||
| 13 | + | - Main consumer comparisons: three 100 ms samples, reported as medians. | |||
| 14 | + | - Additional four/eight-tab cases: one 100 ms sample; directional evidence only. | |||
| 15 | + | - Retained heap: three one-iteration samples with explicit GC; not process RSS. | |||
| 16 | + | ||||
| 17 | + | These are local short-run measurements, not a statistical latency guarantee. | |||
| 18 | + | ||||
| 19 | + | ## Consumer results | |||
| 20 | + | ||||
| 21 | + | | Operation | Dense ms/op | Compact ms/op | Dense bytes/op | Compact bytes/op | Dense allocs/op | Compact allocs/op | | |||
| 22 | + | | --- | ---: | ---: | ---: | ---: | ---: | ---: | | |||
| 23 | + | | Live paint, 1 tab / 1 pane | 0.197 | 0.951 | 356,090 | 86,507 | 36 | 40 | | |||
| 24 | + | | Live paint, 1 tab / 4 panes | 0.129 | 0.444 | 355,884 | 86,041 | 36 | 40 | | |||
| 25 | + | | Copy entry, 1 tab / 1 pane | 0.708 | 2.180 | 2,100,051 | 449,512 | 64 | 85 | | |||
| 26 | + | | Copy entry, 1 tab / 4 panes | 0.629 | 1.389 | 1,542,092 | 296,382 | 82 | 114 | | |||
| 27 | + | | Copy entry, 4 tabs / 1 pane* | 1.165 | 2.144 | 2,096,593 | 433,169 | 68 | 88 | | |||
| 28 | + | | Copy entry, 4 tabs / 4 panes* | 0.974 | 1.383 | 1,541,684 | 296,647 | 86 | 118 | | |||
| 29 | + | | Copy entry, 8 tabs / 1 pane* | 1.113 | 2.169 | 2,098,087 | 434,861 | 76 | 96 | | |||
| 30 | + | ||||
| 31 | + | `*` Single-sample comparison. | |||
| 32 | + | ||||
| 33 | + | Main workloads emitted the same output bytes and one output frame per operation: | |||
| 34 | + | 57/59 bytes for live paint and 524/971 bytes for copy entry. | |||
| 35 | + | ||||
| 36 | + | **The trade-off is real:** allocated bytes fall approximately 4–5×, but the | |||
| 37 | + | main workloads take approximately 2.2–4.8× longer. Do not describe this change as | |||
| 38 | + | a general throughput improvement. The integration's cell-by-cell grid copies | |||
| 39 | + | and page-local style bookkeeping remain optimization candidates. No unsafe | |||
| 40 | + | copying, pooling, or new allocator was introduced to make allocation counts pass. | |||
| 41 | + | ||||
| 42 | + | ## Retained history | |||
| 43 | + | ||||
| 44 | + | Plain ASCII, 10,000 × 120 cells: | |||
| 45 | + | ||||
| 46 | + | | Representation | Median retained bytes | | |||
| 47 | + | | --- | ---: | | |||
| 48 | + | | Dense foundation | 95,283,872 | | |||
| 49 | + | | Compact, uncompressed | 22,769,712 | | |||
| 50 | + | | Compact after two idle compression passes | 4,209,776 | | |||
| 51 | + | ||||
| 52 | + | Uncompressed storage meets issue #9's **4× retained-memory reduction** target | |||
| 53 | + | (approximately 4.18×). Cold storage retains approximately 22.6× less than dense | |||
| 54 | + | storage, with 38 cold pages and 173,190 compressed bytes. | |||
| 55 | + | ||||
| 56 | + | Building this synthetic history allocates **148.5 MB cumulatively**, versus | |||
| 57 | + | 95.8 MB for the dense reference. Lower retained memory does not imply lower | |||
| 58 | + | allocation traffic during construction. The mutable semantic tail contributes | |||
| 59 | + | to this cost. Retained-memory sample times include measurement GCs and must not | |||
| 60 | + | be treated as isolated append throughput. | |||
| 61 | + | ||||
| 62 | + | ## Regression gates | |||
| 63 | + | ||||
| 64 | + | The previous 38/66–90 allocation-count thresholds came from vev's dense-frame | |||
| 65 | + | benchmarks, not from the vev-vt issues. The floating composition threshold of | |||
| 66 | + | two allocations explicitly assumed two dense slices. | |||
| 67 | + | ||||
| 68 | + | The recalibrated tests retain count checks and add independent byte checks: | |||
| 69 | + | ||||
| 70 | + | - Live paint: at most **44 allocations** and **96 KiB/op**. | |||
| 71 | + | - Copy entry: approximately 10% count headroom over measured compact values; | |||
| 72 | + | at most **512 KiB/op** for single-pane tabs and **384 KiB/op** for four-pane tabs. | |||
| 73 | + | - Cached floating composition: the measured cost of one compact clone plus one | |||
| 74 | + | allocation for initial style growth, and at most **40 KiB/op**. | |||
| 75 | + | - Warm title/bar composition still requires **zero allocations**. | |||
| 76 | + | - Retained-history and ownership/safety checks remain separate and unchanged. | |||
| 77 | + | ||||
| 78 | + | Byte checks use Go benchmark allocation accounting, excluding fixture setup. | |||
| 79 | + | Instrumented race runs exercise behavior but do not enforce byte budgets. | |||
| 80 | + | Wall-clock timings are reported rather than asserted as machine-dependent CI | |||
| 81 | + | thresholds. The CPU regression remains visible above; these gates do not claim | |||
| 82 | + | that it has been removed. | |||
| 83 | + | ||||
| 84 | + | ## Reproduce | |||
| 85 | + | ||||
| 86 | + | Run the same consumer command in clean vev `9bf07496` and the integration tree. | |||
| 87 | + | The current integration measures the final dependency, not the exact historical | |||
| 88 | + | sample above. For the historical comparison, use a separate checkout of vev | |||
| 89 | + | `b59725e3`, which pins the compatible `f912b86` dependency and pre-review consumer | |||
| 90 | + | API. Do not downgrade the dependency alone in the current integration. | |||
| 91 | + | ||||
| 92 | + | ```sh | |||
| 93 | + | GOMAXPROCS=2 go test ./internal/usecase/daemon -run '^$' \ | |||
| 94 | + | -bench '^BenchmarkDaemonHistory(LivePaint|CopyEnter)$/^1tab-(1pane-control|4panes)$' \ | |||
| 95 | + | -benchmem -benchtime=100ms -count=3 | |||
| 96 | + | ``` | |||
| 97 | + | ||||
| 98 | + | Run in the dense foundation and compact vev-vt trees: | |||
| 99 | + | ||||
| 100 | + | ```sh | |||
| 101 | + | GOMAXPROCS=2 go test -run '^$' \ | |||
| 102 | + | -bench '^BenchmarkHistoryRetained10Kx120$/plain-ascii$' \ | |||
| 103 | + | -benchtime=1x -count=3 -benchmem | |||
| 104 | + | ``` | |||
| 105 | + | ||||
| 106 | + | The compact tree also provides `BenchmarkColdHistoryRetained10Kx120`. | |||
M docs/configuration.md +31 -0
| @@ -40,6 +40,11 @@ focus-pane-down = alt+j | |||
| 40 | 40 | switch-tab-1 = alt+1 | |
| 41 | 41 | # ... through switch-tab-9 = alt+9 | |
| 42 | 42 | ||
| 43 | + | # Per-pane scrollback: decimal MB; zero disables history. | |
| 44 | + | scrollback.megabytes = 50 | |
| 45 | + | # Optional additional line ceiling; zero means byte-only retention. | |
| 46 | + | scrollback.lines = 10000 | |
| 47 | + | ||
| 43 | 48 | # Processes relaunched when a named session is restored. Empty disables relaunch. | |
| 44 | 49 | snapshot.restore_processes = vi,vim,nvim,emacs,man,less,more,tail,top,htop,btop,claude,codex,pi,opencode | |
| 45 | 50 | ||
| @@ -79,6 +84,32 @@ code.detach = DET | |||
| 79 | 84 | ||
| 80 | 85 | Invalid values log a warning and resolve that setting to its default on both initial load and reload. | |
| 81 | 86 | ||
| 87 | + | ## Scrollback | |
| 88 | + | ||
| 89 | + | `vev` chooses the retention policy; `vev-vt` enforces it. The PTY transports | |
| 90 | + | bytes and does not own scrollback. | |
| 91 | + | ||
| 92 | + | - `scrollback.megabytes`: 0–4096 decimal MB, default 50. Zero disables history. | |
| 93 | + | - `scrollback.lines`: 0–1,000,000 lines, default 10,000. Zero removes the | |
| 94 | + | additional line ceiling when the byte budget is positive. | |
| 95 | + | - Limits apply independently to each pane, including hidden floating panes, | |
| 96 | + | and exclude visible primary/alternate grids. They measure uncompressed | |
| 97 | + | logical history bytes, not total process RSS. | |
| 98 | + | - Reload applies to existing panes immediately. Lowering a limit evicts the | |
| 99 | + | oldest rows; disabling history discards its retained contents. Existing copy | |
| 100 | + | documents remain stable. New and restored panes use the current limits. | |
| 101 | + | - Idle maintenance visits at most one sealed page per pane every five seconds, | |
| 102 | + | skips busy panes, and compresses only pages that remain cold. Compression does | |
| 103 | + | not grant additional retention or count compressed bytes toward the budget. | |
| 104 | + | ||
| 105 | + | Persistence has separate per-blob and aggregate safety ceilings; raising the | |
| 106 | + | runtime retention budget does not remove them. The compact codec replaces the | |
| 107 | + | old VT encoding directly: old VT history blobs are not readable by this alpha | |
| 108 | + | build. Test with `VEV_ENV=dev`, not your normal daemon's persisted state. | |
| 109 | + | ||
| 110 | + | See [compact storage measurements](compact-storage-benchmarks.md) for memory, | |
| 111 | + | allocation and CPU trade-offs. | |
| 112 | + | ||
| 82 | 113 | ## Development environments | |
| 83 | 114 | ||
| 84 | 115 | Set `VEV_ENV` to a name such as `dev` to redirect all vev-owned paths into `.dev/<name>/` under the directory where vev was invoked. The name must be one safe segment of at most 64 ASCII letters, digits, dots, underscores, or hyphens, and must start with a letter or digit. | |
| @@ -40,6 +40,11 @@ focus-pane-down = alt+j | |||||
| 40 | switch-tab-1 = alt+1 | 40 | switch-tab-1 = alt+1 | ||
| 41 | # ... through switch-tab-9 = alt+9 | 41 | # ... through switch-tab-9 = alt+9 | ||
| 42 | 42 | ||||
| 43 | + | # Per-pane scrollback: decimal MB; zero disables history. | |||
| 44 | + | scrollback.megabytes = 50 | |||
| 45 | + | # Optional additional line ceiling; zero means byte-only retention. | |||
| 46 | + | scrollback.lines = 10000 | |||
| 47 | + | ||||
| 43 | # Processes relaunched when a named session is restored. Empty disables relaunch. | 48 | # Processes relaunched when a named session is restored. Empty disables relaunch. | ||
| 44 | snapshot.restore_processes = vi,vim,nvim,emacs,man,less,more,tail,top,htop,btop,claude,codex,pi,opencode | 49 | snapshot.restore_processes = vi,vim,nvim,emacs,man,less,more,tail,top,htop,btop,claude,codex,pi,opencode | ||
| 45 | 50 | ||||
| @@ -79,6 +84,32 @@ code.detach = DET | |||||
| 79 | 84 | ||||
| 80 | Invalid values log a warning and resolve that setting to its default on both initial load and reload. | 85 | Invalid values log a warning and resolve that setting to its default on both initial load and reload. | ||
| 81 | 86 | ||||
| 87 | + | ## Scrollback | |||
| 88 | + | ||||
| 89 | + | `vev` chooses the retention policy; `vev-vt` enforces it. The PTY transports | |||
| 90 | + | bytes and does not own scrollback. | |||
| 91 | + | ||||
| 92 | + | - `scrollback.megabytes`: 0–4096 decimal MB, default 50. Zero disables history. | |||
| 93 | + | - `scrollback.lines`: 0–1,000,000 lines, default 10,000. Zero removes the | |||
| 94 | + | additional line ceiling when the byte budget is positive. | |||
| 95 | + | - Limits apply independently to each pane, including hidden floating panes, | |||
| 96 | + | and exclude visible primary/alternate grids. They measure uncompressed | |||
| 97 | + | logical history bytes, not total process RSS. | |||
| 98 | + | - Reload applies to existing panes immediately. Lowering a limit evicts the | |||
| 99 | + | oldest rows; disabling history discards its retained contents. Existing copy | |||
| 100 | + | documents remain stable. New and restored panes use the current limits. | |||
| 101 | + | - Idle maintenance visits at most one sealed page per pane every five seconds, | |||
| 102 | + | skips busy panes, and compresses only pages that remain cold. Compression does | |||
| 103 | + | not grant additional retention or count compressed bytes toward the budget. | |||
| 104 | + | ||||
| 105 | + | Persistence has separate per-blob and aggregate safety ceilings; raising the | |||
| 106 | + | runtime retention budget does not remove them. The compact codec replaces the | |||
| 107 | + | old VT encoding directly: old VT history blobs are not readable by this alpha | |||
| 108 | + | build. Test with `VEV_ENV=dev`, not your normal daemon's persisted state. | |||
| 109 | + | ||||
| 110 | + | See [compact storage measurements](compact-storage-benchmarks.md) for memory, | |||
| 111 | + | allocation and CPU trade-offs. | |||
| 112 | + | ||||
| 82 | ## Development environments | 113 | ## Development environments | ||
| 83 | 114 | ||||
| 84 | Set `VEV_ENV` to a name such as `dev` to redirect all vev-owned paths into `.dev/<name>/` under the directory where vev was invoked. The name must be one safe segment of at most 64 ASCII letters, digits, dots, underscores, or hyphens, and must start with a letter or digit. | 115 | Set `VEV_ENV` to a name such as `dev` to redirect all vev-owned paths into `.dev/<name>/` under the directory where vev was invoked. The name must be one safe segment of at most 64 ASCII letters, digits, dots, underscores, or hyphens, and must start with a letter or digit. | ||
M docs/durable-session-recovery.md +2 -0
| @@ -20,6 +20,8 @@ Before rewriting a legacy catalogue, vev creates and syncs a private `sessions.k | |||
| 20 | 20 | ||
| 21 | 21 | ## Incompatible checkpoints | |
| 22 | 22 | ||
| 23 | + | Compact VT storage uses VEVM version 4 and VEVS version 5. Dense VT checkpoints are intentionally incompatible: there is no scrollback migration or legacy decoder. On the next daemon startup, healthy older VEVM checkpoints take the reset path below. This clears saved tabs, layout, screen contents, scrollback, and recovery transcripts while preserving session names and working directories. The live client/daemon protocol is unchanged. Installing a binary does not restart an existing daemon. | |
| 24 | + | ||
| 23 | 25 | After verifying a VEVM manifest's digest, vev treats any VEVM version mismatch as an incompatible healthy checkpoint. It atomically replaces only that named session's exact healthy checkpoint with a fresh incarnation. Unlike a protocol reset, this replacement retains the session name and working directory, but has no checkpoint, tabs, terminal history, or recovery transcript. | |
| 24 | 26 | ||
| 25 | 27 | Digest mismatches, corruption, validation failures, I/O errors, and ambiguous failures are never reset or purged. The session remains degraded for explicit recovery. | |
| @@ -20,6 +20,8 @@ Before rewriting a legacy catalogue, vev creates and syncs a private `sessions.k | |||||
| 20 | 20 | ||||
| 21 | ## Incompatible checkpoints | 21 | ## Incompatible checkpoints | ||
| 22 | 22 | ||||
| 23 | + | Compact VT storage uses VEVM version 4 and VEVS version 5. Dense VT checkpoints are intentionally incompatible: there is no scrollback migration or legacy decoder. On the next daemon startup, healthy older VEVM checkpoints take the reset path below. This clears saved tabs, layout, screen contents, scrollback, and recovery transcripts while preserving session names and working directories. The live client/daemon protocol is unchanged. Installing a binary does not restart an existing daemon. | |||
| 24 | + | ||||
| 23 | After verifying a VEVM manifest's digest, vev treats any VEVM version mismatch as an incompatible healthy checkpoint. It atomically replaces only that named session's exact healthy checkpoint with a fresh incarnation. Unlike a protocol reset, this replacement retains the session name and working directory, but has no checkpoint, tabs, terminal history, or recovery transcript. | 25 | After verifying a VEVM manifest's digest, vev treats any VEVM version mismatch as an incompatible healthy checkpoint. It atomically replaces only that named session's exact healthy checkpoint with a fresh incarnation. Unlike a protocol reset, this replacement retains the session name and working directory, but has no checkpoint, tabs, terminal history, or recovery transcript. | ||
| 24 | 26 | ||||
| 25 | Digest mismatches, corruption, validation failures, I/O errors, and ambiguous failures are never reset or purged. The session remains degraded for explicit recovery. | 27 | Digest mismatches, corruption, validation failures, I/O errors, and ambiguous failures are never reset or purged. The session remains degraded for explicit recovery. | ||
M go.mod +1 -1
| @@ -3,7 +3,7 @@ module github.com/bnema/vev | |||
| 3 | 3 | go 1.27 | |
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | - | github.com/bnema/vev-vt v0.4.2-0.20260827060951-f53f75f19d7f | |
| 6 | + | github.com/bnema/vev-vt v0.5.0 | |
| 7 | 7 | github.com/stretchr/testify v1.11.1 | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| @@ -3,7 +3,7 @@ module github.com/bnema/vev | |||||
| 3 | go 1.27 | 3 | go 1.27 | ||
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | - | github.com/bnema/vev-vt v0.4.2-0.20260827060951-f53f75f19d7f | 6 | + | github.com/bnema/vev-vt v0.5.0 |
| 7 | github.com/stretchr/testify v1.11.1 | 7 | github.com/stretchr/testify v1.11.1 | ||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
M go.sum +2 -2
| @@ -1,5 +1,5 @@ | |||
| 1 | - | github.com/bnema/vev-vt v0.4.2-0.20260827060951-f53f75f19d7f h1:1lMAFApMUWklMwPm1RJS68RisXLeJYwVKXOLc4vhAzY= | |
| 2 | - | github.com/bnema/vev-vt v0.4.2-0.20260827060951-f53f75f19d7f/go.mod h1:QdV0QI7vUnlAF+7+CiPhThHDpDSkxMEZmCYl/Eo+xCA= | |
| 1 | + | github.com/bnema/vev-vt v0.5.0 h1:sIUgb0UGTJbtqw+c/zpatLSKLaUkbF/GBxMcHWm8vDM= | |
| 2 | + | github.com/bnema/vev-vt v0.5.0/go.mod h1:mBdVV+ALtBgPNVHPrxY50S+d1ZO/sDn0AA7EtJf00M4= | |
| 3 | 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | |
| 4 | 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | |
| 5 | 5 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | |
| @@ -1,5 +1,5 @@ | |||||
| 1 | - | github.com/bnema/vev-vt v0.4.2-0.20260827060951-f53f75f19d7f h1:1lMAFApMUWklMwPm1RJS68RisXLeJYwVKXOLc4vhAzY= | 1 | + | github.com/bnema/vev-vt v0.5.0 h1:sIUgb0UGTJbtqw+c/zpatLSKLaUkbF/GBxMcHWm8vDM= |
| 2 | - | github.com/bnema/vev-vt v0.4.2-0.20260827060951-f53f75f19d7f/go.mod h1:QdV0QI7vUnlAF+7+CiPhThHDpDSkxMEZmCYl/Eo+xCA= | 2 | + | github.com/bnema/vev-vt v0.5.0/go.mod h1:mBdVV+ALtBgPNVHPrxY50S+d1ZO/sDn0AA7EtJf00M4= |
| 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | ||
| 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||
| 5 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | 5 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | ||
M internal/adapters/config/config.go +17 -0
| @@ -35,6 +35,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 35 | 35 | seenPaletteKeys := make(map[string]bool) | |
| 36 | 36 | seenNavKeys := make(map[string]bool) | |
| 37 | 37 | seenTabsKeys := make(map[string]bool) | |
| 38 | + | seenScrollbackKeys := make(map[string]bool) | |
| 38 | 39 | ||
| 39 | 40 | scanner := bufio.NewScanner(r) | |
| 40 | 41 | lineNo := 0 | |
| @@ -185,6 +186,22 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 185 | 186 | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)}) | |
| 186 | 187 | } | |
| 187 | 188 | cfg.Codes[codeKey] = value | |
| 189 | + | case key == "scrollback.megabytes": | |
| 190 | + | warnings = warnDuplicateKey(warnings, seenScrollbackKeys, key, lineNo) | |
| 191 | + | n, err := strconv.ParseUint(value, 10, 64) | |
| 192 | + | if err != nil || n > domain.MaxScrollbackMegabytes { | |
| 193 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid %s %q (want 0–%d)", key, value, domain.MaxScrollbackMegabytes)}) | |
| 194 | + | continue | |
| 195 | + | } | |
| 196 | + | cfg.Scrollback.Megabytes = n | |
| 197 | + | case key == "scrollback.lines": | |
| 198 | + | warnings = warnDuplicateKey(warnings, seenScrollbackKeys, key, lineNo) | |
| 199 | + | n, err := strconv.ParseUint(value, 10, 64) | |
| 200 | + | if err != nil || n > domain.MaxScrollbackLines { | |
| 201 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid %s %q (want 0–%d)", key, value, domain.MaxScrollbackLines)}) | |
| 202 | + | continue | |
| 203 | + | } | |
| 204 | + | cfg.Scrollback.Lines = int(n) | |
| 188 | 205 | case key == "snapshot.restore_processes": | |
| 189 | 206 | var processWarnings []domain.Warning | |
| 190 | 207 | cfg.Snapshot.RestoreProcesses, processWarnings = parseProcessList(value, lineNo) | |
| @@ -35,6 +35,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||||
| 35 | seenPaletteKeys := make(map[string]bool) | 35 | seenPaletteKeys := make(map[string]bool) | ||
| 36 | seenNavKeys := make(map[string]bool) | 36 | seenNavKeys := make(map[string]bool) | ||
| 37 | seenTabsKeys := make(map[string]bool) | 37 | seenTabsKeys := make(map[string]bool) | ||
| 38 | + | seenScrollbackKeys := make(map[string]bool) | |||
| 38 | 39 | ||||
| 39 | scanner := bufio.NewScanner(r) | 40 | scanner := bufio.NewScanner(r) | ||
| 40 | lineNo := 0 | 41 | lineNo := 0 | ||
| @@ -185,6 +186,22 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||||
| 185 | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)}) | 186 | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)}) | ||
| 186 | } | 187 | } | ||
| 187 | cfg.Codes[codeKey] = value | 188 | cfg.Codes[codeKey] = value | ||
| 189 | + | case key == "scrollback.megabytes": | |||
| 190 | + | warnings = warnDuplicateKey(warnings, seenScrollbackKeys, key, lineNo) | |||
| 191 | + | n, err := strconv.ParseUint(value, 10, 64) | |||
| 192 | + | if err != nil || n > domain.MaxScrollbackMegabytes { | |||
| 193 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid %s %q (want 0–%d)", key, value, domain.MaxScrollbackMegabytes)}) | |||
| 194 | + | continue | |||
| 195 | + | } | |||
| 196 | + | cfg.Scrollback.Megabytes = n | |||
| 197 | + | case key == "scrollback.lines": | |||
| 198 | + | warnings = warnDuplicateKey(warnings, seenScrollbackKeys, key, lineNo) | |||
| 199 | + | n, err := strconv.ParseUint(value, 10, 64) | |||
| 200 | + | if err != nil || n > domain.MaxScrollbackLines { | |||
| 201 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid %s %q (want 0–%d)", key, value, domain.MaxScrollbackLines)}) | |||
| 202 | + | continue | |||
| 203 | + | } | |||
| 204 | + | cfg.Scrollback.Lines = int(n) | |||
| 188 | case key == "snapshot.restore_processes": | 205 | case key == "snapshot.restore_processes": | ||
| 189 | var processWarnings []domain.Warning | 206 | var processWarnings []domain.Warning | ||
| 190 | cfg.Snapshot.RestoreProcesses, processWarnings = parseProcessList(value, lineNo) | 207 | cfg.Snapshot.RestoreProcesses, processWarnings = parseProcessList(value, lineNo) | ||
M internal/adapters/config/config_test.go +1 -0
| @@ -358,6 +358,7 @@ func TestParse(t *testing.T) { | |||
| 358 | 358 | if !tt.want.Palette.AnchorSet { | |
| 359 | 359 | tt.want.Palette = domain.Defaults().Palette | |
| 360 | 360 | } | |
| 361 | + | tt.want.Scrollback = domain.DefaultScrollbackConfig() | |
| 361 | 362 | got, warnings, err := Parse(strings.NewReader(tt.input)) | |
| 362 | 363 | if err != nil { | |
| 363 | 364 | t.Fatalf("Parse() error = %v", err) | |
| @@ -358,6 +358,7 @@ func TestParse(t *testing.T) { | |||||
| 358 | if !tt.want.Palette.AnchorSet { | 358 | if !tt.want.Palette.AnchorSet { | ||
| 359 | tt.want.Palette = domain.Defaults().Palette | 359 | tt.want.Palette = domain.Defaults().Palette | ||
| 360 | } | 360 | } | ||
| 361 | + | tt.want.Scrollback = domain.DefaultScrollbackConfig() | |||
| 361 | got, warnings, err := Parse(strings.NewReader(tt.input)) | 362 | got, warnings, err := Parse(strings.NewReader(tt.input)) | ||
| 362 | if err != nil { | 363 | if err != nil { | ||
| 363 | t.Fatalf("Parse() error = %v", err) | 364 | t.Fatalf("Parse() error = %v", err) | ||
A internal/adapters/config/scrollback_test.go +37 -0
| @@ -0,0 +1,37 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestParseScrollbackPolicy(t *testing.T) { | |
| 12 | + | for _, tt := range []struct { | |
| 13 | + | name, input string | |
| 14 | + | want domain.ScrollbackConfig | |
| 15 | + | warnings int | |
| 16 | + | }{ | |
| 17 | + | {"defaults", "", domain.DefaultScrollbackConfig(), 0}, | |
| 18 | + | {"bytes only", "scrollback.megabytes = 20\nscrollback.lines = 0", domain.ScrollbackConfig{Megabytes: 20}, 0}, | |
| 19 | + | {"disabled", "scrollback.megabytes = 0", domain.ScrollbackConfig{Lines: 10_000}, 0}, | |
| 20 | + | {"both limits", "scrollback.megabytes = 10\nscrollback.lines = 100", domain.ScrollbackConfig{Megabytes: 10, Lines: 100}, 0}, | |
| 21 | + | {"upper bounds", "scrollback.megabytes = 4096\nscrollback.lines = 1000000", domain.ScrollbackConfig{Megabytes: 4096, Lines: 1_000_000}, 0}, | |
| 22 | + | {"negative bytes", "scrollback.megabytes = -1", domain.DefaultScrollbackConfig(), 1}, | |
| 23 | + | {"negative lines", "scrollback.lines = -1", domain.DefaultScrollbackConfig(), 1}, | |
| 24 | + | {"oversized bytes", "scrollback.megabytes = 4097", domain.DefaultScrollbackConfig(), 1}, | |
| 25 | + | {"oversized lines", "scrollback.lines = 1000001", domain.DefaultScrollbackConfig(), 1}, | |
| 26 | + | {"overflow", "scrollback.megabytes = 18446744073709551616", domain.DefaultScrollbackConfig(), 1}, | |
| 27 | + | {"duplicate invalid keeps valid", "scrollback.megabytes = 20\nscrollback.megabytes = -1", domain.ScrollbackConfig{Megabytes: 20, Lines: 10_000}, 2}, | |
| 28 | + | } { | |
| 29 | + | t.Run(tt.name, func(t *testing.T) { | |
| 30 | + | cfg, warnings, err := Parse(strings.NewReader(tt.input)) | |
| 31 | + | require.NoError(t, err) | |
| 32 | + | require.Equal(t, tt.want, cfg.Scrollback) | |
| 33 | + | require.Len(t, warnings, tt.warnings) | |
| 34 | + | require.Empty(t, cfg.BindingEntries) | |
| 35 | + | }) | |
| 36 | + | } | |
| 37 | + | } | |
| @@ -0,0 +1,37 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestParseScrollbackPolicy(t *testing.T) { | |||
| 12 | + | for _, tt := range []struct { | |||
| 13 | + | name, input string | |||
| 14 | + | want domain.ScrollbackConfig | |||
| 15 | + | warnings int | |||
| 16 | + | }{ | |||
| 17 | + | {"defaults", "", domain.DefaultScrollbackConfig(), 0}, | |||
| 18 | + | {"bytes only", "scrollback.megabytes = 20\nscrollback.lines = 0", domain.ScrollbackConfig{Megabytes: 20}, 0}, | |||
| 19 | + | {"disabled", "scrollback.megabytes = 0", domain.ScrollbackConfig{Lines: 10_000}, 0}, | |||
| 20 | + | {"both limits", "scrollback.megabytes = 10\nscrollback.lines = 100", domain.ScrollbackConfig{Megabytes: 10, Lines: 100}, 0}, | |||
| 21 | + | {"upper bounds", "scrollback.megabytes = 4096\nscrollback.lines = 1000000", domain.ScrollbackConfig{Megabytes: 4096, Lines: 1_000_000}, 0}, | |||
| 22 | + | {"negative bytes", "scrollback.megabytes = -1", domain.DefaultScrollbackConfig(), 1}, | |||
| 23 | + | {"negative lines", "scrollback.lines = -1", domain.DefaultScrollbackConfig(), 1}, | |||
| 24 | + | {"oversized bytes", "scrollback.megabytes = 4097", domain.DefaultScrollbackConfig(), 1}, | |||
| 25 | + | {"oversized lines", "scrollback.lines = 1000001", domain.DefaultScrollbackConfig(), 1}, | |||
| 26 | + | {"overflow", "scrollback.megabytes = 18446744073709551616", domain.DefaultScrollbackConfig(), 1}, | |||
| 27 | + | {"duplicate invalid keeps valid", "scrollback.megabytes = 20\nscrollback.megabytes = -1", domain.ScrollbackConfig{Megabytes: 20, Lines: 10_000}, 2}, | |||
| 28 | + | } { | |||
| 29 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 30 | + | cfg, warnings, err := Parse(strings.NewReader(tt.input)) | |||
| 31 | + | require.NoError(t, err) | |||
| 32 | + | require.Equal(t, tt.want, cfg.Scrollback) | |||
| 33 | + | require.Len(t, warnings, tt.warnings) | |||
| 34 | + | require.Empty(t, cfg.BindingEntries) | |||
| 35 | + | }) | |||
| 36 | + | } | |||
| 37 | + | } | |||
M internal/adapters/snapshot/direct_load_test.go +1 -1
| @@ -67,7 +67,7 @@ func TestLoadCheckpointPreservesManifestVersionError(t *testing.T) { | |||
| 67 | 67 | name string | |
| 68 | 68 | version uint16 | |
| 69 | 69 | }{ | |
| 70 | - | {name: "old", version: codec.ManifestVersion - 1}, | |
| 70 | + | {name: "dense VT checkpoint", version: 3}, | |
| 71 | 71 | {name: "future", version: codec.ManifestVersion + 1}, | |
| 72 | 72 | } { | |
| 73 | 73 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -67,7 +67,7 @@ func TestLoadCheckpointPreservesManifestVersionError(t *testing.T) { | |||||
| 67 | name string | 67 | name string | ||
| 68 | version uint16 | 68 | version uint16 | ||
| 69 | }{ | 69 | }{ | ||
| 70 | - | {name: "old", version: codec.ManifestVersion - 1}, | 70 | + | {name: "dense VT checkpoint", version: 3}, |
| 71 | {name: "future", version: codec.ManifestVersion + 1}, | 71 | {name: "future", version: codec.ManifestVersion + 1}, | ||
| 72 | } { | 72 | } { | ||
| 73 | t.Run(tt.name, func(t *testing.T) { | 73 | t.Run(tt.name, func(t *testing.T) { | ||
M internal/app/daemon_integration_test.go +3 -3
| @@ -316,9 +316,9 @@ func assertNoTextAfterInput(t *testing.T, p *pump, sz domain.Size, absent string | |||
| 316 | 316 | ||
| 317 | 317 | func screenText(s *vt.Screen) string { | |
| 318 | 318 | var b strings.Builder | |
| 319 | - | for y := range s.Frame.Height { | |
| 320 | - | for x := range s.Frame.Width { | |
| 321 | - | r := s.Frame.At(x, y).Rune | |
| 319 | + | for y := range s.Rows() { | |
| 320 | + | for x := range s.Columns() { | |
| 321 | + | r := s.Cell(x, y).Rune | |
| 322 | 322 | if r == 0 { | |
| 323 | 323 | r = ' ' | |
| 324 | 324 | } | |
| @@ -316,9 +316,9 @@ func assertNoTextAfterInput(t *testing.T, p *pump, sz domain.Size, absent string | |||||
| 316 | 316 | ||||
| 317 | func screenText(s *vt.Screen) string { | 317 | func screenText(s *vt.Screen) string { | ||
| 318 | var b strings.Builder | 318 | var b strings.Builder | ||
| 319 | - | for y := range s.Frame.Height { | 319 | + | for y := range s.Rows() { |
| 320 | - | for x := range s.Frame.Width { | 320 | + | for x := range s.Columns() { |
| 321 | - | r := s.Frame.At(x, y).Rune | 321 | + | r := s.Cell(x, y).Rune |
| 322 | if r == 0 { | 322 | if r == 0 { | ||
| 323 | r = ' ' | 323 | r = ' ' | ||
| 324 | } | 324 | } | ||
M internal/domain/config.go +23 -0
| @@ -82,6 +82,27 @@ type TabsConfig struct { | |||
| 82 | 82 | TerminalTitle bool | |
| 83 | 83 | } | |
| 84 | 84 | ||
| 85 | + | // ScrollbackConfig is vev's per-pane retention policy. The terminal library | |
| 86 | + | // only enforces these application-selected limits. Zero megabytes disables | |
| 87 | + | // history; zero lines removes the additional line ceiling. | |
| 88 | + | type ScrollbackConfig struct { | |
| 89 | + | Megabytes uint64 | |
| 90 | + | Lines int | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | const DefaultScrollbackMegabytes uint64 = 50 | |
| 94 | + | const DefaultScrollbackLines = 10_000 | |
| 95 | + | const MaxScrollbackMegabytes uint64 = 4096 | |
| 96 | + | const MaxScrollbackLines = 1_000_000 | |
| 97 | + | ||
| 98 | + | func DefaultScrollbackConfig() ScrollbackConfig { | |
| 99 | + | return ScrollbackConfig{Megabytes: DefaultScrollbackMegabytes, Lines: DefaultScrollbackLines} | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func (c ScrollbackConfig) Valid() bool { | |
| 103 | + | return c.Megabytes <= MaxScrollbackMegabytes && c.Lines >= 0 && c.Lines <= MaxScrollbackLines | |
| 104 | + | } | |
| 105 | + | ||
| 85 | 106 | // Config is the user-editable vev configuration after parsing. Unknown binding | |
| 86 | 107 | // keys are preserved here (in BindingEntries, in file order) so the usecase | |
| 87 | 108 | // layer can decide which actions it understands. | |
| @@ -98,6 +119,7 @@ type Config struct { | |||
| 98 | 119 | Floating FloatingConfig | |
| 99 | 120 | Nav NavConfig | |
| 100 | 121 | Tabs TabsConfig | |
| 122 | + | Scrollback ScrollbackConfig | |
| 101 | 123 | } | |
| 102 | 124 | ||
| 103 | 125 | // Warning describes a non-fatal config problem. Parsers and reloaders should | |
| @@ -135,5 +157,6 @@ func Defaults() Config { | |||
| 135 | 157 | Tabs: TabsConfig{ | |
| 136 | 158 | TerminalTitle: true, | |
| 137 | 159 | }, | |
| 160 | + | Scrollback: DefaultScrollbackConfig(), | |
| 138 | 161 | } | |
| 139 | 162 | } | |
| @@ -82,6 +82,27 @@ type TabsConfig struct { | |||||
| 82 | TerminalTitle bool | 82 | TerminalTitle bool | ||
| 83 | } | 83 | } | ||
| 84 | 84 | ||||
| 85 | + | // ScrollbackConfig is vev's per-pane retention policy. The terminal library | |||
| 86 | + | // only enforces these application-selected limits. Zero megabytes disables | |||
| 87 | + | // history; zero lines removes the additional line ceiling. | |||
| 88 | + | type ScrollbackConfig struct { | |||
| 89 | + | Megabytes uint64 | |||
| 90 | + | Lines int | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | const DefaultScrollbackMegabytes uint64 = 50 | |||
| 94 | + | const DefaultScrollbackLines = 10_000 | |||
| 95 | + | const MaxScrollbackMegabytes uint64 = 4096 | |||
| 96 | + | const MaxScrollbackLines = 1_000_000 | |||
| 97 | + | ||||
| 98 | + | func DefaultScrollbackConfig() ScrollbackConfig { | |||
| 99 | + | return ScrollbackConfig{Megabytes: DefaultScrollbackMegabytes, Lines: DefaultScrollbackLines} | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func (c ScrollbackConfig) Valid() bool { | |||
| 103 | + | return c.Megabytes <= MaxScrollbackMegabytes && c.Lines >= 0 && c.Lines <= MaxScrollbackLines | |||
| 104 | + | } | |||
| 105 | + | ||||
| 85 | // Config is the user-editable vev configuration after parsing. Unknown binding | 106 | // Config is the user-editable vev configuration after parsing. Unknown binding | ||
| 86 | // keys are preserved here (in BindingEntries, in file order) so the usecase | 107 | // keys are preserved here (in BindingEntries, in file order) so the usecase | ||
| 87 | // layer can decide which actions it understands. | 108 | // layer can decide which actions it understands. | ||
| @@ -98,6 +119,7 @@ type Config struct { | |||||
| 98 | Floating FloatingConfig | 119 | Floating FloatingConfig | ||
| 99 | Nav NavConfig | 120 | Nav NavConfig | ||
| 100 | Tabs TabsConfig | 121 | Tabs TabsConfig | ||
| 122 | + | Scrollback ScrollbackConfig | |||
| 101 | } | 123 | } | ||
| 102 | 124 | ||||
| 103 | // Warning describes a non-fatal config problem. Parsers and reloaders should | 125 | // Warning describes a non-fatal config problem. Parsers and reloaders should | ||
| @@ -135,5 +157,6 @@ func Defaults() Config { | |||||
| 135 | Tabs: TabsConfig{ | 157 | Tabs: TabsConfig{ | ||
| 136 | TerminalTitle: true, | 158 | TerminalTitle: true, | ||
| 137 | }, | 159 | }, | ||
| 160 | + | Scrollback: DefaultScrollbackConfig(), | |||
| 138 | } | 161 | } | ||
| 139 | } | 162 | } | ||
M internal/usecase/copy/document.go +19 -19
| @@ -41,6 +41,8 @@ func (d *Document) Len() int { return d.snapshot.Len() } | |||
| 41 | 41 | func (d *Document) Width() int { return d.snapshot.Width } | |
| 42 | 42 | func (d *Document) Height() int { return d.snapshot.Height } | |
| 43 | 43 | func (d *Document) Row(row int) []renderer.Cell { return d.snapshot.Row(row) } | |
| 44 | + | func (d *Document) RowWidth(row int) int { return d.snapshot.RowWidth(row) } | |
| 45 | + | func (d *Document) Cell(x, y int) renderer.Cell { return d.snapshot.Cell(x, y) } | |
| 44 | 46 | func (d *Document) RowID(row int) vt.RowID { return d.snapshot.RowID(row) } | |
| 45 | 47 | func (d *Document) FindRowID(id vt.RowID) int { return d.snapshot.FindRowID(id) } | |
| 46 | 48 | ||
| @@ -50,20 +52,20 @@ func (d *Document) Normalize(pos Pos) (Pos, bool) { | |||
| 50 | 52 | if pos.Row < 0 || pos.Row >= d.Len() || pos.Col < 0 { | |
| 51 | 53 | return Pos{}, false | |
| 52 | 54 | } | |
| 53 | - | row := d.Row(pos.Row) | |
| 54 | - | if len(row) == 0 { | |
| 55 | + | width := d.RowWidth(pos.Row) | |
| 56 | + | if width == 0 { | |
| 55 | 57 | if pos.Col == 0 { | |
| 56 | 58 | return pos, true | |
| 57 | 59 | } | |
| 58 | 60 | return Pos{}, false | |
| 59 | 61 | } | |
| 60 | - | if pos.Col >= len(row) { | |
| 62 | + | if pos.Col >= width { | |
| 61 | 63 | return Pos{}, false | |
| 62 | 64 | } | |
| 63 | - | for pos.Col > 0 && row[pos.Col].Continuation { | |
| 65 | + | for pos.Col > 0 && d.Cell(pos.Col, pos.Row).Continuation { | |
| 64 | 66 | pos.Col-- | |
| 65 | 67 | } | |
| 66 | - | if row[pos.Col].Continuation { | |
| 68 | + | if d.Cell(pos.Col, pos.Row).Continuation { | |
| 67 | 69 | return Pos{}, false | |
| 68 | 70 | } | |
| 69 | 71 | return pos, true | |
| @@ -72,11 +74,11 @@ func (d *Document) Normalize(pos Pos) (Pos, bool) { | |||
| 72 | 74 | // PrevGlyph returns the previous glyph head on pos's physical row. | |
| 73 | 75 | func (d *Document) PrevGlyph(pos Pos) (Pos, bool) { | |
| 74 | 76 | pos, ok := d.Normalize(pos) | |
| 75 | - | if !ok || len(d.Row(pos.Row)) == 0 { | |
| 77 | + | if !ok || d.RowWidth(pos.Row) == 0 { | |
| 76 | 78 | return Pos{}, false | |
| 77 | 79 | } | |
| 78 | 80 | for col := pos.Col - 1; col >= 0; col-- { | |
| 79 | - | if !d.Row(pos.Row)[col].Continuation { | |
| 81 | + | if !d.Cell(col, pos.Row).Continuation { | |
| 80 | 82 | return Pos{Row: pos.Row, Col: col}, true | |
| 81 | 83 | } | |
| 82 | 84 | } | |
| @@ -89,9 +91,9 @@ func (d *Document) NextGlyph(pos Pos) (Pos, bool) { | |||
| 89 | 91 | if !ok { | |
| 90 | 92 | return Pos{}, false | |
| 91 | 93 | } | |
| 92 | - | row := d.Row(pos.Row) | |
| 93 | - | for col := pos.Col + 1; col < len(row); col++ { | |
| 94 | - | if !row[col].Continuation { | |
| 94 | + | width := d.RowWidth(pos.Row) | |
| 95 | + | for col := pos.Col + 1; col < width; col++ { | |
| 96 | + | if !d.Cell(col, pos.Row).Continuation { | |
| 95 | 97 | return Pos{Row: pos.Row, Col: col}, true | |
| 96 | 98 | } | |
| 97 | 99 | } | |
| @@ -357,12 +359,11 @@ func (d *Document) isSeparator(r rune) bool { | |||
| 357 | 359 | } | |
| 358 | 360 | ||
| 359 | 361 | func (d *Document) isGlyph(pos Pos) bool { | |
| 360 | - | row := d.Row(pos.Row) | |
| 361 | - | return pos.Col >= 0 && pos.Col < len(row) && !row[pos.Col].Continuation | |
| 362 | + | return pos.Col >= 0 && pos.Col < d.RowWidth(pos.Row) && !d.Cell(pos.Col, pos.Row).Continuation | |
| 362 | 363 | } | |
| 363 | 364 | ||
| 364 | 365 | func (d *Document) runeAt(pos Pos) rune { | |
| 365 | - | cell := d.Row(pos.Row)[pos.Col] | |
| 366 | + | cell := d.Cell(pos.Col, pos.Row) | |
| 366 | 367 | if cell.Rune == 0 { | |
| 367 | 368 | return ' ' | |
| 368 | 369 | } | |
| @@ -373,13 +374,13 @@ func (d *Document) runeAt(pos Pos) rune { | |||
| 373 | 374 | // an index for unrelated scrollback rows. | |
| 374 | 375 | func (d *Document) nextDocumentGlyph(pos Pos) (Pos, bool) { | |
| 375 | 376 | for row := pos.Row; row < d.Len(); row++ { | |
| 376 | - | cells := d.Row(row) | |
| 377 | + | width := d.RowWidth(row) | |
| 377 | 378 | start := 0 | |
| 378 | 379 | if row == pos.Row { | |
| 379 | 380 | start = pos.Col + 1 | |
| 380 | 381 | } | |
| 381 | - | for col := start; col < len(cells); col++ { | |
| 382 | - | if !cells[col].Continuation { | |
| 382 | + | for col := start; col < width; col++ { | |
| 383 | + | if !d.Cell(col, row).Continuation { | |
| 383 | 384 | return Pos{Row: row, Col: col}, true | |
| 384 | 385 | } | |
| 385 | 386 | } | |
| @@ -391,13 +392,12 @@ func (d *Document) nextDocumentGlyph(pos Pos) (Pos, bool) { | |||
| 391 | 392 | // building an index for unrelated scrollback rows. | |
| 392 | 393 | func (d *Document) previousDocumentGlyph(pos Pos) (Pos, bool) { | |
| 393 | 394 | for row := pos.Row; row >= 0; row-- { | |
| 394 | - | cells := d.Row(row) | |
| 395 | - | start := len(cells) - 1 | |
| 395 | + | start := d.RowWidth(row) - 1 | |
| 396 | 396 | if row == pos.Row { | |
| 397 | 397 | start = min(pos.Col-1, start) | |
| 398 | 398 | } | |
| 399 | 399 | for col := start; col >= 0; col-- { | |
| 400 | - | if !cells[col].Continuation { | |
| 400 | + | if !d.Cell(col, row).Continuation { | |
| 401 | 401 | return Pos{Row: row, Col: col}, true | |
| 402 | 402 | } | |
| 403 | 403 | } | |
| @@ -41,6 +41,8 @@ func (d *Document) Len() int { return d.snapshot.Len() } | |||||
| 41 | func (d *Document) Width() int { return d.snapshot.Width } | 41 | func (d *Document) Width() int { return d.snapshot.Width } | ||
| 42 | func (d *Document) Height() int { return d.snapshot.Height } | 42 | func (d *Document) Height() int { return d.snapshot.Height } | ||
| 43 | func (d *Document) Row(row int) []renderer.Cell { return d.snapshot.Row(row) } | 43 | func (d *Document) Row(row int) []renderer.Cell { return d.snapshot.Row(row) } | ||
| 44 | + | func (d *Document) RowWidth(row int) int { return d.snapshot.RowWidth(row) } | |||
| 45 | + | func (d *Document) Cell(x, y int) renderer.Cell { return d.snapshot.Cell(x, y) } | |||
| 44 | func (d *Document) RowID(row int) vt.RowID { return d.snapshot.RowID(row) } | 46 | func (d *Document) RowID(row int) vt.RowID { return d.snapshot.RowID(row) } | ||
| 45 | func (d *Document) FindRowID(id vt.RowID) int { return d.snapshot.FindRowID(id) } | 47 | func (d *Document) FindRowID(id vt.RowID) int { return d.snapshot.FindRowID(id) } | ||
| 46 | 48 | ||||
| @@ -50,20 +52,20 @@ func (d *Document) Normalize(pos Pos) (Pos, bool) { | |||||
| 50 | if pos.Row < 0 || pos.Row >= d.Len() || pos.Col < 0 { | 52 | if pos.Row < 0 || pos.Row >= d.Len() || pos.Col < 0 { | ||
| 51 | return Pos{}, false | 53 | return Pos{}, false | ||
| 52 | } | 54 | } | ||
| 53 | - | row := d.Row(pos.Row) | 55 | + | width := d.RowWidth(pos.Row) |
| 54 | - | if len(row) == 0 { | 56 | + | if width == 0 { |
| 55 | if pos.Col == 0 { | 57 | if pos.Col == 0 { | ||
| 56 | return pos, true | 58 | return pos, true | ||
| 57 | } | 59 | } | ||
| 58 | return Pos{}, false | 60 | return Pos{}, false | ||
| 59 | } | 61 | } | ||
| 60 | - | if pos.Col >= len(row) { | 62 | + | if pos.Col >= width { |
| 61 | return Pos{}, false | 63 | return Pos{}, false | ||
| 62 | } | 64 | } | ||
| 63 | - | for pos.Col > 0 && row[pos.Col].Continuation { | 65 | + | for pos.Col > 0 && d.Cell(pos.Col, pos.Row).Continuation { |
| 64 | pos.Col-- | 66 | pos.Col-- | ||
| 65 | } | 67 | } | ||
| 66 | - | if row[pos.Col].Continuation { | 68 | + | if d.Cell(pos.Col, pos.Row).Continuation { |
| 67 | return Pos{}, false | 69 | return Pos{}, false | ||
| 68 | } | 70 | } | ||
| 69 | return pos, true | 71 | return pos, true | ||
| @@ -72,11 +74,11 @@ func (d *Document) Normalize(pos Pos) (Pos, bool) { | |||||
| 72 | // PrevGlyph returns the previous glyph head on pos's physical row. | 74 | // PrevGlyph returns the previous glyph head on pos's physical row. | ||
| 73 | func (d *Document) PrevGlyph(pos Pos) (Pos, bool) { | 75 | func (d *Document) PrevGlyph(pos Pos) (Pos, bool) { | ||
| 74 | pos, ok := d.Normalize(pos) | 76 | pos, ok := d.Normalize(pos) | ||
| 75 | - | if !ok || len(d.Row(pos.Row)) == 0 { | 77 | + | if !ok || d.RowWidth(pos.Row) == 0 { |
| 76 | return Pos{}, false | 78 | return Pos{}, false | ||
| 77 | } | 79 | } | ||
| 78 | for col := pos.Col - 1; col >= 0; col-- { | 80 | for col := pos.Col - 1; col >= 0; col-- { | ||
| 79 | - | if !d.Row(pos.Row)[col].Continuation { | 81 | + | if !d.Cell(col, pos.Row).Continuation { |
| 80 | return Pos{Row: pos.Row, Col: col}, true | 82 | return Pos{Row: pos.Row, Col: col}, true | ||
| 81 | } | 83 | } | ||
| 82 | } | 84 | } | ||
| @@ -89,9 +91,9 @@ func (d *Document) NextGlyph(pos Pos) (Pos, bool) { | |||||
| 89 | if !ok { | 91 | if !ok { | ||
| 90 | return Pos{}, false | 92 | return Pos{}, false | ||
| 91 | } | 93 | } | ||
| 92 | - | row := d.Row(pos.Row) | 94 | + | width := d.RowWidth(pos.Row) |
| 93 | - | for col := pos.Col + 1; col < len(row); col++ { | 95 | + | for col := pos.Col + 1; col < width; col++ { |
| 94 | - | if !row[col].Continuation { | 96 | + | if !d.Cell(col, pos.Row).Continuation { |
| 95 | return Pos{Row: pos.Row, Col: col}, true | 97 | return Pos{Row: pos.Row, Col: col}, true | ||
| 96 | } | 98 | } | ||
| 97 | } | 99 | } | ||
| @@ -357,12 +359,11 @@ func (d *Document) isSeparator(r rune) bool { | |||||
| 357 | } | 359 | } | ||
| 358 | 360 | ||||
| 359 | func (d *Document) isGlyph(pos Pos) bool { | 361 | func (d *Document) isGlyph(pos Pos) bool { | ||
| 360 | - | row := d.Row(pos.Row) | 362 | + | return pos.Col >= 0 && pos.Col < d.RowWidth(pos.Row) && !d.Cell(pos.Col, pos.Row).Continuation |
| 361 | - | return pos.Col >= 0 && pos.Col < len(row) && !row[pos.Col].Continuation | |||
| 362 | } | 363 | } | ||
| 363 | 364 | ||||
| 364 | func (d *Document) runeAt(pos Pos) rune { | 365 | func (d *Document) runeAt(pos Pos) rune { | ||
| 365 | - | cell := d.Row(pos.Row)[pos.Col] | 366 | + | cell := d.Cell(pos.Col, pos.Row) |
| 366 | if cell.Rune == 0 { | 367 | if cell.Rune == 0 { | ||
| 367 | return ' ' | 368 | return ' ' | ||
| 368 | } | 369 | } | ||
| @@ -373,13 +374,13 @@ func (d *Document) runeAt(pos Pos) rune { | |||||
| 373 | // an index for unrelated scrollback rows. | 374 | // an index for unrelated scrollback rows. | ||
| 374 | func (d *Document) nextDocumentGlyph(pos Pos) (Pos, bool) { | 375 | func (d *Document) nextDocumentGlyph(pos Pos) (Pos, bool) { | ||
| 375 | for row := pos.Row; row < d.Len(); row++ { | 376 | for row := pos.Row; row < d.Len(); row++ { | ||
| 376 | - | cells := d.Row(row) | 377 | + | width := d.RowWidth(row) |
| 377 | start := 0 | 378 | start := 0 | ||
| 378 | if row == pos.Row { | 379 | if row == pos.Row { | ||
| 379 | start = pos.Col + 1 | 380 | start = pos.Col + 1 | ||
| 380 | } | 381 | } | ||
| 381 | - | for col := start; col < len(cells); col++ { | 382 | + | for col := start; col < width; col++ { |
| 382 | - | if !cells[col].Continuation { | 383 | + | if !d.Cell(col, row).Continuation { |
| 383 | return Pos{Row: row, Col: col}, true | 384 | return Pos{Row: row, Col: col}, true | ||
| 384 | } | 385 | } | ||
| 385 | } | 386 | } | ||
| @@ -391,13 +392,12 @@ func (d *Document) nextDocumentGlyph(pos Pos) (Pos, bool) { | |||||
| 391 | // building an index for unrelated scrollback rows. | 392 | // building an index for unrelated scrollback rows. | ||
| 392 | func (d *Document) previousDocumentGlyph(pos Pos) (Pos, bool) { | 393 | func (d *Document) previousDocumentGlyph(pos Pos) (Pos, bool) { | ||
| 393 | for row := pos.Row; row >= 0; row-- { | 394 | for row := pos.Row; row >= 0; row-- { | ||
| 394 | - | cells := d.Row(row) | 395 | + | start := d.RowWidth(row) - 1 |
| 395 | - | start := len(cells) - 1 | |||
| 396 | if row == pos.Row { | 396 | if row == pos.Row { | ||
| 397 | start = min(pos.Col-1, start) | 397 | start = min(pos.Col-1, start) | ||
| 398 | } | 398 | } | ||
| 399 | for col := start; col >= 0; col-- { | 399 | for col := start; col >= 0; col-- { | ||
| 400 | - | if !cells[col].Continuation { | 400 | + | if !d.Cell(col, row).Continuation { |
| 401 | return Pos{Row: row, Col: col}, true | 401 | return Pos{Row: row, Col: col}, true | ||
| 402 | } | 402 | } | ||
| 403 | } | 403 | } | ||
M internal/usecase/copy/mode.go +99 -31
| @@ -24,7 +24,7 @@ type Snapshot struct { | |||
| 24 | 24 | Width, Height int | |
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | - | func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound, rowIDs []vt.RowID) Snapshot { | |
| 27 | + | func NewSnapshot(historySource *vt.History, screen vt.CellSource, bounds []vt.LineBound, rowIDs []vt.RowID) Snapshot { | |
| 28 | 28 | return newSnapshot(historySource, screen, bounds, append([]vt.RowID(nil), rowIDs...)) | |
| 29 | 29 | } | |
| 30 | 30 | ||
| @@ -37,21 +37,27 @@ func NewSnapshotFromScreen(historySource *vt.History, screen *vt.Screen) Snapsho | |||
| 37 | 37 | if screen == nil { | |
| 38 | 38 | return NewSnapshot(historySource, renderer.Frame{}, nil, nil) | |
| 39 | 39 | } | |
| 40 | - | return newSnapshot(historySource, screen.Frame, screen.LineBounds(), screen.RowIDs()) | |
| 40 | + | return newSnapshot(historySource, screen, screen.LineBounds(), screen.RowIDs()) | |
| 41 | 41 | } | |
| 42 | 42 | ||
| 43 | - | func newSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound, screenRowIDs []vt.RowID) Snapshot { | |
| 43 | + | func newSnapshot(historySource *vt.History, screen vt.CellSource, bounds []vt.LineBound, screenRowIDs []vt.RowID) Snapshot { | |
| 44 | + | owned := renderer.NewFrame(screen.Columns(), screen.Rows()) | |
| 45 | + | for y := range screen.Rows() { | |
| 46 | + | for x := range screen.Columns() { | |
| 47 | + | owned.Set(x, y, screen.Cell(x, y)) | |
| 48 | + | } | |
| 49 | + | } | |
| 44 | 50 | var history vt.HistoryView | |
| 45 | 51 | if historySource != nil { | |
| 46 | 52 | history = historySource.SealAndView() | |
| 47 | 53 | } | |
| 48 | 54 | return Snapshot{ | |
| 49 | 55 | history: history, | |
| 50 | - | screen: screen.Clone(), | |
| 56 | + | screen: owned, | |
| 51 | 57 | screenBounds: append([]vt.LineBound(nil), bounds...), | |
| 52 | 58 | screenRowIDs: screenRowIDs, | |
| 53 | - | Width: screen.Width, | |
| 54 | - | Height: screen.Height, | |
| 59 | + | Width: screen.Columns(), | |
| 60 | + | Height: screen.Rows(), | |
| 55 | 61 | } | |
| 56 | 62 | } | |
| 57 | 63 | ||
| @@ -60,7 +66,7 @@ func newSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.L | |||
| 60 | 66 | func NewSnapshotFromLines(rows [][]renderer.Cell, bounds []vt.LineBound, width, height int) Snapshot { | |
| 61 | 67 | history := vt.NewHistory(vt.HistoryConfig{ | |
| 62 | 68 | MaxRows: max(len(rows), 1), | |
| 63 | - | MaxCells: snapshotRowsCellBudget(rows), | |
| 69 | + | MaxBytes: math.MaxUint64, // Input is already owned and bounded by the caller. | |
| 64 | 70 | ChunkRows: 256, | |
| 65 | 71 | }) | |
| 66 | 72 | for i, row := range rows { | |
| @@ -80,25 +86,13 @@ func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot { | |||
| 80 | 86 | return NewSnapshotFromLines(rows, nil, width, height) | |
| 81 | 87 | } | |
| 82 | 88 | ||
| 83 | - | // snapshotRowsCellBudget returns a capacity that retains every supplied row. | |
| 84 | - | // Saturation is safe because a larger representable capacity does not exist. | |
| 85 | - | func snapshotRowsCellBudget(rows [][]renderer.Cell) int { | |
| 86 | - | cells := 0 | |
| 87 | - | for _, row := range rows { | |
| 88 | - | if len(row) > math.MaxInt-cells { | |
| 89 | - | return math.MaxInt | |
| 90 | - | } | |
| 91 | - | cells += len(row) | |
| 92 | - | } | |
| 93 | - | return cells | |
| 94 | - | } | |
| 95 | 89 | func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height } | |
| 96 | 90 | func (s Snapshot) Row(i int) []renderer.Cell { | |
| 97 | 91 | if i < 0 { | |
| 98 | 92 | return nil | |
| 99 | 93 | } | |
| 100 | 94 | if i < s.history.Len() { | |
| 101 | - | return s.history.BorrowedRow(i) | |
| 95 | + | return s.history.Row(i) | |
| 102 | 96 | } | |
| 103 | 97 | i -= s.history.Len() | |
| 104 | 98 | if i >= s.screen.Height { | |
| @@ -107,6 +101,52 @@ func (s Snapshot) Row(i int) []renderer.Cell { | |||
| 107 | 101 | return s.screen.Row(i) | |
| 108 | 102 | } | |
| 109 | 103 | ||
| 104 | + | func (s Snapshot) RowWidth(y int) int { | |
| 105 | + | if y < 0 { | |
| 106 | + | return 0 | |
| 107 | + | } | |
| 108 | + | if y < s.history.Len() { | |
| 109 | + | return s.history.RowWidth(y) | |
| 110 | + | } | |
| 111 | + | y -= s.history.Len() | |
| 112 | + | if y >= s.screen.Height { | |
| 113 | + | return 0 | |
| 114 | + | } | |
| 115 | + | return s.screen.Width | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func (s Snapshot) Cell(x, y int) renderer.Cell { | |
| 119 | + | if y < 0 || x < 0 { | |
| 120 | + | return renderer.BlankCell() | |
| 121 | + | } | |
| 122 | + | if y < s.history.Len() { | |
| 123 | + | return s.history.Cell(x, y) | |
| 124 | + | } | |
| 125 | + | y -= s.history.Len() | |
| 126 | + | if y >= s.screen.Height || x >= s.screen.Width { | |
| 127 | + | return renderer.BlankCell() | |
| 128 | + | } | |
| 129 | + | return s.screen.Cell(x, y) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func (s Snapshot) CopyRow(y int, dst []renderer.Cell) int { | |
| 133 | + | if y < 0 { | |
| 134 | + | return 0 | |
| 135 | + | } | |
| 136 | + | if y < s.history.Len() { | |
| 137 | + | return s.history.CopyRow(y, dst) | |
| 138 | + | } | |
| 139 | + | y -= s.history.Len() | |
| 140 | + | if y >= s.screen.Height { | |
| 141 | + | return 0 | |
| 142 | + | } | |
| 143 | + | n := min(len(dst), s.screen.Width) | |
| 144 | + | for x := range n { | |
| 145 | + | dst[x] = s.screen.Cell(x, y) | |
| 146 | + | } | |
| 147 | + | return n | |
| 148 | + | } | |
| 149 | + | ||
| 110 | 150 | // Bound returns the logical extent of row i, dispatching between sealed history | |
| 111 | 151 | // and the live screen exactly as Row does. | |
| 112 | 152 | func (s Snapshot) Bound(i int) vt.LineBound { | |
| @@ -167,10 +207,10 @@ func (s Snapshot) FindRowID(id vt.RowID) int { | |||
| 167 | 207 | return -1 | |
| 168 | 208 | } | |
| 169 | 209 | ||
| 170 | - | func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) { | |
| 210 | + | func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) error { | |
| 171 | 211 | i := 0 | |
| 172 | 212 | stopped := false | |
| 173 | - | s.history.Range(func(r []renderer.Cell) bool { | |
| 213 | + | err := s.history.Range(func(r []renderer.Cell) bool { | |
| 174 | 214 | if !yield(i, r) { | |
| 175 | 215 | stopped = true | |
| 176 | 216 | return false | |
| @@ -178,14 +218,18 @@ func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) { | |||
| 178 | 218 | i++ | |
| 179 | 219 | return true | |
| 180 | 220 | }) | |
| 221 | + | if err != nil { | |
| 222 | + | return err | |
| 223 | + | } | |
| 181 | 224 | if stopped { | |
| 182 | - | return | |
| 225 | + | return nil | |
| 183 | 226 | } | |
| 184 | 227 | for y := range s.screen.Height { | |
| 185 | 228 | if !yield(i+y, s.screen.Row(y)) { | |
| 186 | - | return | |
| 229 | + | return nil | |
| 187 | 230 | } | |
| 188 | 231 | } | |
| 232 | + | return nil | |
| 189 | 233 | } | |
| 190 | 234 | ||
| 191 | 235 | type SearchMatch struct { | |
| @@ -425,9 +469,13 @@ func FindMatches(doc *Document, query string) []SearchMatch { | |||
| 425 | 469 | if doc == nil || query == "" { | |
| 426 | 470 | return nil | |
| 427 | 471 | } | |
| 472 | + | return findMatches(doc, query, doc.Snapshot().rangeRows) | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | func findMatches(doc *Document, query string, rangeRows func(func(int, []renderer.Cell) bool) error) []SearchMatch { | |
| 428 | 476 | needle := lowerRunes(query) | |
| 429 | 477 | matches := []SearchMatch{} | |
| 430 | - | doc.Snapshot().rangeRows(func(row int, cells []renderer.Cell) bool { | |
| 478 | + | err := rangeRows(func(row int, cells []renderer.Cell) bool { | |
| 431 | 479 | hay, indexes := searchableCells(cells) | |
| 432 | 480 | text := "" | |
| 433 | 481 | for start := 0; start+len(needle) <= len(hay); { | |
| @@ -448,6 +496,10 @@ func FindMatches(doc *Document, query string) []SearchMatch { | |||
| 448 | 496 | } | |
| 449 | 497 | return true | |
| 450 | 498 | }) | |
| 499 | + | if err != nil { | |
| 500 | + | // Discard partial results without taking down the daemon on corrupt history. | |
| 501 | + | return nil | |
| 502 | + | } | |
| 451 | 503 | return matches | |
| 452 | 504 | } | |
| 453 | 505 | func searchableCells(cells []renderer.Cell) ([]rune, []int) { | |
| @@ -478,18 +530,33 @@ func (m *Mode) Render(styles ...renderer.Style) renderer.Frame { | |||
| 478 | 530 | if m == nil || m.document == nil { | |
| 479 | 531 | return renderer.NewFrame(0, 0) | |
| 480 | 532 | } | |
| 533 | + | frame := renderer.NewFrame(m.document.Width(), m.document.Height()+1) | |
| 534 | + | m.RenderRows(func(y int, row []renderer.Cell) { frame.WriteRow(y, 0, row) }, styles...) | |
| 535 | + | return frame | |
| 536 | + | } | |
| 537 | + | ||
| 538 | + | // RenderRows paints viewport rows followed by the status row at Document.Height(). | |
| 539 | + | // The callback borrows a reusable semantic row only until it returns. Rendering | |
| 540 | + | // directly into a compositor avoids building and then decoding a compact frame. | |
| 541 | + | func (m *Mode) RenderRows(paint func(int, []renderer.Cell), styles ...renderer.Style) { | |
| 542 | + | if m == nil || m.document == nil { | |
| 543 | + | return | |
| 544 | + | } | |
| 481 | 545 | d := m.document | |
| 482 | - | frame := renderer.NewFrame(d.Width(), d.Height()+1) | |
| 483 | 546 | selectionBounds, hasSelectionBounds := m.selection.bounds(d) | |
| 484 | 547 | selection, hasSelection := optionalStyle(styles, 1) | |
| 485 | 548 | cursor, cursorValid := d.Normalize(m.navigator.Pos) | |
| 549 | + | row := make([]renderer.Cell, d.Width()) | |
| 486 | 550 | for y := range d.Height() { | |
| 487 | 551 | src := m.ViewportTop + y | |
| 552 | + | for x := range row { | |
| 553 | + | row[x] = renderer.BlankCell() | |
| 554 | + | } | |
| 488 | 555 | if src >= d.Len() { | |
| 489 | - | break | |
| 556 | + | paint(y, row) | |
| 557 | + | continue | |
| 490 | 558 | } | |
| 491 | - | row := frame.Row(y) | |
| 492 | - | copy(row, d.Row(src)) | |
| 559 | + | d.snapshot.CopyRow(src, row) | |
| 493 | 560 | if match, ok := m.currentSearchMatchForRow(src); ok { | |
| 494 | 561 | for x := max(match.Start, 0); x < min(match.End, len(row)); x++ { | |
| 495 | 562 | applySelectionStyle(&row[x].Style, selection, hasSelection) | |
| @@ -510,13 +577,14 @@ func (m *Mode) Render(styles ...renderer.Style) renderer.Frame { | |||
| 510 | 577 | cursorCol := min(cursor.Col, len(row)-1) | |
| 511 | 578 | applySelectionStyle(&row[cursorCol].Style, selection, hasSelection) | |
| 512 | 579 | } | |
| 580 | + | paint(y, row) | |
| 513 | 581 | } | |
| 514 | 582 | status := inverseStyle() | |
| 515 | 583 | if len(styles) > 0 { | |
| 516 | 584 | status = styles[0] | |
| 517 | 585 | } | |
| 518 | - | drawCopyStatus(frame.Row(d.Height()), m, d.Len(), status) | |
| 519 | - | return frame | |
| 586 | + | drawCopyStatus(row, m, d.Len(), status) | |
| 587 | + | paint(d.Height(), row) | |
| 520 | 588 | } | |
| 521 | 589 | func (m *Mode) currentSearchMatchForRow(row int) (SearchMatch, bool) { | |
| 522 | 590 | if m == nil || m.SearchIndex < 0 || m.SearchIndex >= len(m.Searches) { | |
| @@ -24,7 +24,7 @@ type Snapshot struct { | |||||
| 24 | Width, Height int | 24 | Width, Height int | ||
| 25 | } | 25 | } | ||
| 26 | 26 | ||||
| 27 | - | func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound, rowIDs []vt.RowID) Snapshot { | 27 | + | func NewSnapshot(historySource *vt.History, screen vt.CellSource, bounds []vt.LineBound, rowIDs []vt.RowID) Snapshot { |
| 28 | return newSnapshot(historySource, screen, bounds, append([]vt.RowID(nil), rowIDs...)) | 28 | return newSnapshot(historySource, screen, bounds, append([]vt.RowID(nil), rowIDs...)) | ||
| 29 | } | 29 | } | ||
| 30 | 30 | ||||
| @@ -37,21 +37,27 @@ func NewSnapshotFromScreen(historySource *vt.History, screen *vt.Screen) Snapsho | |||||
| 37 | if screen == nil { | 37 | if screen == nil { | ||
| 38 | return NewSnapshot(historySource, renderer.Frame{}, nil, nil) | 38 | return NewSnapshot(historySource, renderer.Frame{}, nil, nil) | ||
| 39 | } | 39 | } | ||
| 40 | - | return newSnapshot(historySource, screen.Frame, screen.LineBounds(), screen.RowIDs()) | 40 | + | return newSnapshot(historySource, screen, screen.LineBounds(), screen.RowIDs()) |
| 41 | } | 41 | } | ||
| 42 | 42 | ||||
| 43 | - | func newSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound, screenRowIDs []vt.RowID) Snapshot { | 43 | + | func newSnapshot(historySource *vt.History, screen vt.CellSource, bounds []vt.LineBound, screenRowIDs []vt.RowID) Snapshot { |
| 44 | + | owned := renderer.NewFrame(screen.Columns(), screen.Rows()) | |||
| 45 | + | for y := range screen.Rows() { | |||
| 46 | + | for x := range screen.Columns() { | |||
| 47 | + | owned.Set(x, y, screen.Cell(x, y)) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 44 | var history vt.HistoryView | 50 | var history vt.HistoryView | ||
| 45 | if historySource != nil { | 51 | if historySource != nil { | ||
| 46 | history = historySource.SealAndView() | 52 | history = historySource.SealAndView() | ||
| 47 | } | 53 | } | ||
| 48 | return Snapshot{ | 54 | return Snapshot{ | ||
| 49 | history: history, | 55 | history: history, | ||
| 50 | - | screen: screen.Clone(), | 56 | + | screen: owned, |
| 51 | screenBounds: append([]vt.LineBound(nil), bounds...), | 57 | screenBounds: append([]vt.LineBound(nil), bounds...), | ||
| 52 | screenRowIDs: screenRowIDs, | 58 | screenRowIDs: screenRowIDs, | ||
| 53 | - | Width: screen.Width, | 59 | + | Width: screen.Columns(), |
| 54 | - | Height: screen.Height, | 60 | + | Height: screen.Rows(), |
| 55 | } | 61 | } | ||
| 56 | } | 62 | } | ||
| 57 | 63 | ||||
| @@ -60,7 +66,7 @@ func newSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.L | |||||
| 60 | func NewSnapshotFromLines(rows [][]renderer.Cell, bounds []vt.LineBound, width, height int) Snapshot { | 66 | func NewSnapshotFromLines(rows [][]renderer.Cell, bounds []vt.LineBound, width, height int) Snapshot { | ||
| 61 | history := vt.NewHistory(vt.HistoryConfig{ | 67 | history := vt.NewHistory(vt.HistoryConfig{ | ||
| 62 | MaxRows: max(len(rows), 1), | 68 | MaxRows: max(len(rows), 1), | ||
| 63 | - | MaxCells: snapshotRowsCellBudget(rows), | 69 | + | MaxBytes: math.MaxUint64, // Input is already owned and bounded by the caller. |
| 64 | ChunkRows: 256, | 70 | ChunkRows: 256, | ||
| 65 | }) | 71 | }) | ||
| 66 | for i, row := range rows { | 72 | for i, row := range rows { | ||
| @@ -80,25 +86,13 @@ func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot { | |||||
| 80 | return NewSnapshotFromLines(rows, nil, width, height) | 86 | return NewSnapshotFromLines(rows, nil, width, height) | ||
| 81 | } | 87 | } | ||
| 82 | 88 | ||||
| 83 | - | // snapshotRowsCellBudget returns a capacity that retains every supplied row. | |||
| 84 | - | // Saturation is safe because a larger representable capacity does not exist. | |||
| 85 | - | func snapshotRowsCellBudget(rows [][]renderer.Cell) int { | |||
| 86 | - | cells := 0 | |||
| 87 | - | for _, row := range rows { | |||
| 88 | - | if len(row) > math.MaxInt-cells { | |||
| 89 | - | return math.MaxInt | |||
| 90 | - | } | |||
| 91 | - | cells += len(row) | |||
| 92 | - | } | |||
| 93 | - | return cells | |||
| 94 | - | } | |||
| 95 | func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height } | 89 | func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height } | ||
| 96 | func (s Snapshot) Row(i int) []renderer.Cell { | 90 | func (s Snapshot) Row(i int) []renderer.Cell { | ||
| 97 | if i < 0 { | 91 | if i < 0 { | ||
| 98 | return nil | 92 | return nil | ||
| 99 | } | 93 | } | ||
| 100 | if i < s.history.Len() { | 94 | if i < s.history.Len() { | ||
| 101 | - | return s.history.BorrowedRow(i) | 95 | + | return s.history.Row(i) |
| 102 | } | 96 | } | ||
| 103 | i -= s.history.Len() | 97 | i -= s.history.Len() | ||
| 104 | if i >= s.screen.Height { | 98 | if i >= s.screen.Height { | ||
| @@ -107,6 +101,52 @@ func (s Snapshot) Row(i int) []renderer.Cell { | |||||
| 107 | return s.screen.Row(i) | 101 | return s.screen.Row(i) | ||
| 108 | } | 102 | } | ||
| 109 | 103 | ||||
| 104 | + | func (s Snapshot) RowWidth(y int) int { | |||
| 105 | + | if y < 0 { | |||
| 106 | + | return 0 | |||
| 107 | + | } | |||
| 108 | + | if y < s.history.Len() { | |||
| 109 | + | return s.history.RowWidth(y) | |||
| 110 | + | } | |||
| 111 | + | y -= s.history.Len() | |||
| 112 | + | if y >= s.screen.Height { | |||
| 113 | + | return 0 | |||
| 114 | + | } | |||
| 115 | + | return s.screen.Width | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func (s Snapshot) Cell(x, y int) renderer.Cell { | |||
| 119 | + | if y < 0 || x < 0 { | |||
| 120 | + | return renderer.BlankCell() | |||
| 121 | + | } | |||
| 122 | + | if y < s.history.Len() { | |||
| 123 | + | return s.history.Cell(x, y) | |||
| 124 | + | } | |||
| 125 | + | y -= s.history.Len() | |||
| 126 | + | if y >= s.screen.Height || x >= s.screen.Width { | |||
| 127 | + | return renderer.BlankCell() | |||
| 128 | + | } | |||
| 129 | + | return s.screen.Cell(x, y) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func (s Snapshot) CopyRow(y int, dst []renderer.Cell) int { | |||
| 133 | + | if y < 0 { | |||
| 134 | + | return 0 | |||
| 135 | + | } | |||
| 136 | + | if y < s.history.Len() { | |||
| 137 | + | return s.history.CopyRow(y, dst) | |||
| 138 | + | } | |||
| 139 | + | y -= s.history.Len() | |||
| 140 | + | if y >= s.screen.Height { | |||
| 141 | + | return 0 | |||
| 142 | + | } | |||
| 143 | + | n := min(len(dst), s.screen.Width) | |||
| 144 | + | for x := range n { | |||
| 145 | + | dst[x] = s.screen.Cell(x, y) | |||
| 146 | + | } | |||
| 147 | + | return n | |||
| 148 | + | } | |||
| 149 | + | ||||
| 110 | // Bound returns the logical extent of row i, dispatching between sealed history | 150 | // Bound returns the logical extent of row i, dispatching between sealed history | ||
| 111 | // and the live screen exactly as Row does. | 151 | // and the live screen exactly as Row does. | ||
| 112 | func (s Snapshot) Bound(i int) vt.LineBound { | 152 | func (s Snapshot) Bound(i int) vt.LineBound { | ||
| @@ -167,10 +207,10 @@ func (s Snapshot) FindRowID(id vt.RowID) int { | |||||
| 167 | return -1 | 207 | return -1 | ||
| 168 | } | 208 | } | ||
| 169 | 209 | ||||
| 170 | - | func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) { | 210 | + | func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) error { |
| 171 | i := 0 | 211 | i := 0 | ||
| 172 | stopped := false | 212 | stopped := false | ||
| 173 | - | s.history.Range(func(r []renderer.Cell) bool { | 213 | + | err := s.history.Range(func(r []renderer.Cell) bool { |
| 174 | if !yield(i, r) { | 214 | if !yield(i, r) { | ||
| 175 | stopped = true | 215 | stopped = true | ||
| 176 | return false | 216 | return false | ||
| @@ -178,14 +218,18 @@ func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) { | |||||
| 178 | i++ | 218 | i++ | ||
| 179 | return true | 219 | return true | ||
| 180 | }) | 220 | }) | ||
| 221 | + | if err != nil { | |||
| 222 | + | return err | |||
| 223 | + | } | |||
| 181 | if stopped { | 224 | if stopped { | ||
| 182 | - | return | 225 | + | return nil |
| 183 | } | 226 | } | ||
| 184 | for y := range s.screen.Height { | 227 | for y := range s.screen.Height { | ||
| 185 | if !yield(i+y, s.screen.Row(y)) { | 228 | if !yield(i+y, s.screen.Row(y)) { | ||
| 186 | - | return | 229 | + | return nil |
| 187 | } | 230 | } | ||
| 188 | } | 231 | } | ||
| 232 | + | return nil | |||
| 189 | } | 233 | } | ||
| 190 | 234 | ||||
| 191 | type SearchMatch struct { | 235 | type SearchMatch struct { | ||
| @@ -425,9 +469,13 @@ func FindMatches(doc *Document, query string) []SearchMatch { | |||||
| 425 | if doc == nil || query == "" { | 469 | if doc == nil || query == "" { | ||
| 426 | return nil | 470 | return nil | ||
| 427 | } | 471 | } | ||
| 472 | + | return findMatches(doc, query, doc.Snapshot().rangeRows) | |||
| 473 | + | } | |||
| 474 | + | ||||
| 475 | + | func findMatches(doc *Document, query string, rangeRows func(func(int, []renderer.Cell) bool) error) []SearchMatch { | |||
| 428 | needle := lowerRunes(query) | 476 | needle := lowerRunes(query) | ||
| 429 | matches := []SearchMatch{} | 477 | matches := []SearchMatch{} | ||
| 430 | - | doc.Snapshot().rangeRows(func(row int, cells []renderer.Cell) bool { | 478 | + | err := rangeRows(func(row int, cells []renderer.Cell) bool { |
| 431 | hay, indexes := searchableCells(cells) | 479 | hay, indexes := searchableCells(cells) | ||
| 432 | text := "" | 480 | text := "" | ||
| 433 | for start := 0; start+len(needle) <= len(hay); { | 481 | for start := 0; start+len(needle) <= len(hay); { | ||
| @@ -448,6 +496,10 @@ func FindMatches(doc *Document, query string) []SearchMatch { | |||||
| 448 | } | 496 | } | ||
| 449 | return true | 497 | return true | ||
| 450 | }) | 498 | }) | ||
| 499 | + | if err != nil { | |||
| 500 | + | // Discard partial results without taking down the daemon on corrupt history. | |||
| 501 | + | return nil | |||
| 502 | + | } | |||
| 451 | return matches | 503 | return matches | ||
| 452 | } | 504 | } | ||
| 453 | func searchableCells(cells []renderer.Cell) ([]rune, []int) { | 505 | func searchableCells(cells []renderer.Cell) ([]rune, []int) { | ||
| @@ -478,18 +530,33 @@ func (m *Mode) Render(styles ...renderer.Style) renderer.Frame { | |||||
| 478 | if m == nil || m.document == nil { | 530 | if m == nil || m.document == nil { | ||
| 479 | return renderer.NewFrame(0, 0) | 531 | return renderer.NewFrame(0, 0) | ||
| 480 | } | 532 | } | ||
| 533 | + | frame := renderer.NewFrame(m.document.Width(), m.document.Height()+1) | |||
| 534 | + | m.RenderRows(func(y int, row []renderer.Cell) { frame.WriteRow(y, 0, row) }, styles...) | |||
| 535 | + | return frame | |||
| 536 | + | } | |||
| 537 | + | ||||
| 538 | + | // RenderRows paints viewport rows followed by the status row at Document.Height(). | |||
| 539 | + | // The callback borrows a reusable semantic row only until it returns. Rendering | |||
| 540 | + | // directly into a compositor avoids building and then decoding a compact frame. | |||
| 541 | + | func (m *Mode) RenderRows(paint func(int, []renderer.Cell), styles ...renderer.Style) { | |||
| 542 | + | if m == nil || m.document == nil { | |||
| 543 | + | return | |||
| 544 | + | } | |||
| 481 | d := m.document | 545 | d := m.document | ||
| 482 | - | frame := renderer.NewFrame(d.Width(), d.Height()+1) | |||
| 483 | selectionBounds, hasSelectionBounds := m.selection.bounds(d) | 546 | selectionBounds, hasSelectionBounds := m.selection.bounds(d) | ||
| 484 | selection, hasSelection := optionalStyle(styles, 1) | 547 | selection, hasSelection := optionalStyle(styles, 1) | ||
| 485 | cursor, cursorValid := d.Normalize(m.navigator.Pos) | 548 | cursor, cursorValid := d.Normalize(m.navigator.Pos) | ||
| 549 | + | row := make([]renderer.Cell, d.Width()) | |||
| 486 | for y := range d.Height() { | 550 | for y := range d.Height() { | ||
| 487 | src := m.ViewportTop + y | 551 | src := m.ViewportTop + y | ||
| 552 | + | for x := range row { | |||
| 553 | + | row[x] = renderer.BlankCell() | |||
| 554 | + | } | |||
| 488 | if src >= d.Len() { | 555 | if src >= d.Len() { | ||
| 489 | - | break | 556 | + | paint(y, row) |
| 557 | + | continue | |||
| 490 | } | 558 | } | ||
| 491 | - | row := frame.Row(y) | 559 | + | d.snapshot.CopyRow(src, row) |
| 492 | - | copy(row, d.Row(src)) | |||
| 493 | if match, ok := m.currentSearchMatchForRow(src); ok { | 560 | if match, ok := m.currentSearchMatchForRow(src); ok { | ||
| 494 | for x := max(match.Start, 0); x < min(match.End, len(row)); x++ { | 561 | for x := max(match.Start, 0); x < min(match.End, len(row)); x++ { | ||
| 495 | applySelectionStyle(&row[x].Style, selection, hasSelection) | 562 | applySelectionStyle(&row[x].Style, selection, hasSelection) | ||
| @@ -510,13 +577,14 @@ func (m *Mode) Render(styles ...renderer.Style) renderer.Frame { | |||||
| 510 | cursorCol := min(cursor.Col, len(row)-1) | 577 | cursorCol := min(cursor.Col, len(row)-1) | ||
| 511 | applySelectionStyle(&row[cursorCol].Style, selection, hasSelection) | 578 | applySelectionStyle(&row[cursorCol].Style, selection, hasSelection) | ||
| 512 | } | 579 | } | ||
| 580 | + | paint(y, row) | |||
| 513 | } | 581 | } | ||
| 514 | status := inverseStyle() | 582 | status := inverseStyle() | ||
| 515 | if len(styles) > 0 { | 583 | if len(styles) > 0 { | ||
| 516 | status = styles[0] | 584 | status = styles[0] | ||
| 517 | } | 585 | } | ||
| 518 | - | drawCopyStatus(frame.Row(d.Height()), m, d.Len(), status) | 586 | + | drawCopyStatus(row, m, d.Len(), status) |
| 519 | - | return frame | 587 | + | paint(d.Height(), row) |
| 520 | } | 588 | } | ||
| 521 | func (m *Mode) currentSearchMatchForRow(row int) (SearchMatch, bool) { | 589 | func (m *Mode) currentSearchMatchForRow(row int) (SearchMatch, bool) { | ||
| 522 | if m == nil || m.SearchIndex < 0 || m.SearchIndex >= len(m.Searches) { | 590 | if m == nil || m.SearchIndex < 0 || m.SearchIndex >= len(m.Searches) { | ||
M internal/usecase/copy/mode_test.go +10 -10
| @@ -43,12 +43,12 @@ func TestNewSnapshotFromRowsPreservesWideRows(t *testing.T) { | |||
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | 45 | func TestSnapshotCarriesImmutableRowIDsAndDocumentsResolveBookmarks(t *testing.T) { | |
| 46 | - | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 1024}) | |
| 46 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8}) | |
| 47 | 47 | require.NoError(t, history.AppendWithID(row("hist"), vt.LineBound{End: 4}, vt.RowID(11))) | |
| 48 | 48 | require.NoError(t, history.AppendWithID(row("tail"), vt.LineBound{End: 4}, vt.RowID(12))) | |
| 49 | 49 | screen := renderer.NewFrame(4, 2) | |
| 50 | - | copy(screen.Row(0), row("live")) | |
| 51 | - | copy(screen.Row(1), row("more")) | |
| 50 | + | screen.WriteRow(0, 0, row("live")) | |
| 51 | + | screen.WriteRow(1, 0, row("more")) | |
| 52 | 52 | rowIDs := []vt.RowID{21, 22} | |
| 53 | 53 | ||
| 54 | 54 | snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 4}}, rowIDs) | |
| @@ -74,12 +74,12 @@ func TestSnapshotBoundDispatchesLikeRow(t *testing.T) { | |||
| 74 | 74 | return cells | |
| 75 | 75 | } | |
| 76 | 76 | ||
| 77 | - | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 1024}) | |
| 77 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8}) | |
| 78 | 78 | if err := history.Append(row("hist"), vt.LineBound{End: 4, Soft: true}); err != nil { | |
| 79 | 79 | t.Fatalf("append: %v", err) | |
| 80 | 80 | } | |
| 81 | 81 | screen := renderer.NewFrame(4, 2) | |
| 82 | - | copy(screen.Row(0), row("live")) | |
| 82 | + | screen.WriteRow(0, 0, row("live")) | |
| 83 | 83 | ||
| 84 | 84 | snap := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 0, Soft: true}}, nil) | |
| 85 | 85 | ||
| @@ -359,14 +359,14 @@ func TestSelectedTextJoinsAcrossTheHistoryScreenBoundary(t *testing.T) { | |||
| 359 | 359 | } | |
| 360 | 360 | ||
| 361 | 361 | const width = 4 | |
| 362 | - | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 1024}) | |
| 362 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8}) | |
| 363 | 363 | // The last history row wrapped into what is now the first live screen row. | |
| 364 | 364 | if err := history.Append(row("abcd", width), vt.LineBound{End: width, Soft: true}); err != nil { | |
| 365 | 365 | t.Fatalf("append: %v", err) | |
| 366 | 366 | } | |
| 367 | 367 | screen := renderer.NewFrame(width, 2) | |
| 368 | - | copy(screen.Row(0), row("ef", width)) | |
| 369 | - | copy(screen.Row(1), row("gh", width)) | |
| 368 | + | screen.WriteRow(0, 0, row("ef", width)) | |
| 369 | + | screen.WriteRow(1, 0, row("gh", width)) | |
| 370 | 370 | ||
| 371 | 371 | snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 2}, {End: 2}}, nil) | |
| 372 | 372 | doc := NewDocument(snapshot, "") | |
| @@ -434,10 +434,10 @@ func TestSelectedTextJoinsRowsWrappedByTheVT(t *testing.T) { | |||
| 434 | 434 | } | |
| 435 | 435 | for _, tc := range cases { | |
| 436 | 436 | t.Run(tc.name, func(t *testing.T) { | |
| 437 | - | screen := vt.NewScreenWithHistory(width, height, vt.HistoryConfig{MaxRows: 64, MaxCells: 4096}) | |
| 437 | + | screen := vt.NewScreenWithHistory(width, height, vt.HistoryConfig{MaxRows: 64}) | |
| 438 | 438 | screen.Write([]byte(tc.input)) | |
| 439 | 439 | ||
| 440 | - | snapshot := NewSnapshot(screen.History(), screen.Frame, screen.LineBounds(), nil) | |
| 440 | + | snapshot := NewSnapshot(screen.History(), screen, screen.LineBounds(), nil) | |
| 441 | 441 | require.Equal(t, tc.wantHistoryLen, snapshot.history.Len(), "history/screen seam moved") | |
| 442 | 442 | doc := NewDocument(snapshot, domain.DefaultWordSeparators) | |
| 443 | 443 | ||
| @@ -43,12 +43,12 @@ func TestNewSnapshotFromRowsPreservesWideRows(t *testing.T) { | |||||
| 43 | } | 43 | } | ||
| 44 | 44 | ||||
| 45 | func TestSnapshotCarriesImmutableRowIDsAndDocumentsResolveBookmarks(t *testing.T) { | 45 | func TestSnapshotCarriesImmutableRowIDsAndDocumentsResolveBookmarks(t *testing.T) { | ||
| 46 | - | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 1024}) | 46 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8}) |
| 47 | require.NoError(t, history.AppendWithID(row("hist"), vt.LineBound{End: 4}, vt.RowID(11))) | 47 | require.NoError(t, history.AppendWithID(row("hist"), vt.LineBound{End: 4}, vt.RowID(11))) | ||
| 48 | require.NoError(t, history.AppendWithID(row("tail"), vt.LineBound{End: 4}, vt.RowID(12))) | 48 | require.NoError(t, history.AppendWithID(row("tail"), vt.LineBound{End: 4}, vt.RowID(12))) | ||
| 49 | screen := renderer.NewFrame(4, 2) | 49 | screen := renderer.NewFrame(4, 2) | ||
| 50 | - | copy(screen.Row(0), row("live")) | 50 | + | screen.WriteRow(0, 0, row("live")) |
| 51 | - | copy(screen.Row(1), row("more")) | 51 | + | screen.WriteRow(1, 0, row("more")) |
| 52 | rowIDs := []vt.RowID{21, 22} | 52 | rowIDs := []vt.RowID{21, 22} | ||
| 53 | 53 | ||||
| 54 | snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 4}}, rowIDs) | 54 | snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 4}}, rowIDs) | ||
| @@ -74,12 +74,12 @@ func TestSnapshotBoundDispatchesLikeRow(t *testing.T) { | |||||
| 74 | return cells | 74 | return cells | ||
| 75 | } | 75 | } | ||
| 76 | 76 | ||||
| 77 | - | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 1024}) | 77 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8}) |
| 78 | if err := history.Append(row("hist"), vt.LineBound{End: 4, Soft: true}); err != nil { | 78 | if err := history.Append(row("hist"), vt.LineBound{End: 4, Soft: true}); err != nil { | ||
| 79 | t.Fatalf("append: %v", err) | 79 | t.Fatalf("append: %v", err) | ||
| 80 | } | 80 | } | ||
| 81 | screen := renderer.NewFrame(4, 2) | 81 | screen := renderer.NewFrame(4, 2) | ||
| 82 | - | copy(screen.Row(0), row("live")) | 82 | + | screen.WriteRow(0, 0, row("live")) |
| 83 | 83 | ||||
| 84 | snap := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 0, Soft: true}}, nil) | 84 | snap := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 0, Soft: true}}, nil) | ||
| 85 | 85 | ||||
| @@ -359,14 +359,14 @@ func TestSelectedTextJoinsAcrossTheHistoryScreenBoundary(t *testing.T) { | |||||
| 359 | } | 359 | } | ||
| 360 | 360 | ||||
| 361 | const width = 4 | 361 | const width = 4 | ||
| 362 | - | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 1024}) | 362 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8}) |
| 363 | // The last history row wrapped into what is now the first live screen row. | 363 | // The last history row wrapped into what is now the first live screen row. | ||
| 364 | if err := history.Append(row("abcd", width), vt.LineBound{End: width, Soft: true}); err != nil { | 364 | if err := history.Append(row("abcd", width), vt.LineBound{End: width, Soft: true}); err != nil { | ||
| 365 | t.Fatalf("append: %v", err) | 365 | t.Fatalf("append: %v", err) | ||
| 366 | } | 366 | } | ||
| 367 | screen := renderer.NewFrame(width, 2) | 367 | screen := renderer.NewFrame(width, 2) | ||
| 368 | - | copy(screen.Row(0), row("ef", width)) | 368 | + | screen.WriteRow(0, 0, row("ef", width)) |
| 369 | - | copy(screen.Row(1), row("gh", width)) | 369 | + | screen.WriteRow(1, 0, row("gh", width)) |
| 370 | 370 | ||||
| 371 | snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 2}, {End: 2}}, nil) | 371 | snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 2}, {End: 2}}, nil) | ||
| 372 | doc := NewDocument(snapshot, "") | 372 | doc := NewDocument(snapshot, "") | ||
| @@ -434,10 +434,10 @@ func TestSelectedTextJoinsRowsWrappedByTheVT(t *testing.T) { | |||||
| 434 | } | 434 | } | ||
| 435 | for _, tc := range cases { | 435 | for _, tc := range cases { | ||
| 436 | t.Run(tc.name, func(t *testing.T) { | 436 | t.Run(tc.name, func(t *testing.T) { | ||
| 437 | - | screen := vt.NewScreenWithHistory(width, height, vt.HistoryConfig{MaxRows: 64, MaxCells: 4096}) | 437 | + | screen := vt.NewScreenWithHistory(width, height, vt.HistoryConfig{MaxRows: 64}) |
| 438 | screen.Write([]byte(tc.input)) | 438 | screen.Write([]byte(tc.input)) | ||
| 439 | 439 | ||||
| 440 | - | snapshot := NewSnapshot(screen.History(), screen.Frame, screen.LineBounds(), nil) | 440 | + | snapshot := NewSnapshot(screen.History(), screen, screen.LineBounds(), nil) |
| 441 | require.Equal(t, tc.wantHistoryLen, snapshot.history.Len(), "history/screen seam moved") | 441 | require.Equal(t, tc.wantHistoryLen, snapshot.history.Len(), "history/screen seam moved") | ||
| 442 | doc := NewDocument(snapshot, domain.DefaultWordSeparators) | 442 | doc := NewDocument(snapshot, domain.DefaultWordSeparators) | ||
| 443 | 443 | ||||
A internal/usecase/copy/render_rows_test.go +56 -0
| @@ -0,0 +1,56 @@ | |||
| 1 | + | package copy | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | renderer "github.com/bnema/vev-vt/ansi" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestRenderRowsFixedSemanticCells(t *testing.T) { | |
| 12 | + | base := renderer.DefaultStyle() | |
| 13 | + | base.Bold = true | |
| 14 | + | selected := renderer.DefaultStyle() | |
| 15 | + | selected.Inverse = true | |
| 16 | + | status := renderer.DefaultStyle() | |
| 17 | + | status.Inverse = true | |
| 18 | + | input := []renderer.Cell{ | |
| 19 | + | {Rune: 'A', Style: base}, | |
| 20 | + | {Rune: '界', Style: base}, | |
| 21 | + | {Continuation: true, Style: base}, | |
| 22 | + | {Rune: 'Z', Style: base}, | |
| 23 | + | } | |
| 24 | + | m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{input}, 32, 3), "")) | |
| 25 | + | require.True(t, m.Search("A")) | |
| 26 | + | require.True(t, m.StartCharacterSelection(Pos{Col: 1})) | |
| 27 | + | require.True(t, m.ExtendCharacterSelection(Pos{Col: 2})) | |
| 28 | + | ||
| 29 | + | want := make([][]renderer.Cell, 4) | |
| 30 | + | for y := range want { | |
| 31 | + | want[y] = make([]renderer.Cell, 32) | |
| 32 | + | for x := range want[y] { | |
| 33 | + | want[y][x] = renderer.BlankCell() | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | copy(want[0], input) | |
| 37 | + | // Search highlights A; selection covers both cells of the wide glyph. | |
| 38 | + | // Inverse highlighting preserves the source's bold style, including the | |
| 39 | + | // cursor already covered by selection (it must not toggle inverse twice). | |
| 40 | + | for x := 0; x < 3; x++ { | |
| 41 | + | want[0][x].Style.Inverse = true | |
| 42 | + | } | |
| 43 | + | text := " [SELECT] 1/1 1/1 /A " | |
| 44 | + | for x, r := range text + strings.Repeat(" ", 32-len(text)) { | |
| 45 | + | want[3][x] = renderer.Cell{Rune: r, Style: status} | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | var got [][]renderer.Cell | |
| 49 | + | m.RenderRows(func(y int, cells []renderer.Cell) { | |
| 50 | + | require.Equal(t, len(got), y, "body rows precede the final status row") | |
| 51 | + | // Own the callback data; later callbacks reuse the borrowed row. | |
| 52 | + | got = append(got, append([]renderer.Cell(nil), cells...)) | |
| 53 | + | }, status, selected) | |
| 54 | + | require.Equal(t, want, got) | |
| 55 | + | require.Equal(t, input, m.Document().Row(0), "rendering must not style the immutable document") | |
| 56 | + | } | |
| @@ -0,0 +1,56 @@ | |||||
| 1 | + | package copy | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | renderer "github.com/bnema/vev-vt/ansi" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestRenderRowsFixedSemanticCells(t *testing.T) { | |||
| 12 | + | base := renderer.DefaultStyle() | |||
| 13 | + | base.Bold = true | |||
| 14 | + | selected := renderer.DefaultStyle() | |||
| 15 | + | selected.Inverse = true | |||
| 16 | + | status := renderer.DefaultStyle() | |||
| 17 | + | status.Inverse = true | |||
| 18 | + | input := []renderer.Cell{ | |||
| 19 | + | {Rune: 'A', Style: base}, | |||
| 20 | + | {Rune: '界', Style: base}, | |||
| 21 | + | {Continuation: true, Style: base}, | |||
| 22 | + | {Rune: 'Z', Style: base}, | |||
| 23 | + | } | |||
| 24 | + | m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{input}, 32, 3), "")) | |||
| 25 | + | require.True(t, m.Search("A")) | |||
| 26 | + | require.True(t, m.StartCharacterSelection(Pos{Col: 1})) | |||
| 27 | + | require.True(t, m.ExtendCharacterSelection(Pos{Col: 2})) | |||
| 28 | + | ||||
| 29 | + | want := make([][]renderer.Cell, 4) | |||
| 30 | + | for y := range want { | |||
| 31 | + | want[y] = make([]renderer.Cell, 32) | |||
| 32 | + | for x := range want[y] { | |||
| 33 | + | want[y][x] = renderer.BlankCell() | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | copy(want[0], input) | |||
| 37 | + | // Search highlights A; selection covers both cells of the wide glyph. | |||
| 38 | + | // Inverse highlighting preserves the source's bold style, including the | |||
| 39 | + | // cursor already covered by selection (it must not toggle inverse twice). | |||
| 40 | + | for x := 0; x < 3; x++ { | |||
| 41 | + | want[0][x].Style.Inverse = true | |||
| 42 | + | } | |||
| 43 | + | text := " [SELECT] 1/1 1/1 /A " | |||
| 44 | + | for x, r := range text + strings.Repeat(" ", 32-len(text)) { | |||
| 45 | + | want[3][x] = renderer.Cell{Rune: r, Style: status} | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | var got [][]renderer.Cell | |||
| 49 | + | m.RenderRows(func(y int, cells []renderer.Cell) { | |||
| 50 | + | require.Equal(t, len(got), y, "body rows precede the final status row") | |||
| 51 | + | // Own the callback data; later callbacks reuse the borrowed row. | |||
| 52 | + | got = append(got, append([]renderer.Cell(nil), cells...)) | |||
| 53 | + | }, status, selected) | |||
| 54 | + | require.Equal(t, want, got) | |||
| 55 | + | require.Equal(t, input, m.Document().Row(0), "rendering must not style the immutable document") | |||
| 56 | + | } | |||
A internal/usecase/copy/search_error_test.go +34 -0
| @@ -0,0 +1,34 @@ | |||
| 1 | + | package copy | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | renderer "github.com/bnema/vev-vt/ansi" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestFindMatchesDiscardsPartialResultsOnHistoryError(t *testing.T) { | |
| 12 | + | for _, tc := range []struct { | |
| 13 | + | name string | |
| 14 | + | yielded bool | |
| 15 | + | }{ | |
| 16 | + | {name: "before rows"}, | |
| 17 | + | {name: "after match", yielded: true}, | |
| 18 | + | } { | |
| 19 | + | t.Run(tc.name, func(t *testing.T) { | |
| 20 | + | m := modeFor([]string{"match"}, 1) | |
| 21 | + | require.True(t, m.Search("match")) | |
| 22 | + | matches := findMatches(m.document, "match", func(yield func(int, []renderer.Cell) bool) error { | |
| 23 | + | if tc.yielded { | |
| 24 | + | require.True(t, yield(0, row("match"))) | |
| 25 | + | } | |
| 26 | + | return errors.New("corrupt cold history") | |
| 27 | + | }) | |
| 28 | + | require.Nil(t, matches) | |
| 29 | + | require.False(t, m.SetSearchMatches("match", matches, 0)) | |
| 30 | + | require.Empty(t, m.Searches) | |
| 31 | + | require.Equal(t, -1, m.SearchIndex) | |
| 32 | + | }) | |
| 33 | + | } | |
| 34 | + | } | |
| @@ -0,0 +1,34 @@ | |||||
| 1 | + | package copy | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | renderer "github.com/bnema/vev-vt/ansi" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestFindMatchesDiscardsPartialResultsOnHistoryError(t *testing.T) { | |||
| 12 | + | for _, tc := range []struct { | |||
| 13 | + | name string | |||
| 14 | + | yielded bool | |||
| 15 | + | }{ | |||
| 16 | + | {name: "before rows"}, | |||
| 17 | + | {name: "after match", yielded: true}, | |||
| 18 | + | } { | |||
| 19 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 20 | + | m := modeFor([]string{"match"}, 1) | |||
| 21 | + | require.True(t, m.Search("match")) | |||
| 22 | + | matches := findMatches(m.document, "match", func(yield func(int, []renderer.Cell) bool) error { | |||
| 23 | + | if tc.yielded { | |||
| 24 | + | require.True(t, yield(0, row("match"))) | |||
| 25 | + | } | |||
| 26 | + | return errors.New("corrupt cold history") | |||
| 27 | + | }) | |||
| 28 | + | require.Nil(t, matches) | |||
| 29 | + | require.False(t, m.SetSearchMatches("match", matches, 0)) | |||
| 30 | + | require.Empty(t, m.Searches) | |||
| 31 | + | require.Equal(t, -1, m.SearchIndex) | |||
| 32 | + | }) | |||
| 33 | + | } | |||
| 34 | + | } | |||
M internal/usecase/daemon/attachment_output_test.go +5 -5
| @@ -350,7 +350,7 @@ func TestAttachmentOutputRepeatedUnackedScrollRemainsClientCorrect(t *testing.T) | |||
| 350 | 350 | require.True(t, ok) | |
| 351 | 351 | mustApplyOutput(t, client, third) | |
| 352 | 352 | for y, want := range []string{"BBBB", "CCCC", "N "} { | |
| 353 | - | require.Equal(t, want, outputStateRow(client.Frame.Row(y))) | |
| 353 | + | require.Equal(t, want, outputStateRow(client.RowCells(y))) | |
| 354 | 354 | } | |
| 355 | 355 | } | |
| 356 | 356 | ||
| @@ -416,7 +416,7 @@ func TestAttachmentOutputResizeFrameThenNoopAndDamageAreDifferential(t *testing. | |||
| 416 | 416 | screen.Resize(6, 2) | |
| 417 | 417 | ||
| 418 | 418 | stream := newOutputStateStream() | |
| 419 | - | resized, ok, err := drawOutputState(t, stream, screen.Frame, screen.Damage(), true, 0) | |
| 419 | + | resized, ok, err := drawOutputState(t, stream, captureTestFrame(screen), screen.Damage(), true, 0) | |
| 420 | 420 | require.NoError(t, err) | |
| 421 | 421 | require.True(t, ok) | |
| 422 | 422 | resizeOutput, err := wire.UnmarshalOutput(resized.Payload) | |
| @@ -424,12 +424,12 @@ func TestAttachmentOutputResizeFrameThenNoopAndDamageAreDifferential(t *testing. | |||
| 424 | 424 | require.Zero(t, resizeOutput.Base, "resize must emit the one reset frame") | |
| 425 | 425 | screen.ClearDamage() | |
| 426 | 426 | ||
| 427 | - | _, ok, err = drawOutputState(t, stream, screen.Frame, screen.Damage(), false, 0) | |
| 427 | + | _, ok, err = drawOutputState(t, stream, captureTestFrame(screen), screen.Damage(), false, 0) | |
| 428 | 428 | require.NoError(t, err) | |
| 429 | 429 | require.False(t, ok, "an unchanged render after resize must emit nothing") | |
| 430 | 430 | ||
| 431 | - | screen.Frame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |
| 432 | - | damaged, ok, err := drawOutputState(t, stream, screen.Frame, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false, 0) | |
| 431 | + | screen.Write([]byte("\x1b[1;1Hx")) | |
| 432 | + | damaged, ok, err := drawOutputState(t, stream, captureTestFrame(screen), []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false, 0) | |
| 433 | 433 | require.NoError(t, err) | |
| 434 | 434 | require.True(t, ok) | |
| 435 | 435 | damageOutput, err := wire.UnmarshalOutput(damaged.Payload) | |
| @@ -350,7 +350,7 @@ func TestAttachmentOutputRepeatedUnackedScrollRemainsClientCorrect(t *testing.T) | |||||
| 350 | require.True(t, ok) | 350 | require.True(t, ok) | ||
| 351 | mustApplyOutput(t, client, third) | 351 | mustApplyOutput(t, client, third) | ||
| 352 | for y, want := range []string{"BBBB", "CCCC", "N "} { | 352 | for y, want := range []string{"BBBB", "CCCC", "N "} { | ||
| 353 | - | require.Equal(t, want, outputStateRow(client.Frame.Row(y))) | 353 | + | require.Equal(t, want, outputStateRow(client.RowCells(y))) |
| 354 | } | 354 | } | ||
| 355 | } | 355 | } | ||
| 356 | 356 | ||||
| @@ -416,7 +416,7 @@ func TestAttachmentOutputResizeFrameThenNoopAndDamageAreDifferential(t *testing. | |||||
| 416 | screen.Resize(6, 2) | 416 | screen.Resize(6, 2) | ||
| 417 | 417 | ||||
| 418 | stream := newOutputStateStream() | 418 | stream := newOutputStateStream() | ||
| 419 | - | resized, ok, err := drawOutputState(t, stream, screen.Frame, screen.Damage(), true, 0) | 419 | + | resized, ok, err := drawOutputState(t, stream, captureTestFrame(screen), screen.Damage(), true, 0) |
| 420 | require.NoError(t, err) | 420 | require.NoError(t, err) | ||
| 421 | require.True(t, ok) | 421 | require.True(t, ok) | ||
| 422 | resizeOutput, err := wire.UnmarshalOutput(resized.Payload) | 422 | resizeOutput, err := wire.UnmarshalOutput(resized.Payload) | ||
| @@ -424,12 +424,12 @@ func TestAttachmentOutputResizeFrameThenNoopAndDamageAreDifferential(t *testing. | |||||
| 424 | require.Zero(t, resizeOutput.Base, "resize must emit the one reset frame") | 424 | require.Zero(t, resizeOutput.Base, "resize must emit the one reset frame") | ||
| 425 | screen.ClearDamage() | 425 | screen.ClearDamage() | ||
| 426 | 426 | ||||
| 427 | - | _, ok, err = drawOutputState(t, stream, screen.Frame, screen.Damage(), false, 0) | 427 | + | _, ok, err = drawOutputState(t, stream, captureTestFrame(screen), screen.Damage(), false, 0) |
| 428 | require.NoError(t, err) | 428 | require.NoError(t, err) | ||
| 429 | require.False(t, ok, "an unchanged render after resize must emit nothing") | 429 | require.False(t, ok, "an unchanged render after resize must emit nothing") | ||
| 430 | 430 | ||||
| 431 | - | screen.Frame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | 431 | + | screen.Write([]byte("\x1b[1;1Hx")) |
| 432 | - | damaged, ok, err := drawOutputState(t, stream, screen.Frame, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false, 0) | 432 | + | damaged, ok, err := drawOutputState(t, stream, captureTestFrame(screen), []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false, 0) |
| 433 | require.NoError(t, err) | 433 | require.NoError(t, err) | ||
| 434 | require.True(t, ok) | 434 | require.True(t, ok) | ||
| 435 | damageOutput, err := wire.UnmarshalOutput(damaged.Payload) | 435 | damageOutput, err := wire.UnmarshalOutput(damaged.Payload) | ||
M internal/usecase/daemon/attention_test.go +3 -3
| @@ -384,13 +384,13 @@ func TestComposeFrameAttentionFrameChangeDamagesOnlyBars(t *testing.T) { | |||
| 384 | 384 | pane := tb.focusedPane() | |
| 385 | 385 | pane.screen.Write([]byte("ATTENTION-PANE-CONTENT")) | |
| 386 | 386 | bars := d.barStateFor(sess, "") | |
| 387 | - | area := domain.Rect{Width: pane.screen.Frame.Width, Height: pane.screen.Frame.Height} | |
| 387 | + | area := domain.Rect{Width: pane.screen.Columns(), Height: pane.screen.Rows()} | |
| 388 | 388 | placement := layout.Placement{ID: pane.id, Content: area} | |
| 389 | 389 | state := capturedRenderState{ | |
| 390 | 390 | reset: true, | |
| 391 | 391 | layout: capturedTabLayout{area: area, focus: pane.id, placements: []layout.Placement{placement}, valid: true}, | |
| 392 | 392 | panes: []capturedPaneRenderState{{ | |
| 393 | - | id: pane.id, frame: pane.screen.Frame.Clone(), placement: placement, focused: true, | |
| 393 | + | id: pane.id, frame: captureTestFrame(pane.screen), placement: placement, focused: true, | |
| 394 | 394 | damage: []renderer.Damage{renderer.FullRedraw()}, | |
| 395 | 395 | }}, | |
| 396 | 396 | bars: bars, | |
| @@ -413,7 +413,7 @@ func TestComposeFrameAttentionFrameChangeDamagesOnlyBars(t *testing.T) { | |||
| 413 | 413 | require.Equal(t, firstContent, rowText(out.frame.Row(1)), "unchanged pane content must remain in the composed frame") | |
| 414 | 414 | require.Contains(t, rowText(out.frame.Row(1)), "ATTENTION-PANE-CONTENT") | |
| 415 | 415 | require.NotEmpty(t, damage) | |
| 416 | - | lastRow := pane.screen.Frame.Height + 1 | |
| 416 | + | lastRow := pane.screen.Rows() + 1 | |
| 417 | 417 | for _, dmg := range damage { | |
| 418 | 418 | require.Contains(t, []int{0, lastRow}, dmg.Y, "expected damage confined to bar rows, got y=%d", dmg.Y) | |
| 419 | 419 | } | |
| @@ -384,13 +384,13 @@ func TestComposeFrameAttentionFrameChangeDamagesOnlyBars(t *testing.T) { | |||||
| 384 | pane := tb.focusedPane() | 384 | pane := tb.focusedPane() | ||
| 385 | pane.screen.Write([]byte("ATTENTION-PANE-CONTENT")) | 385 | pane.screen.Write([]byte("ATTENTION-PANE-CONTENT")) | ||
| 386 | bars := d.barStateFor(sess, "") | 386 | bars := d.barStateFor(sess, "") | ||
| 387 | - | area := domain.Rect{Width: pane.screen.Frame.Width, Height: pane.screen.Frame.Height} | 387 | + | area := domain.Rect{Width: pane.screen.Columns(), Height: pane.screen.Rows()} |
| 388 | placement := layout.Placement{ID: pane.id, Content: area} | 388 | placement := layout.Placement{ID: pane.id, Content: area} | ||
| 389 | state := capturedRenderState{ | 389 | state := capturedRenderState{ | ||
| 390 | reset: true, | 390 | reset: true, | ||
| 391 | layout: capturedTabLayout{area: area, focus: pane.id, placements: []layout.Placement{placement}, valid: true}, | 391 | layout: capturedTabLayout{area: area, focus: pane.id, placements: []layout.Placement{placement}, valid: true}, | ||
| 392 | panes: []capturedPaneRenderState{{ | 392 | panes: []capturedPaneRenderState{{ | ||
| 393 | - | id: pane.id, frame: pane.screen.Frame.Clone(), placement: placement, focused: true, | 393 | + | id: pane.id, frame: captureTestFrame(pane.screen), placement: placement, focused: true, |
| 394 | damage: []renderer.Damage{renderer.FullRedraw()}, | 394 | damage: []renderer.Damage{renderer.FullRedraw()}, | ||
| 395 | }}, | 395 | }}, | ||
| 396 | bars: bars, | 396 | bars: bars, | ||
| @@ -413,7 +413,7 @@ func TestComposeFrameAttentionFrameChangeDamagesOnlyBars(t *testing.T) { | |||||
| 413 | require.Equal(t, firstContent, rowText(out.frame.Row(1)), "unchanged pane content must remain in the composed frame") | 413 | require.Equal(t, firstContent, rowText(out.frame.Row(1)), "unchanged pane content must remain in the composed frame") | ||
| 414 | require.Contains(t, rowText(out.frame.Row(1)), "ATTENTION-PANE-CONTENT") | 414 | require.Contains(t, rowText(out.frame.Row(1)), "ATTENTION-PANE-CONTENT") | ||
| 415 | require.NotEmpty(t, damage) | 415 | require.NotEmpty(t, damage) | ||
| 416 | - | lastRow := pane.screen.Frame.Height + 1 | 416 | + | lastRow := pane.screen.Rows() + 1 |
| 417 | for _, dmg := range damage { | 417 | for _, dmg := range damage { | ||
| 418 | require.Contains(t, []int{0, lastRow}, dmg.Y, "expected damage confined to bar rows, got y=%d", dmg.Y) | 418 | require.Contains(t, []int{0, lastRow}, dmg.Y, "expected damage confined to bar rows, got y=%d", dmg.Y) | ||
| 419 | } | 419 | } | ||
M internal/usecase/daemon/config.go +7 -0
| @@ -43,6 +43,12 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) { | |||
| 43 | 43 | bindings, warnings := keys.BuildBindingEntries(cfg.BindingEntries) | |
| 44 | 44 | overrides, codeWarnings := d.buildCodeOverrides(cfg.Codes) | |
| 45 | 45 | allWarnings := append(append([]domain.Warning{}, warnings...), codeWarnings...) | |
| 46 | + | scrollback := cfg.Scrollback | |
| 47 | + | if !scrollback.Valid() { | |
| 48 | + | scrollback = domain.DefaultScrollbackConfig() | |
| 49 | + | allWarnings = append(allWarnings, domain.Warning{Msg: "invalid scrollback limits; using vev defaults"}) | |
| 50 | + | } | |
| 51 | + | d.scrollbackConfig.Store(&scrollback) | |
| 46 | 52 | for _, warning := range allWarnings { | |
| 47 | 53 | d.logConfigWarning(warning) | |
| 48 | 54 | } | |
| @@ -61,6 +67,7 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) { | |||
| 61 | 67 | tabs := cfg.Tabs | |
| 62 | 68 | d.tabsConfig.Store(&tabs) | |
| 63 | 69 | d.storeThemeConfig(cfg) | |
| 70 | + | d.applyHistoryLimits() | |
| 64 | 71 | barChanged := false | |
| 65 | 72 | if d.barScripts != nil { | |
| 66 | 73 | d.barScripts.mu.Lock() | |
| @@ -43,6 +43,12 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) { | |||||
| 43 | bindings, warnings := keys.BuildBindingEntries(cfg.BindingEntries) | 43 | bindings, warnings := keys.BuildBindingEntries(cfg.BindingEntries) | ||
| 44 | overrides, codeWarnings := d.buildCodeOverrides(cfg.Codes) | 44 | overrides, codeWarnings := d.buildCodeOverrides(cfg.Codes) | ||
| 45 | allWarnings := append(append([]domain.Warning{}, warnings...), codeWarnings...) | 45 | allWarnings := append(append([]domain.Warning{}, warnings...), codeWarnings...) | ||
| 46 | + | scrollback := cfg.Scrollback | |||
| 47 | + | if !scrollback.Valid() { | |||
| 48 | + | scrollback = domain.DefaultScrollbackConfig() | |||
| 49 | + | allWarnings = append(allWarnings, domain.Warning{Msg: "invalid scrollback limits; using vev defaults"}) | |||
| 50 | + | } | |||
| 51 | + | d.scrollbackConfig.Store(&scrollback) | |||
| 46 | for _, warning := range allWarnings { | 52 | for _, warning := range allWarnings { | ||
| 47 | d.logConfigWarning(warning) | 53 | d.logConfigWarning(warning) | ||
| 48 | } | 54 | } | ||
| @@ -61,6 +67,7 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) { | |||||
| 61 | tabs := cfg.Tabs | 67 | tabs := cfg.Tabs | ||
| 62 | d.tabsConfig.Store(&tabs) | 68 | d.tabsConfig.Store(&tabs) | ||
| 63 | d.storeThemeConfig(cfg) | 69 | d.storeThemeConfig(cfg) | ||
| 70 | + | d.applyHistoryLimits() | |||
| 64 | barChanged := false | 71 | barChanged := false | ||
| 65 | if d.barScripts != nil { | 72 | if d.barScripts != nil { | ||
| 66 | d.barScripts.mu.Lock() | 73 | d.barScripts.mu.Lock() | ||
A internal/usecase/daemon/copy_scroll_ownership_test.go +121 -0
| @@ -0,0 +1,121 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | renderer "github.com/bnema/vev-vt" | |
| 7 | + | "github.com/bnema/vev/internal/protocol/wire" | |
| 8 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestCopyCacheFailedPublicationAndRetry(t *testing.T) { | |
| 13 | + | for _, failure := range []string{"prepare", "send"} { | |
| 14 | + | t.Run(failure, func(t *testing.T) { | |
| 15 | + | d, sess, ac, sends := newManualSessionWithPTYs(t) | |
| 16 | + | healthy := ac.transport() | |
| 17 | + | state := cacheState("live", 1) | |
| 18 | + | state.floating = capturedFloatingRenderState{} | |
| 19 | + | state.overlays = capturedOverlayRenderState{ | |
| 20 | + | copyActive: true, copyPaneID: "pane", | |
| 21 | + | copyMode: scopy.NewMode(scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("COPY")}, 6, 1), "")), | |
| 22 | + | } | |
| 23 | + | committed := composeFrame(state, composeCacheInput{}) | |
| 24 | + | require.True(t, committed.cache.valid) | |
| 25 | + | ac.pipelineCache = committed.cache | |
| 26 | + | before := cloneComposeCache(ac.pipelineCache) | |
| 27 | + | state.bars.topRight = "retry" | |
| 28 | + | state.attachment = ac | |
| 29 | + | pending := composeFrame(state, ac.pipelineCache, ac.pipelineScratch) | |
| 30 | + | if failure == "prepare" { | |
| 31 | + | pending.frame = renderer.Frame{Width: 1} | |
| 32 | + | } else { | |
| 33 | + | ac.replaceTransport(cacheFailTransport{}) | |
| 34 | + | } | |
| 35 | + | ac.sendMu.Lock() | |
| 36 | + | require.True(t, d.emitFrame(sess, ac, &state, pending)) | |
| 37 | + | require.Equal(t, before, cloneComposeCache(ac.pipelineCache)) | |
| 38 | + | require.Zero(t, ac.output.next) | |
| 39 | + | if failure == "send" { | |
| 40 | + | sess.mu.Lock() | |
| 41 | + | sess.registerAttachmentLocked(ac) | |
| 42 | + | sess.mu.Unlock() | |
| 43 | + | ac.setSession(sess) | |
| 44 | + | ac.replaceTransport(healthy) | |
| 45 | + | } | |
| 46 | + | pending = composeFrame(state, ac.pipelineCache, ac.pipelineScratch) | |
| 47 | + | ac.sendMu.Lock() | |
| 48 | + | require.True(t, d.emitFrame(sess, ac, &state, pending)) | |
| 49 | + | require.Equal(t, pending.cache, ac.pipelineCache) | |
| 50 | + | require.NotContains(t, frameText(ac.pipelineCache.frame), "COPY") | |
| 51 | + | output, err := wire.UnmarshalOutput((<-sends).Payload) | |
| 52 | + | require.NoError(t, err) | |
| 53 | + | terminal := renderer.NewScreen(pending.frame.Width, pending.frame.Height) | |
| 54 | + | terminal.Write(output.Data) | |
| 55 | + | require.Contains(t, frameText(captureTestFrame(terminal)), "COPY", "retry must render copy cells despite interleaved ANSI styling") | |
| 56 | + | require.Equal(t, uint64(1), ac.output.next) | |
| 57 | + | }) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestCopyCacheSearchTransition(t *testing.T) { | |
| 62 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | |
| 63 | + | sess.tabs[0].focusedPane().screen.Write([]byte("alpha")) | |
| 64 | + | ack := func() { | |
| 65 | + | mustOutputData(t, sends) | |
| 66 | + | ac.ackOutputState(ac.output.currentEpoch(), ac.output.next) | |
| 67 | + | } | |
| 68 | + | d.enterCopyMode(sess, ac) | |
| 69 | + | ack() | |
| 70 | + | require.True(t, ac.pipelineCache.valid) | |
| 71 | + | base := captureTestFrame(ac.pipelineCache.frame) | |
| 72 | + | d.handleInput(sess, ac, []byte("/")) | |
| 73 | + | ack() | |
| 74 | + | require.NotNil(t, ac.overlays.copySearch) | |
| 75 | + | require.False(t, ac.pipelineCache.valid) | |
| 76 | + | require.Equal(t, base, captureTestFrame(ac.pipelineCache.frame), "search modal must not contaminate the live base") | |
| 77 | + | d.handleInput(sess, ac, []byte("\x1b")) | |
| 78 | + | ack() | |
| 79 | + | require.Nil(t, ac.overlays.copySearch) | |
| 80 | + | require.NotNil(t, ac.overlays.copyMode) | |
| 81 | + | require.True(t, ac.pipelineCache.valid) | |
| 82 | + | require.Equal(t, base, captureTestFrame(ac.pipelineCache.frame)) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestCopyExitRefreshesAfterOtherAttachmentConsumesDamage(t *testing.T) { | |
| 86 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | |
| 87 | + | p := sess.tabs[0].focusedPane() | |
| 88 | + | p.screen.Write([]byte("before")) | |
| 89 | + | d.enterCopyMode(sess, ac) | |
| 90 | + | mustOutputData(t, sends) | |
| 91 | + | ac.ackOutputState(ac.output.currentEpoch(), ac.output.next) | |
| 92 | + | copyBefore := captureTestFrame(ac.pipelineCache.frame) | |
| 93 | + | ||
| 94 | + | tr, peerSends := newCapturingTransport(t) | |
| 95 | + | peer := &attachedClient{tr: tr, output: newOutputStateStream(), size: ac.size} | |
| 96 | + | peer.output.attachment = peer | |
| 97 | + | peer.initOverlays() | |
| 98 | + | peer.setSession(sess) | |
| 99 | + | sess.mu.Lock() | |
| 100 | + | require.True(t, sess.registerAttachmentLocked(peer)) | |
| 101 | + | sess.mu.Unlock() | |
| 102 | + | p.mu.Lock() | |
| 103 | + | p.screen.Write([]byte("\x1b[1;1Hafter-peer")) | |
| 104 | + | p.mu.Unlock() | |
| 105 | + | // A real peer capture/emission consumes the shared pane damage first. | |
| 106 | + | d.paint(sess, peer, true, nil) | |
| 107 | + | mustOutputData(t, peerSends) | |
| 108 | + | p.mu.Lock() | |
| 109 | + | require.Empty(t, p.screen.Damage()) | |
| 110 | + | p.mu.Unlock() | |
| 111 | + | require.Contains(t, frameText(peer.pipelineCache.frame), "after-peer") | |
| 112 | + | require.Equal(t, copyBefore, captureTestFrame(ac.pipelineCache.frame), "peer publication cannot mutate another attachment's cache") | |
| 113 | + | ||
| 114 | + | // The copy document is still frozen, but leaving it must recapture live | |
| 115 | + | // cells even though the other attachment already acknowledged the damage. | |
| 116 | + | d.copyWheel(sess, ac, 1) | |
| 117 | + | mustOutputData(t, sends) | |
| 118 | + | require.Nil(t, ac.overlays.copyMode) | |
| 119 | + | require.Contains(t, frameText(ac.pipelineCache.frame), "after-peer") | |
| 120 | + | require.NoError(t, ac.pipelineCache.frame.CheckInvariants()) | |
| 121 | + | } | |
| @@ -0,0 +1,121 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | renderer "github.com/bnema/vev-vt" | |||
| 7 | + | "github.com/bnema/vev/internal/protocol/wire" | |||
| 8 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestCopyCacheFailedPublicationAndRetry(t *testing.T) { | |||
| 13 | + | for _, failure := range []string{"prepare", "send"} { | |||
| 14 | + | t.Run(failure, func(t *testing.T) { | |||
| 15 | + | d, sess, ac, sends := newManualSessionWithPTYs(t) | |||
| 16 | + | healthy := ac.transport() | |||
| 17 | + | state := cacheState("live", 1) | |||
| 18 | + | state.floating = capturedFloatingRenderState{} | |||
| 19 | + | state.overlays = capturedOverlayRenderState{ | |||
| 20 | + | copyActive: true, copyPaneID: "pane", | |||
| 21 | + | copyMode: scopy.NewMode(scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("COPY")}, 6, 1), "")), | |||
| 22 | + | } | |||
| 23 | + | committed := composeFrame(state, composeCacheInput{}) | |||
| 24 | + | require.True(t, committed.cache.valid) | |||
| 25 | + | ac.pipelineCache = committed.cache | |||
| 26 | + | before := cloneComposeCache(ac.pipelineCache) | |||
| 27 | + | state.bars.topRight = "retry" | |||
| 28 | + | state.attachment = ac | |||
| 29 | + | pending := composeFrame(state, ac.pipelineCache, ac.pipelineScratch) | |||
| 30 | + | if failure == "prepare" { | |||
| 31 | + | pending.frame = renderer.Frame{Width: 1} | |||
| 32 | + | } else { | |||
| 33 | + | ac.replaceTransport(cacheFailTransport{}) | |||
| 34 | + | } | |||
| 35 | + | ac.sendMu.Lock() | |||
| 36 | + | require.True(t, d.emitFrame(sess, ac, &state, pending)) | |||
| 37 | + | require.Equal(t, before, cloneComposeCache(ac.pipelineCache)) | |||
| 38 | + | require.Zero(t, ac.output.next) | |||
| 39 | + | if failure == "send" { | |||
| 40 | + | sess.mu.Lock() | |||
| 41 | + | sess.registerAttachmentLocked(ac) | |||
| 42 | + | sess.mu.Unlock() | |||
| 43 | + | ac.setSession(sess) | |||
| 44 | + | ac.replaceTransport(healthy) | |||
| 45 | + | } | |||
| 46 | + | pending = composeFrame(state, ac.pipelineCache, ac.pipelineScratch) | |||
| 47 | + | ac.sendMu.Lock() | |||
| 48 | + | require.True(t, d.emitFrame(sess, ac, &state, pending)) | |||
| 49 | + | require.Equal(t, pending.cache, ac.pipelineCache) | |||
| 50 | + | require.NotContains(t, frameText(ac.pipelineCache.frame), "COPY") | |||
| 51 | + | output, err := wire.UnmarshalOutput((<-sends).Payload) | |||
| 52 | + | require.NoError(t, err) | |||
| 53 | + | terminal := renderer.NewScreen(pending.frame.Width, pending.frame.Height) | |||
| 54 | + | terminal.Write(output.Data) | |||
| 55 | + | require.Contains(t, frameText(captureTestFrame(terminal)), "COPY", "retry must render copy cells despite interleaved ANSI styling") | |||
| 56 | + | require.Equal(t, uint64(1), ac.output.next) | |||
| 57 | + | }) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestCopyCacheSearchTransition(t *testing.T) { | |||
| 62 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | |||
| 63 | + | sess.tabs[0].focusedPane().screen.Write([]byte("alpha")) | |||
| 64 | + | ack := func() { | |||
| 65 | + | mustOutputData(t, sends) | |||
| 66 | + | ac.ackOutputState(ac.output.currentEpoch(), ac.output.next) | |||
| 67 | + | } | |||
| 68 | + | d.enterCopyMode(sess, ac) | |||
| 69 | + | ack() | |||
| 70 | + | require.True(t, ac.pipelineCache.valid) | |||
| 71 | + | base := captureTestFrame(ac.pipelineCache.frame) | |||
| 72 | + | d.handleInput(sess, ac, []byte("/")) | |||
| 73 | + | ack() | |||
| 74 | + | require.NotNil(t, ac.overlays.copySearch) | |||
| 75 | + | require.False(t, ac.pipelineCache.valid) | |||
| 76 | + | require.Equal(t, base, captureTestFrame(ac.pipelineCache.frame), "search modal must not contaminate the live base") | |||
| 77 | + | d.handleInput(sess, ac, []byte("\x1b")) | |||
| 78 | + | ack() | |||
| 79 | + | require.Nil(t, ac.overlays.copySearch) | |||
| 80 | + | require.NotNil(t, ac.overlays.copyMode) | |||
| 81 | + | require.True(t, ac.pipelineCache.valid) | |||
| 82 | + | require.Equal(t, base, captureTestFrame(ac.pipelineCache.frame)) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestCopyExitRefreshesAfterOtherAttachmentConsumesDamage(t *testing.T) { | |||
| 86 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | |||
| 87 | + | p := sess.tabs[0].focusedPane() | |||
| 88 | + | p.screen.Write([]byte("before")) | |||
| 89 | + | d.enterCopyMode(sess, ac) | |||
| 90 | + | mustOutputData(t, sends) | |||
| 91 | + | ac.ackOutputState(ac.output.currentEpoch(), ac.output.next) | |||
| 92 | + | copyBefore := captureTestFrame(ac.pipelineCache.frame) | |||
| 93 | + | ||||
| 94 | + | tr, peerSends := newCapturingTransport(t) | |||
| 95 | + | peer := &attachedClient{tr: tr, output: newOutputStateStream(), size: ac.size} | |||
| 96 | + | peer.output.attachment = peer | |||
| 97 | + | peer.initOverlays() | |||
| 98 | + | peer.setSession(sess) | |||
| 99 | + | sess.mu.Lock() | |||
| 100 | + | require.True(t, sess.registerAttachmentLocked(peer)) | |||
| 101 | + | sess.mu.Unlock() | |||
| 102 | + | p.mu.Lock() | |||
| 103 | + | p.screen.Write([]byte("\x1b[1;1Hafter-peer")) | |||
| 104 | + | p.mu.Unlock() | |||
| 105 | + | // A real peer capture/emission consumes the shared pane damage first. | |||
| 106 | + | d.paint(sess, peer, true, nil) | |||
| 107 | + | mustOutputData(t, peerSends) | |||
| 108 | + | p.mu.Lock() | |||
| 109 | + | require.Empty(t, p.screen.Damage()) | |||
| 110 | + | p.mu.Unlock() | |||
| 111 | + | require.Contains(t, frameText(peer.pipelineCache.frame), "after-peer") | |||
| 112 | + | require.Equal(t, copyBefore, captureTestFrame(ac.pipelineCache.frame), "peer publication cannot mutate another attachment's cache") | |||
| 113 | + | ||||
| 114 | + | // The copy document is still frozen, but leaving it must recapture live | |||
| 115 | + | // cells even though the other attachment already acknowledged the damage. | |||
| 116 | + | d.copyWheel(sess, ac, 1) | |||
| 117 | + | mustOutputData(t, sends) | |||
| 118 | + | require.Nil(t, ac.overlays.copyMode) | |||
| 119 | + | require.Contains(t, frameText(ac.pipelineCache.frame), "after-peer") | |||
| 120 | + | require.NoError(t, ac.pipelineCache.frame.CheckInvariants()) | |||
| 121 | + | } | |||
A internal/usecase/daemon/copy_scroll_performance_test.go +57 -0
| @@ -0,0 +1,57 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | renderer "github.com/bnema/vev-vt" | |
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestCopyCompositionDoesNotAllocateIntermediateFrame(t *testing.T) { | |
| 13 | + | rows := make([][]renderer.Cell, 53) | |
| 14 | + | for y := range rows { | |
| 15 | + | rows[y] = testRow("scrollback") | |
| 16 | + | } | |
| 17 | + | mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshotFromRows(rows, 182, 53), "")) | |
| 18 | + | frame := renderer.NewFrame(182, 55) | |
| 19 | + | target := domain.Rect{Y: 1, Width: 182, Height: 53} | |
| 20 | + | styles := resolveStyles(nil) | |
| 21 | + | result := testing.Benchmark(func(b *testing.B) { | |
| 22 | + | for b.Loop() { | |
| 23 | + | composeCopyClientFrame(mode, target, frame, styles) | |
| 24 | + | } | |
| 25 | + | }) | |
| 26 | + | // A reusable semantic row and status text fit in 32 KiB. A second compact | |
| 27 | + | // viewport alone needs over 150 KiB and must not be built just to copy it. | |
| 28 | + | require.LessOrEqual(t, result.AllocedBytesPerOp(), int64(32<<10)) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // Each operation renders two wheel movements, up three rows then down three. | |
| 32 | + | // Keep history setup and copy entry outside the measured steady-state scroll. | |
| 33 | + | func BenchmarkDaemonHistoryCopyScroll(b *testing.B) { | |
| 34 | + | for _, size := range []struct { | |
| 35 | + | name string | |
| 36 | + | size domain.Size | |
| 37 | + | }{ | |
| 38 | + | {"120x40", domain.Size{Cols: 120, Rows: 40}}, | |
| 39 | + | {"182x53", domain.Size{Cols: 182, Rows: 53}}, | |
| 40 | + | {"240x70", domain.Size{Cols: 240, Rows: 70}}, | |
| 41 | + | } { | |
| 42 | + | b.Run(size.name, func(b *testing.B) { | |
| 43 | + | f := newPerformanceFixture(b, performanceConfig{size: size.size, tabs: 1, panes: 1, historyRows: 10000}) | |
| 44 | + | require.True(b, f.hasHistoryTopology(1, 1, 10000)) | |
| 45 | + | f.d.enterCopyMode(f.sess, f.ac) | |
| 46 | + | f.d.copyWheel(f.sess, f.ac, -5000) | |
| 47 | + | f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) | |
| 48 | + | b.ReportAllocs() | |
| 49 | + | for b.Loop() { | |
| 50 | + | f.d.copyWheel(f.sess, f.ac, -3) | |
| 51 | + | f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) | |
| 52 | + | f.d.copyWheel(f.sess, f.ac, 3) | |
| 53 | + | f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) | |
| 54 | + | } | |
| 55 | + | }) | |
| 56 | + | } | |
| 57 | + | } | |
| @@ -0,0 +1,57 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | renderer "github.com/bnema/vev-vt" | |||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestCopyCompositionDoesNotAllocateIntermediateFrame(t *testing.T) { | |||
| 13 | + | rows := make([][]renderer.Cell, 53) | |||
| 14 | + | for y := range rows { | |||
| 15 | + | rows[y] = testRow("scrollback") | |||
| 16 | + | } | |||
| 17 | + | mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshotFromRows(rows, 182, 53), "")) | |||
| 18 | + | frame := renderer.NewFrame(182, 55) | |||
| 19 | + | target := domain.Rect{Y: 1, Width: 182, Height: 53} | |||
| 20 | + | styles := resolveStyles(nil) | |||
| 21 | + | result := testing.Benchmark(func(b *testing.B) { | |||
| 22 | + | for b.Loop() { | |||
| 23 | + | composeCopyClientFrame(mode, target, frame, styles) | |||
| 24 | + | } | |||
| 25 | + | }) | |||
| 26 | + | // A reusable semantic row and status text fit in 32 KiB. A second compact | |||
| 27 | + | // viewport alone needs over 150 KiB and must not be built just to copy it. | |||
| 28 | + | require.LessOrEqual(t, result.AllocedBytesPerOp(), int64(32<<10)) | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // Each operation renders two wheel movements, up three rows then down three. | |||
| 32 | + | // Keep history setup and copy entry outside the measured steady-state scroll. | |||
| 33 | + | func BenchmarkDaemonHistoryCopyScroll(b *testing.B) { | |||
| 34 | + | for _, size := range []struct { | |||
| 35 | + | name string | |||
| 36 | + | size domain.Size | |||
| 37 | + | }{ | |||
| 38 | + | {"120x40", domain.Size{Cols: 120, Rows: 40}}, | |||
| 39 | + | {"182x53", domain.Size{Cols: 182, Rows: 53}}, | |||
| 40 | + | {"240x70", domain.Size{Cols: 240, Rows: 70}}, | |||
| 41 | + | } { | |||
| 42 | + | b.Run(size.name, func(b *testing.B) { | |||
| 43 | + | f := newPerformanceFixture(b, performanceConfig{size: size.size, tabs: 1, panes: 1, historyRows: 10000}) | |||
| 44 | + | require.True(b, f.hasHistoryTopology(1, 1, 10000)) | |||
| 45 | + | f.d.enterCopyMode(f.sess, f.ac) | |||
| 46 | + | f.d.copyWheel(f.sess, f.ac, -5000) | |||
| 47 | + | f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) | |||
| 48 | + | b.ReportAllocs() | |||
| 49 | + | for b.Loop() { | |||
| 50 | + | f.d.copyWheel(f.sess, f.ac, -3) | |||
| 51 | + | f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) | |||
| 52 | + | f.d.copyWheel(f.sess, f.ac, 3) | |||
| 53 | + | f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) | |||
| 54 | + | } | |||
| 55 | + | }) | |||
| 56 | + | } | |||
| 57 | + | } | |||
A internal/usecase/daemon/copy_scroll_render_test.go +87 -0
| @@ -0,0 +1,87 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | renderer "github.com/bnema/vev-vt" | |
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestCopyRowCompositionMatchesFrameReference(t *testing.T) { | |
| 14 | + | for _, target := range []domain.Rect{ | |
| 15 | + | {X: 2, Y: 2, Width: 12, Height: 4}, | |
| 16 | + | {X: -3, Y: -1, Width: 12, Height: 4}, | |
| 17 | + | {X: 15, Y: 5, Width: 12, Height: 4}, | |
| 18 | + | {X: -20, Y: 2, Width: 12, Height: 4}, | |
| 19 | + | {X: 25, Y: 2, Width: 12, Height: 4}, | |
| 20 | + | {X: 2, Y: 20, Width: 12, Height: 4}, | |
| 21 | + | {X: 2, Y: 2, Width: 3, Height: 1}, | |
| 22 | + | } { | |
| 23 | + | t.Run(fmt.Sprint(target), func(t *testing.T) { | |
| 24 | + | // The viewport is taller than the document: blank rows must erase the | |
| 25 | + | // old pane rather than leak its contents. Selection/search share the | |
| 26 | + | // same row renderer as the owned-frame API. | |
| 27 | + | rows := [][]renderer.Cell{testRow("alpha beta"), testRow("second row")} | |
| 28 | + | mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshotFromRows(rows, 12, 4), "")) | |
| 29 | + | mode.Search("alpha") | |
| 30 | + | require.True(t, mode.StartCharacterSelection(scopy.Pos{Col: 1})) | |
| 31 | + | require.True(t, mode.ExtendCharacterSelection(scopy.Pos{Row: 1, Col: 3})) | |
| 32 | + | styles := resolveStyles(nil) | |
| 33 | + | base := renderer.NewFrame(20, 7) | |
| 34 | + | for y := range base.Height { | |
| 35 | + | base.FillRow(y, 0, base.Width, renderer.Cell{Rune: '#'}) | |
| 36 | + | } | |
| 37 | + | want := base.Clone() | |
| 38 | + | src := mode.Render(styles.CopyStatus, styles.Selection) | |
| 39 | + | // Reference placement deliberately uses independent per-cell clipping. | |
| 40 | + | for y := 0; y < src.Height-1 && y < target.Height; y++ { | |
| 41 | + | for x := 0; x < src.Width && x < target.Width; x++ { | |
| 42 | + | dx, dy := target.X+x, target.Y+y | |
| 43 | + | if dx >= 0 && dx < want.Width && dy >= 0 && dy < want.Height-1 { | |
| 44 | + | want.Set(dx, dy, src.Cell(x, y)) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | want.FillRow(want.Height-1, 0, want.Width, renderer.Cell{Rune: ' ', Style: styles.SurfaceBar}) | |
| 49 | + | for x := range min(want.Width, src.Width) { | |
| 50 | + | want.Set(x, want.Height-1, src.Cell(x, src.Height-1)) | |
| 51 | + | } | |
| 52 | + | got, _ := composeCopyClientFrame(mode, target, base, styles) | |
| 53 | + | require.Equal(t, captureTestFrame(want), captureTestFrame(got)) | |
| 54 | + | require.NoError(t, got.CheckInvariants()) | |
| 55 | + | }) | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestCopyScrollKeepsBaseUnadornedAndExitRefreshesLiveState(t *testing.T) { | |
| 60 | + | f := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}, panes: 1, historyRows: 100}) | |
| 61 | + | ack := func() { f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) } | |
| 62 | + | f.d.enterCopyMode(f.sess, f.ac) | |
| 63 | + | ack() | |
| 64 | + | require.True(t, f.ac.pipelineCache.valid) | |
| 65 | + | committed := f.ac.pipelineCache.frame | |
| 66 | + | before := captureTestFrame(committed) | |
| 67 | + | f.d.copyWheel(f.sess, f.ac, -30) | |
| 68 | + | ack() | |
| 69 | + | require.True(t, f.ac.pipelineCache.valid) | |
| 70 | + | require.Equal(t, before, captureTestFrame(committed), "scroll must not mutate the committed buffer") | |
| 71 | + | require.NotContains(t, frameText(f.ac.pipelineCache.frame), "[SCROLL]") | |
| 72 | + | ||
| 73 | + | // Live damage still updates the base behind the immutable history viewport. | |
| 74 | + | f.activePane.mu.Lock() | |
| 75 | + | f.activePane.screen.Write([]byte("\x1b[1;1HLIVE-UPDATED")) | |
| 76 | + | f.activePane.mu.Unlock() | |
| 77 | + | f.d.copyWheel(f.sess, f.ac, -3) | |
| 78 | + | ack() | |
| 79 | + | require.Contains(t, frameText(f.ac.pipelineCache.frame), "LIVE-UPDATED") | |
| 80 | + | require.NotContains(t, frameText(f.ac.pipelineCache.frame), "[SCROLL]") | |
| 81 | + | ||
| 82 | + | f.d.copyWheel(f.sess, f.ac, 1000) | |
| 83 | + | ack() | |
| 84 | + | require.Nil(t, f.ac.overlays.copyMode) | |
| 85 | + | require.Contains(t, frameText(f.ac.pipelineCache.frame), "LIVE-UPDATED") | |
| 86 | + | require.NoError(t, f.ac.pipelineCache.frame.CheckInvariants()) | |
| 87 | + | } | |
| @@ -0,0 +1,87 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | renderer "github.com/bnema/vev-vt" | |||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestCopyRowCompositionMatchesFrameReference(t *testing.T) { | |||
| 14 | + | for _, target := range []domain.Rect{ | |||
| 15 | + | {X: 2, Y: 2, Width: 12, Height: 4}, | |||
| 16 | + | {X: -3, Y: -1, Width: 12, Height: 4}, | |||
| 17 | + | {X: 15, Y: 5, Width: 12, Height: 4}, | |||
| 18 | + | {X: -20, Y: 2, Width: 12, Height: 4}, | |||
| 19 | + | {X: 25, Y: 2, Width: 12, Height: 4}, | |||
| 20 | + | {X: 2, Y: 20, Width: 12, Height: 4}, | |||
| 21 | + | {X: 2, Y: 2, Width: 3, Height: 1}, | |||
| 22 | + | } { | |||
| 23 | + | t.Run(fmt.Sprint(target), func(t *testing.T) { | |||
| 24 | + | // The viewport is taller than the document: blank rows must erase the | |||
| 25 | + | // old pane rather than leak its contents. Selection/search share the | |||
| 26 | + | // same row renderer as the owned-frame API. | |||
| 27 | + | rows := [][]renderer.Cell{testRow("alpha beta"), testRow("second row")} | |||
| 28 | + | mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshotFromRows(rows, 12, 4), "")) | |||
| 29 | + | mode.Search("alpha") | |||
| 30 | + | require.True(t, mode.StartCharacterSelection(scopy.Pos{Col: 1})) | |||
| 31 | + | require.True(t, mode.ExtendCharacterSelection(scopy.Pos{Row: 1, Col: 3})) | |||
| 32 | + | styles := resolveStyles(nil) | |||
| 33 | + | base := renderer.NewFrame(20, 7) | |||
| 34 | + | for y := range base.Height { | |||
| 35 | + | base.FillRow(y, 0, base.Width, renderer.Cell{Rune: '#'}) | |||
| 36 | + | } | |||
| 37 | + | want := base.Clone() | |||
| 38 | + | src := mode.Render(styles.CopyStatus, styles.Selection) | |||
| 39 | + | // Reference placement deliberately uses independent per-cell clipping. | |||
| 40 | + | for y := 0; y < src.Height-1 && y < target.Height; y++ { | |||
| 41 | + | for x := 0; x < src.Width && x < target.Width; x++ { | |||
| 42 | + | dx, dy := target.X+x, target.Y+y | |||
| 43 | + | if dx >= 0 && dx < want.Width && dy >= 0 && dy < want.Height-1 { | |||
| 44 | + | want.Set(dx, dy, src.Cell(x, y)) | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | want.FillRow(want.Height-1, 0, want.Width, renderer.Cell{Rune: ' ', Style: styles.SurfaceBar}) | |||
| 49 | + | for x := range min(want.Width, src.Width) { | |||
| 50 | + | want.Set(x, want.Height-1, src.Cell(x, src.Height-1)) | |||
| 51 | + | } | |||
| 52 | + | got, _ := composeCopyClientFrame(mode, target, base, styles) | |||
| 53 | + | require.Equal(t, captureTestFrame(want), captureTestFrame(got)) | |||
| 54 | + | require.NoError(t, got.CheckInvariants()) | |||
| 55 | + | }) | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func TestCopyScrollKeepsBaseUnadornedAndExitRefreshesLiveState(t *testing.T) { | |||
| 60 | + | f := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}, panes: 1, historyRows: 100}) | |||
| 61 | + | ack := func() { f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) } | |||
| 62 | + | f.d.enterCopyMode(f.sess, f.ac) | |||
| 63 | + | ack() | |||
| 64 | + | require.True(t, f.ac.pipelineCache.valid) | |||
| 65 | + | committed := f.ac.pipelineCache.frame | |||
| 66 | + | before := captureTestFrame(committed) | |||
| 67 | + | f.d.copyWheel(f.sess, f.ac, -30) | |||
| 68 | + | ack() | |||
| 69 | + | require.True(t, f.ac.pipelineCache.valid) | |||
| 70 | + | require.Equal(t, before, captureTestFrame(committed), "scroll must not mutate the committed buffer") | |||
| 71 | + | require.NotContains(t, frameText(f.ac.pipelineCache.frame), "[SCROLL]") | |||
| 72 | + | ||||
| 73 | + | // Live damage still updates the base behind the immutable history viewport. | |||
| 74 | + | f.activePane.mu.Lock() | |||
| 75 | + | f.activePane.screen.Write([]byte("\x1b[1;1HLIVE-UPDATED")) | |||
| 76 | + | f.activePane.mu.Unlock() | |||
| 77 | + | f.d.copyWheel(f.sess, f.ac, -3) | |||
| 78 | + | ack() | |||
| 79 | + | require.Contains(t, frameText(f.ac.pipelineCache.frame), "LIVE-UPDATED") | |||
| 80 | + | require.NotContains(t, frameText(f.ac.pipelineCache.frame), "[SCROLL]") | |||
| 81 | + | ||||
| 82 | + | f.d.copyWheel(f.sess, f.ac, 1000) | |||
| 83 | + | ack() | |||
| 84 | + | require.Nil(t, f.ac.overlays.copyMode) | |||
| 85 | + | require.Contains(t, frameText(f.ac.pipelineCache.frame), "LIVE-UPDATED") | |||
| 86 | + | require.NoError(t, f.ac.pipelineCache.frame.CheckInvariants()) | |||
| 87 | + | } | |||
M internal/usecase/daemon/copymode.go +18 -14
| @@ -60,7 +60,10 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) { | |||
| 60 | 60 | rt.clearCopyModeLocked() | |
| 61 | 61 | } | |
| 62 | 62 | rt.copyMu.Unlock() | |
| 63 | - | d.invalidateRender(sess, ac, true, "copymode.go") | |
| 63 | + | // Moving within the immutable copy document does not invalidate live pane | |
| 64 | + | // captures. The compositor still redraws the overlay; leaving it resets live | |
| 65 | + | // state so attachment-local snapshots cannot survive the return to the PTY. | |
| 66 | + | d.invalidateRender(sess, ac, exit, "copymode.go") | |
| 64 | 67 | } | |
| 65 | 68 | func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |
| 66 | 69 | tb := sess.tabForAttachment(ac) | |
| @@ -416,21 +419,22 @@ func composeCopyClientFrame(mode *scopy.Mode, target domain.Rect, frame renderer | |||
| 416 | 419 | if mode == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 { | |
| 417 | 420 | return frame, nil | |
| 418 | 421 | } | |
| 419 | - | copyFrame := mode.Render(styles.CopyStatus, styles.Selection) | |
| 420 | - | bodyRows := max(copyFrame.Height-1, 0) | |
| 421 | - | for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ { | |
| 422 | + | statusY := frame.Height - 1 | |
| 423 | + | frame.FillRow(statusY, 0, frame.Width, renderer.Cell{Rune: ' ', Style: styles.SurfaceBar}) | |
| 424 | + | mode.RenderRows(func(y int, row []renderer.Cell) { | |
| 425 | + | if y == mode.Document().Height() { | |
| 426 | + | frame.WriteRow(statusY, 0, row[:min(frame.Width, len(row))]) | |
| 427 | + | return | |
| 428 | + | } | |
| 429 | + | if y >= target.Height || target.Y+y < 0 || target.Y+y >= statusY { | |
| 430 | + | return | |
| 431 | + | } | |
| 422 | 432 | dstX := max(target.X, 0) | |
| 423 | 433 | srcX := max(-target.X, 0) | |
| 424 | - | width := min(target.Width-srcX, copyFrame.Width-srcX) | |
| 425 | - | width = min(width, frame.Width-dstX) | |
| 426 | - | if width > 0 && target.Y+y >= 0 { | |
| 427 | - | copy(frame.Row(target.Y + y)[dstX:dstX+width], copyFrame.Row(y)[srcX:srcX+width]) | |
| 434 | + | width := min(target.Width-srcX, len(row)-srcX, frame.Width-dstX) | |
| 435 | + | if width > 0 { | |
| 436 | + | frame.WriteRow(target.Y+y, dstX, row[srcX:srcX+width]) | |
| 428 | 437 | } | |
| 429 | - | } | |
| 430 | - | statusY := frame.Height - 1 | |
| 431 | - | for x := range frame.Row(statusY) { | |
| 432 | - | frame.Row(statusY)[x] = renderer.Cell{Rune: ' ', Style: styles.SurfaceBar} | |
| 433 | - | } | |
| 434 | - | copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(frame.Width, copyFrame.Width)]) | |
| 438 | + | }, styles.CopyStatus, styles.Selection) | |
| 435 | 439 | return frame, []renderer.Damage{renderer.FullRedraw()} | |
| 436 | 440 | } | |
| @@ -60,7 +60,10 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) { | |||||
| 60 | rt.clearCopyModeLocked() | 60 | rt.clearCopyModeLocked() | ||
| 61 | } | 61 | } | ||
| 62 | rt.copyMu.Unlock() | 62 | rt.copyMu.Unlock() | ||
| 63 | - | d.invalidateRender(sess, ac, true, "copymode.go") | 63 | + | // Moving within the immutable copy document does not invalidate live pane |
| 64 | + | // captures. The compositor still redraws the overlay; leaving it resets live | |||
| 65 | + | // state so attachment-local snapshots cannot survive the return to the PTY. | |||
| 66 | + | d.invalidateRender(sess, ac, exit, "copymode.go") | |||
| 64 | } | 67 | } | ||
| 65 | func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | 68 | func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | ||
| 66 | tb := sess.tabForAttachment(ac) | 69 | tb := sess.tabForAttachment(ac) | ||
| @@ -416,21 +419,22 @@ func composeCopyClientFrame(mode *scopy.Mode, target domain.Rect, frame renderer | |||||
| 416 | if mode == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 { | 419 | if mode == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 { | ||
| 417 | return frame, nil | 420 | return frame, nil | ||
| 418 | } | 421 | } | ||
| 419 | - | copyFrame := mode.Render(styles.CopyStatus, styles.Selection) | 422 | + | statusY := frame.Height - 1 |
| 420 | - | bodyRows := max(copyFrame.Height-1, 0) | 423 | + | frame.FillRow(statusY, 0, frame.Width, renderer.Cell{Rune: ' ', Style: styles.SurfaceBar}) |
| 421 | - | for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ { | 424 | + | mode.RenderRows(func(y int, row []renderer.Cell) { |
| 425 | + | if y == mode.Document().Height() { | |||
| 426 | + | frame.WriteRow(statusY, 0, row[:min(frame.Width, len(row))]) | |||
| 427 | + | return | |||
| 428 | + | } | |||
| 429 | + | if y >= target.Height || target.Y+y < 0 || target.Y+y >= statusY { | |||
| 430 | + | return | |||
| 431 | + | } | |||
| 422 | dstX := max(target.X, 0) | 432 | dstX := max(target.X, 0) | ||
| 423 | srcX := max(-target.X, 0) | 433 | srcX := max(-target.X, 0) | ||
| 424 | - | width := min(target.Width-srcX, copyFrame.Width-srcX) | 434 | + | width := min(target.Width-srcX, len(row)-srcX, frame.Width-dstX) |
| 425 | - | width = min(width, frame.Width-dstX) | 435 | + | if width > 0 { |
| 426 | - | if width > 0 && target.Y+y >= 0 { | 436 | + | frame.WriteRow(target.Y+y, dstX, row[srcX:srcX+width]) |
| 427 | - | copy(frame.Row(target.Y + y)[dstX:dstX+width], copyFrame.Row(y)[srcX:srcX+width]) | |||
| 428 | } | 437 | } | ||
| 429 | - | } | 438 | + | }, styles.CopyStatus, styles.Selection) |
| 430 | - | statusY := frame.Height - 1 | |||
| 431 | - | for x := range frame.Row(statusY) { | |||
| 432 | - | frame.Row(statusY)[x] = renderer.Cell{Rune: ' ', Style: styles.SurfaceBar} | |||
| 433 | - | } | |||
| 434 | - | copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(frame.Width, copyFrame.Width)]) | |||
| 435 | return frame, []renderer.Damage{renderer.FullRedraw()} | 439 | return frame, []renderer.Damage{renderer.FullRedraw()} | ||
| 436 | } | 440 | } | ||
M internal/usecase/daemon/copymode_lifecycle_test.go +3 -3
| @@ -101,7 +101,7 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) { | |||
| 101 | 101 | sess.registerAttachment(ac) | |
| 102 | 102 | ac.setSession(sess) | |
| 103 | 103 | closing.mu.Lock() | |
| 104 | - | document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame, closing.screen.LineBounds(), nil), domain.DefaultWordSeparators) | |
| 104 | + | document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen, closing.screen.LineBounds(), nil), domain.DefaultWordSeparators) | |
| 105 | 105 | closing.mu.Unlock() | |
| 106 | 106 | ac.overlays.copyMu.Lock() | |
| 107 | 107 | ac.overlays.copyPane = closing | |
| @@ -146,7 +146,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) { | |||
| 146 | 146 | tb := testAttachmentTab(sess) | |
| 147 | 147 | p := tb.focusedPane() | |
| 148 | 148 | p.mu.Lock() | |
| 149 | - | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators) | |
| 149 | + | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen, p.screen.LineBounds(), nil), domain.DefaultWordSeparators) | |
| 150 | 150 | p.mu.Unlock() | |
| 151 | 151 | ||
| 152 | 152 | // Pause after candidate publication, then make the captured target stale. | |
| @@ -370,7 +370,7 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing. | |||
| 370 | 370 | tb := testAttachmentTab(sess) | |
| 371 | 371 | p := tb.focusedPane() | |
| 372 | 372 | p.mu.Lock() | |
| 373 | - | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators) | |
| 373 | + | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen, p.screen.LineBounds(), nil), domain.DefaultWordSeparators) | |
| 374 | 374 | p.mu.Unlock() | |
| 375 | 375 | tc.invalidate(sess, tb, p) | |
| 376 | 376 | ||
| @@ -101,7 +101,7 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) { | |||||
| 101 | sess.registerAttachment(ac) | 101 | sess.registerAttachment(ac) | ||
| 102 | ac.setSession(sess) | 102 | ac.setSession(sess) | ||
| 103 | closing.mu.Lock() | 103 | closing.mu.Lock() | ||
| 104 | - | document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame, closing.screen.LineBounds(), nil), domain.DefaultWordSeparators) | 104 | + | document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen, closing.screen.LineBounds(), nil), domain.DefaultWordSeparators) |
| 105 | closing.mu.Unlock() | 105 | closing.mu.Unlock() | ||
| 106 | ac.overlays.copyMu.Lock() | 106 | ac.overlays.copyMu.Lock() | ||
| 107 | ac.overlays.copyPane = closing | 107 | ac.overlays.copyPane = closing | ||
| @@ -146,7 +146,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) { | |||||
| 146 | tb := testAttachmentTab(sess) | 146 | tb := testAttachmentTab(sess) | ||
| 147 | p := tb.focusedPane() | 147 | p := tb.focusedPane() | ||
| 148 | p.mu.Lock() | 148 | p.mu.Lock() | ||
| 149 | - | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators) | 149 | + | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen, p.screen.LineBounds(), nil), domain.DefaultWordSeparators) |
| 150 | p.mu.Unlock() | 150 | p.mu.Unlock() | ||
| 151 | 151 | ||||
| 152 | // Pause after candidate publication, then make the captured target stale. | 152 | // Pause after candidate publication, then make the captured target stale. | ||
| @@ -370,7 +370,7 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing. | |||||
| 370 | tb := testAttachmentTab(sess) | 370 | tb := testAttachmentTab(sess) | ||
| 371 | p := tb.focusedPane() | 371 | p := tb.focusedPane() | ||
| 372 | p.mu.Lock() | 372 | p.mu.Lock() | ||
| 373 | - | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators) | 373 | + | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen, p.screen.LineBounds(), nil), domain.DefaultWordSeparators) |
| 374 | p.mu.Unlock() | 374 | p.mu.Unlock() | ||
| 375 | tc.invalidate(sess, tb, p) | 375 | tc.invalidate(sess, tb, p) | ||
| 376 | 376 | ||||
M internal/usecase/daemon/copymode_mouse_test.go +4 -5
| @@ -28,7 +28,7 @@ func mouseCopyHarness(t *testing.T, rows ...string) (*Daemon, *session, *attache | |||
| 28 | 28 | d.clock = clock | |
| 29 | 29 | pane := testAttachmentTab(sess).focusedPane() | |
| 30 | 30 | for i, row := range rows { | |
| 31 | - | copy(pane.screen.Frame.Row(i), testRow(row)) | |
| 31 | + | writeTestRow(pane.screen, i, row) | |
| 32 | 32 | } | |
| 33 | 33 | d.enterCopyMode(sess, ac) | |
| 34 | 34 | return d, sess, ac, clock | |
| @@ -44,8 +44,7 @@ func TestCopyDoubleClick(t *testing.T) { | |||
| 44 | 44 | pane := testAttachmentTab(sess).focusedPane() | |
| 45 | 45 | d.exitCopyMode(ac) | |
| 46 | 46 | pane.mu.Lock() | |
| 47 | - | row := pane.screen.Frame.Row(0) | |
| 48 | - | row[2] = renderer.Cell{Continuation: true} | |
| 47 | + | require.True(t, pane.screen.Cell(2, 0).Continuation) | |
| 49 | 48 | pane.mu.Unlock() | |
| 50 | 49 | d.enterCopyMode(sess, ac) | |
| 51 | 50 | ||
| @@ -324,7 +323,7 @@ func TestCopyWordSelectionYanksExactOSC52(t *testing.T) { | |||
| 324 | 323 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 325 | 324 | clock := &manualMouseClock{now: time.Unix(1, 0)} | |
| 326 | 325 | d.clock = clock | |
| 327 | - | copy(testAttachmentTab(sess).focusedPane().screen.Frame.Row(0), testRow("alpha beta")) | |
| 326 | + | writeTestRow(testAttachmentTab(sess).focusedPane().screen, 0, "alpha beta") | |
| 328 | 327 | d.enterCopyMode(sess, ac) | |
| 329 | 328 | mustOutputData(t, sends) | |
| 330 | 329 | ||
| @@ -350,7 +349,7 @@ func TestCopyPointerAndClickResetOnCopyExitAndReplacement(t *testing.T) { | |||
| 350 | 349 | {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) { | |
| 351 | 350 | p := testAttachmentTab(sess).focusedPane() | |
| 352 | 351 | p.mu.Lock() | |
| 353 | - | doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators) | |
| 352 | + | doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen, p.screen.LineBounds(), nil), domain.DefaultWordSeparators) | |
| 354 | 353 | p.mu.Unlock() | |
| 355 | 354 | require.True(t, d.publishCopyMode(sess, ac, testAttachmentTab(sess), p, doc, nil, nil)) | |
| 356 | 355 | }}, | |
| @@ -28,7 +28,7 @@ func mouseCopyHarness(t *testing.T, rows ...string) (*Daemon, *session, *attache | |||||
| 28 | d.clock = clock | 28 | d.clock = clock | ||
| 29 | pane := testAttachmentTab(sess).focusedPane() | 29 | pane := testAttachmentTab(sess).focusedPane() | ||
| 30 | for i, row := range rows { | 30 | for i, row := range rows { | ||
| 31 | - | copy(pane.screen.Frame.Row(i), testRow(row)) | 31 | + | writeTestRow(pane.screen, i, row) |
| 32 | } | 32 | } | ||
| 33 | d.enterCopyMode(sess, ac) | 33 | d.enterCopyMode(sess, ac) | ||
| 34 | return d, sess, ac, clock | 34 | return d, sess, ac, clock | ||
| @@ -44,8 +44,7 @@ func TestCopyDoubleClick(t *testing.T) { | |||||
| 44 | pane := testAttachmentTab(sess).focusedPane() | 44 | pane := testAttachmentTab(sess).focusedPane() | ||
| 45 | d.exitCopyMode(ac) | 45 | d.exitCopyMode(ac) | ||
| 46 | pane.mu.Lock() | 46 | pane.mu.Lock() | ||
| 47 | - | row := pane.screen.Frame.Row(0) | 47 | + | require.True(t, pane.screen.Cell(2, 0).Continuation) |
| 48 | - | row[2] = renderer.Cell{Continuation: true} | |||
| 49 | pane.mu.Unlock() | 48 | pane.mu.Unlock() | ||
| 50 | d.enterCopyMode(sess, ac) | 49 | d.enterCopyMode(sess, ac) | ||
| 51 | 50 | ||||
| @@ -324,7 +323,7 @@ func TestCopyWordSelectionYanksExactOSC52(t *testing.T) { | |||||
| 324 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 323 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 325 | clock := &manualMouseClock{now: time.Unix(1, 0)} | 324 | clock := &manualMouseClock{now: time.Unix(1, 0)} | ||
| 326 | d.clock = clock | 325 | d.clock = clock | ||
| 327 | - | copy(testAttachmentTab(sess).focusedPane().screen.Frame.Row(0), testRow("alpha beta")) | 326 | + | writeTestRow(testAttachmentTab(sess).focusedPane().screen, 0, "alpha beta") |
| 328 | d.enterCopyMode(sess, ac) | 327 | d.enterCopyMode(sess, ac) | ||
| 329 | mustOutputData(t, sends) | 328 | mustOutputData(t, sends) | ||
| 330 | 329 | ||||
| @@ -350,7 +349,7 @@ func TestCopyPointerAndClickResetOnCopyExitAndReplacement(t *testing.T) { | |||||
| 350 | {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) { | 349 | {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) { | ||
| 351 | p := testAttachmentTab(sess).focusedPane() | 350 | p := testAttachmentTab(sess).focusedPane() | ||
| 352 | p.mu.Lock() | 351 | p.mu.Lock() | ||
| 353 | - | doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators) | 352 | + | doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen, p.screen.LineBounds(), nil), domain.DefaultWordSeparators) |
| 354 | p.mu.Unlock() | 353 | p.mu.Unlock() | ||
| 355 | require.True(t, d.publishCopyMode(sess, ac, testAttachmentTab(sess), p, doc, nil, nil)) | 354 | require.True(t, d.publishCopyMode(sess, ac, testAttachmentTab(sess), p, doc, nil, nil)) | ||
| 356 | }}, | 355 | }}, | ||
M internal/usecase/daemon/copymode_test.go +19 -19
| @@ -133,7 +133,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) { | |||
| 133 | 133 | tb := testAttachmentTab(sess) | |
| 134 | 134 | pane := tb.focusedPane() | |
| 135 | 135 | pane.mu.Lock() | |
| 136 | - | snap := scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds(), nil) | |
| 136 | + | snap := scopy.NewSnapshot(pane.history, pane.screen, pane.screen.LineBounds(), nil) | |
| 137 | 137 | pane.mu.Unlock() | |
| 138 | 138 | mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators)) | |
| 139 | 139 | ||
| @@ -167,7 +167,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) { | |||
| 167 | 167 | tb := testAttachmentTab(sess) | |
| 168 | 168 | tb.focusedPane().screen = vt.NewScreen(12, 3) | |
| 169 | 169 | tb.focusedPane().screen.Write([]byte("live")) | |
| 170 | - | snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame, tb.focusedPane().screen.LineBounds(), nil) | |
| 170 | + | snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen, tb.focusedPane().screen.LineBounds(), nil) | |
| 171 | 171 | mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators)) | |
| 172 | 172 | ||
| 173 | 173 | bars := barState{status: sess.statusSegments(true)} | |
| @@ -176,7 +176,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) { | |||
| 176 | 176 | reset: true, | |
| 177 | 177 | layout: capturedTabLayout{area: content, focus: tb.focusedPane().id, valid: true}, | |
| 178 | 178 | panes: []capturedPaneRenderState{{ | |
| 179 | - | id: tb.focusedPane().id, frame: tb.focusedPane().screen.Frame.Clone(), placement: layout.Placement{ID: tb.focusedPane().id, Content: content}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}, | |
| 179 | + | id: tb.focusedPane().id, frame: captureTestFrame(tb.focusedPane().screen), placement: layout.Placement{ID: tb.focusedPane().id, Content: content}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}, | |
| 180 | 180 | }}, | |
| 181 | 181 | bars: bars, | |
| 182 | 182 | }, composeCacheInput{}).frame | |
| @@ -230,8 +230,8 @@ func TestCopyModeSearchModalRoutesBatchedInputAfterSlash(t *testing.T) { | |||
| 230 | 230 | p, _ := newBlockingPTYWithWrites(t, writes) | |
| 231 | 231 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 232 | 232 | pane := sess.tabs[0].focusedPane() | |
| 233 | - | copy(pane.screen.Frame.Row(0), testRow("alpha")) | |
| 234 | - | copy(pane.screen.Frame.Row(1), testRow("beta alpha")) | |
| 233 | + | writeTestRow(pane.screen, 0, "alpha") | |
| 234 | + | writeTestRow(pane.screen, 1, "beta alpha") | |
| 235 | 235 | ||
| 236 | 236 | d.enterCopyMode(sess, ac) | |
| 237 | 237 | awaitFrame(t, sends, wire.MsgOutput) | |
| @@ -253,9 +253,9 @@ func TestCopyModeSearchModalJumpsAndKeepsNavigation(t *testing.T) { | |||
| 253 | 253 | p, _ := newBlockingPTYWithWrites(t, writes) | |
| 254 | 254 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 255 | 255 | pane := sess.tabs[0].focusedPane() | |
| 256 | - | copy(pane.screen.Frame.Row(0), testRow("alpha")) | |
| 257 | - | copy(pane.screen.Frame.Row(1), testRow("beta alpha")) | |
| 258 | - | copy(pane.screen.Frame.Row(2), testRow("gamma")) | |
| 256 | + | writeTestRow(pane.screen, 0, "alpha") | |
| 257 | + | writeTestRow(pane.screen, 1, "beta alpha") | |
| 258 | + | writeTestRow(pane.screen, 2, "gamma") | |
| 259 | 259 | ||
| 260 | 260 | d.enterCopyMode(sess, ac) | |
| 261 | 261 | awaitFrame(t, sends, wire.MsgOutput) | |
| @@ -297,9 +297,9 @@ func TestCopyModeSearchModalSelectionPreviewsBehindModal(t *testing.T) { | |||
| 297 | 297 | p, _ := newBlockingPTY(t) | |
| 298 | 298 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 299 | 299 | pane := sess.tabs[0].focusedPane() | |
| 300 | - | copy(pane.screen.Frame.Row(0), testRow("alpha")) | |
| 301 | - | copy(pane.screen.Frame.Row(1), testRow("beta alpha")) | |
| 302 | - | copy(pane.screen.Frame.Row(2), testRow("gamma")) | |
| 300 | + | writeTestRow(pane.screen, 0, "alpha") | |
| 301 | + | writeTestRow(pane.screen, 1, "beta alpha") | |
| 302 | + | writeTestRow(pane.screen, 2, "gamma") | |
| 303 | 303 | ||
| 304 | 304 | d.enterCopyMode(sess, ac) | |
| 305 | 305 | awaitFrame(t, sends, wire.MsgOutput) | |
| @@ -323,7 +323,7 @@ func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) { | |||
| 323 | 323 | pane := sess.tabs[0].focusedPane() | |
| 324 | 324 | installTestHistory(pane, vt.HistoryConfig{MaxRows: 4}) | |
| 325 | 325 | appendHistoryRow(t, pane.history, testRow("old alpha")) | |
| 326 | - | copy(pane.screen.Frame.Row(0), testRow("live alpha")) | |
| 326 | + | writeTestRow(pane.screen, 0, "live alpha") | |
| 327 | 327 | ||
| 328 | 328 | d.enterCopyMode(sess, ac) | |
| 329 | 329 | awaitFrame(t, sends, wire.MsgOutput) | |
| @@ -455,7 +455,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||
| 455 | 455 | } | |
| 456 | 456 | p.mu.Lock() | |
| 457 | 457 | defer p.mu.Unlock() | |
| 458 | - | return scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil).Len() >= 12 | |
| 458 | + | return scopy.NewSnapshot(p.history, p.screen, p.screen.LineBounds(), nil).Len() >= 12 | |
| 459 | 459 | }, 2*time.Second, 5*time.Millisecond) | |
| 460 | 460 | ||
| 461 | 461 | sess := firstSession(d) | |
| @@ -548,8 +548,8 @@ func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) { | |||
| 548 | 548 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 549 | 549 | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 1}) | |
| 550 | 550 | longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1) | |
| 551 | - | sess.tabs[0].focusedPane().screen.Frame = renderer.NewFrame(len(longLine), 1) | |
| 552 | - | copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow(longLine)) | |
| 551 | + | sess.tabs[0].focusedPane().screen.Resize(len(longLine), 1) | |
| 552 | + | writeTestRow(sess.tabs[0].focusedPane().screen, 0, longLine) | |
| 553 | 553 | ||
| 554 | 554 | d.enterCopyMode(sess, ac) | |
| 555 | 555 | awaitFrame(t, sends, wire.MsgOutput) | |
| @@ -806,7 +806,7 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) { | |||
| 806 | 806 | } | |
| 807 | 807 | fp.screen.Write([]byte("live")) | |
| 808 | 808 | fp.mu.Lock() | |
| 809 | - | total := scopy.NewSnapshot(fp.history, fp.screen.Frame, fp.screen.LineBounds(), nil).Len() | |
| 809 | + | total := scopy.NewSnapshot(fp.history, fp.screen, fp.screen.LineBounds(), nil).Len() | |
| 810 | 810 | fp.mu.Unlock() | |
| 811 | 811 | ||
| 812 | 812 | d.enterCopyMode(sess, ac) | |
| @@ -885,8 +885,8 @@ func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) { | |||
| 885 | 885 | pty, _ := newBlockingPTY(t) | |
| 886 | 886 | d, sess, ac, sends := newManualSessionWithPTYs(t, pty) | |
| 887 | 887 | pane := sess.tabs[0].focusedPane() | |
| 888 | - | copy(pane.screen.Frame.Row(0), testRow("alpha")) | |
| 889 | - | copy(pane.screen.Frame.Row(1), testRow("bravo")) | |
| 888 | + | writeTestRow(pane.screen, 0, "alpha") | |
| 889 | + | writeTestRow(pane.screen, 1, "bravo") | |
| 890 | 890 | ||
| 891 | 891 | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<32;1;3M")) | |
| 892 | 892 | mustOutputData(t, sends) | |
| @@ -917,7 +917,7 @@ func TestComposeCopyClientFrameStylesStatusContentAndSurround(t *testing.T) { | |||
| 917 | 917 | base := renderer.NewFrame(20, 8) | |
| 918 | 918 | pane := newPane("split", nil, domain.Size{Cols: 12, Rows: 2}) | |
| 919 | 919 | pane.screen.Write([]byte("copy")) | |
| 920 | - | mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds(), nil), domain.DefaultWordSeparators)) | |
| 920 | + | mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen, pane.screen.LineBounds(), nil), domain.DefaultWordSeparators)) | |
| 921 | 921 | if tt.selectMode { | |
| 922 | 922 | mode.ToggleLineSelection() | |
| 923 | 923 | } | |
| @@ -133,7 +133,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) { | |||||
| 133 | tb := testAttachmentTab(sess) | 133 | tb := testAttachmentTab(sess) | ||
| 134 | pane := tb.focusedPane() | 134 | pane := tb.focusedPane() | ||
| 135 | pane.mu.Lock() | 135 | pane.mu.Lock() | ||
| 136 | - | snap := scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds(), nil) | 136 | + | snap := scopy.NewSnapshot(pane.history, pane.screen, pane.screen.LineBounds(), nil) |
| 137 | pane.mu.Unlock() | 137 | pane.mu.Unlock() | ||
| 138 | mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators)) | 138 | mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators)) | ||
| 139 | 139 | ||||
| @@ -167,7 +167,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) { | |||||
| 167 | tb := testAttachmentTab(sess) | 167 | tb := testAttachmentTab(sess) | ||
| 168 | tb.focusedPane().screen = vt.NewScreen(12, 3) | 168 | tb.focusedPane().screen = vt.NewScreen(12, 3) | ||
| 169 | tb.focusedPane().screen.Write([]byte("live")) | 169 | tb.focusedPane().screen.Write([]byte("live")) | ||
| 170 | - | snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame, tb.focusedPane().screen.LineBounds(), nil) | 170 | + | snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen, tb.focusedPane().screen.LineBounds(), nil) |
| 171 | mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators)) | 171 | mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators)) | ||
| 172 | 172 | ||||
| 173 | bars := barState{status: sess.statusSegments(true)} | 173 | bars := barState{status: sess.statusSegments(true)} | ||
| @@ -176,7 +176,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) { | |||||
| 176 | reset: true, | 176 | reset: true, | ||
| 177 | layout: capturedTabLayout{area: content, focus: tb.focusedPane().id, valid: true}, | 177 | layout: capturedTabLayout{area: content, focus: tb.focusedPane().id, valid: true}, | ||
| 178 | panes: []capturedPaneRenderState{{ | 178 | panes: []capturedPaneRenderState{{ | ||
| 179 | - | id: tb.focusedPane().id, frame: tb.focusedPane().screen.Frame.Clone(), placement: layout.Placement{ID: tb.focusedPane().id, Content: content}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}, | 179 | + | id: tb.focusedPane().id, frame: captureTestFrame(tb.focusedPane().screen), placement: layout.Placement{ID: tb.focusedPane().id, Content: content}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}, |
| 180 | }}, | 180 | }}, | ||
| 181 | bars: bars, | 181 | bars: bars, | ||
| 182 | }, composeCacheInput{}).frame | 182 | }, composeCacheInput{}).frame | ||
| @@ -230,8 +230,8 @@ func TestCopyModeSearchModalRoutesBatchedInputAfterSlash(t *testing.T) { | |||||
| 230 | p, _ := newBlockingPTYWithWrites(t, writes) | 230 | p, _ := newBlockingPTYWithWrites(t, writes) | ||
| 231 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 231 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 232 | pane := sess.tabs[0].focusedPane() | 232 | pane := sess.tabs[0].focusedPane() | ||
| 233 | - | copy(pane.screen.Frame.Row(0), testRow("alpha")) | 233 | + | writeTestRow(pane.screen, 0, "alpha") |
| 234 | - | copy(pane.screen.Frame.Row(1), testRow("beta alpha")) | 234 | + | writeTestRow(pane.screen, 1, "beta alpha") |
| 235 | 235 | ||||
| 236 | d.enterCopyMode(sess, ac) | 236 | d.enterCopyMode(sess, ac) | ||
| 237 | awaitFrame(t, sends, wire.MsgOutput) | 237 | awaitFrame(t, sends, wire.MsgOutput) | ||
| @@ -253,9 +253,9 @@ func TestCopyModeSearchModalJumpsAndKeepsNavigation(t *testing.T) { | |||||
| 253 | p, _ := newBlockingPTYWithWrites(t, writes) | 253 | p, _ := newBlockingPTYWithWrites(t, writes) | ||
| 254 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 254 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 255 | pane := sess.tabs[0].focusedPane() | 255 | pane := sess.tabs[0].focusedPane() | ||
| 256 | - | copy(pane.screen.Frame.Row(0), testRow("alpha")) | 256 | + | writeTestRow(pane.screen, 0, "alpha") |
| 257 | - | copy(pane.screen.Frame.Row(1), testRow("beta alpha")) | 257 | + | writeTestRow(pane.screen, 1, "beta alpha") |
| 258 | - | copy(pane.screen.Frame.Row(2), testRow("gamma")) | 258 | + | writeTestRow(pane.screen, 2, "gamma") |
| 259 | 259 | ||||
| 260 | d.enterCopyMode(sess, ac) | 260 | d.enterCopyMode(sess, ac) | ||
| 261 | awaitFrame(t, sends, wire.MsgOutput) | 261 | awaitFrame(t, sends, wire.MsgOutput) | ||
| @@ -297,9 +297,9 @@ func TestCopyModeSearchModalSelectionPreviewsBehindModal(t *testing.T) { | |||||
| 297 | p, _ := newBlockingPTY(t) | 297 | p, _ := newBlockingPTY(t) | ||
| 298 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 298 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 299 | pane := sess.tabs[0].focusedPane() | 299 | pane := sess.tabs[0].focusedPane() | ||
| 300 | - | copy(pane.screen.Frame.Row(0), testRow("alpha")) | 300 | + | writeTestRow(pane.screen, 0, "alpha") |
| 301 | - | copy(pane.screen.Frame.Row(1), testRow("beta alpha")) | 301 | + | writeTestRow(pane.screen, 1, "beta alpha") |
| 302 | - | copy(pane.screen.Frame.Row(2), testRow("gamma")) | 302 | + | writeTestRow(pane.screen, 2, "gamma") |
| 303 | 303 | ||||
| 304 | d.enterCopyMode(sess, ac) | 304 | d.enterCopyMode(sess, ac) | ||
| 305 | awaitFrame(t, sends, wire.MsgOutput) | 305 | awaitFrame(t, sends, wire.MsgOutput) | ||
| @@ -323,7 +323,7 @@ func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) { | |||||
| 323 | pane := sess.tabs[0].focusedPane() | 323 | pane := sess.tabs[0].focusedPane() | ||
| 324 | installTestHistory(pane, vt.HistoryConfig{MaxRows: 4}) | 324 | installTestHistory(pane, vt.HistoryConfig{MaxRows: 4}) | ||
| 325 | appendHistoryRow(t, pane.history, testRow("old alpha")) | 325 | appendHistoryRow(t, pane.history, testRow("old alpha")) | ||
| 326 | - | copy(pane.screen.Frame.Row(0), testRow("live alpha")) | 326 | + | writeTestRow(pane.screen, 0, "live alpha") |
| 327 | 327 | ||||
| 328 | d.enterCopyMode(sess, ac) | 328 | d.enterCopyMode(sess, ac) | ||
| 329 | awaitFrame(t, sends, wire.MsgOutput) | 329 | awaitFrame(t, sends, wire.MsgOutput) | ||
| @@ -455,7 +455,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||||
| 455 | } | 455 | } | ||
| 456 | p.mu.Lock() | 456 | p.mu.Lock() | ||
| 457 | defer p.mu.Unlock() | 457 | defer p.mu.Unlock() | ||
| 458 | - | return scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil).Len() >= 12 | 458 | + | return scopy.NewSnapshot(p.history, p.screen, p.screen.LineBounds(), nil).Len() >= 12 |
| 459 | }, 2*time.Second, 5*time.Millisecond) | 459 | }, 2*time.Second, 5*time.Millisecond) | ||
| 460 | 460 | ||||
| 461 | sess := firstSession(d) | 461 | sess := firstSession(d) | ||
| @@ -548,8 +548,8 @@ func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) { | |||||
| 548 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 548 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 549 | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 1}) | 549 | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 1}) | ||
| 550 | longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1) | 550 | longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1) | ||
| 551 | - | sess.tabs[0].focusedPane().screen.Frame = renderer.NewFrame(len(longLine), 1) | 551 | + | sess.tabs[0].focusedPane().screen.Resize(len(longLine), 1) |
| 552 | - | copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow(longLine)) | 552 | + | writeTestRow(sess.tabs[0].focusedPane().screen, 0, longLine) |
| 553 | 553 | ||||
| 554 | d.enterCopyMode(sess, ac) | 554 | d.enterCopyMode(sess, ac) | ||
| 555 | awaitFrame(t, sends, wire.MsgOutput) | 555 | awaitFrame(t, sends, wire.MsgOutput) | ||
| @@ -806,7 +806,7 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) { | |||||
| 806 | } | 806 | } | ||
| 807 | fp.screen.Write([]byte("live")) | 807 | fp.screen.Write([]byte("live")) | ||
| 808 | fp.mu.Lock() | 808 | fp.mu.Lock() | ||
| 809 | - | total := scopy.NewSnapshot(fp.history, fp.screen.Frame, fp.screen.LineBounds(), nil).Len() | 809 | + | total := scopy.NewSnapshot(fp.history, fp.screen, fp.screen.LineBounds(), nil).Len() |
| 810 | fp.mu.Unlock() | 810 | fp.mu.Unlock() | ||
| 811 | 811 | ||||
| 812 | d.enterCopyMode(sess, ac) | 812 | d.enterCopyMode(sess, ac) | ||
| @@ -885,8 +885,8 @@ func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) { | |||||
| 885 | pty, _ := newBlockingPTY(t) | 885 | pty, _ := newBlockingPTY(t) | ||
| 886 | d, sess, ac, sends := newManualSessionWithPTYs(t, pty) | 886 | d, sess, ac, sends := newManualSessionWithPTYs(t, pty) | ||
| 887 | pane := sess.tabs[0].focusedPane() | 887 | pane := sess.tabs[0].focusedPane() | ||
| 888 | - | copy(pane.screen.Frame.Row(0), testRow("alpha")) | 888 | + | writeTestRow(pane.screen, 0, "alpha") |
| 889 | - | copy(pane.screen.Frame.Row(1), testRow("bravo")) | 889 | + | writeTestRow(pane.screen, 1, "bravo") |
| 890 | 890 | ||||
| 891 | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<32;1;3M")) | 891 | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<32;1;3M")) | ||
| 892 | mustOutputData(t, sends) | 892 | mustOutputData(t, sends) | ||
| @@ -917,7 +917,7 @@ func TestComposeCopyClientFrameStylesStatusContentAndSurround(t *testing.T) { | |||||
| 917 | base := renderer.NewFrame(20, 8) | 917 | base := renderer.NewFrame(20, 8) | ||
| 918 | pane := newPane("split", nil, domain.Size{Cols: 12, Rows: 2}) | 918 | pane := newPane("split", nil, domain.Size{Cols: 12, Rows: 2}) | ||
| 919 | pane.screen.Write([]byte("copy")) | 919 | pane.screen.Write([]byte("copy")) | ||
| 920 | - | mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds(), nil), domain.DefaultWordSeparators)) | 920 | + | mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen, pane.screen.LineBounds(), nil), domain.DefaultWordSeparators)) |
| 921 | if tt.selectMode { | 921 | if tt.selectMode { | ||
| 922 | mode.ToggleLineSelection() | 922 | mode.ToggleLineSelection() | ||
| 923 | } | 923 | } | ||
M internal/usecase/daemon/daemon.go +10 -5
| @@ -35,12 +35,11 @@ const debounceInterval = minDebounceInterval | |||
| 35 | 35 | // ptyReadBufSize is the PTY reader's read buffer. | |
| 36 | 36 | const ptyReadBufSize = 32 * 1024 | |
| 37 | 37 | ||
| 38 | - | // New and restored panes are bounded by both history rows and cells. The | |
| 39 | - | // cell budget keeps a very wide terminal from retaining disproportionately | |
| 40 | - | // more scrollback than the normal 160-column case. | |
| 38 | + | // New and restored panes have independent line and uncompressed logical-byte | |
| 39 | + | // ceilings. Visible primary and alternate screens are outside this budget. | |
| 41 | 40 | const ( | |
| 42 | - | defaultScrollbackRows = 10_000 | |
| 43 | - | defaultScrollbackCells = 12_000 * 160 | |
| 41 | + | defaultScrollbackRows = domain.DefaultScrollbackLines | |
| 42 | + | defaultScrollbackBytes = domain.DefaultScrollbackMegabytes * 1_000_000 | |
| 44 | 43 | ) | |
| 45 | 44 | ||
| 46 | 45 | // detachNotifyTimeout bounds the best-effort Detached notification on the | |
| @@ -294,6 +293,7 @@ type Daemon struct { | |||
| 294 | 293 | paletteConfig atomic.Pointer[domain.PaletteConfig] | |
| 295 | 294 | navConfig atomic.Pointer[domain.NavConfig] | |
| 296 | 295 | tabsConfig atomic.Pointer[domain.TabsConfig] | |
| 296 | + | scrollbackConfig atomic.Pointer[domain.ScrollbackConfig] | |
| 297 | 297 | themeConfig atomic.Pointer[themeConfigSnapshot] | |
| 298 | 298 | barScripts *barScriptState | |
| 299 | 299 | notices *noticeCenter | |
| @@ -688,6 +688,8 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 688 | 688 | d.copyConfig.Store(&defaultCopy) | |
| 689 | 689 | defaultPalette := defaults.Palette | |
| 690 | 690 | d.paletteConfig.Store(&defaultPalette) | |
| 691 | + | defaultTabs := defaults.Tabs | |
| 692 | + | d.tabsConfig.Store(&defaultTabs) | |
| 691 | 693 | for _, o := range opts { | |
| 692 | 694 | o(d) | |
| 693 | 695 | } | |
| @@ -747,6 +749,9 @@ func (d *Daemon) Serve(ctx context.Context, l ports.ServerListener) error { | |||
| 747 | 749 | d.sessWg.Go(func() { | |
| 748 | 750 | d.barScriptPoller(d.serveCtx) | |
| 749 | 751 | }) | |
| 752 | + | d.sessWg.Go(func() { | |
| 753 | + | d.historyMaintenance(d.serveCtx) | |
| 754 | + | }) | |
| 750 | 755 | if d.persistEnabled { | |
| 751 | 756 | d.sessWg.Go(func() { | |
| 752 | 757 | d.cwdSampler(d.serveCtx) | |
| @@ -35,12 +35,11 @@ const debounceInterval = minDebounceInterval | |||||
| 35 | // ptyReadBufSize is the PTY reader's read buffer. | 35 | // ptyReadBufSize is the PTY reader's read buffer. | ||
| 36 | const ptyReadBufSize = 32 * 1024 | 36 | const ptyReadBufSize = 32 * 1024 | ||
| 37 | 37 | ||||
| 38 | - | // New and restored panes are bounded by both history rows and cells. The | 38 | + | // New and restored panes have independent line and uncompressed logical-byte |
| 39 | - | // cell budget keeps a very wide terminal from retaining disproportionately | 39 | + | // ceilings. Visible primary and alternate screens are outside this budget. |
| 40 | - | // more scrollback than the normal 160-column case. | |||
| 41 | const ( | 40 | const ( | ||
| 42 | - | defaultScrollbackRows = 10_000 | 41 | + | defaultScrollbackRows = domain.DefaultScrollbackLines |
| 43 | - | defaultScrollbackCells = 12_000 * 160 | 42 | + | defaultScrollbackBytes = domain.DefaultScrollbackMegabytes * 1_000_000 |
| 44 | ) | 43 | ) | ||
| 45 | 44 | ||||
| 46 | // detachNotifyTimeout bounds the best-effort Detached notification on the | 45 | // detachNotifyTimeout bounds the best-effort Detached notification on the | ||
| @@ -294,6 +293,7 @@ type Daemon struct { | |||||
| 294 | paletteConfig atomic.Pointer[domain.PaletteConfig] | 293 | paletteConfig atomic.Pointer[domain.PaletteConfig] | ||
| 295 | navConfig atomic.Pointer[domain.NavConfig] | 294 | navConfig atomic.Pointer[domain.NavConfig] | ||
| 296 | tabsConfig atomic.Pointer[domain.TabsConfig] | 295 | tabsConfig atomic.Pointer[domain.TabsConfig] | ||
| 296 | + | scrollbackConfig atomic.Pointer[domain.ScrollbackConfig] | |||
| 297 | themeConfig atomic.Pointer[themeConfigSnapshot] | 297 | themeConfig atomic.Pointer[themeConfigSnapshot] | ||
| 298 | barScripts *barScriptState | 298 | barScripts *barScriptState | ||
| 299 | notices *noticeCenter | 299 | notices *noticeCenter | ||
| @@ -688,6 +688,8 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||||
| 688 | d.copyConfig.Store(&defaultCopy) | 688 | d.copyConfig.Store(&defaultCopy) | ||
| 689 | defaultPalette := defaults.Palette | 689 | defaultPalette := defaults.Palette | ||
| 690 | d.paletteConfig.Store(&defaultPalette) | 690 | d.paletteConfig.Store(&defaultPalette) | ||
| 691 | + | defaultTabs := defaults.Tabs | |||
| 692 | + | d.tabsConfig.Store(&defaultTabs) | |||
| 691 | for _, o := range opts { | 693 | for _, o := range opts { | ||
| 692 | o(d) | 694 | o(d) | ||
| 693 | } | 695 | } | ||
| @@ -747,6 +749,9 @@ func (d *Daemon) Serve(ctx context.Context, l ports.ServerListener) error { | |||||
| 747 | d.sessWg.Go(func() { | 749 | d.sessWg.Go(func() { | ||
| 748 | d.barScriptPoller(d.serveCtx) | 750 | d.barScriptPoller(d.serveCtx) | ||
| 749 | }) | 751 | }) | ||
| 752 | + | d.sessWg.Go(func() { | |||
| 753 | + | d.historyMaintenance(d.serveCtx) | |||
| 754 | + | }) | |||
| 750 | if d.persistEnabled { | 755 | if d.persistEnabled { | ||
| 751 | d.sessWg.Go(func() { | 756 | d.sessWg.Go(func() { | ||
| 752 | d.cwdSampler(d.serveCtx) | 757 | d.cwdSampler(d.serveCtx) | ||
M internal/usecase/daemon/daemon_test.go +3 -3
| @@ -277,9 +277,9 @@ func frameInput(data []byte) wire.Frame { | |||
| 277 | 277 | } | |
| 278 | 278 | ||
| 279 | 279 | func screenLineText(s *vt.Screen, y int) string { | |
| 280 | - | out := make([]rune, s.Frame.Width) | |
| 281 | - | for x := range s.Frame.Width { | |
| 282 | - | out[x] = s.Frame.At(x, y).Rune | |
| 280 | + | out := make([]rune, s.Columns()) | |
| 281 | + | for x := range s.Columns() { | |
| 282 | + | out[x] = s.Cell(x, y).Rune | |
| 283 | 283 | } | |
| 284 | 284 | return string(out) | |
| 285 | 285 | } | |
| @@ -277,9 +277,9 @@ func frameInput(data []byte) wire.Frame { | |||||
| 277 | } | 277 | } | ||
| 278 | 278 | ||||
| 279 | func screenLineText(s *vt.Screen, y int) string { | 279 | func screenLineText(s *vt.Screen, y int) string { | ||
| 280 | - | out := make([]rune, s.Frame.Width) | 280 | + | out := make([]rune, s.Columns()) |
| 281 | - | for x := range s.Frame.Width { | 281 | + | for x := range s.Columns() { |
| 282 | - | out[x] = s.Frame.At(x, y).Rune | 282 | + | out[x] = s.Cell(x, y).Rune |
| 283 | } | 283 | } | ||
| 284 | return string(out) | 284 | return string(out) | ||
| 285 | } | 285 | } | ||
M internal/usecase/daemon/final_acceptance_test.go +1 -1
| @@ -73,7 +73,7 @@ func TestAcceptancePaletteNewTabUsesSessionGeometryAfterAttachmentResize(t *test | |||
| 73 | 73 | gotTabSize := tb.size | |
| 74 | 74 | pane := tb.focusedPane() | |
| 75 | 75 | pane.mu.Lock() | |
| 76 | - | gotPaneSize := domain.Size{Cols: pane.screen.Frame.Width, Rows: pane.screen.Frame.Height} | |
| 76 | + | gotPaneSize := domain.Size{Cols: pane.screen.Columns(), Rows: pane.screen.Rows()} | |
| 77 | 77 | pane.mu.Unlock() | |
| 78 | 78 | tb.mu.Unlock() | |
| 79 | 79 | require.Equal(t, wantContent, gotTabSize) | |
| @@ -73,7 +73,7 @@ func TestAcceptancePaletteNewTabUsesSessionGeometryAfterAttachmentResize(t *test | |||||
| 73 | gotTabSize := tb.size | 73 | gotTabSize := tb.size | ||
| 74 | pane := tb.focusedPane() | 74 | pane := tb.focusedPane() | ||
| 75 | pane.mu.Lock() | 75 | pane.mu.Lock() | ||
| 76 | - | gotPaneSize := domain.Size{Cols: pane.screen.Frame.Width, Rows: pane.screen.Frame.Height} | 76 | + | gotPaneSize := domain.Size{Cols: pane.screen.Columns(), Rows: pane.screen.Rows()} |
| 77 | pane.mu.Unlock() | 77 | pane.mu.Unlock() | ||
| 78 | tb.mu.Unlock() | 78 | tb.mu.Unlock() | ||
| 79 | require.Equal(t, wantContent, gotTabSize) | 79 | require.Equal(t, wantContent, gotTabSize) | ||
M internal/usecase/daemon/floating.go +1 -1
| @@ -427,7 +427,7 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau | |||
| 427 | 427 | return | |
| 428 | 428 | } | |
| 429 | 429 | ptyGeometry := spec.ptyGeometry | |
| 430 | - | p := newPaneWithStableIDAndTitle(layout.PaneID("floating"), spec.paneStableID, pty, spec.size, spec.fallback) | |
| 430 | + | p := newPaneWithStableIDAndTitle(layout.PaneID("floating"), spec.paneStableID, pty, spec.size, spec.fallback, d.currentHistoryConfig()) | |
| 431 | 431 | p.geometry = ptyGeometry | |
| 432 | 432 | setScreenGeometry(p.screen, ptyGeometry) | |
| 433 | 433 | p.rect = spec.geometry.ptyRect() | |
| @@ -427,7 +427,7 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau | |||||
| 427 | return | 427 | return | ||
| 428 | } | 428 | } | ||
| 429 | ptyGeometry := spec.ptyGeometry | 429 | ptyGeometry := spec.ptyGeometry | ||
| 430 | - | p := newPaneWithStableIDAndTitle(layout.PaneID("floating"), spec.paneStableID, pty, spec.size, spec.fallback) | 430 | + | p := newPaneWithStableIDAndTitle(layout.PaneID("floating"), spec.paneStableID, pty, spec.size, spec.fallback, d.currentHistoryConfig()) |
| 431 | p.geometry = ptyGeometry | 431 | p.geometry = ptyGeometry | ||
| 432 | setScreenGeometry(p.screen, ptyGeometry) | 432 | setScreenGeometry(p.screen, ptyGeometry) | ||
| 433 | p.rect = spec.geometry.ptyRect() | 433 | p.rect = spec.geometry.ptyRect() | ||
M internal/usecase/daemon/floating_render_test.go +27 -22
| @@ -208,8 +208,8 @@ func TestResponsiveFloatingResizeCommitsZeroContentDrawerWithoutPhysicalResize(t | |||
| 208 | 208 | require.Empty(t, pty.sizes(), "a zero-inner drawer must not issue a synthetic PTY resize") | |
| 209 | 209 | require.Equal(t, requested, floating.popupGeometry) | |
| 210 | 210 | require.Equal(t, initial.Inner, floating.rect, "physical geometry remains at the last usable size") | |
| 211 | - | require.Equal(t, initial.Inner.Width, floating.screen.Frame.Width) | |
| 212 | - | require.Equal(t, initial.Inner.Height, floating.screen.Frame.Height) | |
| 211 | + | require.Equal(t, initial.Inner.Width, floating.screen.Columns()) | |
| 212 | + | require.Equal(t, initial.Inner.Height, floating.screen.Rows()) | |
| 213 | 213 | } | |
| 214 | 214 | ||
| 215 | 215 | func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) { | |
| @@ -296,11 +296,13 @@ func TestComposeCapturedFloatingFrameClipsRetainedGeometryAfterFailedShrink(t *t | |||
| 296 | 296 | floatingPane := newPane("floating", pty, rectSize(retained.Inner)) | |
| 297 | 297 | floatingPane.rect = retained.Inner | |
| 298 | 298 | floatingPane.popupGeometry = retained | |
| 299 | - | for y := range floatingPane.screen.Frame.Height { | |
| 300 | - | for x := range floatingPane.screen.Frame.Width { | |
| 301 | - | floatingPane.screen.Frame.Set(x, y, renderer.Cell{Rune: rune('a' + y*10 + x)}) | |
| 299 | + | seed := renderer.NewFrame(floatingPane.screen.Columns(), floatingPane.screen.Rows()) | |
| 300 | + | for y := range seed.Height { | |
| 301 | + | for x := range seed.Width { | |
| 302 | + | seed.Set(x, y, renderer.Cell{Rune: rune('a' + y*10 + x)}) | |
| 302 | 303 | } | |
| 303 | 304 | } | |
| 305 | + | writeTestFrame(t, floatingPane.screen, seed) | |
| 304 | 306 | requested := calculateContentFloatingGeometry(rectSize(content), domain.FloatingConfig{Width: 80, Height: 80}) | |
| 305 | 307 | d := newTestDaemon(t, nil, stubClock{}) | |
| 306 | 308 | require.False(t, applyFloatingResizePlanForTest(d, floatingPane, requested)) | |
| @@ -557,8 +559,8 @@ func TestResizeFloatingPaneCommitsSameSizeGeometryWithoutPTYResize(t *testing.T) | |||
| 557 | 559 | require.Empty(t, pty.sizes(), "same-size geometry must not resize the PTY") | |
| 558 | 560 | require.Equal(t, tt.next.Inner, p.rect) | |
| 559 | 561 | require.Equal(t, tt.next, p.popupGeometry) | |
| 560 | - | require.Equal(t, 6, p.screen.Frame.Width) | |
| 561 | - | require.Equal(t, 3, p.screen.Frame.Height) | |
| 562 | + | require.Equal(t, 6, p.screen.Columns()) | |
| 563 | + | require.Equal(t, 3, p.screen.Rows()) | |
| 562 | 564 | }) | |
| 563 | 565 | } | |
| 564 | 566 | } | |
| @@ -633,7 +635,7 @@ func TestCaptureAndComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) { | |||
| 633 | 635 | func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) { | |
| 634 | 636 | frame := renderer.NewFrame(2, 1) | |
| 635 | 637 | drawFloatingBorder(frame, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 2, Height: 1}}, "title", renderer.Style{}) | |
| 636 | - | for _, cell := range frame.Cells { | |
| 638 | + | for _, cell := range frame.Row(0) { | |
| 637 | 639 | require.Equal(t, renderer.BlankCell().Rune, cell.Rune) | |
| 638 | 640 | } | |
| 639 | 641 | } | |
| @@ -698,10 +700,13 @@ func TestComposeCapturedFloatingFrameCachedAllocationsAreOnlyFrameClone(t *testi | |||
| 698 | 700 | } | |
| 699 | 701 | ||
| 700 | 702 | allocs := testing.AllocsPerRun(100, func() { composeCapturedFloatingFrame(input) }) | |
| 701 | - | // The production entry point deliberately clones Frame.Cells and its row | |
| 702 | - | // offsets to keep the cached base immutable; those are its two unavoidable | |
| 703 | - | // allocations. A higher count would reintroduce avoidable cache churn. | |
| 704 | - | require.LessOrEqual(t, allocs, float64(2)) | |
| 703 | + | // Compare with the actual compact clone rather than assuming the old two | |
| 704 | + | // dense slices. One additional allocation permits the first style insertion. | |
| 705 | + | cloneAllocs := testing.AllocsPerRun(100, func() { benchmarkComposeSink.frame = base.Clone() }) | |
| 706 | + | require.LessOrEqual(t, allocs, cloneAllocs+1) | |
| 707 | + | if copyEnterAllocationBudgetEnabled { | |
| 708 | + | assertRenderByteBudget(t, func() { benchmarkComposeSink.frame, _ = composeCapturedFloatingFrame(input) }, 40<<10) | |
| 709 | + | } | |
| 705 | 710 | } | |
| 706 | 711 | ||
| 707 | 712 | var ( | |
| @@ -851,16 +856,16 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) { | |||
| 851 | 856 | require.NoError(t, d.toggleFloating(sess, nil)) | |
| 852 | 857 | require.Equal(t, []domain.Size{rectSize(current)}, pty.sizes()) | |
| 853 | 858 | require.Equal(t, initial, floating.rect) | |
| 854 | - | require.Equal(t, initial.Width, floating.screen.Frame.Width) | |
| 855 | - | require.Equal(t, initial.Height, floating.screen.Frame.Height) | |
| 859 | + | require.Equal(t, initial.Width, floating.screen.Columns()) | |
| 860 | + | require.Equal(t, initial.Height, floating.screen.Rows()) | |
| 856 | 861 | require.Equal(t, initialGeometry, floating.popupGeometry) | |
| 857 | 862 | ||
| 858 | 863 | require.NoError(t, d.toggleFloating(sess, nil)) // hide | |
| 859 | 864 | require.NoError(t, d.toggleFloating(sess, nil)) // retry show | |
| 860 | 865 | require.Equal(t, []domain.Size{rectSize(current), rectSize(current)}, pty.sizes()) | |
| 861 | 866 | require.Equal(t, current, floating.rect) | |
| 862 | - | require.Equal(t, current.Width, floating.screen.Frame.Width) | |
| 863 | - | require.Equal(t, current.Height, floating.screen.Frame.Height) | |
| 867 | + | require.Equal(t, current.Width, floating.screen.Columns()) | |
| 868 | + | require.Equal(t, current.Height, floating.screen.Rows()) | |
| 864 | 869 | require.Equal(t, currentGeometry, floating.popupGeometry) | |
| 865 | 870 | } | |
| 866 | 871 | ||
| @@ -945,8 +950,8 @@ func TestFloatingLayoutRejectsStaleResizeBeforePublishing(t *testing.T) { | |||
| 945 | 950 | p.mu.Lock() | |
| 946 | 951 | require.Equal(t, previousGeometry.Inner, p.rect, "a stale floating apply must not publish its rectangle") | |
| 947 | 952 | require.Equal(t, previousGeometry, p.popupGeometry, "a stale floating apply must not publish its geometry") | |
| 948 | - | require.Equal(t, previousGeometry.Inner.Width, p.screen.Frame.Width, "a stale floating apply must not resize the screen") | |
| 949 | - | require.Equal(t, previousGeometry.Inner.Height, p.screen.Frame.Height, "a stale floating apply must not resize the screen") | |
| 953 | + | require.Equal(t, previousGeometry.Inner.Width, p.screen.Columns(), "a stale floating apply must not resize the screen") | |
| 954 | + | require.Equal(t, previousGeometry.Inner.Height, p.screen.Rows(), "a stale floating apply must not resize the screen") | |
| 950 | 955 | require.False(t, p.resizeApplying, "a stale floating apply must release its parser gate") | |
| 951 | 956 | p.mu.Unlock() | |
| 952 | 957 | ||
| @@ -955,8 +960,8 @@ func TestFloatingLayoutRejectsStaleResizeBeforePublishing(t *testing.T) { | |||
| 955 | 960 | require.Equal(t, []domain.Size{rectSize(oldGeometry.Inner), rectSize(newGeometry.Inner)}, pty.sizes()) | |
| 956 | 961 | require.Equal(t, newGeometry.Inner, p.rect) | |
| 957 | 962 | require.Equal(t, newGeometry, p.popupGeometry) | |
| 958 | - | require.Equal(t, newGeometry.Inner.Width, p.screen.Frame.Width) | |
| 959 | - | require.Equal(t, newGeometry.Inner.Height, p.screen.Frame.Height) | |
| 963 | + | require.Equal(t, newGeometry.Inner.Width, p.screen.Columns()) | |
| 964 | + | require.Equal(t, newGeometry.Inner.Height, p.screen.Rows()) | |
| 960 | 965 | } | |
| 961 | 966 | ||
| 962 | 967 | func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) { | |
| @@ -969,8 +974,8 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) { | |||
| 969 | 974 | require.False(t, applyFloatingResizePlanForTest(d, p, requested)) | |
| 970 | 975 | require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect) | |
| 971 | 976 | require.Equal(t, floatingGeometry{}, p.popupGeometry) | |
| 972 | - | require.Equal(t, 5, p.screen.Frame.Width) | |
| 973 | - | require.Equal(t, 4, p.screen.Frame.Height) | |
| 977 | + | require.Equal(t, 5, p.screen.Columns()) | |
| 978 | + | require.Equal(t, 4, p.screen.Rows()) | |
| 974 | 979 | }) | |
| 975 | 980 | ||
| 976 | 981 | t.Run("competing resizes serialize", func(t *testing.T) { | |
| @@ -208,8 +208,8 @@ func TestResponsiveFloatingResizeCommitsZeroContentDrawerWithoutPhysicalResize(t | |||||
| 208 | require.Empty(t, pty.sizes(), "a zero-inner drawer must not issue a synthetic PTY resize") | 208 | require.Empty(t, pty.sizes(), "a zero-inner drawer must not issue a synthetic PTY resize") | ||
| 209 | require.Equal(t, requested, floating.popupGeometry) | 209 | require.Equal(t, requested, floating.popupGeometry) | ||
| 210 | require.Equal(t, initial.Inner, floating.rect, "physical geometry remains at the last usable size") | 210 | require.Equal(t, initial.Inner, floating.rect, "physical geometry remains at the last usable size") | ||
| 211 | - | require.Equal(t, initial.Inner.Width, floating.screen.Frame.Width) | 211 | + | require.Equal(t, initial.Inner.Width, floating.screen.Columns()) |
| 212 | - | require.Equal(t, initial.Inner.Height, floating.screen.Frame.Height) | 212 | + | require.Equal(t, initial.Inner.Height, floating.screen.Rows()) |
| 213 | } | 213 | } | ||
| 214 | 214 | ||||
| 215 | func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) { | 215 | func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) { | ||
| @@ -296,11 +296,13 @@ func TestComposeCapturedFloatingFrameClipsRetainedGeometryAfterFailedShrink(t *t | |||||
| 296 | floatingPane := newPane("floating", pty, rectSize(retained.Inner)) | 296 | floatingPane := newPane("floating", pty, rectSize(retained.Inner)) | ||
| 297 | floatingPane.rect = retained.Inner | 297 | floatingPane.rect = retained.Inner | ||
| 298 | floatingPane.popupGeometry = retained | 298 | floatingPane.popupGeometry = retained | ||
| 299 | - | for y := range floatingPane.screen.Frame.Height { | 299 | + | seed := renderer.NewFrame(floatingPane.screen.Columns(), floatingPane.screen.Rows()) |
| 300 | - | for x := range floatingPane.screen.Frame.Width { | 300 | + | for y := range seed.Height { |
| 301 | - | floatingPane.screen.Frame.Set(x, y, renderer.Cell{Rune: rune('a' + y*10 + x)}) | 301 | + | for x := range seed.Width { |
| 302 | + | seed.Set(x, y, renderer.Cell{Rune: rune('a' + y*10 + x)}) | |||
| 302 | } | 303 | } | ||
| 303 | } | 304 | } | ||
| 305 | + | writeTestFrame(t, floatingPane.screen, seed) | |||
| 304 | requested := calculateContentFloatingGeometry(rectSize(content), domain.FloatingConfig{Width: 80, Height: 80}) | 306 | requested := calculateContentFloatingGeometry(rectSize(content), domain.FloatingConfig{Width: 80, Height: 80}) | ||
| 305 | d := newTestDaemon(t, nil, stubClock{}) | 307 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 306 | require.False(t, applyFloatingResizePlanForTest(d, floatingPane, requested)) | 308 | require.False(t, applyFloatingResizePlanForTest(d, floatingPane, requested)) | ||
| @@ -557,8 +559,8 @@ func TestResizeFloatingPaneCommitsSameSizeGeometryWithoutPTYResize(t *testing.T) | |||||
| 557 | require.Empty(t, pty.sizes(), "same-size geometry must not resize the PTY") | 559 | require.Empty(t, pty.sizes(), "same-size geometry must not resize the PTY") | ||
| 558 | require.Equal(t, tt.next.Inner, p.rect) | 560 | require.Equal(t, tt.next.Inner, p.rect) | ||
| 559 | require.Equal(t, tt.next, p.popupGeometry) | 561 | require.Equal(t, tt.next, p.popupGeometry) | ||
| 560 | - | require.Equal(t, 6, p.screen.Frame.Width) | 562 | + | require.Equal(t, 6, p.screen.Columns()) |
| 561 | - | require.Equal(t, 3, p.screen.Frame.Height) | 563 | + | require.Equal(t, 3, p.screen.Rows()) |
| 562 | }) | 564 | }) | ||
| 563 | } | 565 | } | ||
| 564 | } | 566 | } | ||
| @@ -633,7 +635,7 @@ func TestCaptureAndComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) { | |||||
| 633 | func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) { | 635 | func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) { | ||
| 634 | frame := renderer.NewFrame(2, 1) | 636 | frame := renderer.NewFrame(2, 1) | ||
| 635 | drawFloatingBorder(frame, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 2, Height: 1}}, "title", renderer.Style{}) | 637 | drawFloatingBorder(frame, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 2, Height: 1}}, "title", renderer.Style{}) | ||
| 636 | - | for _, cell := range frame.Cells { | 638 | + | for _, cell := range frame.Row(0) { |
| 637 | require.Equal(t, renderer.BlankCell().Rune, cell.Rune) | 639 | require.Equal(t, renderer.BlankCell().Rune, cell.Rune) | ||
| 638 | } | 640 | } | ||
| 639 | } | 641 | } | ||
| @@ -698,10 +700,13 @@ func TestComposeCapturedFloatingFrameCachedAllocationsAreOnlyFrameClone(t *testi | |||||
| 698 | } | 700 | } | ||
| 699 | 701 | ||||
| 700 | allocs := testing.AllocsPerRun(100, func() { composeCapturedFloatingFrame(input) }) | 702 | allocs := testing.AllocsPerRun(100, func() { composeCapturedFloatingFrame(input) }) | ||
| 701 | - | // The production entry point deliberately clones Frame.Cells and its row | 703 | + | // Compare with the actual compact clone rather than assuming the old two |
| 702 | - | // offsets to keep the cached base immutable; those are its two unavoidable | 704 | + | // dense slices. One additional allocation permits the first style insertion. |
| 703 | - | // allocations. A higher count would reintroduce avoidable cache churn. | 705 | + | cloneAllocs := testing.AllocsPerRun(100, func() { benchmarkComposeSink.frame = base.Clone() }) |
| 704 | - | require.LessOrEqual(t, allocs, float64(2)) | 706 | + | require.LessOrEqual(t, allocs, cloneAllocs+1) |
| 707 | + | if copyEnterAllocationBudgetEnabled { | |||
| 708 | + | assertRenderByteBudget(t, func() { benchmarkComposeSink.frame, _ = composeCapturedFloatingFrame(input) }, 40<<10) | |||
| 709 | + | } | |||
| 705 | } | 710 | } | ||
| 706 | 711 | ||||
| 707 | var ( | 712 | var ( | ||
| @@ -851,16 +856,16 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) { | |||||
| 851 | require.NoError(t, d.toggleFloating(sess, nil)) | 856 | require.NoError(t, d.toggleFloating(sess, nil)) | ||
| 852 | require.Equal(t, []domain.Size{rectSize(current)}, pty.sizes()) | 857 | require.Equal(t, []domain.Size{rectSize(current)}, pty.sizes()) | ||
| 853 | require.Equal(t, initial, floating.rect) | 858 | require.Equal(t, initial, floating.rect) | ||
| 854 | - | require.Equal(t, initial.Width, floating.screen.Frame.Width) | 859 | + | require.Equal(t, initial.Width, floating.screen.Columns()) |
| 855 | - | require.Equal(t, initial.Height, floating.screen.Frame.Height) | 860 | + | require.Equal(t, initial.Height, floating.screen.Rows()) |
| 856 | require.Equal(t, initialGeometry, floating.popupGeometry) | 861 | require.Equal(t, initialGeometry, floating.popupGeometry) | ||
| 857 | 862 | ||||
| 858 | require.NoError(t, d.toggleFloating(sess, nil)) // hide | 863 | require.NoError(t, d.toggleFloating(sess, nil)) // hide | ||
| 859 | require.NoError(t, d.toggleFloating(sess, nil)) // retry show | 864 | require.NoError(t, d.toggleFloating(sess, nil)) // retry show | ||
| 860 | require.Equal(t, []domain.Size{rectSize(current), rectSize(current)}, pty.sizes()) | 865 | require.Equal(t, []domain.Size{rectSize(current), rectSize(current)}, pty.sizes()) | ||
| 861 | require.Equal(t, current, floating.rect) | 866 | require.Equal(t, current, floating.rect) | ||
| 862 | - | require.Equal(t, current.Width, floating.screen.Frame.Width) | 867 | + | require.Equal(t, current.Width, floating.screen.Columns()) |
| 863 | - | require.Equal(t, current.Height, floating.screen.Frame.Height) | 868 | + | require.Equal(t, current.Height, floating.screen.Rows()) |
| 864 | require.Equal(t, currentGeometry, floating.popupGeometry) | 869 | require.Equal(t, currentGeometry, floating.popupGeometry) | ||
| 865 | } | 870 | } | ||
| 866 | 871 | ||||
| @@ -945,8 +950,8 @@ func TestFloatingLayoutRejectsStaleResizeBeforePublishing(t *testing.T) { | |||||
| 945 | p.mu.Lock() | 950 | p.mu.Lock() | ||
| 946 | require.Equal(t, previousGeometry.Inner, p.rect, "a stale floating apply must not publish its rectangle") | 951 | require.Equal(t, previousGeometry.Inner, p.rect, "a stale floating apply must not publish its rectangle") | ||
| 947 | require.Equal(t, previousGeometry, p.popupGeometry, "a stale floating apply must not publish its geometry") | 952 | require.Equal(t, previousGeometry, p.popupGeometry, "a stale floating apply must not publish its geometry") | ||
| 948 | - | require.Equal(t, previousGeometry.Inner.Width, p.screen.Frame.Width, "a stale floating apply must not resize the screen") | 953 | + | require.Equal(t, previousGeometry.Inner.Width, p.screen.Columns(), "a stale floating apply must not resize the screen") |
| 949 | - | require.Equal(t, previousGeometry.Inner.Height, p.screen.Frame.Height, "a stale floating apply must not resize the screen") | 954 | + | require.Equal(t, previousGeometry.Inner.Height, p.screen.Rows(), "a stale floating apply must not resize the screen") |
| 950 | require.False(t, p.resizeApplying, "a stale floating apply must release its parser gate") | 955 | require.False(t, p.resizeApplying, "a stale floating apply must release its parser gate") | ||
| 951 | p.mu.Unlock() | 956 | p.mu.Unlock() | ||
| 952 | 957 | ||||
| @@ -955,8 +960,8 @@ func TestFloatingLayoutRejectsStaleResizeBeforePublishing(t *testing.T) { | |||||
| 955 | require.Equal(t, []domain.Size{rectSize(oldGeometry.Inner), rectSize(newGeometry.Inner)}, pty.sizes()) | 960 | require.Equal(t, []domain.Size{rectSize(oldGeometry.Inner), rectSize(newGeometry.Inner)}, pty.sizes()) | ||
| 956 | require.Equal(t, newGeometry.Inner, p.rect) | 961 | require.Equal(t, newGeometry.Inner, p.rect) | ||
| 957 | require.Equal(t, newGeometry, p.popupGeometry) | 962 | require.Equal(t, newGeometry, p.popupGeometry) | ||
| 958 | - | require.Equal(t, newGeometry.Inner.Width, p.screen.Frame.Width) | 963 | + | require.Equal(t, newGeometry.Inner.Width, p.screen.Columns()) |
| 959 | - | require.Equal(t, newGeometry.Inner.Height, p.screen.Frame.Height) | 964 | + | require.Equal(t, newGeometry.Inner.Height, p.screen.Rows()) |
| 960 | } | 965 | } | ||
| 961 | 966 | ||||
| 962 | func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) { | 967 | func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) { | ||
| @@ -969,8 +974,8 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) { | |||||
| 969 | require.False(t, applyFloatingResizePlanForTest(d, p, requested)) | 974 | require.False(t, applyFloatingResizePlanForTest(d, p, requested)) | ||
| 970 | require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect) | 975 | require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect) | ||
| 971 | require.Equal(t, floatingGeometry{}, p.popupGeometry) | 976 | require.Equal(t, floatingGeometry{}, p.popupGeometry) | ||
| 972 | - | require.Equal(t, 5, p.screen.Frame.Width) | 977 | + | require.Equal(t, 5, p.screen.Columns()) |
| 973 | - | require.Equal(t, 4, p.screen.Frame.Height) | 978 | + | require.Equal(t, 4, p.screen.Rows()) |
| 974 | }) | 979 | }) | ||
| 975 | 980 | ||||
| 976 | t.Run("competing resizes serialize", func(t *testing.T) { | 981 | t.Run("competing resizes serialize", func(t *testing.T) { | ||
A internal/usecase/daemon/history_policy.go +115 -0
| @@ -0,0 +1,115 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | vt "github.com/bnema/vev-vt" | |
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func historyConfigFor(policy domain.ScrollbackConfig) vt.HistoryConfig { | |
| 12 | + | if policy.Megabytes == 0 { | |
| 13 | + | return vt.HistoryConfig{} | |
| 14 | + | } | |
| 15 | + | return vt.HistoryConfig{MaxBytes: policy.Megabytes * 1_000_000, MaxRows: policy.Lines} | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func (d *Daemon) currentHistoryConfig() vt.HistoryConfig { | |
| 19 | + | policy := d.scrollbackConfig.Load() | |
| 20 | + | if policy == nil { | |
| 21 | + | return historyConfigFor(domain.DefaultScrollbackConfig()) | |
| 22 | + | } | |
| 23 | + | return historyConfigFor(*policy) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // historyPanes gathers references without holding a session/tab lock while | |
| 27 | + | // touching history. Include hidden floating panes; deduplicate a pane that moves | |
| 28 | + | // between tabs while the separate session snapshots are being collected. | |
| 29 | + | func (d *Daemon) historyPanes() []*pane { | |
| 30 | + | var panes []*pane | |
| 31 | + | seen := make(map[*pane]struct{}) | |
| 32 | + | for _, sess := range d.sessionsSnapshot() { | |
| 33 | + | sess.mu.Lock() | |
| 34 | + | tabs := append([]*tab(nil), sess.tabs...) | |
| 35 | + | sess.mu.Unlock() | |
| 36 | + | for _, tb := range tabs { | |
| 37 | + | if tb == nil { | |
| 38 | + | continue | |
| 39 | + | } | |
| 40 | + | tb.mu.Lock() | |
| 41 | + | current := tb.panesSnapshot() | |
| 42 | + | if tb.floating.pane != nil { | |
| 43 | + | current = append(current, tb.floating.pane) | |
| 44 | + | } | |
| 45 | + | tb.mu.Unlock() | |
| 46 | + | for _, p := range current { | |
| 47 | + | if p == nil { | |
| 48 | + | continue | |
| 49 | + | } | |
| 50 | + | if _, exists := seen[p]; exists { | |
| 51 | + | continue | |
| 52 | + | } | |
| 53 | + | seen[p] = struct{}{} | |
| 54 | + | panes = append(panes, p) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | return panes | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func (d *Daemon) applyHistoryLimitsToPane(p *pane) { | |
| 62 | + | p.mu.Lock() | |
| 63 | + | var err error | |
| 64 | + | if p.history != nil { | |
| 65 | + | // Load inside the pane lock so an older concurrent reload cannot install | |
| 66 | + | // a stale policy after a newer reload has already published its value. | |
| 67 | + | err = p.history.SetLimits(d.currentHistoryConfig()) | |
| 68 | + | } | |
| 69 | + | p.mu.Unlock() | |
| 70 | + | if err != nil { | |
| 71 | + | d.log.Warn("cannot apply scrollback limits", "err", err) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func (d *Daemon) applyHistoryLimits() { | |
| 76 | + | for _, p := range d.historyPanes() { | |
| 77 | + | d.applyHistoryLimitsToPane(p) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // Keep background compression infrequent and bounded; it must not compete | |
| 82 | + | // with interactive output when many panes retain history. | |
| 83 | + | const historyMaintenanceInterval = 5 * time.Second | |
| 84 | + | ||
| 85 | + | // historyMaintenance is application scheduling, not a VT-owned worker. It | |
| 86 | + | // visits one history page per pane per interval and skips busy/resizing panes. | |
| 87 | + | func (d *Daemon) historyMaintenance(ctx context.Context) { | |
| 88 | + | timer := d.clock.NewTimer(historyMaintenanceInterval) | |
| 89 | + | defer timer.Stop() | |
| 90 | + | for { | |
| 91 | + | select { | |
| 92 | + | case <-ctx.Done(): | |
| 93 | + | return | |
| 94 | + | case <-timer.C(): | |
| 95 | + | d.compressIdleHistories() | |
| 96 | + | timer.Reset(historyMaintenanceInterval) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func (d *Daemon) compressIdleHistories() { | |
| 102 | + | for _, p := range d.historyPanes() { | |
| 103 | + | if !p.mu.TryLock() { | |
| 104 | + | continue | |
| 105 | + | } | |
| 106 | + | var err error | |
| 107 | + | if p.history != nil && !p.resizeApplying { | |
| 108 | + | _, err = p.history.CompressIdle(1) | |
| 109 | + | } | |
| 110 | + | p.mu.Unlock() | |
| 111 | + | if err != nil { | |
| 112 | + | d.log.Warn("cannot compress idle scrollback", "err", err) | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | } | |
| @@ -0,0 +1,115 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | vt "github.com/bnema/vev-vt" | |||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func historyConfigFor(policy domain.ScrollbackConfig) vt.HistoryConfig { | |||
| 12 | + | if policy.Megabytes == 0 { | |||
| 13 | + | return vt.HistoryConfig{} | |||
| 14 | + | } | |||
| 15 | + | return vt.HistoryConfig{MaxBytes: policy.Megabytes * 1_000_000, MaxRows: policy.Lines} | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func (d *Daemon) currentHistoryConfig() vt.HistoryConfig { | |||
| 19 | + | policy := d.scrollbackConfig.Load() | |||
| 20 | + | if policy == nil { | |||
| 21 | + | return historyConfigFor(domain.DefaultScrollbackConfig()) | |||
| 22 | + | } | |||
| 23 | + | return historyConfigFor(*policy) | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // historyPanes gathers references without holding a session/tab lock while | |||
| 27 | + | // touching history. Include hidden floating panes; deduplicate a pane that moves | |||
| 28 | + | // between tabs while the separate session snapshots are being collected. | |||
| 29 | + | func (d *Daemon) historyPanes() []*pane { | |||
| 30 | + | var panes []*pane | |||
| 31 | + | seen := make(map[*pane]struct{}) | |||
| 32 | + | for _, sess := range d.sessionsSnapshot() { | |||
| 33 | + | sess.mu.Lock() | |||
| 34 | + | tabs := append([]*tab(nil), sess.tabs...) | |||
| 35 | + | sess.mu.Unlock() | |||
| 36 | + | for _, tb := range tabs { | |||
| 37 | + | if tb == nil { | |||
| 38 | + | continue | |||
| 39 | + | } | |||
| 40 | + | tb.mu.Lock() | |||
| 41 | + | current := tb.panesSnapshot() | |||
| 42 | + | if tb.floating.pane != nil { | |||
| 43 | + | current = append(current, tb.floating.pane) | |||
| 44 | + | } | |||
| 45 | + | tb.mu.Unlock() | |||
| 46 | + | for _, p := range current { | |||
| 47 | + | if p == nil { | |||
| 48 | + | continue | |||
| 49 | + | } | |||
| 50 | + | if _, exists := seen[p]; exists { | |||
| 51 | + | continue | |||
| 52 | + | } | |||
| 53 | + | seen[p] = struct{}{} | |||
| 54 | + | panes = append(panes, p) | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | return panes | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func (d *Daemon) applyHistoryLimitsToPane(p *pane) { | |||
| 62 | + | p.mu.Lock() | |||
| 63 | + | var err error | |||
| 64 | + | if p.history != nil { | |||
| 65 | + | // Load inside the pane lock so an older concurrent reload cannot install | |||
| 66 | + | // a stale policy after a newer reload has already published its value. | |||
| 67 | + | err = p.history.SetLimits(d.currentHistoryConfig()) | |||
| 68 | + | } | |||
| 69 | + | p.mu.Unlock() | |||
| 70 | + | if err != nil { | |||
| 71 | + | d.log.Warn("cannot apply scrollback limits", "err", err) | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func (d *Daemon) applyHistoryLimits() { | |||
| 76 | + | for _, p := range d.historyPanes() { | |||
| 77 | + | d.applyHistoryLimitsToPane(p) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // Keep background compression infrequent and bounded; it must not compete | |||
| 82 | + | // with interactive output when many panes retain history. | |||
| 83 | + | const historyMaintenanceInterval = 5 * time.Second | |||
| 84 | + | ||||
| 85 | + | // historyMaintenance is application scheduling, not a VT-owned worker. It | |||
| 86 | + | // visits one history page per pane per interval and skips busy/resizing panes. | |||
| 87 | + | func (d *Daemon) historyMaintenance(ctx context.Context) { | |||
| 88 | + | timer := d.clock.NewTimer(historyMaintenanceInterval) | |||
| 89 | + | defer timer.Stop() | |||
| 90 | + | for { | |||
| 91 | + | select { | |||
| 92 | + | case <-ctx.Done(): | |||
| 93 | + | return | |||
| 94 | + | case <-timer.C(): | |||
| 95 | + | d.compressIdleHistories() | |||
| 96 | + | timer.Reset(historyMaintenanceInterval) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func (d *Daemon) compressIdleHistories() { | |||
| 102 | + | for _, p := range d.historyPanes() { | |||
| 103 | + | if !p.mu.TryLock() { | |||
| 104 | + | continue | |||
| 105 | + | } | |||
| 106 | + | var err error | |||
| 107 | + | if p.history != nil && !p.resizeApplying { | |||
| 108 | + | _, err = p.history.CompressIdle(1) | |||
| 109 | + | } | |||
| 110 | + | p.mu.Unlock() | |||
| 111 | + | if err != nil { | |||
| 112 | + | d.log.Warn("cannot compress idle scrollback", "err", err) | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | } | |||
A internal/usecase/daemon/history_policy_test.go +83 -0
| @@ -0,0 +1,83 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | vt "github.com/bnema/vev-vt" | |
| 10 | + | "github.com/bnema/vev/internal/domain" | |
| 11 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestHistoryPolicyReloadIncludesHiddenFloatingPanes(t *testing.T) { | |
| 16 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 17 | + | sess := addControlSession(d, "history", "tab", "pane") | |
| 18 | + | tb := sess.tabs[0] | |
| 19 | + | primary := tb.focusedPane() | |
| 20 | + | floating := newPane("floating", nil, domain.Size{Cols: 80, Rows: 24}) | |
| 21 | + | installTestFloating(tb, floating, false) | |
| 22 | + | panes := []*pane{primary, floating} | |
| 23 | + | views := make([]vt.HistoryView, len(panes)) | |
| 24 | + | for i, p := range panes { | |
| 25 | + | for _, text := range []string{"a", "b", "c"} { | |
| 26 | + | appendHistoryRow(t, p.history, testRow(text)) | |
| 27 | + | } | |
| 28 | + | views[i] = p.history.View() | |
| 29 | + | } | |
| 30 | + | cfg := domain.Defaults() | |
| 31 | + | cfg.Scrollback = domain.ScrollbackConfig{Megabytes: 1, Lines: 2} | |
| 32 | + | d.ApplyConfig(cfg) | |
| 33 | + | for i, p := range panes { | |
| 34 | + | require.Equal(t, 2, p.history.Len()) | |
| 35 | + | require.Equal(t, uint64(1_000_000), p.history.ByteCap()) | |
| 36 | + | require.Equal(t, "b", cellsString(p.history.View().Row(0))) | |
| 37 | + | require.Equal(t, 3, views[i].Len(), "reload must preserve captured documents") | |
| 38 | + | } | |
| 39 | + | cfg.Scrollback.Megabytes = 0 | |
| 40 | + | d.ApplyConfig(cfg) | |
| 41 | + | for _, p := range panes { | |
| 42 | + | require.Zero(t, p.history.Len()) | |
| 43 | + | require.Zero(t, p.history.Cap()) | |
| 44 | + | } | |
| 45 | + | cfg.Scrollback = domain.ScrollbackConfig{Megabytes: 1} | |
| 46 | + | d.ApplyConfig(cfg) | |
| 47 | + | created := newPaneWithStableIDAndTitle("new", "stable-new", nil, domain.Size{Cols: 80, Rows: 24}, "shell", d.currentHistoryConfig()) | |
| 48 | + | require.Zero(t, created.history.Cap()) | |
| 49 | + | require.Equal(t, uint64(1_000_000), created.history.ByteCap()) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestHistoryCompressionSkipsBusyPanesAndRemainsBounded(t *testing.T) { | |
| 53 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 54 | + | sess := addControlSession(d, "history", "tab", "pane") | |
| 55 | + | p := sess.tabs[0].focusedPane() | |
| 56 | + | installTestHistory(p, vt.HistoryConfig{MaxRows: 64, MaxBytes: 1 << 20, ChunkRows: 16}) | |
| 57 | + | for range 48 { | |
| 58 | + | appendHistoryRow(t, p.history, testRow(strings.Repeat("a", 80))) | |
| 59 | + | } | |
| 60 | + | before := p.history.View() | |
| 61 | + | p.mu.Lock() | |
| 62 | + | d.compressIdleHistories() // TryLock must not wait for the reader's lock. | |
| 63 | + | p.mu.Unlock() | |
| 64 | + | require.Zero(t, p.history.CompressionStats().ColdPages) | |
| 65 | + | for range 4 { | |
| 66 | + | d.compressIdleHistories() | |
| 67 | + | } | |
| 68 | + | require.Equal(t, 2, p.history.CompressionStats().ColdPages) | |
| 69 | + | require.Equal(t, 'a', before.Cell(0, 0).Rune) | |
| 70 | + | require.Equal(t, 48, p.history.Len()) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestHistoryMaintenanceStopsItsTimerOnCancellation(t *testing.T) { | |
| 74 | + | clock := portsmocks.NewMockClock(t) | |
| 75 | + | timer := portsmocks.NewMockTimer(t) | |
| 76 | + | clock.EXPECT().NewTimer(historyMaintenanceInterval).Return(timer).Once() | |
| 77 | + | timer.EXPECT().C().Return(make(<-chan time.Time)).Once() | |
| 78 | + | timer.EXPECT().Stop().Return(true).Once() | |
| 79 | + | ctx, cancel := context.WithCancel(t.Context()) | |
| 80 | + | cancel() | |
| 81 | + | d := &Daemon{clock: clock} | |
| 82 | + | d.historyMaintenance(ctx) | |
| 83 | + | } | |
| @@ -0,0 +1,83 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | vt "github.com/bnema/vev-vt" | |||
| 10 | + | "github.com/bnema/vev/internal/domain" | |||
| 11 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestHistoryPolicyReloadIncludesHiddenFloatingPanes(t *testing.T) { | |||
| 16 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 17 | + | sess := addControlSession(d, "history", "tab", "pane") | |||
| 18 | + | tb := sess.tabs[0] | |||
| 19 | + | primary := tb.focusedPane() | |||
| 20 | + | floating := newPane("floating", nil, domain.Size{Cols: 80, Rows: 24}) | |||
| 21 | + | installTestFloating(tb, floating, false) | |||
| 22 | + | panes := []*pane{primary, floating} | |||
| 23 | + | views := make([]vt.HistoryView, len(panes)) | |||
| 24 | + | for i, p := range panes { | |||
| 25 | + | for _, text := range []string{"a", "b", "c"} { | |||
| 26 | + | appendHistoryRow(t, p.history, testRow(text)) | |||
| 27 | + | } | |||
| 28 | + | views[i] = p.history.View() | |||
| 29 | + | } | |||
| 30 | + | cfg := domain.Defaults() | |||
| 31 | + | cfg.Scrollback = domain.ScrollbackConfig{Megabytes: 1, Lines: 2} | |||
| 32 | + | d.ApplyConfig(cfg) | |||
| 33 | + | for i, p := range panes { | |||
| 34 | + | require.Equal(t, 2, p.history.Len()) | |||
| 35 | + | require.Equal(t, uint64(1_000_000), p.history.ByteCap()) | |||
| 36 | + | require.Equal(t, "b", cellsString(p.history.View().Row(0))) | |||
| 37 | + | require.Equal(t, 3, views[i].Len(), "reload must preserve captured documents") | |||
| 38 | + | } | |||
| 39 | + | cfg.Scrollback.Megabytes = 0 | |||
| 40 | + | d.ApplyConfig(cfg) | |||
| 41 | + | for _, p := range panes { | |||
| 42 | + | require.Zero(t, p.history.Len()) | |||
| 43 | + | require.Zero(t, p.history.Cap()) | |||
| 44 | + | } | |||
| 45 | + | cfg.Scrollback = domain.ScrollbackConfig{Megabytes: 1} | |||
| 46 | + | d.ApplyConfig(cfg) | |||
| 47 | + | created := newPaneWithStableIDAndTitle("new", "stable-new", nil, domain.Size{Cols: 80, Rows: 24}, "shell", d.currentHistoryConfig()) | |||
| 48 | + | require.Zero(t, created.history.Cap()) | |||
| 49 | + | require.Equal(t, uint64(1_000_000), created.history.ByteCap()) | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func TestHistoryCompressionSkipsBusyPanesAndRemainsBounded(t *testing.T) { | |||
| 53 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 54 | + | sess := addControlSession(d, "history", "tab", "pane") | |||
| 55 | + | p := sess.tabs[0].focusedPane() | |||
| 56 | + | installTestHistory(p, vt.HistoryConfig{MaxRows: 64, MaxBytes: 1 << 20, ChunkRows: 16}) | |||
| 57 | + | for range 48 { | |||
| 58 | + | appendHistoryRow(t, p.history, testRow(strings.Repeat("a", 80))) | |||
| 59 | + | } | |||
| 60 | + | before := p.history.View() | |||
| 61 | + | p.mu.Lock() | |||
| 62 | + | d.compressIdleHistories() // TryLock must not wait for the reader's lock. | |||
| 63 | + | p.mu.Unlock() | |||
| 64 | + | require.Zero(t, p.history.CompressionStats().ColdPages) | |||
| 65 | + | for range 4 { | |||
| 66 | + | d.compressIdleHistories() | |||
| 67 | + | } | |||
| 68 | + | require.Equal(t, 2, p.history.CompressionStats().ColdPages) | |||
| 69 | + | require.Equal(t, 'a', before.Cell(0, 0).Rune) | |||
| 70 | + | require.Equal(t, 48, p.history.Len()) | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func TestHistoryMaintenanceStopsItsTimerOnCancellation(t *testing.T) { | |||
| 74 | + | clock := portsmocks.NewMockClock(t) | |||
| 75 | + | timer := portsmocks.NewMockTimer(t) | |||
| 76 | + | clock.EXPECT().NewTimer(historyMaintenanceInterval).Return(timer).Once() | |||
| 77 | + | timer.EXPECT().C().Return(make(<-chan time.Time)).Once() | |||
| 78 | + | timer.EXPECT().Stop().Return(true).Once() | |||
| 79 | + | ctx, cancel := context.WithCancel(t.Context()) | |||
| 80 | + | cancel() | |||
| 81 | + | d := &Daemon{clock: clock} | |||
| 82 | + | d.historyMaintenance(ctx) | |||
| 83 | + | } | |||
M internal/usecase/daemon/history_test_helper_test.go +30 -2
| @@ -1,10 +1,38 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | - | import vt "github.com/bnema/vev-vt" | |
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | vt "github.com/bnema/vev-vt" | |
| 8 | + | "github.com/bnema/vev-vt/ansi" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 4 | 11 | ||
| 5 | 12 | func newTestHistory(rows int) *vt.History { return vt.NewHistory(vt.HistoryConfig{MaxRows: rows}) } | |
| 6 | 13 | ||
| 14 | + | func captureTestFrame(source vt.CellSource) vt.Frame { | |
| 15 | + | frame := vt.NewFrame(source.Columns(), source.Rows()) | |
| 16 | + | for y := range source.Rows() { | |
| 17 | + | for x := range source.Columns() { | |
| 18 | + | frame.Set(x, y, source.Cell(x, y)) | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | return frame | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func writeTestRow(screen *vt.Screen, row int, text string) { | |
| 25 | + | screen.Write([]byte(fmt.Sprintf("\x1b7\x1b[%d;1H%s\x1b8", row+1, text))) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func writeTestFrame(t testing.TB, screen *vt.Screen, frame vt.Frame) { | |
| 29 | + | t.Helper() | |
| 30 | + | data, err := ansi.New(ansi.Capabilities{}).Draw(frame, []ansi.Damage{ansi.FullRedraw()}) | |
| 31 | + | require.NoError(t, err) | |
| 32 | + | screen.Write(data) | |
| 33 | + | } | |
| 34 | + | ||
| 7 | 35 | func installTestHistory(p *pane, config vt.HistoryConfig) { | |
| 8 | - | p.screen = vt.NewScreenWithHistory(p.screen.Frame.Width, p.screen.Frame.Height, config) | |
| 36 | + | p.screen = vt.NewScreenWithHistory(p.screen.Columns(), p.screen.Rows(), config) | |
| 9 | 37 | p.history = p.screen.History() | |
| 10 | 38 | } | |
| @@ -1,10 +1,38 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | - | import vt "github.com/bnema/vev-vt" | 3 | + | import ( |
| 4 | + | "fmt" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | vt "github.com/bnema/vev-vt" | |||
| 8 | + | "github.com/bnema/vev-vt/ansi" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 4 | 11 | ||||
| 5 | func newTestHistory(rows int) *vt.History { return vt.NewHistory(vt.HistoryConfig{MaxRows: rows}) } | 12 | func newTestHistory(rows int) *vt.History { return vt.NewHistory(vt.HistoryConfig{MaxRows: rows}) } | ||
| 6 | 13 | ||||
| 14 | + | func captureTestFrame(source vt.CellSource) vt.Frame { | |||
| 15 | + | frame := vt.NewFrame(source.Columns(), source.Rows()) | |||
| 16 | + | for y := range source.Rows() { | |||
| 17 | + | for x := range source.Columns() { | |||
| 18 | + | frame.Set(x, y, source.Cell(x, y)) | |||
| 19 | + | } | |||
| 20 | + | } | |||
| 21 | + | return frame | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func writeTestRow(screen *vt.Screen, row int, text string) { | |||
| 25 | + | screen.Write([]byte(fmt.Sprintf("\x1b7\x1b[%d;1H%s\x1b8", row+1, text))) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func writeTestFrame(t testing.TB, screen *vt.Screen, frame vt.Frame) { | |||
| 29 | + | t.Helper() | |||
| 30 | + | data, err := ansi.New(ansi.Capabilities{}).Draw(frame, []ansi.Damage{ansi.FullRedraw()}) | |||
| 31 | + | require.NoError(t, err) | |||
| 32 | + | screen.Write(data) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 7 | func installTestHistory(p *pane, config vt.HistoryConfig) { | 35 | func installTestHistory(p *pane, config vt.HistoryConfig) { | ||
| 8 | - | p.screen = vt.NewScreenWithHistory(p.screen.Frame.Width, p.screen.Frame.Height, config) | 36 | + | p.screen = vt.NewScreenWithHistory(p.screen.Columns(), p.screen.Rows(), config) |
| 9 | p.history = p.screen.History() | 37 | p.history = p.screen.History() | ||
| 10 | } | 38 | } | ||
M internal/usecase/daemon/input.go +1 -1
| @@ -253,7 +253,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||
| 253 | 253 | return | |
| 254 | 254 | } | |
| 255 | 255 | p.mu.Lock() | |
| 256 | - | childRows := p.screen.Frame.Height | |
| 256 | + | childRows := p.screen.Rows() | |
| 257 | 257 | mouseMode, mouseSGR := p.screen.MouseMode() | |
| 258 | 258 | altScreen := p.screen.AltScreenActive() | |
| 259 | 259 | p.mu.Unlock() | |
| @@ -253,7 +253,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||||
| 253 | return | 253 | return | ||
| 254 | } | 254 | } | ||
| 255 | p.mu.Lock() | 255 | p.mu.Lock() | ||
| 256 | - | childRows := p.screen.Frame.Height | 256 | + | childRows := p.screen.Rows() |
| 257 | mouseMode, mouseSGR := p.screen.MouseMode() | 257 | mouseMode, mouseSGR := p.screen.MouseMode() | ||
| 258 | altScreen := p.screen.AltScreenActive() | 258 | altScreen := p.screen.AltScreenActive() | ||
| 259 | p.mu.Unlock() | 259 | p.mu.Unlock() | ||
M internal/usecase/daemon/input_test.go +17 -17
| @@ -217,7 +217,7 @@ func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) { | |||
| 217 | 217 | require.NoError(t, err) | |
| 218 | 218 | require.Zero(t, secondOutput.Base, "tab switch must emit the complete target frame first") | |
| 219 | 219 | terminal.Write(secondOutput.Data) | |
| 220 | - | require.NotContains(t, strings.Join(frameRows(terminal.Frame), "\n"), "source", "the first target-tab frame must not retain source pane cells") | |
| 220 | + | require.NotContains(t, strings.Join(frameRows(terminal), "\n"), "source", "the first target-tab frame must not retain source pane cells") | |
| 221 | 221 | } | |
| 222 | 222 | ||
| 223 | 223 | func TestClosePanePrunesAttachedCaptureFrameAndKeepsSurvivor(t *testing.T) { | |
| @@ -314,8 +314,8 @@ func TestAltFToggleRetainedFloatingPaneRepaintsImmediately(t *testing.T) { | |||
| 314 | 314 | // Route the real Alt+F binding. Showing a retained pane must paint once. | |
| 315 | 315 | d.handleInput(sess, ac, []byte("\x1bf")) | |
| 316 | 316 | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | |
| 317 | - | require.Contains(t, strings.Join(frameRows(client.Frame), "\n"), "popup-content") | |
| 318 | - | require.Contains(t, strings.Join(frameRows(client.Frame), "\n"), "┌") | |
| 317 | + | require.Contains(t, strings.Join(frameRows(client), "\n"), "popup-content") | |
| 318 | + | require.Contains(t, strings.Join(frameRows(client), "\n"), "┌") | |
| 319 | 319 | select { | |
| 320 | 320 | case extra := <-sends: | |
| 321 | 321 | t.Fatalf("show emitted duplicate output: %#v", extra) | |
| @@ -326,7 +326,7 @@ func TestAltFToggleRetainedFloatingPaneRepaintsImmediately(t *testing.T) { | |||
| 326 | 326 | // unrelated output, while retaining the existing PTY and context. | |
| 327 | 327 | d.handleInput(sess, ac, []byte("\x1bf")) | |
| 328 | 328 | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | |
| 329 | - | require.NotContains(t, strings.Join(frameRows(client.Frame), "\n"), "popup-content") | |
| 329 | + | require.NotContains(t, strings.Join(frameRows(client), "\n"), "popup-content") | |
| 330 | 330 | tb := testAttachmentTab(sess) | |
| 331 | 331 | tb.mu.Lock() | |
| 332 | 332 | require.Equal(t, floatingHidden, tb.floating.state) | |
| @@ -344,7 +344,7 @@ func TestAltFToggleRetainedFloatingPaneRepaintsImmediately(t *testing.T) { | |||
| 344 | 344 | // resize/start frame. | |
| 345 | 345 | d.handleInput(sess, ac, []byte("\x1bf")) | |
| 346 | 346 | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | |
| 347 | - | require.Contains(t, strings.Join(frameRows(client.Frame), "\n"), "popup-content") | |
| 347 | + | require.Contains(t, strings.Join(frameRows(client), "\n"), "popup-content") | |
| 348 | 348 | tb.mu.Lock() | |
| 349 | 349 | require.Equal(t, floatingVisible, tb.floating.state) | |
| 350 | 350 | require.Same(t, floating, tb.floating.pane) | |
| @@ -1063,7 +1063,7 @@ func TestCopyModeMousePressReleaseWithoutMotionEmitsNoOSC52(t *testing.T) { | |||
| 1063 | 1063 | p, release := newBlockingPTY(t) | |
| 1064 | 1064 | defer release() | |
| 1065 | 1065 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 1066 | - | copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha")) | |
| 1066 | + | writeTestRow(sess.tabs[0].focusedPane().screen, 0, "alpha") | |
| 1067 | 1067 | d.enterCopyMode(sess, ac) | |
| 1068 | 1068 | mustOutputData(t, sends) | |
| 1069 | 1069 | ||
| @@ -1079,7 +1079,7 @@ func TestCopyModeMousePressReleaseWithoutMotionEmitsNoOSC52(t *testing.T) { | |||
| 1079 | 1079 | func TestCopyModeMouseHorizontalReverseDragUsesExactOSC52(t *testing.T) { | |
| 1080 | 1080 | p, _ := newBlockingPTY(t) | |
| 1081 | 1081 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 1082 | - | copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha")) | |
| 1082 | + | writeTestRow(sess.tabs[0].focusedPane().screen, 0, "alpha") | |
| 1083 | 1083 | d.enterCopyMode(sess, ac) | |
| 1084 | 1084 | mustOutputData(t, sends) | |
| 1085 | 1085 | ||
| @@ -1103,9 +1103,9 @@ func TestCopyModeMouseHorizontalReverseDragUsesExactOSC52(t *testing.T) { | |||
| 1103 | 1103 | func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) { | |
| 1104 | 1104 | p, _ := newBlockingPTY(t) | |
| 1105 | 1105 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 1106 | - | copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha")) | |
| 1107 | - | copy(sess.tabs[0].focusedPane().screen.Frame.Row(1), testRow("bravo")) | |
| 1108 | - | copy(sess.tabs[0].focusedPane().screen.Frame.Row(2), testRow("charlie")) | |
| 1106 | + | writeTestRow(sess.tabs[0].focusedPane().screen, 0, "alpha") | |
| 1107 | + | writeTestRow(sess.tabs[0].focusedPane().screen, 1, "bravo") | |
| 1108 | + | writeTestRow(sess.tabs[0].focusedPane().screen, 2, "charlie") | |
| 1109 | 1109 | ||
| 1110 | 1110 | d.enterCopyMode(sess, ac) | |
| 1111 | 1111 | mustOutputData(t, sends) | |
| @@ -1146,7 +1146,7 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) { | |||
| 1146 | 1146 | func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) { | |
| 1147 | 1147 | p, _ := newBlockingPTY(t) | |
| 1148 | 1148 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 1149 | - | require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24") | |
| 1149 | + | require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Rows(), "fixture assumption: status row is wire row 24") | |
| 1150 | 1150 | ||
| 1151 | 1151 | // Press on a content row establishes a (soon to be stale) anchor. | |
| 1152 | 1152 | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | |
| @@ -1197,10 +1197,10 @@ func TestCopyModeRejectedReleaseClearsPointerAndClickBeforeMotion(t *testing.T) | |||
| 1197 | 1197 | func TestCopyModeStatusRowPressClearsDragState(t *testing.T) { | |
| 1198 | 1198 | p, _ := newBlockingPTY(t) | |
| 1199 | 1199 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 1200 | - | copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha")) | |
| 1201 | - | copy(sess.tabs[0].focusedPane().screen.Frame.Row(1), testRow("bravo")) | |
| 1202 | - | copy(sess.tabs[0].focusedPane().screen.Frame.Row(2), testRow("charlie")) | |
| 1203 | - | require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24") | |
| 1200 | + | writeTestRow(sess.tabs[0].focusedPane().screen, 0, "alpha") | |
| 1201 | + | writeTestRow(sess.tabs[0].focusedPane().screen, 1, "bravo") | |
| 1202 | + | writeTestRow(sess.tabs[0].focusedPane().screen, 2, "charlie") | |
| 1203 | + | require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Rows(), "fixture assumption: status row is wire row 24") | |
| 1204 | 1204 | ||
| 1205 | 1205 | d.enterCopyMode(sess, ac) | |
| 1206 | 1206 | mustOutputData(t, sends) | |
| @@ -1244,7 +1244,7 @@ func TestMouseNormalScreenDragUsesPressOwnedDocumentAfterOutputEviction(t *testi | |||
| 1244 | 1244 | p, _ := newBlockingPTY(t) | |
| 1245 | 1245 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 1246 | 1246 | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 50}) | |
| 1247 | - | require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24") | |
| 1247 | + | require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Rows(), "fixture assumption: status row is wire row 24") | |
| 1248 | 1248 | ||
| 1249 | 1249 | // Press anchors document row 0 before output eviction. | |
| 1250 | 1250 | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | |
| @@ -1502,7 +1502,7 @@ func TestCopyModeDragOutsideSplitPaneClampsToPaneContent(t *testing.T) { | |||
| 1502 | 1502 | d.procComm = nil | |
| 1503 | 1503 | tb := testAttachmentTab(sess) | |
| 1504 | 1504 | for i := range 10 { | |
| 1505 | - | copy(tb.focusedPane().screen.Frame.Row(i), testRow(string(rune('a'+i)))) | |
| 1505 | + | writeTestRow(tb.focusedPane().screen, i, string(rune('a'+i))) | |
| 1506 | 1506 | } | |
| 1507 | 1507 | tb.mu.Lock() | |
| 1508 | 1508 | tb.size = domain.Size{Cols: 41, Rows: 10} | |
| @@ -217,7 +217,7 @@ func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) { | |||||
| 217 | require.NoError(t, err) | 217 | require.NoError(t, err) | ||
| 218 | require.Zero(t, secondOutput.Base, "tab switch must emit the complete target frame first") | 218 | require.Zero(t, secondOutput.Base, "tab switch must emit the complete target frame first") | ||
| 219 | terminal.Write(secondOutput.Data) | 219 | terminal.Write(secondOutput.Data) | ||
| 220 | - | require.NotContains(t, strings.Join(frameRows(terminal.Frame), "\n"), "source", "the first target-tab frame must not retain source pane cells") | 220 | + | require.NotContains(t, strings.Join(frameRows(terminal), "\n"), "source", "the first target-tab frame must not retain source pane cells") |
| 221 | } | 221 | } | ||
| 222 | 222 | ||||
| 223 | func TestClosePanePrunesAttachedCaptureFrameAndKeepsSurvivor(t *testing.T) { | 223 | func TestClosePanePrunesAttachedCaptureFrameAndKeepsSurvivor(t *testing.T) { | ||
| @@ -314,8 +314,8 @@ func TestAltFToggleRetainedFloatingPaneRepaintsImmediately(t *testing.T) { | |||||
| 314 | // Route the real Alt+F binding. Showing a retained pane must paint once. | 314 | // Route the real Alt+F binding. Showing a retained pane must paint once. | ||
| 315 | d.handleInput(sess, ac, []byte("\x1bf")) | 315 | d.handleInput(sess, ac, []byte("\x1bf")) | ||
| 316 | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | 316 | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | ||
| 317 | - | require.Contains(t, strings.Join(frameRows(client.Frame), "\n"), "popup-content") | 317 | + | require.Contains(t, strings.Join(frameRows(client), "\n"), "popup-content") |
| 318 | - | require.Contains(t, strings.Join(frameRows(client.Frame), "\n"), "┌") | 318 | + | require.Contains(t, strings.Join(frameRows(client), "\n"), "┌") |
| 319 | select { | 319 | select { | ||
| 320 | case extra := <-sends: | 320 | case extra := <-sends: | ||
| 321 | t.Fatalf("show emitted duplicate output: %#v", extra) | 321 | t.Fatalf("show emitted duplicate output: %#v", extra) | ||
| @@ -326,7 +326,7 @@ func TestAltFToggleRetainedFloatingPaneRepaintsImmediately(t *testing.T) { | |||||
| 326 | // unrelated output, while retaining the existing PTY and context. | 326 | // unrelated output, while retaining the existing PTY and context. | ||
| 327 | d.handleInput(sess, ac, []byte("\x1bf")) | 327 | d.handleInput(sess, ac, []byte("\x1bf")) | ||
| 328 | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | 328 | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | ||
| 329 | - | require.NotContains(t, strings.Join(frameRows(client.Frame), "\n"), "popup-content") | 329 | + | require.NotContains(t, strings.Join(frameRows(client), "\n"), "popup-content") |
| 330 | tb := testAttachmentTab(sess) | 330 | tb := testAttachmentTab(sess) | ||
| 331 | tb.mu.Lock() | 331 | tb.mu.Lock() | ||
| 332 | require.Equal(t, floatingHidden, tb.floating.state) | 332 | require.Equal(t, floatingHidden, tb.floating.state) | ||
| @@ -344,7 +344,7 @@ func TestAltFToggleRetainedFloatingPaneRepaintsImmediately(t *testing.T) { | |||||
| 344 | // resize/start frame. | 344 | // resize/start frame. | ||
| 345 | d.handleInput(sess, ac, []byte("\x1bf")) | 345 | d.handleInput(sess, ac, []byte("\x1bf")) | ||
| 346 | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | 346 | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | ||
| 347 | - | require.Contains(t, strings.Join(frameRows(client.Frame), "\n"), "popup-content") | 347 | + | require.Contains(t, strings.Join(frameRows(client), "\n"), "popup-content") |
| 348 | tb.mu.Lock() | 348 | tb.mu.Lock() | ||
| 349 | require.Equal(t, floatingVisible, tb.floating.state) | 349 | require.Equal(t, floatingVisible, tb.floating.state) | ||
| 350 | require.Same(t, floating, tb.floating.pane) | 350 | require.Same(t, floating, tb.floating.pane) | ||
| @@ -1063,7 +1063,7 @@ func TestCopyModeMousePressReleaseWithoutMotionEmitsNoOSC52(t *testing.T) { | |||||
| 1063 | p, release := newBlockingPTY(t) | 1063 | p, release := newBlockingPTY(t) | ||
| 1064 | defer release() | 1064 | defer release() | ||
| 1065 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 1065 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 1066 | - | copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha")) | 1066 | + | writeTestRow(sess.tabs[0].focusedPane().screen, 0, "alpha") |
| 1067 | d.enterCopyMode(sess, ac) | 1067 | d.enterCopyMode(sess, ac) | ||
| 1068 | mustOutputData(t, sends) | 1068 | mustOutputData(t, sends) | ||
| 1069 | 1069 | ||||
| @@ -1079,7 +1079,7 @@ func TestCopyModeMousePressReleaseWithoutMotionEmitsNoOSC52(t *testing.T) { | |||||
| 1079 | func TestCopyModeMouseHorizontalReverseDragUsesExactOSC52(t *testing.T) { | 1079 | func TestCopyModeMouseHorizontalReverseDragUsesExactOSC52(t *testing.T) { | ||
| 1080 | p, _ := newBlockingPTY(t) | 1080 | p, _ := newBlockingPTY(t) | ||
| 1081 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 1081 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 1082 | - | copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha")) | 1082 | + | writeTestRow(sess.tabs[0].focusedPane().screen, 0, "alpha") |
| 1083 | d.enterCopyMode(sess, ac) | 1083 | d.enterCopyMode(sess, ac) | ||
| 1084 | mustOutputData(t, sends) | 1084 | mustOutputData(t, sends) | ||
| 1085 | 1085 | ||||
| @@ -1103,9 +1103,9 @@ func TestCopyModeMouseHorizontalReverseDragUsesExactOSC52(t *testing.T) { | |||||
| 1103 | func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) { | 1103 | func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) { | ||
| 1104 | p, _ := newBlockingPTY(t) | 1104 | p, _ := newBlockingPTY(t) | ||
| 1105 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 1105 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 1106 | - | copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha")) | 1106 | + | writeTestRow(sess.tabs[0].focusedPane().screen, 0, "alpha") |
| 1107 | - | copy(sess.tabs[0].focusedPane().screen.Frame.Row(1), testRow("bravo")) | 1107 | + | writeTestRow(sess.tabs[0].focusedPane().screen, 1, "bravo") |
| 1108 | - | copy(sess.tabs[0].focusedPane().screen.Frame.Row(2), testRow("charlie")) | 1108 | + | writeTestRow(sess.tabs[0].focusedPane().screen, 2, "charlie") |
| 1109 | 1109 | ||||
| 1110 | d.enterCopyMode(sess, ac) | 1110 | d.enterCopyMode(sess, ac) | ||
| 1111 | mustOutputData(t, sends) | 1111 | mustOutputData(t, sends) | ||
| @@ -1146,7 +1146,7 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) { | |||||
| 1146 | func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) { | 1146 | func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) { | ||
| 1147 | p, _ := newBlockingPTY(t) | 1147 | p, _ := newBlockingPTY(t) | ||
| 1148 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | 1148 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | ||
| 1149 | - | require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24") | 1149 | + | require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Rows(), "fixture assumption: status row is wire row 24") |
| 1150 | 1150 | ||||
| 1151 | // Press on a content row establishes a (soon to be stale) anchor. | 1151 | // Press on a content row establishes a (soon to be stale) anchor. | ||
| 1152 | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | 1152 | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | ||
| @@ -1197,10 +1197,10 @@ func TestCopyModeRejectedReleaseClearsPointerAndClickBeforeMotion(t *testing.T) | |||||
| 1197 | func TestCopyModeStatusRowPressClearsDragState(t *testing.T) { | 1197 | func TestCopyModeStatusRowPressClearsDragState(t *testing.T) { | ||
| 1198 | p, _ := newBlockingPTY(t) | 1198 | p, _ := newBlockingPTY(t) | ||
| 1199 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 1199 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 1200 | - | copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha")) | 1200 | + | writeTestRow(sess.tabs[0].focusedPane().screen, 0, "alpha") |
| 1201 | - | copy(sess.tabs[0].focusedPane().screen.Frame.Row(1), testRow("bravo")) | 1201 | + | writeTestRow(sess.tabs[0].focusedPane().screen, 1, "bravo") |
| 1202 | - | copy(sess.tabs[0].focusedPane().screen.Frame.Row(2), testRow("charlie")) | 1202 | + | writeTestRow(sess.tabs[0].focusedPane().screen, 2, "charlie") |
| 1203 | - | require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24") | 1203 | + | require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Rows(), "fixture assumption: status row is wire row 24") |
| 1204 | 1204 | ||||
| 1205 | d.enterCopyMode(sess, ac) | 1205 | d.enterCopyMode(sess, ac) | ||
| 1206 | mustOutputData(t, sends) | 1206 | mustOutputData(t, sends) | ||
| @@ -1244,7 +1244,7 @@ func TestMouseNormalScreenDragUsesPressOwnedDocumentAfterOutputEviction(t *testi | |||||
| 1244 | p, _ := newBlockingPTY(t) | 1244 | p, _ := newBlockingPTY(t) | ||
| 1245 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | 1245 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | ||
| 1246 | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 50}) | 1246 | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 50}) | ||
| 1247 | - | require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24") | 1247 | + | require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Rows(), "fixture assumption: status row is wire row 24") |
| 1248 | 1248 | ||||
| 1249 | // Press anchors document row 0 before output eviction. | 1249 | // Press anchors document row 0 before output eviction. | ||
| 1250 | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | 1250 | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | ||
| @@ -1502,7 +1502,7 @@ func TestCopyModeDragOutsideSplitPaneClampsToPaneContent(t *testing.T) { | |||||
| 1502 | d.procComm = nil | 1502 | d.procComm = nil | ||
| 1503 | tb := testAttachmentTab(sess) | 1503 | tb := testAttachmentTab(sess) | ||
| 1504 | for i := range 10 { | 1504 | for i := range 10 { | ||
| 1505 | - | copy(tb.focusedPane().screen.Frame.Row(i), testRow(string(rune('a'+i)))) | 1505 | + | writeTestRow(tb.focusedPane().screen, i, string(rune('a'+i))) |
| 1506 | } | 1506 | } | ||
| 1507 | tb.mu.Lock() | 1507 | tb.mu.Lock() | ||
| 1508 | tb.size = domain.Size{Cols: 41, Rows: 10} | 1508 | tb.size = domain.Size{Cols: 41, Rows: 10} | ||
M internal/usecase/daemon/nil_pty_test.go +2 -2
| @@ -30,8 +30,8 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) { | |||
| 30 | 30 | require.NotPanics(t, func() { | |
| 31 | 31 | sess.geometry.applyTabLayout(d, sess, first) | |
| 32 | 32 | }) | |
| 33 | - | require.Equal(t, 20, first.focusedPane().screen.Frame.Width) | |
| 34 | - | require.Equal(t, 5, first.focusedPane().screen.Frame.Height) | |
| 33 | + | require.Equal(t, 20, first.focusedPane().screen.Columns()) | |
| 34 | + | require.Equal(t, 5, first.focusedPane().screen.Rows()) | |
| 35 | 35 | ||
| 36 | 36 | first.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"} | |
| 37 | 37 | first.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | |
| @@ -30,8 +30,8 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) { | |||||
| 30 | require.NotPanics(t, func() { | 30 | require.NotPanics(t, func() { | ||
| 31 | sess.geometry.applyTabLayout(d, sess, first) | 31 | sess.geometry.applyTabLayout(d, sess, first) | ||
| 32 | }) | 32 | }) | ||
| 33 | - | require.Equal(t, 20, first.focusedPane().screen.Frame.Width) | 33 | + | require.Equal(t, 20, first.focusedPane().screen.Columns()) |
| 34 | - | require.Equal(t, 5, first.focusedPane().screen.Frame.Height) | 34 | + | require.Equal(t, 5, first.focusedPane().screen.Rows()) |
| 35 | 35 | ||||
| 36 | first.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"} | 36 | first.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"} | ||
| 37 | first.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | 37 | first.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | ||
M internal/usecase/daemon/notice_render_test.go +2 -2
| @@ -87,7 +87,7 @@ func TestPaintComposesNoticeToastTopRightAndExpiresOnTTL(t *testing.T) { | |||
| 87 | 87 | awaitToastCount(t, ac, 1) | |
| 88 | 88 | replayOutputFrames(t, screen, drainAllFrames(sends)) | |
| 89 | 89 | ||
| 90 | - | rows := frameRows(screen.Frame) | |
| 90 | + | rows := frameRows(captureTestFrame(screen)) | |
| 91 | 91 | if !containsTopRight(rows, "couldn't open pane", ac.size.Cols/2) { | |
| 92 | 92 | t.Fatalf("composed frame missing toast message top-right:\n%s", strings.Join(rows, "\n")) | |
| 93 | 93 | } | |
| @@ -101,7 +101,7 @@ func TestPaintComposesNoticeToastTopRightAndExpiresOnTTL(t *testing.T) { | |||
| 101 | 101 | t.Fatal("expiry did not invalidate the render") | |
| 102 | 102 | } | |
| 103 | 103 | ||
| 104 | - | rows = frameRows(screen.Frame) | |
| 104 | + | rows = frameRows(captureTestFrame(screen)) | |
| 105 | 105 | if containsTopRight(rows, "couldn't open pane", ac.size.Cols/2) { | |
| 106 | 106 | t.Fatalf("toast message still present after TTL expired:\n%s", strings.Join(rows, "\n")) | |
| 107 | 107 | } | |
| @@ -87,7 +87,7 @@ func TestPaintComposesNoticeToastTopRightAndExpiresOnTTL(t *testing.T) { | |||||
| 87 | awaitToastCount(t, ac, 1) | 87 | awaitToastCount(t, ac, 1) | ||
| 88 | replayOutputFrames(t, screen, drainAllFrames(sends)) | 88 | replayOutputFrames(t, screen, drainAllFrames(sends)) | ||
| 89 | 89 | ||||
| 90 | - | rows := frameRows(screen.Frame) | 90 | + | rows := frameRows(captureTestFrame(screen)) |
| 91 | if !containsTopRight(rows, "couldn't open pane", ac.size.Cols/2) { | 91 | if !containsTopRight(rows, "couldn't open pane", ac.size.Cols/2) { | ||
| 92 | t.Fatalf("composed frame missing toast message top-right:\n%s", strings.Join(rows, "\n")) | 92 | t.Fatalf("composed frame missing toast message top-right:\n%s", strings.Join(rows, "\n")) | ||
| 93 | } | 93 | } | ||
| @@ -101,7 +101,7 @@ func TestPaintComposesNoticeToastTopRightAndExpiresOnTTL(t *testing.T) { | |||||
| 101 | t.Fatal("expiry did not invalidate the render") | 101 | t.Fatal("expiry did not invalidate the render") | ||
| 102 | } | 102 | } | ||
| 103 | 103 | ||||
| 104 | - | rows = frameRows(screen.Frame) | 104 | + | rows = frameRows(captureTestFrame(screen)) |
| 105 | if containsTopRight(rows, "couldn't open pane", ac.size.Cols/2) { | 105 | if containsTopRight(rows, "couldn't open pane", ac.size.Cols/2) { | ||
| 106 | t.Fatalf("toast message still present after TTL expired:\n%s", strings.Join(rows, "\n")) | 106 | t.Fatalf("toast message still present after TTL expired:\n%s", strings.Join(rows, "\n")) | ||
| 107 | } | 107 | } | ||
M internal/usecase/daemon/notify_test.go +1 -1
| @@ -893,7 +893,7 @@ func TestToastExpiresOnFakeClock(t *testing.T) { | |||
| 893 | 893 | ac.sendMu.Lock() | |
| 894 | 894 | base := ac.pipelineCache.frame.Clone() | |
| 895 | 895 | ac.sendMu.Unlock() | |
| 896 | - | require.Equal(t, frameRows(base), frameRows(terminal.Frame), "fake-clock expiry must restore every old toast cell") | |
| 896 | + | require.Equal(t, frameRows(base), frameRows(terminal), "fake-clock expiry must restore every old toast cell") | |
| 897 | 897 | } | |
| 898 | 898 | ||
| 899 | 899 | // TestToastExpiryOnlyRemovesItsOwnEntry proves a fired timer cannot dismiss a | |
| @@ -893,7 +893,7 @@ func TestToastExpiresOnFakeClock(t *testing.T) { | |||||
| 893 | ac.sendMu.Lock() | 893 | ac.sendMu.Lock() | ||
| 894 | base := ac.pipelineCache.frame.Clone() | 894 | base := ac.pipelineCache.frame.Clone() | ||
| 895 | ac.sendMu.Unlock() | 895 | ac.sendMu.Unlock() | ||
| 896 | - | require.Equal(t, frameRows(base), frameRows(terminal.Frame), "fake-clock expiry must restore every old toast cell") | 896 | + | require.Equal(t, frameRows(base), frameRows(terminal), "fake-clock expiry must restore every old toast cell") |
| 897 | } | 897 | } | ||
| 898 | 898 | ||||
| 899 | // TestToastExpiryOnlyRemovesItsOwnEntry proves a fired timer cannot dismiss a | 899 | // TestToastExpiryOnlyRemovesItsOwnEntry proves a fired timer cannot dismiss a | ||
M internal/usecase/daemon/output_state_sync_test.go +12 -12
| @@ -147,12 +147,12 @@ func TestDatagramMultipleUnackedScrollPaintsMatchLatestFrame(t *testing.T) { | |||
| 147 | 147 | require.NoError(t, err) | |
| 148 | 148 | ||
| 149 | 149 | pane := sess.tabs[0].focusedPane() | |
| 150 | - | for y := range pane.screen.Frame.Height { | |
| 150 | + | initial := renderer.NewFrame(pane.screen.Columns(), pane.screen.Rows()) | |
| 151 | + | for y := range initial.Height { | |
| 151 | 152 | cell := renderer.Cell{Rune: rune('A' + y), Style: renderer.DefaultStyle()} | |
| 152 | - | for x := range pane.screen.Frame.Width { | |
| 153 | - | pane.screen.Frame.Set(x, y, cell) | |
| 154 | - | } | |
| 153 | + | initial.FillRow(y, 0, initial.Width, cell) | |
| 155 | 154 | } | |
| 155 | + | writeTestFrame(t, pane.screen, initial) | |
| 156 | 156 | pane.screen.ClearDamage() | |
| 157 | 157 | client := vt.NewScreen(80, 25) | |
| 158 | 158 | d.paint(sess, ac, true, nil) | |
| @@ -164,22 +164,22 @@ func TestDatagramMultipleUnackedScrollPaintsMatchLatestFrame(t *testing.T) { | |||
| 164 | 164 | // paints are generated from the ACKed frame and apply the scroll twice to | |
| 165 | 165 | // the client. The ordered stream instead renders the second paint from the | |
| 166 | 166 | // preceding emitted frame and overwrites the incompatible repeated damage. | |
| 167 | - | desired := pane.screen.Frame.Clone() | |
| 167 | + | desired := captureTestFrame(pane.screen) | |
| 168 | 168 | desired.ScrollUp(0, desired.Height-1, 1) | |
| 169 | 169 | for x := range desired.Width { | |
| 170 | 170 | desired.Set(x, desired.Height-1, renderer.Cell{Rune: 'z', Style: renderer.DefaultStyle()}) | |
| 171 | 171 | } | |
| 172 | 172 | for range 2 { | |
| 173 | - | pane.screen.Frame = desired.Clone() | |
| 174 | - | pane.screen.Row = pane.screen.Frame.Height - 1 | |
| 173 | + | writeTestFrame(t, pane.screen, desired) | |
| 174 | + | pane.screen.Row = pane.screen.Rows() - 1 | |
| 175 | 175 | pane.screen.Col = 0 | |
| 176 | 176 | pane.screen.Write([]byte("\nq")) | |
| 177 | - | pane.screen.Frame = desired.Clone() | |
| 177 | + | writeTestFrame(t, pane.screen, desired) | |
| 178 | 178 | d.paint(sess, ac, false, nil) | |
| 179 | 179 | mustApplyOutput(t, client, awaitFrame(t, tr.sends, wire.MsgOutput)) | |
| 180 | 180 | } | |
| 181 | 181 | ||
| 182 | - | require.Equal(t, 'B', client.Frame.At(0, 1).Rune, | |
| 182 | + | require.Equal(t, 'B', client.Cell(0, 1).Rune, | |
| 183 | 183 | "client-visible content must equal the latest daemon-composed frame after pipelined scroll paints") | |
| 184 | 184 | } | |
| 185 | 185 | ||
| @@ -323,11 +323,11 @@ func TestResizeGrowthFirstFrameIncludesConcurrentPTYRedraw(t *testing.T) { | |||
| 323 | 323 | timer.ch <- time.Time{} | |
| 324 | 324 | awaitTestCompletion(t, done, "resize callback did not complete") | |
| 325 | 325 | require.True(t, p.deliveredWhileApplying(), "redraw must arrive while PTY.Resize owns the apply gate") | |
| 326 | - | require.Equal(t, 'B', pane.screen.Frame.At(100, 0).Rune, "replay must parse the redraw before commit") | |
| 326 | + | require.Equal(t, 'B', pane.screen.Cell(100, 0).Rune, "replay must parse the redraw before commit") | |
| 327 | 327 | ||
| 328 | 328 | client.Resize(120, 24) | |
| 329 | 329 | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | |
| 330 | - | require.Equal(t, 'B', client.Frame.At(100, 1).Rune, | |
| 330 | + | require.Equal(t, 'B', client.Cell(100, 1).Rune, | |
| 331 | 331 | "first grown frame exposed stale pre-SIGWINCH pane content") | |
| 332 | 332 | select { | |
| 333 | 333 | case extra := <-sends: | |
| @@ -398,6 +398,6 @@ func TestResizeBurstFlushesOnlyLatestGeometry(t *testing.T) { | |||
| 398 | 398 | awaitTestCompletion(t, latestDone, "latest resize callback did not complete") | |
| 399 | 399 | awaitFrame(t, sends, wire.MsgOutput) | |
| 400 | 400 | require.Equal(t, domain.Size{Cols: 120, Rows: 24}, ac.size) | |
| 401 | - | require.Equal(t, 120, testAttachmentTab(sess).focusedPane().screen.Frame.Width) | |
| 401 | + | require.Equal(t, 120, testAttachmentTab(sess).focusedPane().screen.Columns()) | |
| 402 | 402 | requireNoOutputFrame(t, sends) | |
| 403 | 403 | } | |
| @@ -147,12 +147,12 @@ func TestDatagramMultipleUnackedScrollPaintsMatchLatestFrame(t *testing.T) { | |||||
| 147 | require.NoError(t, err) | 147 | require.NoError(t, err) | ||
| 148 | 148 | ||||
| 149 | pane := sess.tabs[0].focusedPane() | 149 | pane := sess.tabs[0].focusedPane() | ||
| 150 | - | for y := range pane.screen.Frame.Height { | 150 | + | initial := renderer.NewFrame(pane.screen.Columns(), pane.screen.Rows()) |
| 151 | + | for y := range initial.Height { | |||
| 151 | cell := renderer.Cell{Rune: rune('A' + y), Style: renderer.DefaultStyle()} | 152 | cell := renderer.Cell{Rune: rune('A' + y), Style: renderer.DefaultStyle()} | ||
| 152 | - | for x := range pane.screen.Frame.Width { | 153 | + | initial.FillRow(y, 0, initial.Width, cell) |
| 153 | - | pane.screen.Frame.Set(x, y, cell) | |||
| 154 | - | } | |||
| 155 | } | 154 | } | ||
| 155 | + | writeTestFrame(t, pane.screen, initial) | |||
| 156 | pane.screen.ClearDamage() | 156 | pane.screen.ClearDamage() | ||
| 157 | client := vt.NewScreen(80, 25) | 157 | client := vt.NewScreen(80, 25) | ||
| 158 | d.paint(sess, ac, true, nil) | 158 | d.paint(sess, ac, true, nil) | ||
| @@ -164,22 +164,22 @@ func TestDatagramMultipleUnackedScrollPaintsMatchLatestFrame(t *testing.T) { | |||||
| 164 | // paints are generated from the ACKed frame and apply the scroll twice to | 164 | // paints are generated from the ACKed frame and apply the scroll twice to | ||
| 165 | // the client. The ordered stream instead renders the second paint from the | 165 | // the client. The ordered stream instead renders the second paint from the | ||
| 166 | // preceding emitted frame and overwrites the incompatible repeated damage. | 166 | // preceding emitted frame and overwrites the incompatible repeated damage. | ||
| 167 | - | desired := pane.screen.Frame.Clone() | 167 | + | desired := captureTestFrame(pane.screen) |
| 168 | desired.ScrollUp(0, desired.Height-1, 1) | 168 | desired.ScrollUp(0, desired.Height-1, 1) | ||
| 169 | for x := range desired.Width { | 169 | for x := range desired.Width { | ||
| 170 | desired.Set(x, desired.Height-1, renderer.Cell{Rune: 'z', Style: renderer.DefaultStyle()}) | 170 | desired.Set(x, desired.Height-1, renderer.Cell{Rune: 'z', Style: renderer.DefaultStyle()}) | ||
| 171 | } | 171 | } | ||
| 172 | for range 2 { | 172 | for range 2 { | ||
| 173 | - | pane.screen.Frame = desired.Clone() | 173 | + | writeTestFrame(t, pane.screen, desired) |
| 174 | - | pane.screen.Row = pane.screen.Frame.Height - 1 | 174 | + | pane.screen.Row = pane.screen.Rows() - 1 |
| 175 | pane.screen.Col = 0 | 175 | pane.screen.Col = 0 | ||
| 176 | pane.screen.Write([]byte("\nq")) | 176 | pane.screen.Write([]byte("\nq")) | ||
| 177 | - | pane.screen.Frame = desired.Clone() | 177 | + | writeTestFrame(t, pane.screen, desired) |
| 178 | d.paint(sess, ac, false, nil) | 178 | d.paint(sess, ac, false, nil) | ||
| 179 | mustApplyOutput(t, client, awaitFrame(t, tr.sends, wire.MsgOutput)) | 179 | mustApplyOutput(t, client, awaitFrame(t, tr.sends, wire.MsgOutput)) | ||
| 180 | } | 180 | } | ||
| 181 | 181 | ||||
| 182 | - | require.Equal(t, 'B', client.Frame.At(0, 1).Rune, | 182 | + | require.Equal(t, 'B', client.Cell(0, 1).Rune, |
| 183 | "client-visible content must equal the latest daemon-composed frame after pipelined scroll paints") | 183 | "client-visible content must equal the latest daemon-composed frame after pipelined scroll paints") | ||
| 184 | } | 184 | } | ||
| 185 | 185 | ||||
| @@ -323,11 +323,11 @@ func TestResizeGrowthFirstFrameIncludesConcurrentPTYRedraw(t *testing.T) { | |||||
| 323 | timer.ch <- time.Time{} | 323 | timer.ch <- time.Time{} | ||
| 324 | awaitTestCompletion(t, done, "resize callback did not complete") | 324 | awaitTestCompletion(t, done, "resize callback did not complete") | ||
| 325 | require.True(t, p.deliveredWhileApplying(), "redraw must arrive while PTY.Resize owns the apply gate") | 325 | require.True(t, p.deliveredWhileApplying(), "redraw must arrive while PTY.Resize owns the apply gate") | ||
| 326 | - | require.Equal(t, 'B', pane.screen.Frame.At(100, 0).Rune, "replay must parse the redraw before commit") | 326 | + | require.Equal(t, 'B', pane.screen.Cell(100, 0).Rune, "replay must parse the redraw before commit") |
| 327 | 327 | ||||
| 328 | client.Resize(120, 24) | 328 | client.Resize(120, 24) | ||
| 329 | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | 329 | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | ||
| 330 | - | require.Equal(t, 'B', client.Frame.At(100, 1).Rune, | 330 | + | require.Equal(t, 'B', client.Cell(100, 1).Rune, |
| 331 | "first grown frame exposed stale pre-SIGWINCH pane content") | 331 | "first grown frame exposed stale pre-SIGWINCH pane content") | ||
| 332 | select { | 332 | select { | ||
| 333 | case extra := <-sends: | 333 | case extra := <-sends: | ||
| @@ -398,6 +398,6 @@ func TestResizeBurstFlushesOnlyLatestGeometry(t *testing.T) { | |||||
| 398 | awaitTestCompletion(t, latestDone, "latest resize callback did not complete") | 398 | awaitTestCompletion(t, latestDone, "latest resize callback did not complete") | ||
| 399 | awaitFrame(t, sends, wire.MsgOutput) | 399 | awaitFrame(t, sends, wire.MsgOutput) | ||
| 400 | require.Equal(t, domain.Size{Cols: 120, Rows: 24}, ac.size) | 400 | require.Equal(t, domain.Size{Cols: 120, Rows: 24}, ac.size) | ||
| 401 | - | require.Equal(t, 120, testAttachmentTab(sess).focusedPane().screen.Frame.Width) | 401 | + | require.Equal(t, 120, testAttachmentTab(sess).focusedPane().screen.Columns()) |
| 402 | requireNoOutputFrame(t, sends) | 402 | requireNoOutputFrame(t, sends) | ||
| 403 | } | 403 | } | ||
M internal/usecase/daemon/overlay_backdrop_test.go +2 -2
| @@ -29,7 +29,7 @@ func TestApplyOverlayBackdropDimsCompleteFrame(t *testing.T) { | |||
| 29 | 29 | dimmed := themeui.NewDimmer(backdropTheme()).Dim(renderer.DefaultStyle()) | |
| 30 | 30 | for y := range frame.Height { | |
| 31 | 31 | for x := range frame.Width { | |
| 32 | - | require.Equal(t, dimmed, frame.At(x, y).Style, "cell (%d,%d)", x, y) | |
| 32 | + | require.Equal(t, dimmed.Canonical(), frame.At(x, y).Style.Canonical(), "cell (%d,%d)", x, y) | |
| 33 | 33 | } | |
| 34 | 34 | } | |
| 35 | 35 | require.Equal(t, '界', frame.At(1, 1).Rune) | |
| @@ -44,7 +44,7 @@ func TestDimFrameRectClipsToFrame(t *testing.T) { | |||
| 44 | 44 | dimmed := themeui.NewDimmer(backdropTheme()).Dim(renderer.DefaultStyle()) | |
| 45 | 45 | ||
| 46 | 46 | for _, point := range [][2]int{{2, 2}, {3, 2}, {2, 3}, {3, 3}} { | |
| 47 | - | require.Equal(t, dimmed, frame.At(point[0], point[1]).Style) | |
| 47 | + | require.Equal(t, dimmed.Canonical(), frame.At(point[0], point[1]).Style.Canonical()) | |
| 48 | 48 | } | |
| 49 | 49 | for _, point := range [][2]int{{0, 0}, {1, 1}, {3, 1}, {1, 3}} { | |
| 50 | 50 | require.Equal(t, before.At(point[0], point[1]).Style, frame.At(point[0], point[1]).Style) | |
| @@ -29,7 +29,7 @@ func TestApplyOverlayBackdropDimsCompleteFrame(t *testing.T) { | |||||
| 29 | dimmed := themeui.NewDimmer(backdropTheme()).Dim(renderer.DefaultStyle()) | 29 | dimmed := themeui.NewDimmer(backdropTheme()).Dim(renderer.DefaultStyle()) | ||
| 30 | for y := range frame.Height { | 30 | for y := range frame.Height { | ||
| 31 | for x := range frame.Width { | 31 | for x := range frame.Width { | ||
| 32 | - | require.Equal(t, dimmed, frame.At(x, y).Style, "cell (%d,%d)", x, y) | 32 | + | require.Equal(t, dimmed.Canonical(), frame.At(x, y).Style.Canonical(), "cell (%d,%d)", x, y) |
| 33 | } | 33 | } | ||
| 34 | } | 34 | } | ||
| 35 | require.Equal(t, '界', frame.At(1, 1).Rune) | 35 | require.Equal(t, '界', frame.At(1, 1).Rune) | ||
| @@ -44,7 +44,7 @@ func TestDimFrameRectClipsToFrame(t *testing.T) { | |||||
| 44 | dimmed := themeui.NewDimmer(backdropTheme()).Dim(renderer.DefaultStyle()) | 44 | dimmed := themeui.NewDimmer(backdropTheme()).Dim(renderer.DefaultStyle()) | ||
| 45 | 45 | ||||
| 46 | for _, point := range [][2]int{{2, 2}, {3, 2}, {2, 3}, {3, 3}} { | 46 | for _, point := range [][2]int{{2, 2}, {3, 2}, {2, 3}, {3, 3}} { | ||
| 47 | - | require.Equal(t, dimmed, frame.At(point[0], point[1]).Style) | 47 | + | require.Equal(t, dimmed.Canonical(), frame.At(point[0], point[1]).Style.Canonical()) |
| 48 | } | 48 | } | ||
| 49 | for _, point := range [][2]int{{0, 0}, {1, 1}, {3, 1}, {1, 3}} { | 49 | for _, point := range [][2]int{{0, 0}, {1, 1}, {3, 1}, {1, 3}} { | ||
| 50 | require.Equal(t, before.At(point[0], point[1]).Style, frame.At(point[0], point[1]).Style) | 50 | require.Equal(t, before.At(point[0], point[1]).Style, frame.At(point[0], point[1]).Style) | ||
M internal/usecase/daemon/palette_test.go +1 -1
| @@ -173,7 +173,7 @@ func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t | |||
| 173 | 173 | captureOverlayLayers(&state, snap, domain.PaletteConfig{}) | |
| 174 | 174 | ||
| 175 | 175 | inactive := state.overlays.palette.inner.At(4, 1).Style | |
| 176 | - | require.Equal(t, state.styles.PickerDescription.Foreground, inactive.Foreground) | |
| 176 | + | require.Equal(t, state.styles.PickerDescription.Canonical().Foreground, inactive.Canonical().Foreground) | |
| 177 | 177 | require.Equal(t, state.styles.PickerDescription.HasForegroundRGB, inactive.HasForegroundRGB) | |
| 178 | 178 | require.Equal(t, state.styles.PickerDescription.ForegroundRGB, inactive.ForegroundRGB) | |
| 179 | 179 | require.False(t, inactive.HasBackgroundRGB) | |
| @@ -173,7 +173,7 @@ func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t | |||||
| 173 | captureOverlayLayers(&state, snap, domain.PaletteConfig{}) | 173 | captureOverlayLayers(&state, snap, domain.PaletteConfig{}) | ||
| 174 | 174 | ||||
| 175 | inactive := state.overlays.palette.inner.At(4, 1).Style | 175 | inactive := state.overlays.palette.inner.At(4, 1).Style | ||
| 176 | - | require.Equal(t, state.styles.PickerDescription.Foreground, inactive.Foreground) | 176 | + | require.Equal(t, state.styles.PickerDescription.Canonical().Foreground, inactive.Canonical().Foreground) |
| 177 | require.Equal(t, state.styles.PickerDescription.HasForegroundRGB, inactive.HasForegroundRGB) | 177 | require.Equal(t, state.styles.PickerDescription.HasForegroundRGB, inactive.HasForegroundRGB) | ||
| 178 | require.Equal(t, state.styles.PickerDescription.ForegroundRGB, inactive.ForegroundRGB) | 178 | require.Equal(t, state.styles.PickerDescription.ForegroundRGB, inactive.ForegroundRGB) | ||
| 179 | require.False(t, inactive.HasBackgroundRGB) | 179 | require.False(t, inactive.HasBackgroundRGB) | ||
M internal/usecase/daemon/pane.go +3 -3
| @@ -171,11 +171,11 @@ func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane { | |||
| 171 | 171 | } | |
| 172 | 172 | ||
| 173 | 173 | func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane { | |
| 174 | - | return newPaneWithStableIDAndTitle(id, stableID, pty, sz, defaultShellTitle) | |
| 174 | + | return newPaneWithStableIDAndTitle(id, stableID, pty, sz, defaultShellTitle, historyConfigFor(domain.DefaultScrollbackConfig())) | |
| 175 | 175 | } | |
| 176 | 176 | ||
| 177 | - | func newPaneWithStableIDAndTitle(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size, title string) *pane { | |
| 178 | - | screen := vt.NewScreenWithHistory(sz.Cols, sz.Rows, vt.HistoryConfig{MaxRows: defaultScrollbackRows, MaxCells: defaultScrollbackCells}) | |
| 177 | + | func newPaneWithStableIDAndTitle(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size, title string, historyConfig vt.HistoryConfig) *pane { | |
| 178 | + | screen := vt.NewScreenWithHistory(sz.Cols, sz.Rows, historyConfig) | |
| 179 | 179 | return &pane{ | |
| 180 | 180 | id: id, | |
| 181 | 181 | stableID: stableID, | |
| @@ -171,11 +171,11 @@ func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane { | |||||
| 171 | } | 171 | } | ||
| 172 | 172 | ||||
| 173 | func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane { | 173 | func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane { | ||
| 174 | - | return newPaneWithStableIDAndTitle(id, stableID, pty, sz, defaultShellTitle) | 174 | + | return newPaneWithStableIDAndTitle(id, stableID, pty, sz, defaultShellTitle, historyConfigFor(domain.DefaultScrollbackConfig())) |
| 175 | } | 175 | } | ||
| 176 | 176 | ||||
| 177 | - | func newPaneWithStableIDAndTitle(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size, title string) *pane { | 177 | + | func newPaneWithStableIDAndTitle(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size, title string, historyConfig vt.HistoryConfig) *pane { |
| 178 | - | screen := vt.NewScreenWithHistory(sz.Cols, sz.Rows, vt.HistoryConfig{MaxRows: defaultScrollbackRows, MaxCells: defaultScrollbackCells}) | 178 | + | screen := vt.NewScreenWithHistory(sz.Cols, sz.Rows, historyConfig) |
| 179 | return &pane{ | 179 | return &pane{ | ||
| 180 | id: id, | 180 | id: id, | ||
| 181 | stableID: stableID, | 181 | stableID: stableID, | ||
M internal/usecase/daemon/pane_actions.go +1 -1
| @@ -98,7 +98,7 @@ func (d *Daemon) spawnPaneOpAt( | |||
| 98 | 98 | d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane") | |
| 99 | 99 | return paneFocusChange{}, domain.UserErr(domain.NoticePaneSpawn, "couldn't open pane: shell failed to start", err) | |
| 100 | 100 | } | |
| 101 | - | p := newPaneWithStableIDAndTitle(newID, paneStableID, pty, rectSize(newRect), launch.title) | |
| 101 | + | p := newPaneWithStableIDAndTitle(newID, paneStableID, pty, rectSize(newRect), launch.title, d.currentHistoryConfig()) | |
| 102 | 102 | p.geometry = initialGeometry | |
| 103 | 103 | setScreenGeometry(p.screen, initialGeometry) | |
| 104 | 104 | p.rect = newRect | |
| @@ -98,7 +98,7 @@ func (d *Daemon) spawnPaneOpAt( | |||||
| 98 | d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane") | 98 | d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane") | ||
| 99 | return paneFocusChange{}, domain.UserErr(domain.NoticePaneSpawn, "couldn't open pane: shell failed to start", err) | 99 | return paneFocusChange{}, domain.UserErr(domain.NoticePaneSpawn, "couldn't open pane: shell failed to start", err) | ||
| 100 | } | 100 | } | ||
| 101 | - | p := newPaneWithStableIDAndTitle(newID, paneStableID, pty, rectSize(newRect), launch.title) | 101 | + | p := newPaneWithStableIDAndTitle(newID, paneStableID, pty, rectSize(newRect), launch.title, d.currentHistoryConfig()) |
| 102 | p.geometry = initialGeometry | 102 | p.geometry = initialGeometry | ||
| 103 | setScreenGeometry(p.screen, initialGeometry) | 103 | setScreenGeometry(p.screen, initialGeometry) | ||
| 104 | p.rect = newRect | 104 | p.rect = newRect | ||
M internal/usecase/daemon/pane_actions_test.go +13 -13
| @@ -188,14 +188,14 @@ func TestLayoutApplicationRetriesOnlyAcceptedFailedGeometry(t *testing.T) { | |||
| 188 | 188 | } | |
| 189 | 189 | } | |
| 190 | 190 | require.Equal(t, []domain.Size{{Cols: 100, Rows: 20}}, pty.requested()) | |
| 191 | - | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "failed apply must not publish a VT resize") | |
| 191 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()}, "failed apply must not publish a VT resize") | |
| 192 | 192 | timer.fire() | |
| 193 | 193 | awaitSignal(t, secondResize, "accepted failure was not retried") | |
| 194 | 194 | require.Equal(t, []domain.Size{{Cols: 100, Rows: 20}, {Cols: 100, Rows: 20}}, pty.requested()) | |
| 195 | 195 | require.Eventually(t, func() bool { | |
| 196 | 196 | p.mu.Lock() | |
| 197 | 197 | defer p.mu.Unlock() | |
| 198 | - | return p.screen.Frame.Width == 100 && p.screen.Frame.Height == 20 | |
| 198 | + | return p.screen.Columns() == 100 && p.screen.Rows() == 20 | |
| 199 | 199 | }, time.Second, time.Millisecond, "successful retry did not publish its VT size") | |
| 200 | 200 | } | |
| 201 | 201 | ||
| @@ -245,7 +245,7 @@ func TestDelayedRetryUsesFreshLayoutAfterPaneResize(t *testing.T) { | |||
| 245 | 245 | require.True(t, ok) | |
| 246 | 246 | require.Equal(t, currentFirst, first.rect) | |
| 247 | 247 | require.Equal(t, currentSecond, second.rect) | |
| 248 | - | require.Equal(t, rectSize(currentFirst), domain.Size{Cols: first.screen.Frame.Width, Rows: first.screen.Frame.Height}) | |
| 248 | + | require.Equal(t, rectSize(currentFirst), domain.Size{Cols: first.screen.Columns(), Rows: first.screen.Rows()}) | |
| 249 | 249 | require.Equal(t, []domain.Size{{Cols: 40, Rows: 22}, rectSize(currentFirst)}, failed.requested()) | |
| 250 | 250 | ||
| 251 | 251 | rc.mu.Lock() | |
| @@ -256,7 +256,7 @@ func TestDelayedRetryUsesFreshLayoutAfterPaneResize(t *testing.T) { | |||
| 256 | 256 | ||
| 257 | 257 | require.Equal(t, []domain.Size{{Cols: 40, Rows: 22}, rectSize(currentFirst)}, failed.requested(), "delayed retry must not apply stale PTY geometry") | |
| 258 | 258 | require.Equal(t, currentFirst, first.rect, "delayed retry must not publish a stale rectangle") | |
| 259 | - | require.Equal(t, rectSize(currentFirst), domain.Size{Cols: first.screen.Frame.Width, Rows: first.screen.Frame.Height}, "delayed retry must not publish a stale VT size") | |
| 259 | + | require.Equal(t, rectSize(currentFirst), domain.Size{Cols: first.screen.Columns(), Rows: first.screen.Rows()}, "delayed retry must not publish a stale VT size") | |
| 260 | 260 | } | |
| 261 | 261 | ||
| 262 | 262 | func TestLayoutApplicationDoesNotHoldTabLock(t *testing.T) { | |
| @@ -320,9 +320,9 @@ func TestLayoutApplicationRejectsStalePaneIdentity(t *testing.T) { | |||
| 320 | 320 | awaitSignal(t, done, "stale layout did not retry") | |
| 321 | 321 | ||
| 322 | 322 | require.Equal(t, domain.Rect{Width: 20, Height: 10}, oldPane.rect, "stale pane rectangle was published") | |
| 323 | - | require.Equal(t, 20, oldPane.screen.Frame.Width, "stale pane screen was published") | |
| 323 | + | require.Equal(t, 20, oldPane.screen.Columns(), "stale pane screen was published") | |
| 324 | 324 | require.Equal(t, domain.Rect{Width: 41, Height: 10}, replacement.rect) | |
| 325 | - | require.Equal(t, 41, replacement.screen.Frame.Width) | |
| 325 | + | require.Equal(t, 41, replacement.screen.Columns()) | |
| 326 | 326 | } | |
| 327 | 327 | ||
| 328 | 328 | func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) { | |
| @@ -371,7 +371,7 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) { | |||
| 371 | 371 | tb.mu.Unlock() | |
| 372 | 372 | require.False(t, exists) | |
| 373 | 373 | require.Equal(t, domain.Rect{Width: 80, Height: 23}, p2.rect, "removed pane received a stale rectangle publication") | |
| 374 | - | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p2.screen.Frame.Width, Rows: p2.screen.Frame.Height}, "removed pane received a stale screen publication") | |
| 374 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p2.screen.Columns(), Rows: p2.screen.Rows()}, "removed pane received a stale screen publication") | |
| 375 | 375 | p2.mu.Lock() | |
| 376 | 376 | require.False(t, p2.resizeApplying, "removed pane gate was not cancelled") | |
| 377 | 377 | p2.mu.Unlock() | |
| @@ -440,7 +440,7 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) { | |||
| 440 | 440 | awaitSignal(t, oldDone, "old size apply did not finish") | |
| 441 | 441 | require.True(t, <-resizeDone) | |
| 442 | 442 | require.Equal(t, domain.Rect{Width: 100, Height: 28}, p.rect) | |
| 443 | - | require.Equal(t, domain.Size{Cols: 100, Rows: 28}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}) | |
| 443 | + | require.Equal(t, domain.Size{Cols: 100, Rows: 28}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()}) | |
| 444 | 444 | require.Equal(t, []domain.Size{{Cols: 80, Rows: 23}, {Cols: 100, Rows: 28}}, pty.requested()) | |
| 445 | 445 | }) | |
| 446 | 446 | } | |
| @@ -596,8 +596,8 @@ func TestApplyLayoutResizesPTYsAndScreens(t *testing.T) { | |||
| 596 | 596 | ||
| 597 | 597 | sess.geometry.applyTabLayout(d, sess, tb) | |
| 598 | 598 | ||
| 599 | - | require.Equal(t, 20, tb.panes["pane-1"].screen.Frame.Width) | |
| 600 | - | require.Equal(t, 20, tb.panes["pane-2"].screen.Frame.Width) | |
| 599 | + | require.Equal(t, 20, tb.panes["pane-1"].screen.Columns()) | |
| 600 | + | require.Equal(t, 20, tb.panes["pane-2"].screen.Columns()) | |
| 601 | 601 | require.Equal(t, domain.Rect{X: 21, Width: 20, Height: 10}, tb.panes["pane-2"].rect) | |
| 602 | 602 | } | |
| 603 | 603 | ||
| @@ -617,8 +617,8 @@ func TestResizeReflowsAllPanes(t *testing.T) { | |||
| 617 | 617 | require.Equal(t, domain.Size{Cols: 60, Rows: 18}, tb.size) | |
| 618 | 618 | require.Equal(t, domain.Rect{Width: 30, Height: 18}, tb.panes["pane-1"].rect) | |
| 619 | 619 | require.Equal(t, domain.Rect{X: 31, Width: 29, Height: 18}, tb.panes["pane-2"].rect) | |
| 620 | - | require.Equal(t, 30, tb.panes["pane-1"].screen.Frame.Width) | |
| 621 | - | require.Equal(t, 29, tb.panes["pane-2"].screen.Frame.Width) | |
| 620 | + | require.Equal(t, 30, tb.panes["pane-1"].screen.Columns()) | |
| 621 | + | require.Equal(t, 29, tb.panes["pane-2"].screen.Columns()) | |
| 622 | 622 | } | |
| 623 | 623 | ||
| 624 | 624 | func TestFocusDirAtReturnsDepartingSpanAndMapsNoNeighbor(t *testing.T) { | |
| @@ -945,7 +945,7 @@ func TestCloseOriginalPaneLeavesSurvivorFunctional(t *testing.T) { | |||
| 945 | 945 | reset: true, | |
| 946 | 946 | layout: capturedTabLayout{area: content, focus: tb.focusedPane().id, valid: true}, | |
| 947 | 947 | panes: []capturedPaneRenderState{{ | |
| 948 | - | id: tb.focusedPane().id, frame: tb.focusedPane().screen.Frame.Clone(), placement: layout.Placement{ID: tb.focusedPane().id, Content: content}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}, | |
| 948 | + | id: tb.focusedPane().id, frame: captureTestFrame(tb.focusedPane().screen), placement: layout.Placement{ID: tb.focusedPane().id, Content: content}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}, | |
| 949 | 949 | }}, | |
| 950 | 950 | bars: barState{status: sess.statusSegments(true)}, | |
| 951 | 951 | }, composeCacheInput{}).frame | |
| @@ -188,14 +188,14 @@ func TestLayoutApplicationRetriesOnlyAcceptedFailedGeometry(t *testing.T) { | |||||
| 188 | } | 188 | } | ||
| 189 | } | 189 | } | ||
| 190 | require.Equal(t, []domain.Size{{Cols: 100, Rows: 20}}, pty.requested()) | 190 | require.Equal(t, []domain.Size{{Cols: 100, Rows: 20}}, pty.requested()) | ||
| 191 | - | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "failed apply must not publish a VT resize") | 191 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()}, "failed apply must not publish a VT resize") |
| 192 | timer.fire() | 192 | timer.fire() | ||
| 193 | awaitSignal(t, secondResize, "accepted failure was not retried") | 193 | awaitSignal(t, secondResize, "accepted failure was not retried") | ||
| 194 | require.Equal(t, []domain.Size{{Cols: 100, Rows: 20}, {Cols: 100, Rows: 20}}, pty.requested()) | 194 | require.Equal(t, []domain.Size{{Cols: 100, Rows: 20}, {Cols: 100, Rows: 20}}, pty.requested()) | ||
| 195 | require.Eventually(t, func() bool { | 195 | require.Eventually(t, func() bool { | ||
| 196 | p.mu.Lock() | 196 | p.mu.Lock() | ||
| 197 | defer p.mu.Unlock() | 197 | defer p.mu.Unlock() | ||
| 198 | - | return p.screen.Frame.Width == 100 && p.screen.Frame.Height == 20 | 198 | + | return p.screen.Columns() == 100 && p.screen.Rows() == 20 |
| 199 | }, time.Second, time.Millisecond, "successful retry did not publish its VT size") | 199 | }, time.Second, time.Millisecond, "successful retry did not publish its VT size") | ||
| 200 | } | 200 | } | ||
| 201 | 201 | ||||
| @@ -245,7 +245,7 @@ func TestDelayedRetryUsesFreshLayoutAfterPaneResize(t *testing.T) { | |||||
| 245 | require.True(t, ok) | 245 | require.True(t, ok) | ||
| 246 | require.Equal(t, currentFirst, first.rect) | 246 | require.Equal(t, currentFirst, first.rect) | ||
| 247 | require.Equal(t, currentSecond, second.rect) | 247 | require.Equal(t, currentSecond, second.rect) | ||
| 248 | - | require.Equal(t, rectSize(currentFirst), domain.Size{Cols: first.screen.Frame.Width, Rows: first.screen.Frame.Height}) | 248 | + | require.Equal(t, rectSize(currentFirst), domain.Size{Cols: first.screen.Columns(), Rows: first.screen.Rows()}) |
| 249 | require.Equal(t, []domain.Size{{Cols: 40, Rows: 22}, rectSize(currentFirst)}, failed.requested()) | 249 | require.Equal(t, []domain.Size{{Cols: 40, Rows: 22}, rectSize(currentFirst)}, failed.requested()) | ||
| 250 | 250 | ||||
| 251 | rc.mu.Lock() | 251 | rc.mu.Lock() | ||
| @@ -256,7 +256,7 @@ func TestDelayedRetryUsesFreshLayoutAfterPaneResize(t *testing.T) { | |||||
| 256 | 256 | ||||
| 257 | require.Equal(t, []domain.Size{{Cols: 40, Rows: 22}, rectSize(currentFirst)}, failed.requested(), "delayed retry must not apply stale PTY geometry") | 257 | require.Equal(t, []domain.Size{{Cols: 40, Rows: 22}, rectSize(currentFirst)}, failed.requested(), "delayed retry must not apply stale PTY geometry") | ||
| 258 | require.Equal(t, currentFirst, first.rect, "delayed retry must not publish a stale rectangle") | 258 | require.Equal(t, currentFirst, first.rect, "delayed retry must not publish a stale rectangle") | ||
| 259 | - | require.Equal(t, rectSize(currentFirst), domain.Size{Cols: first.screen.Frame.Width, Rows: first.screen.Frame.Height}, "delayed retry must not publish a stale VT size") | 259 | + | require.Equal(t, rectSize(currentFirst), domain.Size{Cols: first.screen.Columns(), Rows: first.screen.Rows()}, "delayed retry must not publish a stale VT size") |
| 260 | } | 260 | } | ||
| 261 | 261 | ||||
| 262 | func TestLayoutApplicationDoesNotHoldTabLock(t *testing.T) { | 262 | func TestLayoutApplicationDoesNotHoldTabLock(t *testing.T) { | ||
| @@ -320,9 +320,9 @@ func TestLayoutApplicationRejectsStalePaneIdentity(t *testing.T) { | |||||
| 320 | awaitSignal(t, done, "stale layout did not retry") | 320 | awaitSignal(t, done, "stale layout did not retry") | ||
| 321 | 321 | ||||
| 322 | require.Equal(t, domain.Rect{Width: 20, Height: 10}, oldPane.rect, "stale pane rectangle was published") | 322 | require.Equal(t, domain.Rect{Width: 20, Height: 10}, oldPane.rect, "stale pane rectangle was published") | ||
| 323 | - | require.Equal(t, 20, oldPane.screen.Frame.Width, "stale pane screen was published") | 323 | + | require.Equal(t, 20, oldPane.screen.Columns(), "stale pane screen was published") |
| 324 | require.Equal(t, domain.Rect{Width: 41, Height: 10}, replacement.rect) | 324 | require.Equal(t, domain.Rect{Width: 41, Height: 10}, replacement.rect) | ||
| 325 | - | require.Equal(t, 41, replacement.screen.Frame.Width) | 325 | + | require.Equal(t, 41, replacement.screen.Columns()) |
| 326 | } | 326 | } | ||
| 327 | 327 | ||||
| 328 | func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) { | 328 | func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) { | ||
| @@ -371,7 +371,7 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) { | |||||
| 371 | tb.mu.Unlock() | 371 | tb.mu.Unlock() | ||
| 372 | require.False(t, exists) | 372 | require.False(t, exists) | ||
| 373 | require.Equal(t, domain.Rect{Width: 80, Height: 23}, p2.rect, "removed pane received a stale rectangle publication") | 373 | require.Equal(t, domain.Rect{Width: 80, Height: 23}, p2.rect, "removed pane received a stale rectangle publication") | ||
| 374 | - | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p2.screen.Frame.Width, Rows: p2.screen.Frame.Height}, "removed pane received a stale screen publication") | 374 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p2.screen.Columns(), Rows: p2.screen.Rows()}, "removed pane received a stale screen publication") |
| 375 | p2.mu.Lock() | 375 | p2.mu.Lock() | ||
| 376 | require.False(t, p2.resizeApplying, "removed pane gate was not cancelled") | 376 | require.False(t, p2.resizeApplying, "removed pane gate was not cancelled") | ||
| 377 | p2.mu.Unlock() | 377 | p2.mu.Unlock() | ||
| @@ -440,7 +440,7 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) { | |||||
| 440 | awaitSignal(t, oldDone, "old size apply did not finish") | 440 | awaitSignal(t, oldDone, "old size apply did not finish") | ||
| 441 | require.True(t, <-resizeDone) | 441 | require.True(t, <-resizeDone) | ||
| 442 | require.Equal(t, domain.Rect{Width: 100, Height: 28}, p.rect) | 442 | require.Equal(t, domain.Rect{Width: 100, Height: 28}, p.rect) | ||
| 443 | - | require.Equal(t, domain.Size{Cols: 100, Rows: 28}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}) | 443 | + | require.Equal(t, domain.Size{Cols: 100, Rows: 28}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()}) |
| 444 | require.Equal(t, []domain.Size{{Cols: 80, Rows: 23}, {Cols: 100, Rows: 28}}, pty.requested()) | 444 | require.Equal(t, []domain.Size{{Cols: 80, Rows: 23}, {Cols: 100, Rows: 28}}, pty.requested()) | ||
| 445 | }) | 445 | }) | ||
| 446 | } | 446 | } | ||
| @@ -596,8 +596,8 @@ func TestApplyLayoutResizesPTYsAndScreens(t *testing.T) { | |||||
| 596 | 596 | ||||
| 597 | sess.geometry.applyTabLayout(d, sess, tb) | 597 | sess.geometry.applyTabLayout(d, sess, tb) | ||
| 598 | 598 | ||||
| 599 | - | require.Equal(t, 20, tb.panes["pane-1"].screen.Frame.Width) | 599 | + | require.Equal(t, 20, tb.panes["pane-1"].screen.Columns()) |
| 600 | - | require.Equal(t, 20, tb.panes["pane-2"].screen.Frame.Width) | 600 | + | require.Equal(t, 20, tb.panes["pane-2"].screen.Columns()) |
| 601 | require.Equal(t, domain.Rect{X: 21, Width: 20, Height: 10}, tb.panes["pane-2"].rect) | 601 | require.Equal(t, domain.Rect{X: 21, Width: 20, Height: 10}, tb.panes["pane-2"].rect) | ||
| 602 | } | 602 | } | ||
| 603 | 603 | ||||
| @@ -617,8 +617,8 @@ func TestResizeReflowsAllPanes(t *testing.T) { | |||||
| 617 | require.Equal(t, domain.Size{Cols: 60, Rows: 18}, tb.size) | 617 | require.Equal(t, domain.Size{Cols: 60, Rows: 18}, tb.size) | ||
| 618 | require.Equal(t, domain.Rect{Width: 30, Height: 18}, tb.panes["pane-1"].rect) | 618 | require.Equal(t, domain.Rect{Width: 30, Height: 18}, tb.panes["pane-1"].rect) | ||
| 619 | require.Equal(t, domain.Rect{X: 31, Width: 29, Height: 18}, tb.panes["pane-2"].rect) | 619 | require.Equal(t, domain.Rect{X: 31, Width: 29, Height: 18}, tb.panes["pane-2"].rect) | ||
| 620 | - | require.Equal(t, 30, tb.panes["pane-1"].screen.Frame.Width) | 620 | + | require.Equal(t, 30, tb.panes["pane-1"].screen.Columns()) |
| 621 | - | require.Equal(t, 29, tb.panes["pane-2"].screen.Frame.Width) | 621 | + | require.Equal(t, 29, tb.panes["pane-2"].screen.Columns()) |
| 622 | } | 622 | } | ||
| 623 | 623 | ||||
| 624 | func TestFocusDirAtReturnsDepartingSpanAndMapsNoNeighbor(t *testing.T) { | 624 | func TestFocusDirAtReturnsDepartingSpanAndMapsNoNeighbor(t *testing.T) { | ||
| @@ -945,7 +945,7 @@ func TestCloseOriginalPaneLeavesSurvivorFunctional(t *testing.T) { | |||||
| 945 | reset: true, | 945 | reset: true, | ||
| 946 | layout: capturedTabLayout{area: content, focus: tb.focusedPane().id, valid: true}, | 946 | layout: capturedTabLayout{area: content, focus: tb.focusedPane().id, valid: true}, | ||
| 947 | panes: []capturedPaneRenderState{{ | 947 | panes: []capturedPaneRenderState{{ | ||
| 948 | - | id: tb.focusedPane().id, frame: tb.focusedPane().screen.Frame.Clone(), placement: layout.Placement{ID: tb.focusedPane().id, Content: content}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}, | 948 | + | id: tb.focusedPane().id, frame: captureTestFrame(tb.focusedPane().screen), placement: layout.Placement{ID: tb.focusedPane().id, Content: content}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}, |
| 949 | }}, | 949 | }}, | ||
| 950 | bars: barState{status: sess.statusSegments(true)}, | 950 | bars: barState{status: sess.statusSegments(true)}, | ||
| 951 | }, composeCacheInput{}).frame | 951 | }, composeCacheInput{}).frame | ||
M internal/usecase/daemon/pane_rearrange_test.go +1 -1
| @@ -411,7 +411,7 @@ func (h *paneRearrangeHarness) snapshot() paneRearrangeSnapshot { | |||
| 411 | 411 | snapshot.panes[id] = p | |
| 412 | 412 | p.mu.Lock() | |
| 413 | 413 | snapshot.rects[id] = p.rect | |
| 414 | - | snapshot.screens[id] = domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height} | |
| 414 | + | snapshot.screens[id] = domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()} | |
| 415 | 415 | p.mu.Unlock() | |
| 416 | 416 | } | |
| 417 | 417 | h.tab.mu.Unlock() | |
| @@ -411,7 +411,7 @@ func (h *paneRearrangeHarness) snapshot() paneRearrangeSnapshot { | |||||
| 411 | snapshot.panes[id] = p | 411 | snapshot.panes[id] = p | ||
| 412 | p.mu.Lock() | 412 | p.mu.Lock() | ||
| 413 | snapshot.rects[id] = p.rect | 413 | snapshot.rects[id] = p.rect | ||
| 414 | - | snapshot.screens[id] = domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height} | 414 | + | snapshot.screens[id] = domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()} |
| 415 | p.mu.Unlock() | 415 | p.mu.Unlock() | ||
| 416 | } | 416 | } | ||
| 417 | h.tab.mu.Unlock() | 417 | h.tab.mu.Unlock() | ||
M internal/usecase/daemon/performance_benchmark_test.go +41 -32
| @@ -80,7 +80,7 @@ func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testin | |||
| 80 | 80 | fixture.snapshots.reset() | |
| 81 | 81 | fixture.activePane.mu.Lock() | |
| 82 | 82 | fixture.activePane.screen.Write([]byte("\x1b[1;1Hchanged")) | |
| 83 | - | frame := fixture.activePane.screen.Frame.Clone() | |
| 83 | + | frame := captureTestFrame(fixture.activePane.screen) | |
| 84 | 84 | fixture.activePane.mu.Unlock() | |
| 85 | 85 | for i, r := range "changed" { | |
| 86 | 86 | require.Equalf(t, r, frame.At(i, 0).Rune, "fixture must overwrite at column %d", i) | |
| @@ -141,13 +141,14 @@ func TestIncrementalSnapshotMetricScenarios(t *testing.T) { | |||
| 141 | 141 | } | |
| 142 | 142 | } | |
| 143 | 143 | ||
| 144 | - | func TestPerformanceFixtureCellLimitedHistory(t *testing.T) { | |
| 145 | - | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 293, Rows: 40}, panes: 1, historyRows: 10_000}) | |
| 144 | + | func TestPerformanceFixtureByteLimitedHistory(t *testing.T) { | |
| 145 | + | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 400, Rows: 40}, panes: 1, historyRows: 10_000}) | |
| 146 | 146 | fixture.activePane.mu.Lock() | |
| 147 | - | rows, cells, capCells := fixture.activePane.history.Len(), fixture.activePane.history.Cells(), fixture.activePane.history.CellCap() | |
| 147 | + | rows, bytes, budget := fixture.activePane.history.Len(), fixture.activePane.history.LogicalBytes(), fixture.activePane.history.ByteCap() | |
| 148 | 148 | fixture.activePane.mu.Unlock() | |
| 149 | - | require.Less(t, rows, 10_000) | |
| 150 | - | require.LessOrEqual(t, cells, capCells) | |
| 149 | + | require.Less(t, rows, 10_000, "byte eviction must happen before the line ceiling") | |
| 150 | + | require.Positive(t, bytes) | |
| 151 | + | require.LessOrEqual(t, bytes, budget) | |
| 151 | 152 | } | |
| 152 | 153 | ||
| 153 | 154 | func TestPerformanceFixtureExplicitCloseReleasesIterationState(t *testing.T) { | |
| @@ -517,7 +518,7 @@ type snapshotBenchmarkScenario struct { | |||
| 517 | 518 | baseline bool | |
| 518 | 519 | mutate func(*performanceFixture, int) | |
| 519 | 520 | unchanged bool | |
| 520 | - | cellLimited bool | |
| 521 | + | byteLimited bool | |
| 521 | 522 | } | |
| 522 | 523 | ||
| 523 | 524 | var snapshotBenchmarkScenarios = []snapshotBenchmarkScenario{ | |
| @@ -528,7 +529,7 @@ var snapshotBenchmarkScenarios = []snapshotBenchmarkScenario{ | |||
| 528 | 529 | // mutable tail and cannot evict a sealed chunk. | |
| 529 | 530 | {name: "tail-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 9_999, baseline: true, mutate: mutateBenchmarkTail}, | |
| 530 | 531 | {name: "new-sealed-chunk", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, mutate: mutateBenchmarkSealedChunk}, | |
| 531 | - | {name: "cell-limited-293-columns", size: domain.Size{Cols: 293, Rows: 40}, historyRows: 10_000, cellLimited: true}, | |
| 532 | + | {name: "byte-limited-400-columns", size: domain.Size{Cols: 400, Rows: 40}, historyRows: 10_000, byteLimited: true}, | |
| 532 | 533 | } | |
| 533 | 534 | ||
| 534 | 535 | func benchmarkIncrementalSnapshotRepository(b *testing.B, scenario snapshotBenchmarkScenario) { | |
| @@ -588,17 +589,17 @@ func benchmarkIncrementalSnapshotRepository(b *testing.B, scenario snapshotBench | |||
| 588 | 589 | ||
| 589 | 590 | func validateSnapshotBenchmarkFixture(b *testing.B, fixture *performanceFixture, scenario snapshotBenchmarkScenario) { | |
| 590 | 591 | b.Helper() | |
| 591 | - | if !fixture.hasHistoryTopology(1, 1, scenario.historyRows) && !scenario.cellLimited { | |
| 592 | + | if !fixture.hasHistoryTopology(1, 1, scenario.historyRows) && !scenario.byteLimited { | |
| 592 | 593 | b.Fatal("invalid incremental snapshot fixture") | |
| 593 | 594 | } | |
| 594 | - | if !scenario.cellLimited { | |
| 595 | + | if !scenario.byteLimited { | |
| 595 | 596 | return | |
| 596 | 597 | } | |
| 597 | 598 | fixture.activePane.mu.Lock() | |
| 598 | - | rows, cells, capCells := fixture.activePane.history.Len(), fixture.activePane.history.Cells(), fixture.activePane.history.CellCap() | |
| 599 | + | rows, bytes, budget := fixture.activePane.history.Len(), fixture.activePane.history.LogicalBytes(), fixture.activePane.history.ByteCap() | |
| 599 | 600 | fixture.activePane.mu.Unlock() | |
| 600 | - | if rows >= scenario.historyRows || cells > capCells { | |
| 601 | - | b.Fatalf("293-column fixture did not enforce cell limit: rows=%d cells=%d cap=%d", rows, cells, capCells) | |
| 601 | + | if rows >= scenario.historyRows || bytes > budget { | |
| 602 | + | b.Fatalf("400-column fixture did not enforce byte retention: rows=%d bytes=%d budget=%d", rows, bytes, budget) | |
| 602 | 603 | } | |
| 603 | 604 | } | |
| 604 | 605 | ||
| @@ -645,7 +646,7 @@ func mutateBenchmarkTranscript(fixture *performanceFixture, operation int) { | |||
| 645 | 646 | func mutateBenchmarkTail(fixture *performanceFixture, operation int) { | |
| 646 | 647 | fixture.activePane.mu.Lock() | |
| 647 | 648 | defer fixture.activePane.mu.Unlock() | |
| 648 | - | row := make([]renderer.Cell, fixture.activePane.screen.Frame.Width) | |
| 649 | + | row := make([]renderer.Cell, fixture.activePane.screen.Columns()) | |
| 649 | 650 | for column := range row { | |
| 650 | 651 | row[column] = renderer.Cell{Rune: rune('a' + (operation+column)%26)} | |
| 651 | 652 | } | |
| @@ -821,10 +822,9 @@ func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations | |||
| 821 | 822 | b.ReportMetric(float64(historyRows), "historyrows/pane") | |
| 822 | 823 | } | |
| 823 | 824 | ||
| 824 | - | // Root-cause hypothesis (verified by this guard before the fix): S2 allocates | |
| 825 | - | // one pane-frame copy during capture plus a new composed frame and its | |
| 826 | - | // base-frame clone for every live paint. Those client-sized copies, rather | |
| 827 | - | // than history, account for the ~1 MiB/op regression in the pinned benchmark. | |
| 825 | + | // Compact-page budgets are measured against the dense baseline in | |
| 826 | + | // docs/compact-storage-benchmarks.md. Count and byte limits are separate: | |
| 827 | + | // small page dictionaries cost allocations but replace much larger cell copies. | |
| 828 | 828 | func TestLivePaintAllocationBudget(t *testing.T) { | |
| 829 | 829 | for _, tt := range []struct { | |
| 830 | 830 | name string | |
| @@ -835,28 +835,32 @@ func TestLivePaintAllocationBudget(t *testing.T) { | |||
| 835 | 835 | } { | |
| 836 | 836 | t.Run(tt.name, func(t *testing.T) { | |
| 837 | 837 | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: tt.panes, historyRows: 10_000}) | |
| 838 | - | allocs := testing.AllocsPerRun(20, func() { | |
| 838 | + | run := func() { | |
| 839 | 839 | fixture.paintLive() | |
| 840 | 840 | fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next) | |
| 841 | - | }) | |
| 842 | - | require.LessOrEqual(t, allocs, float64(38), "live paint must reuse attachment-owned render scratch across warm paints") | |
| 841 | + | } | |
| 842 | + | allocs := testing.AllocsPerRun(20, run) | |
| 843 | + | require.LessOrEqual(t, allocs, float64(44), "warm paint must not regain per-row allocations") | |
| 844 | + | if copyEnterAllocationBudgetEnabled { | |
| 845 | + | assertRenderByteBudget(t, run, 96<<10) | |
| 846 | + | } | |
| 843 | 847 | }) | |
| 844 | 848 | } | |
| 845 | 849 | } | |
| 846 | 850 | ||
| 847 | - | // TestCopyEnterAllocationBudget protects the byte-stream attachment baseline | |
| 848 | - | // plus 10%. Copy rendering borrows sealed VT history rows; allocating a copy | |
| 849 | - | // per viewport row is a production regression even though capture is immutable. | |
| 851 | + | // TestCopyEnterAllocationBudget protects the measured compact baseline plus | |
| 852 | + | // roughly 10% in allocation count, and separately bounds bytes per operation. | |
| 853 | + | // It must still reject per-row copies or a return to dense frame snapshots. | |
| 850 | 854 | func TestCopyEnterAllocationBudget(t *testing.T) { | |
| 851 | 855 | for _, tt := range []struct { | |
| 852 | 856 | name string | |
| 853 | 857 | tabs, panes, max int | |
| 854 | 858 | }{ | |
| 855 | - | {name: "1tab-1pane", tabs: 1, panes: 1, max: 66}, | |
| 856 | - | {name: "1tab-4panes", tabs: 1, panes: 4, max: 85}, | |
| 857 | - | {name: "4tabs-1pane", tabs: 4, panes: 1, max: 71}, | |
| 858 | - | {name: "4tabs-4panes", tabs: 4, panes: 4, max: 90}, | |
| 859 | - | {name: "8tabs-1pane", tabs: 8, panes: 1, max: 79}, | |
| 859 | + | {name: "1tab-1pane", tabs: 1, panes: 1, max: 94}, | |
| 860 | + | {name: "1tab-4panes", tabs: 1, panes: 4, max: 126}, | |
| 861 | + | {name: "4tabs-1pane", tabs: 4, panes: 1, max: 97}, | |
| 862 | + | {name: "4tabs-4panes", tabs: 4, panes: 4, max: 130}, | |
| 863 | + | {name: "8tabs-1pane", tabs: 8, panes: 1, max: 106}, | |
| 860 | 864 | } { | |
| 861 | 865 | t.Run(tt.name, func(t *testing.T) { | |
| 862 | 866 | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: tt.tabs, panes: tt.panes, historyRows: 10_000}) | |
| @@ -875,7 +879,12 @@ func TestCopyEnterAllocationBudget(t *testing.T) { | |||
| 875 | 879 | } | |
| 876 | 880 | ||
| 877 | 881 | allocs := testing.AllocsPerRun(20, run) | |
| 878 | - | require.LessOrEqual(t, allocs, float64(tt.max), "copy enter must stay within 10% of the byte-stream attachment allocation baseline") | |
| 882 | + | require.LessOrEqual(t, allocs, float64(tt.max), "copy entry exceeds its measured compact allocation budget") | |
| 883 | + | budget := int64(512 << 10) | |
| 884 | + | if tt.panes == 4 { | |
| 885 | + | budget = 384 << 10 | |
| 886 | + | } | |
| 887 | + | assertRenderByteBudget(t, run, budget) | |
| 879 | 888 | }) | |
| 880 | 889 | } | |
| 881 | 890 | } | |
| @@ -1140,8 +1149,8 @@ func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyR | |||
| 1140 | 1149 | tb.mu.Unlock() | |
| 1141 | 1150 | for _, p := range panes { | |
| 1142 | 1151 | p.mu.Lock() | |
| 1143 | - | width := p.screen.Frame.Width | |
| 1144 | - | for row := 0; p.history.Len() < historyRows && row < historyRows+p.screen.Frame.Height; row++ { | |
| 1152 | + | width := p.screen.Columns() | |
| 1153 | + | for row := 0; p.history.Len() < historyRows && row < historyRows+p.screen.Rows(); row++ { | |
| 1145 | 1154 | p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n")) | |
| 1146 | 1155 | } | |
| 1147 | 1156 | p.mu.Unlock() | |
| @@ -80,7 +80,7 @@ func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testin | |||||
| 80 | fixture.snapshots.reset() | 80 | fixture.snapshots.reset() | ||
| 81 | fixture.activePane.mu.Lock() | 81 | fixture.activePane.mu.Lock() | ||
| 82 | fixture.activePane.screen.Write([]byte("\x1b[1;1Hchanged")) | 82 | fixture.activePane.screen.Write([]byte("\x1b[1;1Hchanged")) | ||
| 83 | - | frame := fixture.activePane.screen.Frame.Clone() | 83 | + | frame := captureTestFrame(fixture.activePane.screen) |
| 84 | fixture.activePane.mu.Unlock() | 84 | fixture.activePane.mu.Unlock() | ||
| 85 | for i, r := range "changed" { | 85 | for i, r := range "changed" { | ||
| 86 | require.Equalf(t, r, frame.At(i, 0).Rune, "fixture must overwrite at column %d", i) | 86 | require.Equalf(t, r, frame.At(i, 0).Rune, "fixture must overwrite at column %d", i) | ||
| @@ -141,13 +141,14 @@ func TestIncrementalSnapshotMetricScenarios(t *testing.T) { | |||||
| 141 | } | 141 | } | ||
| 142 | } | 142 | } | ||
| 143 | 143 | ||||
| 144 | - | func TestPerformanceFixtureCellLimitedHistory(t *testing.T) { | 144 | + | func TestPerformanceFixtureByteLimitedHistory(t *testing.T) { |
| 145 | - | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 293, Rows: 40}, panes: 1, historyRows: 10_000}) | 145 | + | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 400, Rows: 40}, panes: 1, historyRows: 10_000}) |
| 146 | fixture.activePane.mu.Lock() | 146 | fixture.activePane.mu.Lock() | ||
| 147 | - | rows, cells, capCells := fixture.activePane.history.Len(), fixture.activePane.history.Cells(), fixture.activePane.history.CellCap() | 147 | + | rows, bytes, budget := fixture.activePane.history.Len(), fixture.activePane.history.LogicalBytes(), fixture.activePane.history.ByteCap() |
| 148 | fixture.activePane.mu.Unlock() | 148 | fixture.activePane.mu.Unlock() | ||
| 149 | - | require.Less(t, rows, 10_000) | 149 | + | require.Less(t, rows, 10_000, "byte eviction must happen before the line ceiling") |
| 150 | - | require.LessOrEqual(t, cells, capCells) | 150 | + | require.Positive(t, bytes) |
| 151 | + | require.LessOrEqual(t, bytes, budget) | |||
| 151 | } | 152 | } | ||
| 152 | 153 | ||||
| 153 | func TestPerformanceFixtureExplicitCloseReleasesIterationState(t *testing.T) { | 154 | func TestPerformanceFixtureExplicitCloseReleasesIterationState(t *testing.T) { | ||
| @@ -517,7 +518,7 @@ type snapshotBenchmarkScenario struct { | |||||
| 517 | baseline bool | 518 | baseline bool | ||
| 518 | mutate func(*performanceFixture, int) | 519 | mutate func(*performanceFixture, int) | ||
| 519 | unchanged bool | 520 | unchanged bool | ||
| 520 | - | cellLimited bool | 521 | + | byteLimited bool |
| 521 | } | 522 | } | ||
| 522 | 523 | ||||
| 523 | var snapshotBenchmarkScenarios = []snapshotBenchmarkScenario{ | 524 | var snapshotBenchmarkScenarios = []snapshotBenchmarkScenario{ | ||
| @@ -528,7 +529,7 @@ var snapshotBenchmarkScenarios = []snapshotBenchmarkScenario{ | |||||
| 528 | // mutable tail and cannot evict a sealed chunk. | 529 | // mutable tail and cannot evict a sealed chunk. | ||
| 529 | {name: "tail-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 9_999, baseline: true, mutate: mutateBenchmarkTail}, | 530 | {name: "tail-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 9_999, baseline: true, mutate: mutateBenchmarkTail}, | ||
| 530 | {name: "new-sealed-chunk", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, mutate: mutateBenchmarkSealedChunk}, | 531 | {name: "new-sealed-chunk", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, mutate: mutateBenchmarkSealedChunk}, | ||
| 531 | - | {name: "cell-limited-293-columns", size: domain.Size{Cols: 293, Rows: 40}, historyRows: 10_000, cellLimited: true}, | 532 | + | {name: "byte-limited-400-columns", size: domain.Size{Cols: 400, Rows: 40}, historyRows: 10_000, byteLimited: true}, |
| 532 | } | 533 | } | ||
| 533 | 534 | ||||
| 534 | func benchmarkIncrementalSnapshotRepository(b *testing.B, scenario snapshotBenchmarkScenario) { | 535 | func benchmarkIncrementalSnapshotRepository(b *testing.B, scenario snapshotBenchmarkScenario) { | ||
| @@ -588,17 +589,17 @@ func benchmarkIncrementalSnapshotRepository(b *testing.B, scenario snapshotBench | |||||
| 588 | 589 | ||||
| 589 | func validateSnapshotBenchmarkFixture(b *testing.B, fixture *performanceFixture, scenario snapshotBenchmarkScenario) { | 590 | func validateSnapshotBenchmarkFixture(b *testing.B, fixture *performanceFixture, scenario snapshotBenchmarkScenario) { | ||
| 590 | b.Helper() | 591 | b.Helper() | ||
| 591 | - | if !fixture.hasHistoryTopology(1, 1, scenario.historyRows) && !scenario.cellLimited { | 592 | + | if !fixture.hasHistoryTopology(1, 1, scenario.historyRows) && !scenario.byteLimited { |
| 592 | b.Fatal("invalid incremental snapshot fixture") | 593 | b.Fatal("invalid incremental snapshot fixture") | ||
| 593 | } | 594 | } | ||
| 594 | - | if !scenario.cellLimited { | 595 | + | if !scenario.byteLimited { |
| 595 | return | 596 | return | ||
| 596 | } | 597 | } | ||
| 597 | fixture.activePane.mu.Lock() | 598 | fixture.activePane.mu.Lock() | ||
| 598 | - | rows, cells, capCells := fixture.activePane.history.Len(), fixture.activePane.history.Cells(), fixture.activePane.history.CellCap() | 599 | + | rows, bytes, budget := fixture.activePane.history.Len(), fixture.activePane.history.LogicalBytes(), fixture.activePane.history.ByteCap() |
| 599 | fixture.activePane.mu.Unlock() | 600 | fixture.activePane.mu.Unlock() | ||
| 600 | - | if rows >= scenario.historyRows || cells > capCells { | 601 | + | if rows >= scenario.historyRows || bytes > budget { |
| 601 | - | b.Fatalf("293-column fixture did not enforce cell limit: rows=%d cells=%d cap=%d", rows, cells, capCells) | 602 | + | b.Fatalf("400-column fixture did not enforce byte retention: rows=%d bytes=%d budget=%d", rows, bytes, budget) |
| 602 | } | 603 | } | ||
| 603 | } | 604 | } | ||
| 604 | 605 | ||||
| @@ -645,7 +646,7 @@ func mutateBenchmarkTranscript(fixture *performanceFixture, operation int) { | |||||
| 645 | func mutateBenchmarkTail(fixture *performanceFixture, operation int) { | 646 | func mutateBenchmarkTail(fixture *performanceFixture, operation int) { | ||
| 646 | fixture.activePane.mu.Lock() | 647 | fixture.activePane.mu.Lock() | ||
| 647 | defer fixture.activePane.mu.Unlock() | 648 | defer fixture.activePane.mu.Unlock() | ||
| 648 | - | row := make([]renderer.Cell, fixture.activePane.screen.Frame.Width) | 649 | + | row := make([]renderer.Cell, fixture.activePane.screen.Columns()) |
| 649 | for column := range row { | 650 | for column := range row { | ||
| 650 | row[column] = renderer.Cell{Rune: rune('a' + (operation+column)%26)} | 651 | row[column] = renderer.Cell{Rune: rune('a' + (operation+column)%26)} | ||
| 651 | } | 652 | } | ||
| @@ -821,10 +822,9 @@ func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations | |||||
| 821 | b.ReportMetric(float64(historyRows), "historyrows/pane") | 822 | b.ReportMetric(float64(historyRows), "historyrows/pane") | ||
| 822 | } | 823 | } | ||
| 823 | 824 | ||||
| 824 | - | // Root-cause hypothesis (verified by this guard before the fix): S2 allocates | 825 | + | // Compact-page budgets are measured against the dense baseline in |
| 825 | - | // one pane-frame copy during capture plus a new composed frame and its | 826 | + | // docs/compact-storage-benchmarks.md. Count and byte limits are separate: |
| 826 | - | // base-frame clone for every live paint. Those client-sized copies, rather | 827 | + | // small page dictionaries cost allocations but replace much larger cell copies. |
| 827 | - | // than history, account for the ~1 MiB/op regression in the pinned benchmark. | |||
| 828 | func TestLivePaintAllocationBudget(t *testing.T) { | 828 | func TestLivePaintAllocationBudget(t *testing.T) { | ||
| 829 | for _, tt := range []struct { | 829 | for _, tt := range []struct { | ||
| 830 | name string | 830 | name string | ||
| @@ -835,28 +835,32 @@ func TestLivePaintAllocationBudget(t *testing.T) { | |||||
| 835 | } { | 835 | } { | ||
| 836 | t.Run(tt.name, func(t *testing.T) { | 836 | t.Run(tt.name, func(t *testing.T) { | ||
| 837 | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: tt.panes, historyRows: 10_000}) | 837 | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: tt.panes, historyRows: 10_000}) | ||
| 838 | - | allocs := testing.AllocsPerRun(20, func() { | 838 | + | run := func() { |
| 839 | fixture.paintLive() | 839 | fixture.paintLive() | ||
| 840 | fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next) | 840 | fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next) | ||
| 841 | - | }) | 841 | + | } |
| 842 | - | require.LessOrEqual(t, allocs, float64(38), "live paint must reuse attachment-owned render scratch across warm paints") | 842 | + | allocs := testing.AllocsPerRun(20, run) |
| 843 | + | require.LessOrEqual(t, allocs, float64(44), "warm paint must not regain per-row allocations") | |||
| 844 | + | if copyEnterAllocationBudgetEnabled { | |||
| 845 | + | assertRenderByteBudget(t, run, 96<<10) | |||
| 846 | + | } | |||
| 843 | }) | 847 | }) | ||
| 844 | } | 848 | } | ||
| 845 | } | 849 | } | ||
| 846 | 850 | ||||
| 847 | - | // TestCopyEnterAllocationBudget protects the byte-stream attachment baseline | 851 | + | // TestCopyEnterAllocationBudget protects the measured compact baseline plus |
| 848 | - | // plus 10%. Copy rendering borrows sealed VT history rows; allocating a copy | 852 | + | // roughly 10% in allocation count, and separately bounds bytes per operation. |
| 849 | - | // per viewport row is a production regression even though capture is immutable. | 853 | + | // It must still reject per-row copies or a return to dense frame snapshots. |
| 850 | func TestCopyEnterAllocationBudget(t *testing.T) { | 854 | func TestCopyEnterAllocationBudget(t *testing.T) { | ||
| 851 | for _, tt := range []struct { | 855 | for _, tt := range []struct { | ||
| 852 | name string | 856 | name string | ||
| 853 | tabs, panes, max int | 857 | tabs, panes, max int | ||
| 854 | }{ | 858 | }{ | ||
| 855 | - | {name: "1tab-1pane", tabs: 1, panes: 1, max: 66}, | 859 | + | {name: "1tab-1pane", tabs: 1, panes: 1, max: 94}, |
| 856 | - | {name: "1tab-4panes", tabs: 1, panes: 4, max: 85}, | 860 | + | {name: "1tab-4panes", tabs: 1, panes: 4, max: 126}, |
| 857 | - | {name: "4tabs-1pane", tabs: 4, panes: 1, max: 71}, | 861 | + | {name: "4tabs-1pane", tabs: 4, panes: 1, max: 97}, |
| 858 | - | {name: "4tabs-4panes", tabs: 4, panes: 4, max: 90}, | 862 | + | {name: "4tabs-4panes", tabs: 4, panes: 4, max: 130}, |
| 859 | - | {name: "8tabs-1pane", tabs: 8, panes: 1, max: 79}, | 863 | + | {name: "8tabs-1pane", tabs: 8, panes: 1, max: 106}, |
| 860 | } { | 864 | } { | ||
| 861 | t.Run(tt.name, func(t *testing.T) { | 865 | t.Run(tt.name, func(t *testing.T) { | ||
| 862 | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: tt.tabs, panes: tt.panes, historyRows: 10_000}) | 866 | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: tt.tabs, panes: tt.panes, historyRows: 10_000}) | ||
| @@ -875,7 +879,12 @@ func TestCopyEnterAllocationBudget(t *testing.T) { | |||||
| 875 | } | 879 | } | ||
| 876 | 880 | ||||
| 877 | allocs := testing.AllocsPerRun(20, run) | 881 | allocs := testing.AllocsPerRun(20, run) | ||
| 878 | - | require.LessOrEqual(t, allocs, float64(tt.max), "copy enter must stay within 10% of the byte-stream attachment allocation baseline") | 882 | + | require.LessOrEqual(t, allocs, float64(tt.max), "copy entry exceeds its measured compact allocation budget") |
| 883 | + | budget := int64(512 << 10) | |||
| 884 | + | if tt.panes == 4 { | |||
| 885 | + | budget = 384 << 10 | |||
| 886 | + | } | |||
| 887 | + | assertRenderByteBudget(t, run, budget) | |||
| 879 | }) | 888 | }) | ||
| 880 | } | 889 | } | ||
| 881 | } | 890 | } | ||
| @@ -1140,8 +1149,8 @@ func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyR | |||||
| 1140 | tb.mu.Unlock() | 1149 | tb.mu.Unlock() | ||
| 1141 | for _, p := range panes { | 1150 | for _, p := range panes { | ||
| 1142 | p.mu.Lock() | 1151 | p.mu.Lock() | ||
| 1143 | - | width := p.screen.Frame.Width | 1152 | + | width := p.screen.Columns() |
| 1144 | - | for row := 0; p.history.Len() < historyRows && row < historyRows+p.screen.Frame.Height; row++ { | 1153 | + | for row := 0; p.history.Len() < historyRows && row < historyRows+p.screen.Rows(); row++ { |
| 1145 | p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n")) | 1154 | p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n")) | ||
| 1146 | } | 1155 | } | ||
| 1147 | p.mu.Unlock() | 1156 | p.mu.Unlock() | ||
M internal/usecase/daemon/picker.go +8 -5
| @@ -1570,13 +1570,16 @@ func pickerPreviewFromCapturedRender(state capturedRenderState) picker.Preview { | |||
| 1570 | 1570 | } | |
| 1571 | 1571 | ||
| 1572 | 1572 | func pickerPreviewFromLockedPane(p *pane) picker.Preview { | |
| 1573 | - | return pickerPreviewFromFrame(p.screen.Frame) | |
| 1573 | + | return pickerPreviewFromFrame(p.screen) | |
| 1574 | 1574 | } | |
| 1575 | 1575 | ||
| 1576 | - | func pickerPreviewFromFrame(frame renderer.Frame) picker.Preview { | |
| 1577 | - | rows := make([][]renderer.Cell, frame.Height) | |
| 1576 | + | func pickerPreviewFromFrame(frame renderer.CellSource) picker.Preview { | |
| 1577 | + | rows := make([][]renderer.Cell, frame.Rows()) | |
| 1578 | 1578 | for y := range rows { | |
| 1579 | - | rows[y] = append([]renderer.Cell(nil), frame.Row(y)...) | |
| 1579 | + | rows[y] = make([]renderer.Cell, frame.Columns()) | |
| 1580 | + | for x := range rows[y] { | |
| 1581 | + | rows[y][x] = frame.Cell(x, y) | |
| 1582 | + | } | |
| 1580 | 1583 | } | |
| 1581 | - | return picker.Preview{Rows: rows, Width: frame.Width, Height: frame.Height} | |
| 1584 | + | return picker.Preview{Rows: rows, Width: frame.Columns(), Height: frame.Rows()} | |
| 1582 | 1585 | } | |
| @@ -1570,13 +1570,16 @@ func pickerPreviewFromCapturedRender(state capturedRenderState) picker.Preview { | |||||
| 1570 | } | 1570 | } | ||
| 1571 | 1571 | ||||
| 1572 | func pickerPreviewFromLockedPane(p *pane) picker.Preview { | 1572 | func pickerPreviewFromLockedPane(p *pane) picker.Preview { | ||
| 1573 | - | return pickerPreviewFromFrame(p.screen.Frame) | 1573 | + | return pickerPreviewFromFrame(p.screen) |
| 1574 | } | 1574 | } | ||
| 1575 | 1575 | ||||
| 1576 | - | func pickerPreviewFromFrame(frame renderer.Frame) picker.Preview { | 1576 | + | func pickerPreviewFromFrame(frame renderer.CellSource) picker.Preview { |
| 1577 | - | rows := make([][]renderer.Cell, frame.Height) | 1577 | + | rows := make([][]renderer.Cell, frame.Rows()) |
| 1578 | for y := range rows { | 1578 | for y := range rows { | ||
| 1579 | - | rows[y] = append([]renderer.Cell(nil), frame.Row(y)...) | 1579 | + | rows[y] = make([]renderer.Cell, frame.Columns()) |
| 1580 | + | for x := range rows[y] { | |||
| 1581 | + | rows[y][x] = frame.Cell(x, y) | |||
| 1582 | + | } | |||
| 1580 | } | 1583 | } | ||
| 1581 | - | return picker.Preview{Rows: rows, Width: frame.Width, Height: frame.Height} | 1584 | + | return picker.Preview{Rows: rows, Width: frame.Columns(), Height: frame.Rows()} |
| 1582 | } | 1585 | } | ||
M internal/usecase/daemon/remote_preview.go +7 -7
| @@ -68,7 +68,7 @@ func (d *Daemon) captureRemotePreview(request protocol.RemotePreviewRequest) (pr | |||
| 68 | 68 | tab.mu.Unlock() | |
| 69 | 69 | return protocol.RemotePreview{}, errRemotePreviewNoSuchTarget | |
| 70 | 70 | } | |
| 71 | - | source := pane.screen.Frame.Clone() | |
| 71 | + | source := pane.screen.Snapshot() | |
| 72 | 72 | revision := pane.syncGen | |
| 73 | 73 | pane.mu.Unlock() | |
| 74 | 74 | tab.mu.Unlock() | |
| @@ -76,12 +76,12 @@ func (d *Daemon) captureRemotePreview(request protocol.RemotePreviewRequest) (pr | |||
| 76 | 76 | revision = 1 | |
| 77 | 77 | } | |
| 78 | 78 | ||
| 79 | - | width := min(int(request.Width), source.Width) | |
| 80 | - | height := min(int(request.Height), source.Height) | |
| 79 | + | width := min(int(request.Width), source.Columns()) | |
| 80 | + | height := min(int(request.Height), source.Rows()) | |
| 81 | 81 | if width <= 0 || height <= 0 { | |
| 82 | 82 | return protocol.RemotePreview{}, errRemotePreviewNoSuchTarget | |
| 83 | 83 | } | |
| 84 | - | startY, endY := source.Height-height, source.Height | |
| 84 | + | startY, endY := source.Rows()-height, source.Rows() | |
| 85 | 85 | if last := remotePreviewLastContentRow(source, width, startY, endY); last >= startY { | |
| 86 | 86 | endY = last + 1 | |
| 87 | 87 | } else if last := remotePreviewLastContentRow(source, width, 0, startY); last >= 0 { | |
| @@ -119,10 +119,10 @@ func (d *Daemon) captureRemotePreview(request protocol.RemotePreviewRequest) (pr | |||
| 119 | 119 | // the bounded viewport crop. Remote terminal frames retain their full PTY | |
| 120 | 120 | // height even when the only output is near the top; compacting the blank tail lets the | |
| 121 | 121 | // picker bottom-anchor short output instead of clipping it away. | |
| 122 | - | func remotePreviewLastContentRow(frame renderer.Frame, width, start, end int) int { | |
| 122 | + | func remotePreviewLastContentRow(frame renderer.CellSource, width, start, end int) int { | |
| 123 | 123 | for y := end - 1; y >= start; y-- { | |
| 124 | - | for x, cell := range frame.Row(y)[:width] { | |
| 125 | - | cell, splitWideRune := cropRemotePreviewCell(cell, x, width) | |
| 124 | + | for x := range width { | |
| 125 | + | cell, splitWideRune := cropRemotePreviewCell(frame.Cell(x, y), x, width) | |
| 126 | 126 | if !splitWideRune && remotePreviewCellVisible(cell) { | |
| 127 | 127 | return y | |
| 128 | 128 | } | |
| @@ -68,7 +68,7 @@ func (d *Daemon) captureRemotePreview(request protocol.RemotePreviewRequest) (pr | |||||
| 68 | tab.mu.Unlock() | 68 | tab.mu.Unlock() | ||
| 69 | return protocol.RemotePreview{}, errRemotePreviewNoSuchTarget | 69 | return protocol.RemotePreview{}, errRemotePreviewNoSuchTarget | ||
| 70 | } | 70 | } | ||
| 71 | - | source := pane.screen.Frame.Clone() | 71 | + | source := pane.screen.Snapshot() |
| 72 | revision := pane.syncGen | 72 | revision := pane.syncGen | ||
| 73 | pane.mu.Unlock() | 73 | pane.mu.Unlock() | ||
| 74 | tab.mu.Unlock() | 74 | tab.mu.Unlock() | ||
| @@ -76,12 +76,12 @@ func (d *Daemon) captureRemotePreview(request protocol.RemotePreviewRequest) (pr | |||||
| 76 | revision = 1 | 76 | revision = 1 | ||
| 77 | } | 77 | } | ||
| 78 | 78 | ||||
| 79 | - | width := min(int(request.Width), source.Width) | 79 | + | width := min(int(request.Width), source.Columns()) |
| 80 | - | height := min(int(request.Height), source.Height) | 80 | + | height := min(int(request.Height), source.Rows()) |
| 81 | if width <= 0 || height <= 0 { | 81 | if width <= 0 || height <= 0 { | ||
| 82 | return protocol.RemotePreview{}, errRemotePreviewNoSuchTarget | 82 | return protocol.RemotePreview{}, errRemotePreviewNoSuchTarget | ||
| 83 | } | 83 | } | ||
| 84 | - | startY, endY := source.Height-height, source.Height | 84 | + | startY, endY := source.Rows()-height, source.Rows() |
| 85 | if last := remotePreviewLastContentRow(source, width, startY, endY); last >= startY { | 85 | if last := remotePreviewLastContentRow(source, width, startY, endY); last >= startY { | ||
| 86 | endY = last + 1 | 86 | endY = last + 1 | ||
| 87 | } else if last := remotePreviewLastContentRow(source, width, 0, startY); last >= 0 { | 87 | } else if last := remotePreviewLastContentRow(source, width, 0, startY); last >= 0 { | ||
| @@ -119,10 +119,10 @@ func (d *Daemon) captureRemotePreview(request protocol.RemotePreviewRequest) (pr | |||||
| 119 | // the bounded viewport crop. Remote terminal frames retain their full PTY | 119 | // the bounded viewport crop. Remote terminal frames retain their full PTY | ||
| 120 | // height even when the only output is near the top; compacting the blank tail lets the | 120 | // height even when the only output is near the top; compacting the blank tail lets the | ||
| 121 | // picker bottom-anchor short output instead of clipping it away. | 121 | // picker bottom-anchor short output instead of clipping it away. | ||
| 122 | - | func remotePreviewLastContentRow(frame renderer.Frame, width, start, end int) int { | 122 | + | func remotePreviewLastContentRow(frame renderer.CellSource, width, start, end int) int { |
| 123 | for y := end - 1; y >= start; y-- { | 123 | for y := end - 1; y >= start; y-- { | ||
| 124 | - | for x, cell := range frame.Row(y)[:width] { | 124 | + | for x := range width { |
| 125 | - | cell, splitWideRune := cropRemotePreviewCell(cell, x, width) | 125 | + | cell, splitWideRune := cropRemotePreviewCell(frame.Cell(x, y), x, width) |
| 126 | if !splitWideRune && remotePreviewCellVisible(cell) { | 126 | if !splitWideRune && remotePreviewCellVisible(cell) { | ||
| 127 | return y | 127 | return y | ||
| 128 | } | 128 | } | ||
M internal/usecase/daemon/remote_preview_capture_test.go +7 -6
| @@ -19,9 +19,9 @@ func TestCaptureRemotePreviewUsesBottomRowsForShorterPreview(t *testing.T) { | |||
| 19 | 19 | pane := sess.tabs[0].focusedPane() | |
| 20 | 20 | pane.mu.Lock() | |
| 21 | 21 | pane.screen = vt.NewScreen(2, 3) | |
| 22 | - | pane.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'a'}) | |
| 23 | - | pane.screen.Frame.Set(0, 1, renderer.Cell{Rune: 'b'}) | |
| 24 | - | pane.screen.Frame.Set(0, 2, renderer.Cell{Rune: 'c'}) | |
| 22 | + | writeTestRow(pane.screen, 0, "a") | |
| 23 | + | writeTestRow(pane.screen, 1, "b") | |
| 24 | + | writeTestRow(pane.screen, 2, "c") | |
| 25 | 25 | pane.mu.Unlock() | |
| 26 | 26 | ||
| 27 | 27 | target := domain.RemoteSessionTarget{ | |
| @@ -103,9 +103,11 @@ func TestCaptureRemotePreviewCompactsBlankTail(t *testing.T) { | |||
| 103 | 103 | pane := sess.tabs[0].focusedPane() | |
| 104 | 104 | pane.mu.Lock() | |
| 105 | 105 | pane.screen = vt.NewScreen(test.screenWidth, test.screenHeight) | |
| 106 | + | frame := renderer.NewFrame(test.screenWidth, test.screenHeight) | |
| 106 | 107 | for _, cell := range test.cells { | |
| 107 | - | pane.screen.Frame.Set(cell.x, cell.y, cell.cell) | |
| 108 | + | frame.Set(cell.x, cell.y, cell.cell) | |
| 108 | 109 | } | |
| 110 | + | writeTestFrame(t, pane.screen, frame) | |
| 109 | 111 | pane.mu.Unlock() | |
| 110 | 112 | ||
| 111 | 113 | target := domain.RemoteSessionTarget{ | |
| @@ -148,8 +150,7 @@ func TestCaptureRemotePreviewDoesNotSplitWideRuneAtCropBoundary(t *testing.T) { | |||
| 148 | 150 | pane.screen = vt.NewScreen(3, 1) | |
| 149 | 151 | style := renderer.DefaultStyle() | |
| 150 | 152 | style.Bold = true | |
| 151 | - | pane.screen.Frame.Set(1, 0, renderer.Cell{Rune: '界', Style: style}) | |
| 152 | - | pane.screen.Frame.Set(2, 0, renderer.Cell{Continuation: true, Style: style}) | |
| 153 | + | pane.screen.Write([]byte("\x1b[1;2H\x1b[1m界")) | |
| 153 | 154 | pane.mu.Unlock() | |
| 154 | 155 | ||
| 155 | 156 | target := domain.RemoteSessionTarget{ | |
| @@ -19,9 +19,9 @@ func TestCaptureRemotePreviewUsesBottomRowsForShorterPreview(t *testing.T) { | |||||
| 19 | pane := sess.tabs[0].focusedPane() | 19 | pane := sess.tabs[0].focusedPane() | ||
| 20 | pane.mu.Lock() | 20 | pane.mu.Lock() | ||
| 21 | pane.screen = vt.NewScreen(2, 3) | 21 | pane.screen = vt.NewScreen(2, 3) | ||
| 22 | - | pane.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'a'}) | 22 | + | writeTestRow(pane.screen, 0, "a") |
| 23 | - | pane.screen.Frame.Set(0, 1, renderer.Cell{Rune: 'b'}) | 23 | + | writeTestRow(pane.screen, 1, "b") |
| 24 | - | pane.screen.Frame.Set(0, 2, renderer.Cell{Rune: 'c'}) | 24 | + | writeTestRow(pane.screen, 2, "c") |
| 25 | pane.mu.Unlock() | 25 | pane.mu.Unlock() | ||
| 26 | 26 | ||||
| 27 | target := domain.RemoteSessionTarget{ | 27 | target := domain.RemoteSessionTarget{ | ||
| @@ -103,9 +103,11 @@ func TestCaptureRemotePreviewCompactsBlankTail(t *testing.T) { | |||||
| 103 | pane := sess.tabs[0].focusedPane() | 103 | pane := sess.tabs[0].focusedPane() | ||
| 104 | pane.mu.Lock() | 104 | pane.mu.Lock() | ||
| 105 | pane.screen = vt.NewScreen(test.screenWidth, test.screenHeight) | 105 | pane.screen = vt.NewScreen(test.screenWidth, test.screenHeight) | ||
| 106 | + | frame := renderer.NewFrame(test.screenWidth, test.screenHeight) | |||
| 106 | for _, cell := range test.cells { | 107 | for _, cell := range test.cells { | ||
| 107 | - | pane.screen.Frame.Set(cell.x, cell.y, cell.cell) | 108 | + | frame.Set(cell.x, cell.y, cell.cell) |
| 108 | } | 109 | } | ||
| 110 | + | writeTestFrame(t, pane.screen, frame) | |||
| 109 | pane.mu.Unlock() | 111 | pane.mu.Unlock() | ||
| 110 | 112 | ||||
| 111 | target := domain.RemoteSessionTarget{ | 113 | target := domain.RemoteSessionTarget{ | ||
| @@ -148,8 +150,7 @@ func TestCaptureRemotePreviewDoesNotSplitWideRuneAtCropBoundary(t *testing.T) { | |||||
| 148 | pane.screen = vt.NewScreen(3, 1) | 150 | pane.screen = vt.NewScreen(3, 1) | ||
| 149 | style := renderer.DefaultStyle() | 151 | style := renderer.DefaultStyle() | ||
| 150 | style.Bold = true | 152 | style.Bold = true | ||
| 151 | - | pane.screen.Frame.Set(1, 0, renderer.Cell{Rune: '界', Style: style}) | 153 | + | pane.screen.Write([]byte("\x1b[1;2H\x1b[1m界")) |
| 152 | - | pane.screen.Frame.Set(2, 0, renderer.Cell{Continuation: true, Style: style}) | |||
| 153 | pane.mu.Unlock() | 154 | pane.mu.Unlock() | ||
| 154 | 155 | ||||
| 155 | target := domain.RemoteSessionTarget{ | 156 | target := domain.RemoteSessionTarget{ | ||
A internal/usecase/daemon/render_allocation_test.go +19 -0
| @@ -0,0 +1,19 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // Benchmark accounting measures allocated bytes, not retained heap. Setup and | |
| 10 | + | // warm-up belong to the calling fixture, outside this measured operation. | |
| 11 | + | func assertRenderByteBudget(t *testing.T, run func(), maximum int64) { | |
| 12 | + | t.Helper() | |
| 13 | + | result := testing.Benchmark(func(b *testing.B) { | |
| 14 | + | for b.Loop() { | |
| 15 | + | run() | |
| 16 | + | } | |
| 17 | + | }) | |
| 18 | + | require.LessOrEqual(t, result.AllocedBytesPerOp(), maximum, "rendering must preserve the compact byte-allocation benefit") | |
| 19 | + | } | |
| @@ -0,0 +1,19 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/require" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // Benchmark accounting measures allocated bytes, not retained heap. Setup and | |||
| 10 | + | // warm-up belong to the calling fixture, outside this measured operation. | |||
| 11 | + | func assertRenderByteBudget(t *testing.T, run func(), maximum int64) { | |||
| 12 | + | t.Helper() | |||
| 13 | + | result := testing.Benchmark(func(b *testing.B) { | |||
| 14 | + | for b.Loop() { | |||
| 15 | + | run() | |||
| 16 | + | } | |||
| 17 | + | }) | |||
| 18 | + | require.LessOrEqual(t, result.AllocedBytesPerOp(), maximum, "rendering must preserve the compact byte-allocation benefit") | |||
| 19 | + | } | |||
M internal/usecase/daemon/render_capture.go +6 -4
| @@ -197,8 +197,8 @@ func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, out captured | |||
| 197 | 197 | out.damageGeneration = damage.Generation | |
| 198 | 198 | out.rawDamage = append(out.rawDamage[:0], damage.Damage...) | |
| 199 | 199 | ||
| 200 | - | width := min(max(visible.Width, 0), p.screen.Frame.Width) | |
| 201 | - | height := min(max(visible.Height, 0), p.screen.Frame.Height) | |
| 200 | + | width := min(max(visible.Width, 0), p.screen.Columns()) | |
| 201 | + | height := min(max(visible.Height, 0), p.screen.Rows()) | |
| 202 | 202 | needsFrame := len(damage.Damage) > 0 | |
| 203 | 203 | frameChanged := out.frame.Width != width || out.frame.Height != height | |
| 204 | 204 | if frameChanged { | |
| @@ -209,11 +209,13 @@ func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, out captured | |||
| 209 | 209 | // pane. With no VT damage, retain it rather than copying inactive panes. | |
| 210 | 210 | if needsFrame { | |
| 211 | 211 | for y := range height { | |
| 212 | - | copy(out.frame.Row(y), p.screen.Frame.Row(y)[:width]) | |
| 212 | + | for x := range width { | |
| 213 | + | out.frame.Set(x, y, p.screen.Cell(x, y)) | |
| 214 | + | } | |
| 213 | 215 | } | |
| 214 | 216 | } | |
| 215 | 217 | ||
| 216 | - | if frameChanged || uncertainDamage(damage.Damage, p.screen.Frame.Width, p.screen.Frame.Height) { | |
| 218 | + | if frameChanged || uncertainDamage(damage.Damage, p.screen.Columns(), p.screen.Rows()) { | |
| 217 | 219 | // A cache entry with a new frame has no terminal-shadow equivalent. | |
| 218 | 220 | // Redraw it even when the pane has no VT damage, such as an untouched | |
| 219 | 221 | // pane first made visible after a tab or session switch. | |
| @@ -197,8 +197,8 @@ func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, out captured | |||||
| 197 | out.damageGeneration = damage.Generation | 197 | out.damageGeneration = damage.Generation | ||
| 198 | out.rawDamage = append(out.rawDamage[:0], damage.Damage...) | 198 | out.rawDamage = append(out.rawDamage[:0], damage.Damage...) | ||
| 199 | 199 | ||||
| 200 | - | width := min(max(visible.Width, 0), p.screen.Frame.Width) | 200 | + | width := min(max(visible.Width, 0), p.screen.Columns()) |
| 201 | - | height := min(max(visible.Height, 0), p.screen.Frame.Height) | 201 | + | height := min(max(visible.Height, 0), p.screen.Rows()) |
| 202 | needsFrame := len(damage.Damage) > 0 | 202 | needsFrame := len(damage.Damage) > 0 | ||
| 203 | frameChanged := out.frame.Width != width || out.frame.Height != height | 203 | frameChanged := out.frame.Width != width || out.frame.Height != height | ||
| 204 | if frameChanged { | 204 | if frameChanged { | ||
| @@ -209,11 +209,13 @@ func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, out captured | |||||
| 209 | // pane. With no VT damage, retain it rather than copying inactive panes. | 209 | // pane. With no VT damage, retain it rather than copying inactive panes. | ||
| 210 | if needsFrame { | 210 | if needsFrame { | ||
| 211 | for y := range height { | 211 | for y := range height { | ||
| 212 | - | copy(out.frame.Row(y), p.screen.Frame.Row(y)[:width]) | 212 | + | for x := range width { |
| 213 | + | out.frame.Set(x, y, p.screen.Cell(x, y)) | |||
| 214 | + | } | |||
| 213 | } | 215 | } | ||
| 214 | } | 216 | } | ||
| 215 | 217 | ||||
| 216 | - | if frameChanged || uncertainDamage(damage.Damage, p.screen.Frame.Width, p.screen.Frame.Height) { | 218 | + | if frameChanged || uncertainDamage(damage.Damage, p.screen.Columns(), p.screen.Rows()) { |
| 217 | // A cache entry with a new frame has no terminal-shadow equivalent. | 219 | // A cache entry with a new frame has no terminal-shadow equivalent. | ||
| 218 | // Redraw it even when the pane has no VT damage, such as an untouched | 220 | // Redraw it even when the pane has no VT damage, such as an untouched | ||
| 219 | // pane first made visible after a tab or session switch. | 221 | // pane first made visible after a tab or session switch. | ||
M internal/usecase/daemon/render_damage_receipt_test.go +1 -1
| @@ -162,7 +162,7 @@ func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T | |||
| 162 | 162 | require.Contains(t, string(out.Data), "changed", "retry must emit the retained terminal bytes") | |
| 163 | 163 | p.mu.Lock() | |
| 164 | 164 | require.Empty(t, p.screen.Damage(), "only the successful retry commits its receipt") | |
| 165 | - | require.Equal(t, 'c', p.screen.Frame.At(0, 0).Rune, "retry preserves the changed VT shadow") | |
| 165 | + | require.Equal(t, 'c', p.screen.Cell(0, 0).Rune, "retry preserves the changed VT shadow") | |
| 166 | 166 | p.mu.Unlock() | |
| 167 | 167 | }) | |
| 168 | 168 | } | |
| @@ -162,7 +162,7 @@ func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T | |||||
| 162 | require.Contains(t, string(out.Data), "changed", "retry must emit the retained terminal bytes") | 162 | require.Contains(t, string(out.Data), "changed", "retry must emit the retained terminal bytes") | ||
| 163 | p.mu.Lock() | 163 | p.mu.Lock() | ||
| 164 | require.Empty(t, p.screen.Damage(), "only the successful retry commits its receipt") | 164 | require.Empty(t, p.screen.Damage(), "only the successful retry commits its receipt") | ||
| 165 | - | require.Equal(t, 'c', p.screen.Frame.At(0, 0).Rune, "retry preserves the changed VT shadow") | 165 | + | require.Equal(t, 'c', p.screen.Cell(0, 0).Rune, "retry preserves the changed VT shadow") |
| 166 | p.mu.Unlock() | 166 | p.mu.Unlock() | ||
| 167 | }) | 167 | }) | ||
| 168 | } | 168 | } | ||
M internal/usecase/daemon/render_pipeline.go +38 -9
| @@ -2,6 +2,7 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | + | "slices" | |
| 5 | 6 | ||
| 6 | 7 | renderer "github.com/bnema/vev-vt" | |
| 7 | 8 | "github.com/bnema/vev/internal/domain" | |
| @@ -85,8 +86,17 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 85 | 86 | frame = renderer.NewFrame(width, frameHeight) | |
| 86 | 87 | } | |
| 87 | 88 | if in.frame.Width == width && in.frame.Height == frameHeight { | |
| 88 | - | for y := 0; y < frame.Height; y++ { | |
| 89 | - | copy(frame.Row(y), in.frame.Row(y)) | |
| 89 | + | if state.overlays.copyMode != nil { | |
| 90 | + | // Compact Clone preserves page-local style IDs. Re-interning every | |
| 91 | + | // cell into scratch costs more than a structural copy while scrolling. | |
| 92 | + | // The clone still isolates committed state from failed publications. | |
| 93 | + | frame = in.frame.Clone() | |
| 94 | + | } else { | |
| 95 | + | for y := 0; y < frame.Height; y++ { | |
| 96 | + | for x := range frame.Width { | |
| 97 | + | frame.Set(x, y, in.frame.Cell(x, y)) | |
| 98 | + | } | |
| 99 | + | } | |
| 90 | 100 | } | |
| 91 | 101 | } | |
| 92 | 102 | styles := state.styles | |
| @@ -99,8 +109,14 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 99 | 109 | defaultDimmer := themeui.NewDimmer(state.theme) | |
| 100 | 110 | neutralBorder := styles.NeutralBorder | |
| 101 | 111 | inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming)) | |
| 102 | - | drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles) | |
| 103 | - | drawStatusBarState(frame.Row(rows+1), state.bars, styles) | |
| 112 | + | // The alternate cache owns these rows; the committed cache remains intact | |
| 113 | + | // until prepare/send succeeds. Row extraction would allocate on each paint. | |
| 114 | + | topBar := slices.Grow(scratch.bars.top[:0], width)[:width] | |
| 115 | + | drawTopBarSnapshot(topBar, state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles) | |
| 116 | + | frame.WriteRow(0, 0, topBar) | |
| 117 | + | bottomBar := slices.Grow(scratch.bars.bottom[:0], width)[:width] | |
| 118 | + | drawStatusBarState(bottomBar, state.bars, styles) | |
| 119 | + | frame.WriteRow(rows+1, 0, bottomBar) | |
| 104 | 120 | content := domain.Rect{Y: contentY, Width: width, Height: rows} | |
| 105 | 121 | if state.layout.valid { | |
| 106 | 122 | drawDividers(frame, state.layout.dividers, content.Y, defaultDimmer.Dim(neutralBorder)) | |
| @@ -164,11 +180,15 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 164 | 180 | frame, damage = composeCapturedCopyMode(state, frame, damage, content) | |
| 165 | 181 | frame, damage = composeCapturedOverlays(state, frame, damage) | |
| 166 | 182 | } | |
| 183 | + | for x := range width { | |
| 184 | + | topBar[x] = baseFrame.Cell(x, 0) | |
| 185 | + | bottomBar[x] = baseFrame.Cell(x, rows+1) | |
| 186 | + | } | |
| 167 | 187 | if !full { | |
| 168 | - | if !sameCells(in.bars.top, baseFrame.Row(0)) { | |
| 188 | + | if !sameCells(in.bars.top, topBar) { | |
| 169 | 189 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1}) | |
| 170 | 190 | } | |
| 171 | - | if !sameCells(in.bars.bottom, baseFrame.Row(rows+1)) { | |
| 191 | + | if !sameCells(in.bars.bottom, bottomBar) { | |
| 172 | 192 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1}) | |
| 173 | 193 | } | |
| 174 | 194 | } | |
| @@ -185,8 +205,15 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 185 | 205 | cursorInputs := state.cursor | |
| 186 | 206 | cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive | |
| 187 | 207 | cursor := desiredCapturedCursor(cursorInputs, contentY) | |
| 188 | - | outCache := composeCacheInput{valid: !overlaysActive, frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, toastFootprints: append(scratch.toastFootprints[:0], toastFootprints...), floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars} | |
| 189 | - | outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1)) | |
| 208 | + | // A plain copy viewport is painted on a clone, never into baseFrame. Keep | |
| 209 | + | // that unadorned base reusable between wheel events. Modal/floating paths | |
| 210 | + | // retain their conservative invalidation, and copy exit requests a reset. | |
| 211 | + | copyOnly := state.overlays.copyMode != nil && state.overlays.copyActive && | |
| 212 | + | !state.overlays.copySearchActive && !state.overlays.pickerActive && | |
| 213 | + | !state.overlays.paletteActive && !state.overlays.promptActive && | |
| 214 | + | !state.overlays.noticesOverlayActive && !state.overlays.resizeActive && !state.floating.visible | |
| 215 | + | outCache := composeCacheInput{valid: !overlaysActive || copyOnly, frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, toastFootprints: append(scratch.toastFootprints[:0], toastFootprints...), floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars} | |
| 216 | + | outCache.bars = barCache{top: topBar, bottom: bottomBar} | |
| 190 | 217 | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()} | |
| 191 | 218 | } | |
| 192 | 219 | ||
| @@ -345,7 +372,9 @@ func copyModalInner(dst renderer.Frame, target domain.Rect, src renderer.Frame) | |||
| 345 | 372 | return | |
| 346 | 373 | } | |
| 347 | 374 | for y := range height { | |
| 348 | - | copy(dst.Row(top + y)[left:left+width], src.Row(sourceY + y)[sourceX:sourceX+width]) | |
| 375 | + | for x := range width { | |
| 376 | + | dst.Set(left+x, top+y, src.Cell(sourceX+x, sourceY+y)) | |
| 377 | + | } | |
| 349 | 378 | } | |
| 350 | 379 | } | |
| 351 | 380 | ||
| @@ -2,6 +2,7 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "errors" | 4 | "errors" | ||
| 5 | + | "slices" | |||
| 5 | 6 | ||||
| 6 | renderer "github.com/bnema/vev-vt" | 7 | renderer "github.com/bnema/vev-vt" | ||
| 7 | "github.com/bnema/vev/internal/domain" | 8 | "github.com/bnema/vev/internal/domain" | ||
| @@ -85,8 +86,17 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 85 | frame = renderer.NewFrame(width, frameHeight) | 86 | frame = renderer.NewFrame(width, frameHeight) | ||
| 86 | } | 87 | } | ||
| 87 | if in.frame.Width == width && in.frame.Height == frameHeight { | 88 | if in.frame.Width == width && in.frame.Height == frameHeight { | ||
| 88 | - | for y := 0; y < frame.Height; y++ { | 89 | + | if state.overlays.copyMode != nil { |
| 89 | - | copy(frame.Row(y), in.frame.Row(y)) | 90 | + | // Compact Clone preserves page-local style IDs. Re-interning every |
| 91 | + | // cell into scratch costs more than a structural copy while scrolling. | |||
| 92 | + | // The clone still isolates committed state from failed publications. | |||
| 93 | + | frame = in.frame.Clone() | |||
| 94 | + | } else { | |||
| 95 | + | for y := 0; y < frame.Height; y++ { | |||
| 96 | + | for x := range frame.Width { | |||
| 97 | + | frame.Set(x, y, in.frame.Cell(x, y)) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 90 | } | 100 | } | ||
| 91 | } | 101 | } | ||
| 92 | styles := state.styles | 102 | styles := state.styles | ||
| @@ -99,8 +109,14 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 99 | defaultDimmer := themeui.NewDimmer(state.theme) | 109 | defaultDimmer := themeui.NewDimmer(state.theme) | ||
| 100 | neutralBorder := styles.NeutralBorder | 110 | neutralBorder := styles.NeutralBorder | ||
| 101 | inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming)) | 111 | inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming)) | ||
| 102 | - | drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles) | 112 | + | // The alternate cache owns these rows; the committed cache remains intact |
| 103 | - | drawStatusBarState(frame.Row(rows+1), state.bars, styles) | 113 | + | // until prepare/send succeeds. Row extraction would allocate on each paint. |
| 114 | + | topBar := slices.Grow(scratch.bars.top[:0], width)[:width] | |||
| 115 | + | drawTopBarSnapshot(topBar, state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles) | |||
| 116 | + | frame.WriteRow(0, 0, topBar) | |||
| 117 | + | bottomBar := slices.Grow(scratch.bars.bottom[:0], width)[:width] | |||
| 118 | + | drawStatusBarState(bottomBar, state.bars, styles) | |||
| 119 | + | frame.WriteRow(rows+1, 0, bottomBar) | |||
| 104 | content := domain.Rect{Y: contentY, Width: width, Height: rows} | 120 | content := domain.Rect{Y: contentY, Width: width, Height: rows} | ||
| 105 | if state.layout.valid { | 121 | if state.layout.valid { | ||
| 106 | drawDividers(frame, state.layout.dividers, content.Y, defaultDimmer.Dim(neutralBorder)) | 122 | drawDividers(frame, state.layout.dividers, content.Y, defaultDimmer.Dim(neutralBorder)) | ||
| @@ -164,11 +180,15 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 164 | frame, damage = composeCapturedCopyMode(state, frame, damage, content) | 180 | frame, damage = composeCapturedCopyMode(state, frame, damage, content) | ||
| 165 | frame, damage = composeCapturedOverlays(state, frame, damage) | 181 | frame, damage = composeCapturedOverlays(state, frame, damage) | ||
| 166 | } | 182 | } | ||
| 183 | + | for x := range width { | |||
| 184 | + | topBar[x] = baseFrame.Cell(x, 0) | |||
| 185 | + | bottomBar[x] = baseFrame.Cell(x, rows+1) | |||
| 186 | + | } | |||
| 167 | if !full { | 187 | if !full { | ||
| 168 | - | if !sameCells(in.bars.top, baseFrame.Row(0)) { | 188 | + | if !sameCells(in.bars.top, topBar) { |
| 169 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1}) | 189 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1}) | ||
| 170 | } | 190 | } | ||
| 171 | - | if !sameCells(in.bars.bottom, baseFrame.Row(rows+1)) { | 191 | + | if !sameCells(in.bars.bottom, bottomBar) { |
| 172 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1}) | 192 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1}) | ||
| 173 | } | 193 | } | ||
| 174 | } | 194 | } | ||
| @@ -185,8 +205,15 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 185 | cursorInputs := state.cursor | 205 | cursorInputs := state.cursor | ||
| 186 | cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive | 206 | cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive | ||
| 187 | cursor := desiredCapturedCursor(cursorInputs, contentY) | 207 | cursor := desiredCapturedCursor(cursorInputs, contentY) | ||
| 188 | - | outCache := composeCacheInput{valid: !overlaysActive, frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, toastFootprints: append(scratch.toastFootprints[:0], toastFootprints...), floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars} | 208 | + | // A plain copy viewport is painted on a clone, never into baseFrame. Keep |
| 189 | - | outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1)) | 209 | + | // that unadorned base reusable between wheel events. Modal/floating paths |
| 210 | + | // retain their conservative invalidation, and copy exit requests a reset. | |||
| 211 | + | copyOnly := state.overlays.copyMode != nil && state.overlays.copyActive && | |||
| 212 | + | !state.overlays.copySearchActive && !state.overlays.pickerActive && | |||
| 213 | + | !state.overlays.paletteActive && !state.overlays.promptActive && | |||
| 214 | + | !state.overlays.noticesOverlayActive && !state.overlays.resizeActive && !state.floating.visible | |||
| 215 | + | outCache := composeCacheInput{valid: !overlaysActive || copyOnly, frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, toastFootprints: append(scratch.toastFootprints[:0], toastFootprints...), floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars} | |||
| 216 | + | outCache.bars = barCache{top: topBar, bottom: bottomBar} | |||
| 190 | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()} | 217 | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()} | ||
| 191 | } | 218 | } | ||
| 192 | 219 | ||||
| @@ -345,7 +372,9 @@ func copyModalInner(dst renderer.Frame, target domain.Rect, src renderer.Frame) | |||||
| 345 | return | 372 | return | ||
| 346 | } | 373 | } | ||
| 347 | for y := range height { | 374 | for y := range height { | ||
| 348 | - | copy(dst.Row(top + y)[left:left+width], src.Row(sourceY + y)[sourceX:sourceX+width]) | 375 | + | for x := range width { |
| 376 | + | dst.Set(left+x, top+y, src.Cell(sourceX+x, sourceY+y)) | |||
| 377 | + | } | |||
| 349 | } | 378 | } | ||
| 350 | } | 379 | } | ||
| 351 | 380 | ||||
M internal/usecase/daemon/render_pipeline_cache_test.go +1 -3
| @@ -295,9 +295,7 @@ func cloneCapturedStackState(in capturedRenderState) capturedRenderState { | |||
| 295 | 295 | out.layout.placements = append([]layout.Placement(nil), in.layout.placements...) | |
| 296 | 296 | out.panes = append([]capturedPaneRenderState(nil), in.panes...) | |
| 297 | 297 | for i := range out.panes { | |
| 298 | - | if in.panes[i].frame.Cells != nil { | |
| 299 | - | out.panes[i].frame = in.panes[i].frame.Clone() | |
| 300 | - | } | |
| 298 | + | out.panes[i].frame = in.panes[i].frame.Clone() | |
| 301 | 299 | out.panes[i].rawDamage = append([]renderer.Damage(nil), in.panes[i].rawDamage...) | |
| 302 | 300 | out.panes[i].damage = append([]renderer.Damage(nil), in.panes[i].damage...) | |
| 303 | 301 | } | |
| @@ -295,9 +295,7 @@ func cloneCapturedStackState(in capturedRenderState) capturedRenderState { | |||||
| 295 | out.layout.placements = append([]layout.Placement(nil), in.layout.placements...) | 295 | out.layout.placements = append([]layout.Placement(nil), in.layout.placements...) | ||
| 296 | out.panes = append([]capturedPaneRenderState(nil), in.panes...) | 296 | out.panes = append([]capturedPaneRenderState(nil), in.panes...) | ||
| 297 | for i := range out.panes { | 297 | for i := range out.panes { | ||
| 298 | - | if in.panes[i].frame.Cells != nil { | 298 | + | out.panes[i].frame = in.panes[i].frame.Clone() |
| 299 | - | out.panes[i].frame = in.panes[i].frame.Clone() | |||
| 300 | - | } | |||
| 301 | out.panes[i].rawDamage = append([]renderer.Damage(nil), in.panes[i].rawDamage...) | 299 | out.panes[i].rawDamage = append([]renderer.Damage(nil), in.panes[i].rawDamage...) | ||
| 302 | out.panes[i].damage = append([]renderer.Damage(nil), in.panes[i].damage...) | 300 | out.panes[i].damage = append([]renderer.Damage(nil), in.panes[i].damage...) | ||
| 303 | } | 301 | } | ||
M internal/usecase/daemon/render_pipeline_test.go +15 -11
| @@ -63,7 +63,7 @@ func TestTransportReplayFinalShadowAndTerminalBytes(t *testing.T) { | |||
| 63 | 63 | terminal.Write(output.Data) | |
| 64 | 64 | } | |
| 65 | 65 | require.Equal(t, len(frames), transport.next) | |
| 66 | - | require.Equal(t, []string{"one ", "TWO ", " "}, frameRows(terminal.Frame), "terminal replay is the final renderer shadow") | |
| 66 | + | require.Equal(t, []string{"one ", "TWO ", " "}, frameRows(terminal), "terminal replay is the final renderer shadow") | |
| 67 | 67 | } | |
| 68 | 68 | ||
| 69 | 69 | func TestCapturePaneRenderStateOwnsVisibleFrameWithoutConsumingDamage(t *testing.T) { | |
| @@ -343,16 +343,20 @@ func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) { | |||
| 343 | 343 | require.Equal(t, "\x1b[1;1H\x1b[0;7m tab \x1b[0m R\x1b[2;1HAAAAAAAAAAAA\x1b[3;1HBBB┌─fl─┐BBB\x1b[4;1H───Prompt───\x1b[5;1HPROMPT\x1b[K\x1b[B\x1b[2K\x1b[7;1H\x1b[0;7m sess \x1b[0m B\x1b[0m\x1b[?25l", string(terminalBytes)) | |
| 344 | 344 | client := vt.NewScreen(composed.frame.Width, composed.frame.Height) | |
| 345 | 345 | client.Write(terminalBytes) | |
| 346 | - | require.Equal(t, frameRows(composed.frame), frameRows(client.Frame)) | |
| 346 | + | require.Equal(t, frameRows(composed.frame), frameRows(client)) | |
| 347 | 347 | again, err := stream.renderer.Draw(composed.frame, nil) | |
| 348 | 348 | require.NoError(t, err) | |
| 349 | 349 | require.Empty(t, again, "renderer shadow must exactly equal the composed frame") | |
| 350 | 350 | } | |
| 351 | 351 | ||
| 352 | - | func frameRows(frame renderer.Frame) []string { | |
| 353 | - | rows := make([]string, frame.Height) | |
| 352 | + | func frameRows(frame renderer.CellSource) []string { | |
| 353 | + | rows := make([]string, frame.Rows()) | |
| 354 | + | cells := make([]renderer.Cell, frame.Columns()) | |
| 354 | 355 | for y := range rows { | |
| 355 | - | rows[y] = rowText(frame.Row(y)) | |
| 356 | + | for x := range cells { | |
| 357 | + | cells[x] = frame.Cell(x, y) | |
| 358 | + | } | |
| 359 | + | rows[y] = rowText(cells) | |
| 356 | 360 | } | |
| 357 | 361 | return rows | |
| 358 | 362 | } | |
| @@ -391,7 +395,7 @@ func TestComposeEmitExactReplaySafeAndUnsafeScroll(t *testing.T) { | |||
| 391 | 395 | require.NoError(t, err) | |
| 392 | 396 | require.Equal(t, tt.wantBytes, string(secondBytes)) | |
| 393 | 397 | client.Write(secondBytes) | |
| 394 | - | require.Equal(t, frameRows(second.frame), frameRows(client.Frame)) | |
| 398 | + | require.Equal(t, frameRows(second.frame), frameRows(client)) | |
| 395 | 399 | again, err := stream.renderer.Draw(second.frame, nil) | |
| 396 | 400 | require.NoError(t, err) | |
| 397 | 401 | require.Empty(t, again, "renderer shadow must equal replayed terminal state") | |
| @@ -494,12 +498,12 @@ func TestComposeFrameModalBackdropDimsCompleteFrameIncludingToasts(t *testing.T) | |||
| 494 | 498 | } { | |
| 495 | 499 | t.Run(name, func(t *testing.T) { | |
| 496 | 500 | x, y := point[0], point[1] | |
| 497 | - | require.Equal(t, dimmer.Dim(base.frame.At(x, y).Style), composed.frame.At(x, y).Style) | |
| 501 | + | require.Equal(t, dimmer.Dim(base.frame.At(x, y).Style).Canonical(), composed.frame.At(x, y).Style.Canonical()) | |
| 498 | 502 | }) | |
| 499 | 503 | } | |
| 500 | 504 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, composed.damage) | |
| 501 | 505 | require.False(t, composed.cache.valid) | |
| 502 | - | require.Equal(t, base.cache.frame.Cells, composed.cache.frame.Cells, "the reusable cache remains toast-free and unadorned") | |
| 506 | + | require.Equal(t, captureTestFrame(base.cache.frame), captureTestFrame(composed.cache.frame), "the reusable cache remains toast-free and unadorned") | |
| 503 | 507 | } | |
| 504 | 508 | ||
| 505 | 509 | func TestComposeFrameFloatingBackdropDimsCompleteFrameIncludingToasts(t *testing.T) { | |
| @@ -544,10 +548,10 @@ func TestComposeFrameFloatingBackdropDimsCompleteFrameIncludingToasts(t *testing | |||
| 544 | 548 | } { | |
| 545 | 549 | t.Run(name, func(t *testing.T) { | |
| 546 | 550 | x, y := point[0], point[1] | |
| 547 | - | require.Equal(t, dimmer.Dim(toastOnly.frame.At(x, y).Style), composed.frame.At(x, y).Style) | |
| 551 | + | require.Equal(t, dimmer.Dim(toastOnly.frame.At(x, y).Style).Canonical(), composed.frame.At(x, y).Style.Canonical()) | |
| 548 | 552 | }) | |
| 549 | 553 | } | |
| 550 | - | require.Equal(t, toastOnly.cache.frame.Cells, composed.cache.frame.Cells, "floating decoration must not enter the reusable base cache") | |
| 554 | + | require.Equal(t, captureTestFrame(toastOnly.cache.frame), captureTestFrame(composed.cache.frame), "floating decoration must not enter the reusable base cache") | |
| 551 | 555 | } | |
| 552 | 556 | ||
| 553 | 557 | func TestComposeCapturedOverlaysMatchesKeyboardPriority(t *testing.T) { | |
| @@ -593,7 +597,7 @@ func TestComposeCapturedOverlaysMatchesKeyboardPriority(t *testing.T) { | |||
| 593 | 597 | frame, damage := composeCapturedOverlays(state, renderer.NewFrame(5, 3), nil) | |
| 594 | 598 | ||
| 595 | 599 | require.Equal(t, 'L', frame.At(1, 1).Rune) | |
| 596 | - | require.Equal(t, themeui.NewDimmer(theme).Dim(lowerStyle), frame.At(1, 1).Style, "the lower-priority modal is part of the higher modal backdrop") | |
| 600 | + | require.Equal(t, themeui.NewDimmer(theme).Dim(lowerStyle).Canonical(), frame.At(1, 1).Style.Canonical(), "the lower-priority modal is part of the higher modal backdrop") | |
| 597 | 601 | require.Equal(t, 'H', frame.At(3, 1).Rune) | |
| 598 | 602 | require.Equal(t, higherStyle, frame.At(3, 1).Style, "the keyboard owner remains visually topmost") | |
| 599 | 603 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | |
| @@ -63,7 +63,7 @@ func TestTransportReplayFinalShadowAndTerminalBytes(t *testing.T) { | |||||
| 63 | terminal.Write(output.Data) | 63 | terminal.Write(output.Data) | ||
| 64 | } | 64 | } | ||
| 65 | require.Equal(t, len(frames), transport.next) | 65 | require.Equal(t, len(frames), transport.next) | ||
| 66 | - | require.Equal(t, []string{"one ", "TWO ", " "}, frameRows(terminal.Frame), "terminal replay is the final renderer shadow") | 66 | + | require.Equal(t, []string{"one ", "TWO ", " "}, frameRows(terminal), "terminal replay is the final renderer shadow") |
| 67 | } | 67 | } | ||
| 68 | 68 | ||||
| 69 | func TestCapturePaneRenderStateOwnsVisibleFrameWithoutConsumingDamage(t *testing.T) { | 69 | func TestCapturePaneRenderStateOwnsVisibleFrameWithoutConsumingDamage(t *testing.T) { | ||
| @@ -343,16 +343,20 @@ func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) { | |||||
| 343 | require.Equal(t, "\x1b[1;1H\x1b[0;7m tab \x1b[0m R\x1b[2;1HAAAAAAAAAAAA\x1b[3;1HBBB┌─fl─┐BBB\x1b[4;1H───Prompt───\x1b[5;1HPROMPT\x1b[K\x1b[B\x1b[2K\x1b[7;1H\x1b[0;7m sess \x1b[0m B\x1b[0m\x1b[?25l", string(terminalBytes)) | 343 | require.Equal(t, "\x1b[1;1H\x1b[0;7m tab \x1b[0m R\x1b[2;1HAAAAAAAAAAAA\x1b[3;1HBBB┌─fl─┐BBB\x1b[4;1H───Prompt───\x1b[5;1HPROMPT\x1b[K\x1b[B\x1b[2K\x1b[7;1H\x1b[0;7m sess \x1b[0m B\x1b[0m\x1b[?25l", string(terminalBytes)) | ||
| 344 | client := vt.NewScreen(composed.frame.Width, composed.frame.Height) | 344 | client := vt.NewScreen(composed.frame.Width, composed.frame.Height) | ||
| 345 | client.Write(terminalBytes) | 345 | client.Write(terminalBytes) | ||
| 346 | - | require.Equal(t, frameRows(composed.frame), frameRows(client.Frame)) | 346 | + | require.Equal(t, frameRows(composed.frame), frameRows(client)) |
| 347 | again, err := stream.renderer.Draw(composed.frame, nil) | 347 | again, err := stream.renderer.Draw(composed.frame, nil) | ||
| 348 | require.NoError(t, err) | 348 | require.NoError(t, err) | ||
| 349 | require.Empty(t, again, "renderer shadow must exactly equal the composed frame") | 349 | require.Empty(t, again, "renderer shadow must exactly equal the composed frame") | ||
| 350 | } | 350 | } | ||
| 351 | 351 | ||||
| 352 | - | func frameRows(frame renderer.Frame) []string { | 352 | + | func frameRows(frame renderer.CellSource) []string { |
| 353 | - | rows := make([]string, frame.Height) | 353 | + | rows := make([]string, frame.Rows()) |
| 354 | + | cells := make([]renderer.Cell, frame.Columns()) | |||
| 354 | for y := range rows { | 355 | for y := range rows { | ||
| 355 | - | rows[y] = rowText(frame.Row(y)) | 356 | + | for x := range cells { |
| 357 | + | cells[x] = frame.Cell(x, y) | |||
| 358 | + | } | |||
| 359 | + | rows[y] = rowText(cells) | |||
| 356 | } | 360 | } | ||
| 357 | return rows | 361 | return rows | ||
| 358 | } | 362 | } | ||
| @@ -391,7 +395,7 @@ func TestComposeEmitExactReplaySafeAndUnsafeScroll(t *testing.T) { | |||||
| 391 | require.NoError(t, err) | 395 | require.NoError(t, err) | ||
| 392 | require.Equal(t, tt.wantBytes, string(secondBytes)) | 396 | require.Equal(t, tt.wantBytes, string(secondBytes)) | ||
| 393 | client.Write(secondBytes) | 397 | client.Write(secondBytes) | ||
| 394 | - | require.Equal(t, frameRows(second.frame), frameRows(client.Frame)) | 398 | + | require.Equal(t, frameRows(second.frame), frameRows(client)) |
| 395 | again, err := stream.renderer.Draw(second.frame, nil) | 399 | again, err := stream.renderer.Draw(second.frame, nil) | ||
| 396 | require.NoError(t, err) | 400 | require.NoError(t, err) | ||
| 397 | require.Empty(t, again, "renderer shadow must equal replayed terminal state") | 401 | require.Empty(t, again, "renderer shadow must equal replayed terminal state") | ||
| @@ -494,12 +498,12 @@ func TestComposeFrameModalBackdropDimsCompleteFrameIncludingToasts(t *testing.T) | |||||
| 494 | } { | 498 | } { | ||
| 495 | t.Run(name, func(t *testing.T) { | 499 | t.Run(name, func(t *testing.T) { | ||
| 496 | x, y := point[0], point[1] | 500 | x, y := point[0], point[1] | ||
| 497 | - | require.Equal(t, dimmer.Dim(base.frame.At(x, y).Style), composed.frame.At(x, y).Style) | 501 | + | require.Equal(t, dimmer.Dim(base.frame.At(x, y).Style).Canonical(), composed.frame.At(x, y).Style.Canonical()) |
| 498 | }) | 502 | }) | ||
| 499 | } | 503 | } | ||
| 500 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, composed.damage) | 504 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, composed.damage) | ||
| 501 | require.False(t, composed.cache.valid) | 505 | require.False(t, composed.cache.valid) | ||
| 502 | - | require.Equal(t, base.cache.frame.Cells, composed.cache.frame.Cells, "the reusable cache remains toast-free and unadorned") | 506 | + | require.Equal(t, captureTestFrame(base.cache.frame), captureTestFrame(composed.cache.frame), "the reusable cache remains toast-free and unadorned") |
| 503 | } | 507 | } | ||
| 504 | 508 | ||||
| 505 | func TestComposeFrameFloatingBackdropDimsCompleteFrameIncludingToasts(t *testing.T) { | 509 | func TestComposeFrameFloatingBackdropDimsCompleteFrameIncludingToasts(t *testing.T) { | ||
| @@ -544,10 +548,10 @@ func TestComposeFrameFloatingBackdropDimsCompleteFrameIncludingToasts(t *testing | |||||
| 544 | } { | 548 | } { | ||
| 545 | t.Run(name, func(t *testing.T) { | 549 | t.Run(name, func(t *testing.T) { | ||
| 546 | x, y := point[0], point[1] | 550 | x, y := point[0], point[1] | ||
| 547 | - | require.Equal(t, dimmer.Dim(toastOnly.frame.At(x, y).Style), composed.frame.At(x, y).Style) | 551 | + | require.Equal(t, dimmer.Dim(toastOnly.frame.At(x, y).Style).Canonical(), composed.frame.At(x, y).Style.Canonical()) |
| 548 | }) | 552 | }) | ||
| 549 | } | 553 | } | ||
| 550 | - | require.Equal(t, toastOnly.cache.frame.Cells, composed.cache.frame.Cells, "floating decoration must not enter the reusable base cache") | 554 | + | require.Equal(t, captureTestFrame(toastOnly.cache.frame), captureTestFrame(composed.cache.frame), "floating decoration must not enter the reusable base cache") |
| 551 | } | 555 | } | ||
| 552 | 556 | ||||
| 553 | func TestComposeCapturedOverlaysMatchesKeyboardPriority(t *testing.T) { | 557 | func TestComposeCapturedOverlaysMatchesKeyboardPriority(t *testing.T) { | ||
| @@ -593,7 +597,7 @@ func TestComposeCapturedOverlaysMatchesKeyboardPriority(t *testing.T) { | |||||
| 593 | frame, damage := composeCapturedOverlays(state, renderer.NewFrame(5, 3), nil) | 597 | frame, damage := composeCapturedOverlays(state, renderer.NewFrame(5, 3), nil) | ||
| 594 | 598 | ||||
| 595 | require.Equal(t, 'L', frame.At(1, 1).Rune) | 599 | require.Equal(t, 'L', frame.At(1, 1).Rune) | ||
| 596 | - | require.Equal(t, themeui.NewDimmer(theme).Dim(lowerStyle), frame.At(1, 1).Style, "the lower-priority modal is part of the higher modal backdrop") | 600 | + | require.Equal(t, themeui.NewDimmer(theme).Dim(lowerStyle).Canonical(), frame.At(1, 1).Style.Canonical(), "the lower-priority modal is part of the higher modal backdrop") |
| 597 | require.Equal(t, 'H', frame.At(3, 1).Rune) | 601 | require.Equal(t, 'H', frame.At(3, 1).Rune) | ||
| 598 | require.Equal(t, higherStyle, frame.At(3, 1).Style, "the keyboard owner remains visually topmost") | 602 | require.Equal(t, higherStyle, frame.At(3, 1).Style, "the keyboard owner remains visually topmost") | ||
| 599 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | 603 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | ||
M internal/usecase/daemon/render_test.go +35 -35
| @@ -39,23 +39,23 @@ func TestPaletteBackdropDimsSimultaneousCopyMode(t *testing.T) { | |||
| 39 | 39 | ||
| 40 | 40 | d.enterCopyMode(sess, ac) | |
| 41 | 41 | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | |
| 42 | - | undimmed := client.Frame.At(0, 1) | |
| 42 | + | undimmed := client.Cell(0, 1) | |
| 43 | 43 | require.Equal(t, 'X', undimmed.Rune, "fixture must address copy-mode pane content") | |
| 44 | - | copyBar := append([]renderer.Cell(nil), client.Frame.Row(client.Frame.Height-1)...) | |
| 44 | + | copyBar := client.RowCells(client.Rows() - 1) | |
| 45 | 45 | require.Contains(t, rowText(copyBar), "[SCROLL]", "fixture must capture the copy status bar") | |
| 46 | 46 | ||
| 47 | 47 | d.enterPalette(sess, ac) | |
| 48 | 48 | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | |
| 49 | - | dimmed := client.Frame.At(0, 1) | |
| 49 | + | dimmed := client.Cell(0, 1) | |
| 50 | 50 | require.Equal(t, undimmed.Rune, dimmed.Rune, "palette backdrop must preserve copy content") | |
| 51 | - | require.Equal(t, themeui.NewDimmer(theme).Dim(undimmed.Style), dimmed.Style, "palette backdrop must dim the composed copy frame") | |
| 51 | + | require.Equal(t, themeui.NewDimmer(theme).Dim(undimmed.Style).Canonical(), dimmed.Style.Canonical(), "palette backdrop must dim the composed copy frame") | |
| 52 | 52 | for x, cell := range copyBar { | |
| 53 | - | cell.Style = themeui.NewDimmer(theme).Dim(cell.Style) | |
| 54 | - | require.Equal(t, cell, client.Frame.At(x, client.Frame.Height-1), "copy status bar must be part of the complete backdrop") | |
| 53 | + | cell.Style = themeui.NewDimmer(theme).Dim(cell.Style).Canonical() | |
| 54 | + | require.Equal(t, cell, client.Cell(x, client.Rows()-1), "copy status bar must be part of the complete backdrop") | |
| 55 | 55 | } | |
| 56 | 56 | paletteVisible := false | |
| 57 | - | for y := range client.Frame.Height { | |
| 58 | - | paletteVisible = paletteVisible || strings.Contains(rowText(client.Frame.Row(y)), "Commands") | |
| 57 | + | for y := range client.Rows() { | |
| 58 | + | paletteVisible = paletteVisible || strings.Contains(rowText(client.RowCells(y)), "Commands") | |
| 59 | 59 | } | |
| 60 | 60 | require.True(t, paletteVisible, "palette must remain composed above copy mode") | |
| 61 | 61 | } | |
| @@ -108,16 +108,16 @@ func TestPaletteBackdropDimsSimultaneousPicker(t *testing.T) { | |||
| 108 | 108 | ||
| 109 | 109 | d.enterPicker(sess, ac) | |
| 110 | 110 | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | |
| 111 | - | pickerTitle := client.Frame.At(31, 2) | |
| 111 | + | pickerTitle := client.Cell(31, 2) | |
| 112 | 112 | require.Equal(t, 'S', pickerTitle.Rune, "fixture must address the picker title") | |
| 113 | - | undimmedPane := client.Frame.At(0, 1) | |
| 113 | + | undimmedPane := client.Cell(0, 1) | |
| 114 | 114 | ||
| 115 | 115 | d.enterPalette(sess, ac) | |
| 116 | 116 | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | |
| 117 | 117 | dimmedPickerTitle := pickerTitle | |
| 118 | - | dimmedPickerTitle.Style = themeui.NewDimmer(backdropTheme()).Dim(pickerTitle.Style) | |
| 119 | - | require.Equal(t, dimmedPickerTitle, client.Frame.At(31, 2), "the lower-priority picker must be part of the palette backdrop") | |
| 120 | - | require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmedPane.Style), client.Frame.At(0, 1).Style, "pane content outside overlays must use the theme dim style") | |
| 118 | + | dimmedPickerTitle.Style = themeui.NewDimmer(backdropTheme()).Dim(pickerTitle.Style).Canonical() | |
| 119 | + | require.Equal(t, dimmedPickerTitle, client.Cell(31, 2), "the lower-priority picker must be part of the palette backdrop") | |
| 120 | + | require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmedPane.Style).Canonical(), client.Cell(0, 1).Style.Canonical(), "pane content outside overlays must use the theme dim style") | |
| 121 | 121 | } | |
| 122 | 122 | ||
| 123 | 123 | func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) { | |
| @@ -130,27 +130,27 @@ func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) { | |||
| 130 | 130 | pane.screen.Write([]byte("X")) | |
| 131 | 131 | d.paint(sess, ac, true, nil) | |
| 132 | 132 | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | |
| 133 | - | undimmed := client.Frame.At(0, 1) | |
| 134 | - | topBar := client.Frame.At(0, 0) | |
| 135 | - | bottomBar := client.Frame.At(0, 24) | |
| 133 | + | undimmed := client.Cell(0, 1) | |
| 134 | + | topBar := client.Cell(0, 0) | |
| 135 | + | bottomBar := client.Cell(0, 24) | |
| 136 | 136 | ||
| 137 | 137 | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 138 | 138 | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | |
| 139 | - | dimmed := client.Frame.At(0, 1) | |
| 139 | + | dimmed := client.Cell(0, 1) | |
| 140 | 140 | require.Equal(t, 'X', dimmed.Rune) | |
| 141 | - | require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmed.Style), dimmed.Style, "open palette must use the theme dim style") | |
| 141 | + | require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmed.Style).Canonical(), dimmed.Style.Canonical(), "open palette must use the theme dim style") | |
| 142 | 142 | dimmedTopBar := topBar | |
| 143 | - | dimmedTopBar.Style = themeui.NewDimmer(backdropTheme()).Dim(topBar.Style) | |
| 143 | + | dimmedTopBar.Style = themeui.NewDimmer(backdropTheme()).Dim(topBar.Style).Canonical() | |
| 144 | 144 | dimmedBottomBar := bottomBar | |
| 145 | - | dimmedBottomBar.Style = themeui.NewDimmer(backdropTheme()).Dim(bottomBar.Style) | |
| 146 | - | require.Equal(t, dimmedTopBar, client.Frame.At(0, 0), "top chrome is part of the complete backdrop") | |
| 147 | - | require.Equal(t, dimmedBottomBar, client.Frame.At(0, 24), "bottom chrome is part of the complete backdrop") | |
| 145 | + | dimmedBottomBar.Style = themeui.NewDimmer(backdropTheme()).Dim(bottomBar.Style).Canonical() | |
| 146 | + | require.Equal(t, dimmedTopBar, client.Cell(0, 0), "top chrome is part of the complete backdrop") | |
| 147 | + | require.Equal(t, dimmedBottomBar, client.Cell(0, 24), "bottom chrome is part of the complete backdrop") | |
| 148 | 148 | ||
| 149 | 149 | d.handleInput(sess, ac, []byte("\x1b")) | |
| 150 | 150 | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | |
| 151 | - | require.Equal(t, undimmed, client.Frame.At(0, 1), "full redraw restores pane rune and style") | |
| 152 | - | require.Equal(t, topBar, client.Frame.At(0, 0)) | |
| 153 | - | require.Equal(t, bottomBar, client.Frame.At(0, 24)) | |
| 151 | + | require.Equal(t, undimmed, client.Cell(0, 1), "full redraw restores pane rune and style") | |
| 152 | + | require.Equal(t, topBar, client.Cell(0, 0)) | |
| 153 | + | require.Equal(t, bottomBar, client.Cell(0, 24)) | |
| 154 | 154 | } | |
| 155 | 155 | ||
| 156 | 156 | // --- test doubles ----------------------------------------------------------- | |
| @@ -672,7 +672,7 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) { | |||
| 672 | 672 | ||
| 673 | 673 | win := newTab(p, domain.Size{Cols: 4, Rows: 4}) | |
| 674 | 674 | for y, text := range []string{"0000", "1111", "2222", "3333"} { | |
| 675 | - | copy(win.focusedPane().screen.Frame.Row(y), testRow(text)) | |
| 675 | + | writeTestRow(win.focusedPane().screen, y, text) | |
| 676 | 676 | } | |
| 677 | 677 | win.focusedPane().screen.Row = 3 | |
| 678 | 678 | ||
| @@ -690,15 +690,15 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) { | |||
| 690 | 690 | // layout: tabSize reserves 2 chrome rows (top + bottom bar) here, not 1, | |
| 691 | 691 | // so a client height of 4 (not 3) is what yields the same 2-row tab. | |
| 692 | 692 | d.resize(sess, ac, domain.Size{Cols: 4, Rows: 4}) | |
| 693 | - | require.Equal(t, "2222", frameRowString(win.focusedPane().screen.Frame, 0)) | |
| 694 | - | require.Equal(t, "3333", frameRowString(win.focusedPane().screen.Frame, 1)) | |
| 693 | + | require.Equal(t, "2222", frameRowString(captureTestFrame(win.focusedPane().screen), 0)) | |
| 694 | + | require.Equal(t, "3333", frameRowString(captureTestFrame(win.focusedPane().screen), 1)) | |
| 695 | 695 | require.Equal(t, 2, win.focusedPane().history.Len()) | |
| 696 | 696 | require.Equal(t, "0000", cellsString(win.focusedPane().history.View().Row(0))) | |
| 697 | 697 | require.Equal(t, "1111", cellsString(win.focusedPane().history.View().Row(1))) | |
| 698 | 698 | ||
| 699 | 699 | d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6}) | |
| 700 | - | require.Equal(t, "2222 ", frameRowString(win.focusedPane().screen.Frame, 0)) | |
| 701 | - | require.Equal(t, "3333 ", frameRowString(win.focusedPane().screen.Frame, 1)) | |
| 700 | + | require.Equal(t, "2222 ", frameRowString(captureTestFrame(win.focusedPane().screen), 0)) | |
| 701 | + | require.Equal(t, "3333 ", frameRowString(captureTestFrame(win.focusedPane().screen), 1)) | |
| 702 | 702 | require.Equal(t, 2, win.focusedPane().history.Len()) | |
| 703 | 703 | } | |
| 704 | 704 | ||
| @@ -925,7 +925,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) { | |||
| 925 | 925 | win := newTab(newScriptPTY(nil), domain.Size{Cols: 80, Rows: 24}) | |
| 926 | 926 | p.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 100, Rows: 28}}).RunAndReturn(func(domain.Geometry) error { | |
| 927 | 927 | // The screen must not yet be resized when the PTY is: proves order. | |
| 928 | - | screenWidthAtResize = win.focusedPane().screen.Frame.Width | |
| 928 | + | screenWidthAtResize = win.focusedPane().screen.Columns() | |
| 929 | 929 | return nil | |
| 930 | 930 | }).Once() | |
| 931 | 931 | win.focusedPane().pty = p | |
| @@ -952,8 +952,8 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) { | |||
| 952 | 952 | d.paint(sess, ac, false, nil) | |
| 953 | 953 | ||
| 954 | 954 | require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize") | |
| 955 | - | require.Equal(t, 100, win.focusedPane().screen.Frame.Width, "screen resized after pty") | |
| 956 | - | require.Equal(t, 28, win.focusedPane().screen.Frame.Height, "screen reserves top and bottom chrome rows") | |
| 955 | + | require.Equal(t, 100, win.focusedPane().screen.Columns(), "screen resized after pty") | |
| 956 | + | require.Equal(t, 28, win.focusedPane().screen.Rows(), "screen reserves top and bottom chrome rows") | |
| 957 | 957 | require.True(t, gotOutput.Load(), "resize forces a full redraw output") | |
| 958 | 958 | } | |
| 959 | 959 | ||
| @@ -1141,7 +1141,7 @@ func TestPaintAlignsFloatingCursorWithCommittedGeometry(t *testing.T) { | |||
| 1141 | 1141 | cfg := d.currentFloatingConfig() | |
| 1142 | 1142 | committed := calculateContentFloatingGeometry(domain.Size{Cols: contentArea.Width, Rows: contentArea.Height}, cfg) | |
| 1143 | 1143 | floating := newPane("floating", nil, rectSize(committed.Inner)) | |
| 1144 | - | floating.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'F'}) | |
| 1144 | + | writeTestRow(floating.screen, 0, "F") | |
| 1145 | 1145 | floating.popupGeometry = committed | |
| 1146 | 1146 | installTestFloating(testAttachmentTab(sess), floating, true) | |
| 1147 | 1147 | ||
| @@ -1218,7 +1218,7 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) { | |||
| 1218 | 1218 | } | |
| 1219 | 1219 | p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2}) | |
| 1220 | 1220 | p.screen.Write([]byte("ab\r\ncd")) | |
| 1221 | - | document := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil) | |
| 1221 | + | document := scopy.NewSnapshot(p.history, p.screen, p.screen.LineBounds(), nil) | |
| 1222 | 1222 | mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators)) | |
| 1223 | 1223 | target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2} | |
| 1224 | 1224 | ||
| @@ -39,23 +39,23 @@ func TestPaletteBackdropDimsSimultaneousCopyMode(t *testing.T) { | |||||
| 39 | 39 | ||||
| 40 | d.enterCopyMode(sess, ac) | 40 | d.enterCopyMode(sess, ac) | ||
| 41 | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | 41 | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | ||
| 42 | - | undimmed := client.Frame.At(0, 1) | 42 | + | undimmed := client.Cell(0, 1) |
| 43 | require.Equal(t, 'X', undimmed.Rune, "fixture must address copy-mode pane content") | 43 | require.Equal(t, 'X', undimmed.Rune, "fixture must address copy-mode pane content") | ||
| 44 | - | copyBar := append([]renderer.Cell(nil), client.Frame.Row(client.Frame.Height-1)...) | 44 | + | copyBar := client.RowCells(client.Rows() - 1) |
| 45 | require.Contains(t, rowText(copyBar), "[SCROLL]", "fixture must capture the copy status bar") | 45 | require.Contains(t, rowText(copyBar), "[SCROLL]", "fixture must capture the copy status bar") | ||
| 46 | 46 | ||||
| 47 | d.enterPalette(sess, ac) | 47 | d.enterPalette(sess, ac) | ||
| 48 | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | 48 | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | ||
| 49 | - | dimmed := client.Frame.At(0, 1) | 49 | + | dimmed := client.Cell(0, 1) |
| 50 | require.Equal(t, undimmed.Rune, dimmed.Rune, "palette backdrop must preserve copy content") | 50 | require.Equal(t, undimmed.Rune, dimmed.Rune, "palette backdrop must preserve copy content") | ||
| 51 | - | require.Equal(t, themeui.NewDimmer(theme).Dim(undimmed.Style), dimmed.Style, "palette backdrop must dim the composed copy frame") | 51 | + | require.Equal(t, themeui.NewDimmer(theme).Dim(undimmed.Style).Canonical(), dimmed.Style.Canonical(), "palette backdrop must dim the composed copy frame") |
| 52 | for x, cell := range copyBar { | 52 | for x, cell := range copyBar { | ||
| 53 | - | cell.Style = themeui.NewDimmer(theme).Dim(cell.Style) | 53 | + | cell.Style = themeui.NewDimmer(theme).Dim(cell.Style).Canonical() |
| 54 | - | require.Equal(t, cell, client.Frame.At(x, client.Frame.Height-1), "copy status bar must be part of the complete backdrop") | 54 | + | require.Equal(t, cell, client.Cell(x, client.Rows()-1), "copy status bar must be part of the complete backdrop") |
| 55 | } | 55 | } | ||
| 56 | paletteVisible := false | 56 | paletteVisible := false | ||
| 57 | - | for y := range client.Frame.Height { | 57 | + | for y := range client.Rows() { |
| 58 | - | paletteVisible = paletteVisible || strings.Contains(rowText(client.Frame.Row(y)), "Commands") | 58 | + | paletteVisible = paletteVisible || strings.Contains(rowText(client.RowCells(y)), "Commands") |
| 59 | } | 59 | } | ||
| 60 | require.True(t, paletteVisible, "palette must remain composed above copy mode") | 60 | require.True(t, paletteVisible, "palette must remain composed above copy mode") | ||
| 61 | } | 61 | } | ||
| @@ -108,16 +108,16 @@ func TestPaletteBackdropDimsSimultaneousPicker(t *testing.T) { | |||||
| 108 | 108 | ||||
| 109 | d.enterPicker(sess, ac) | 109 | d.enterPicker(sess, ac) | ||
| 110 | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | 110 | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | ||
| 111 | - | pickerTitle := client.Frame.At(31, 2) | 111 | + | pickerTitle := client.Cell(31, 2) |
| 112 | require.Equal(t, 'S', pickerTitle.Rune, "fixture must address the picker title") | 112 | require.Equal(t, 'S', pickerTitle.Rune, "fixture must address the picker title") | ||
| 113 | - | undimmedPane := client.Frame.At(0, 1) | 113 | + | undimmedPane := client.Cell(0, 1) |
| 114 | 114 | ||||
| 115 | d.enterPalette(sess, ac) | 115 | d.enterPalette(sess, ac) | ||
| 116 | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | 116 | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | ||
| 117 | dimmedPickerTitle := pickerTitle | 117 | dimmedPickerTitle := pickerTitle | ||
| 118 | - | dimmedPickerTitle.Style = themeui.NewDimmer(backdropTheme()).Dim(pickerTitle.Style) | 118 | + | dimmedPickerTitle.Style = themeui.NewDimmer(backdropTheme()).Dim(pickerTitle.Style).Canonical() |
| 119 | - | require.Equal(t, dimmedPickerTitle, client.Frame.At(31, 2), "the lower-priority picker must be part of the palette backdrop") | 119 | + | require.Equal(t, dimmedPickerTitle, client.Cell(31, 2), "the lower-priority picker must be part of the palette backdrop") |
| 120 | - | require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmedPane.Style), client.Frame.At(0, 1).Style, "pane content outside overlays must use the theme dim style") | 120 | + | require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmedPane.Style).Canonical(), client.Cell(0, 1).Style.Canonical(), "pane content outside overlays must use the theme dim style") |
| 121 | } | 121 | } | ||
| 122 | 122 | ||||
| 123 | func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) { | 123 | func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) { | ||
| @@ -130,27 +130,27 @@ func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) { | |||||
| 130 | pane.screen.Write([]byte("X")) | 130 | pane.screen.Write([]byte("X")) | ||
| 131 | d.paint(sess, ac, true, nil) | 131 | d.paint(sess, ac, true, nil) | ||
| 132 | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | 132 | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | ||
| 133 | - | undimmed := client.Frame.At(0, 1) | 133 | + | undimmed := client.Cell(0, 1) |
| 134 | - | topBar := client.Frame.At(0, 0) | 134 | + | topBar := client.Cell(0, 0) |
| 135 | - | bottomBar := client.Frame.At(0, 24) | 135 | + | bottomBar := client.Cell(0, 24) |
| 136 | 136 | ||||
| 137 | d.handleInput(sess, ac, []byte("\x1b ")) | 137 | d.handleInput(sess, ac, []byte("\x1b ")) | ||
| 138 | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | 138 | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | ||
| 139 | - | dimmed := client.Frame.At(0, 1) | 139 | + | dimmed := client.Cell(0, 1) |
| 140 | require.Equal(t, 'X', dimmed.Rune) | 140 | require.Equal(t, 'X', dimmed.Rune) | ||
| 141 | - | require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmed.Style), dimmed.Style, "open palette must use the theme dim style") | 141 | + | require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmed.Style).Canonical(), dimmed.Style.Canonical(), "open palette must use the theme dim style") |
| 142 | dimmedTopBar := topBar | 142 | dimmedTopBar := topBar | ||
| 143 | - | dimmedTopBar.Style = themeui.NewDimmer(backdropTheme()).Dim(topBar.Style) | 143 | + | dimmedTopBar.Style = themeui.NewDimmer(backdropTheme()).Dim(topBar.Style).Canonical() |
| 144 | dimmedBottomBar := bottomBar | 144 | dimmedBottomBar := bottomBar | ||
| 145 | - | dimmedBottomBar.Style = themeui.NewDimmer(backdropTheme()).Dim(bottomBar.Style) | 145 | + | dimmedBottomBar.Style = themeui.NewDimmer(backdropTheme()).Dim(bottomBar.Style).Canonical() |
| 146 | - | require.Equal(t, dimmedTopBar, client.Frame.At(0, 0), "top chrome is part of the complete backdrop") | 146 | + | require.Equal(t, dimmedTopBar, client.Cell(0, 0), "top chrome is part of the complete backdrop") |
| 147 | - | require.Equal(t, dimmedBottomBar, client.Frame.At(0, 24), "bottom chrome is part of the complete backdrop") | 147 | + | require.Equal(t, dimmedBottomBar, client.Cell(0, 24), "bottom chrome is part of the complete backdrop") |
| 148 | 148 | ||||
| 149 | d.handleInput(sess, ac, []byte("\x1b")) | 149 | d.handleInput(sess, ac, []byte("\x1b")) | ||
| 150 | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | 150 | mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput)) | ||
| 151 | - | require.Equal(t, undimmed, client.Frame.At(0, 1), "full redraw restores pane rune and style") | 151 | + | require.Equal(t, undimmed, client.Cell(0, 1), "full redraw restores pane rune and style") |
| 152 | - | require.Equal(t, topBar, client.Frame.At(0, 0)) | 152 | + | require.Equal(t, topBar, client.Cell(0, 0)) |
| 153 | - | require.Equal(t, bottomBar, client.Frame.At(0, 24)) | 153 | + | require.Equal(t, bottomBar, client.Cell(0, 24)) |
| 154 | } | 154 | } | ||
| 155 | 155 | ||||
| 156 | // --- test doubles ----------------------------------------------------------- | 156 | // --- test doubles ----------------------------------------------------------- | ||
| @@ -672,7 +672,7 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) { | |||||
| 672 | 672 | ||||
| 673 | win := newTab(p, domain.Size{Cols: 4, Rows: 4}) | 673 | win := newTab(p, domain.Size{Cols: 4, Rows: 4}) | ||
| 674 | for y, text := range []string{"0000", "1111", "2222", "3333"} { | 674 | for y, text := range []string{"0000", "1111", "2222", "3333"} { | ||
| 675 | - | copy(win.focusedPane().screen.Frame.Row(y), testRow(text)) | 675 | + | writeTestRow(win.focusedPane().screen, y, text) |
| 676 | } | 676 | } | ||
| 677 | win.focusedPane().screen.Row = 3 | 677 | win.focusedPane().screen.Row = 3 | ||
| 678 | 678 | ||||
| @@ -690,15 +690,15 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) { | |||||
| 690 | // layout: tabSize reserves 2 chrome rows (top + bottom bar) here, not 1, | 690 | // layout: tabSize reserves 2 chrome rows (top + bottom bar) here, not 1, | ||
| 691 | // so a client height of 4 (not 3) is what yields the same 2-row tab. | 691 | // so a client height of 4 (not 3) is what yields the same 2-row tab. | ||
| 692 | d.resize(sess, ac, domain.Size{Cols: 4, Rows: 4}) | 692 | d.resize(sess, ac, domain.Size{Cols: 4, Rows: 4}) | ||
| 693 | - | require.Equal(t, "2222", frameRowString(win.focusedPane().screen.Frame, 0)) | 693 | + | require.Equal(t, "2222", frameRowString(captureTestFrame(win.focusedPane().screen), 0)) |
| 694 | - | require.Equal(t, "3333", frameRowString(win.focusedPane().screen.Frame, 1)) | 694 | + | require.Equal(t, "3333", frameRowString(captureTestFrame(win.focusedPane().screen), 1)) |
| 695 | require.Equal(t, 2, win.focusedPane().history.Len()) | 695 | require.Equal(t, 2, win.focusedPane().history.Len()) | ||
| 696 | require.Equal(t, "0000", cellsString(win.focusedPane().history.View().Row(0))) | 696 | require.Equal(t, "0000", cellsString(win.focusedPane().history.View().Row(0))) | ||
| 697 | require.Equal(t, "1111", cellsString(win.focusedPane().history.View().Row(1))) | 697 | require.Equal(t, "1111", cellsString(win.focusedPane().history.View().Row(1))) | ||
| 698 | 698 | ||||
| 699 | d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6}) | 699 | d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6}) | ||
| 700 | - | require.Equal(t, "2222 ", frameRowString(win.focusedPane().screen.Frame, 0)) | 700 | + | require.Equal(t, "2222 ", frameRowString(captureTestFrame(win.focusedPane().screen), 0)) |
| 701 | - | require.Equal(t, "3333 ", frameRowString(win.focusedPane().screen.Frame, 1)) | 701 | + | require.Equal(t, "3333 ", frameRowString(captureTestFrame(win.focusedPane().screen), 1)) |
| 702 | require.Equal(t, 2, win.focusedPane().history.Len()) | 702 | require.Equal(t, 2, win.focusedPane().history.Len()) | ||
| 703 | } | 703 | } | ||
| 704 | 704 | ||||
| @@ -925,7 +925,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) { | |||||
| 925 | win := newTab(newScriptPTY(nil), domain.Size{Cols: 80, Rows: 24}) | 925 | win := newTab(newScriptPTY(nil), domain.Size{Cols: 80, Rows: 24}) | ||
| 926 | p.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 100, Rows: 28}}).RunAndReturn(func(domain.Geometry) error { | 926 | p.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 100, Rows: 28}}).RunAndReturn(func(domain.Geometry) error { | ||
| 927 | // The screen must not yet be resized when the PTY is: proves order. | 927 | // The screen must not yet be resized when the PTY is: proves order. | ||
| 928 | - | screenWidthAtResize = win.focusedPane().screen.Frame.Width | 928 | + | screenWidthAtResize = win.focusedPane().screen.Columns() |
| 929 | return nil | 929 | return nil | ||
| 930 | }).Once() | 930 | }).Once() | ||
| 931 | win.focusedPane().pty = p | 931 | win.focusedPane().pty = p | ||
| @@ -952,8 +952,8 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) { | |||||
| 952 | d.paint(sess, ac, false, nil) | 952 | d.paint(sess, ac, false, nil) | ||
| 953 | 953 | ||||
| 954 | require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize") | 954 | require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize") | ||
| 955 | - | require.Equal(t, 100, win.focusedPane().screen.Frame.Width, "screen resized after pty") | 955 | + | require.Equal(t, 100, win.focusedPane().screen.Columns(), "screen resized after pty") |
| 956 | - | require.Equal(t, 28, win.focusedPane().screen.Frame.Height, "screen reserves top and bottom chrome rows") | 956 | + | require.Equal(t, 28, win.focusedPane().screen.Rows(), "screen reserves top and bottom chrome rows") |
| 957 | require.True(t, gotOutput.Load(), "resize forces a full redraw output") | 957 | require.True(t, gotOutput.Load(), "resize forces a full redraw output") | ||
| 958 | } | 958 | } | ||
| 959 | 959 | ||||
| @@ -1141,7 +1141,7 @@ func TestPaintAlignsFloatingCursorWithCommittedGeometry(t *testing.T) { | |||||
| 1141 | cfg := d.currentFloatingConfig() | 1141 | cfg := d.currentFloatingConfig() | ||
| 1142 | committed := calculateContentFloatingGeometry(domain.Size{Cols: contentArea.Width, Rows: contentArea.Height}, cfg) | 1142 | committed := calculateContentFloatingGeometry(domain.Size{Cols: contentArea.Width, Rows: contentArea.Height}, cfg) | ||
| 1143 | floating := newPane("floating", nil, rectSize(committed.Inner)) | 1143 | floating := newPane("floating", nil, rectSize(committed.Inner)) | ||
| 1144 | - | floating.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'F'}) | 1144 | + | writeTestRow(floating.screen, 0, "F") |
| 1145 | floating.popupGeometry = committed | 1145 | floating.popupGeometry = committed | ||
| 1146 | installTestFloating(testAttachmentTab(sess), floating, true) | 1146 | installTestFloating(testAttachmentTab(sess), floating, true) | ||
| 1147 | 1147 | ||||
| @@ -1218,7 +1218,7 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) { | |||||
| 1218 | } | 1218 | } | ||
| 1219 | p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2}) | 1219 | p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2}) | ||
| 1220 | p.screen.Write([]byte("ab\r\ncd")) | 1220 | p.screen.Write([]byte("ab\r\ncd")) | ||
| 1221 | - | document := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil) | 1221 | + | document := scopy.NewSnapshot(p.history, p.screen, p.screen.LineBounds(), nil) |
| 1222 | mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators)) | 1222 | mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators)) | ||
| 1223 | target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2} | 1223 | target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2} | ||
| 1224 | 1224 | ||||
M internal/usecase/daemon/resume_test.go +1 -1
| @@ -1253,7 +1253,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) { | |||
| 1253 | 1253 | require.Zero(t, output.Base, "resume must start with a complete frame") | |
| 1254 | 1254 | terminal := vt.NewScreen(resumedAC.size.Cols, resumedAC.size.Rows) | |
| 1255 | 1255 | terminal.Write(output.Data) | |
| 1256 | - | contents := strings.Join(frameRows(terminal.Frame), "\n") | |
| 1256 | + | contents := strings.Join(frameRows(terminal), "\n") | |
| 1257 | 1257 | require.Contains(t, contents, "survivor", "resume first paint must contain current headless content") | |
| 1258 | 1258 | require.NotContains(t, contents, "closed", "resume first paint must not contain closed pane content") | |
| 1259 | 1259 | } | |
| @@ -1253,7 +1253,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) { | |||||
| 1253 | require.Zero(t, output.Base, "resume must start with a complete frame") | 1253 | require.Zero(t, output.Base, "resume must start with a complete frame") | ||
| 1254 | terminal := vt.NewScreen(resumedAC.size.Cols, resumedAC.size.Rows) | 1254 | terminal := vt.NewScreen(resumedAC.size.Cols, resumedAC.size.Rows) | ||
| 1255 | terminal.Write(output.Data) | 1255 | terminal.Write(output.Data) | ||
| 1256 | - | contents := strings.Join(frameRows(terminal.Frame), "\n") | 1256 | + | contents := strings.Join(frameRows(terminal), "\n") |
| 1257 | require.Contains(t, contents, "survivor", "resume first paint must contain current headless content") | 1257 | require.Contains(t, contents, "survivor", "resume first paint must contain current headless content") | ||
| 1258 | require.NotContains(t, contents, "closed", "resume first paint must not contain closed pane content") | 1258 | require.NotContains(t, contents, "closed", "resume first paint must not contain closed pane content") | ||
| 1259 | } | 1259 | } | ||
M internal/usecase/daemon/session.go +7 -5
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | 15 | "sync/atomic" | |
| 16 | 16 | "time" | |
| 17 | 17 | ||
| 18 | + | vt "github.com/bnema/vev-vt" | |
| 18 | 19 | "github.com/bnema/vev/internal/domain" | |
| 19 | 20 | "github.com/bnema/vev/internal/ports" | |
| 20 | 21 | "github.com/bnema/vev/internal/protocol" | |
| @@ -324,7 +325,7 @@ func (d *Daemon) createSessionLockedWithModeAndInactiveFence(name string, epheme | |||
| 324 | 325 | return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session: shell failed to start", err) | |
| 325 | 326 | } | |
| 326 | 327 | appliedPaneGeometry := initialPaneGeometry | |
| 327 | - | tb := newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, tbSize, launch.title) | |
| 328 | + | tb := newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, tbSize, launch.title, d.currentHistoryConfig()) | |
| 328 | 329 | if pane := tb.focusedPane(); pane != nil { | |
| 329 | 330 | pane.geometry = appliedPaneGeometry | |
| 330 | 331 | setScreenGeometry(pane.screen, appliedPaneGeometry) | |
| @@ -675,7 +676,7 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, _ dom | |||
| 675 | 676 | d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab") | |
| 676 | 677 | return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err) | |
| 677 | 678 | } | |
| 678 | - | tb := newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, tbSize, launch.title) | |
| 679 | + | tb := newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, tbSize, launch.title, d.currentHistoryConfig()) | |
| 679 | 680 | if pane := tb.focusedPane(); pane != nil { | |
| 680 | 681 | pane.geometry = claimGeometry | |
| 681 | 682 | setScreenGeometry(pane.screen, claimGeometry) | |
| @@ -736,12 +737,12 @@ func newTab(pty ports.PTY, sz domain.Size) *tab { | |||
| 736 | 737 | } | |
| 737 | 738 | ||
| 738 | 739 | func newTabWithStableID(tabStableID, paneStableID string, pty ports.PTY, sz domain.Size) *tab { | |
| 739 | - | return newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, sz, defaultShellTitle) | |
| 740 | + | return newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, sz, defaultShellTitle, historyConfigFor(domain.DefaultScrollbackConfig())) | |
| 740 | 741 | } | |
| 741 | 742 | ||
| 742 | - | func newTabWithStableIDAndTitle(tabStableID, paneStableID string, pty ports.PTY, sz domain.Size, title string) *tab { | |
| 743 | + | func newTabWithStableIDAndTitle(tabStableID, paneStableID string, pty ports.PTY, sz domain.Size, title string, historyConfig vt.HistoryConfig) *tab { | |
| 743 | 744 | id := layout.PaneID("pane-1") | |
| 744 | - | p := newPaneWithStableIDAndTitle(id, paneStableID, pty, sz, title) | |
| 745 | + | p := newPaneWithStableIDAndTitle(id, paneStableID, pty, sz, title, historyConfig) | |
| 745 | 746 | return &tab{ | |
| 746 | 747 | stableID: tabStableID, | |
| 747 | 748 | tree: layout.NewTree(id), | |
| @@ -850,6 +851,7 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) { | |||
| 850 | 851 | if owner.session != sess || owner.tab != tb { | |
| 851 | 852 | return | |
| 852 | 853 | } | |
| 854 | + | d.applyHistoryLimitsToPane(p) | |
| 853 | 855 | sess.mu.Lock() | |
| 854 | 856 | name := sess.name | |
| 855 | 857 | sess.mu.Unlock() | |
| @@ -15,6 +15,7 @@ import ( | |||||
| 15 | "sync/atomic" | 15 | "sync/atomic" | ||
| 16 | "time" | 16 | "time" | ||
| 17 | 17 | ||||
| 18 | + | vt "github.com/bnema/vev-vt" | |||
| 18 | "github.com/bnema/vev/internal/domain" | 19 | "github.com/bnema/vev/internal/domain" | ||
| 19 | "github.com/bnema/vev/internal/ports" | 20 | "github.com/bnema/vev/internal/ports" | ||
| 20 | "github.com/bnema/vev/internal/protocol" | 21 | "github.com/bnema/vev/internal/protocol" | ||
| @@ -324,7 +325,7 @@ func (d *Daemon) createSessionLockedWithModeAndInactiveFence(name string, epheme | |||||
| 324 | return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session: shell failed to start", err) | 325 | return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session: shell failed to start", err) | ||
| 325 | } | 326 | } | ||
| 326 | appliedPaneGeometry := initialPaneGeometry | 327 | appliedPaneGeometry := initialPaneGeometry | ||
| 327 | - | tb := newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, tbSize, launch.title) | 328 | + | tb := newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, tbSize, launch.title, d.currentHistoryConfig()) |
| 328 | if pane := tb.focusedPane(); pane != nil { | 329 | if pane := tb.focusedPane(); pane != nil { | ||
| 329 | pane.geometry = appliedPaneGeometry | 330 | pane.geometry = appliedPaneGeometry | ||
| 330 | setScreenGeometry(pane.screen, appliedPaneGeometry) | 331 | setScreenGeometry(pane.screen, appliedPaneGeometry) | ||
| @@ -675,7 +676,7 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, _ dom | |||||
| 675 | d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab") | 676 | d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab") | ||
| 676 | return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err) | 677 | return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err) | ||
| 677 | } | 678 | } | ||
| 678 | - | tb := newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, tbSize, launch.title) | 679 | + | tb := newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, tbSize, launch.title, d.currentHistoryConfig()) |
| 679 | if pane := tb.focusedPane(); pane != nil { | 680 | if pane := tb.focusedPane(); pane != nil { | ||
| 680 | pane.geometry = claimGeometry | 681 | pane.geometry = claimGeometry | ||
| 681 | setScreenGeometry(pane.screen, claimGeometry) | 682 | setScreenGeometry(pane.screen, claimGeometry) | ||
| @@ -736,12 +737,12 @@ func newTab(pty ports.PTY, sz domain.Size) *tab { | |||||
| 736 | } | 737 | } | ||
| 737 | 738 | ||||
| 738 | func newTabWithStableID(tabStableID, paneStableID string, pty ports.PTY, sz domain.Size) *tab { | 739 | func newTabWithStableID(tabStableID, paneStableID string, pty ports.PTY, sz domain.Size) *tab { | ||
| 739 | - | return newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, sz, defaultShellTitle) | 740 | + | return newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, sz, defaultShellTitle, historyConfigFor(domain.DefaultScrollbackConfig())) |
| 740 | } | 741 | } | ||
| 741 | 742 | ||||
| 742 | - | func newTabWithStableIDAndTitle(tabStableID, paneStableID string, pty ports.PTY, sz domain.Size, title string) *tab { | 743 | + | func newTabWithStableIDAndTitle(tabStableID, paneStableID string, pty ports.PTY, sz domain.Size, title string, historyConfig vt.HistoryConfig) *tab { |
| 743 | id := layout.PaneID("pane-1") | 744 | id := layout.PaneID("pane-1") | ||
| 744 | - | p := newPaneWithStableIDAndTitle(id, paneStableID, pty, sz, title) | 745 | + | p := newPaneWithStableIDAndTitle(id, paneStableID, pty, sz, title, historyConfig) |
| 745 | return &tab{ | 746 | return &tab{ | ||
| 746 | stableID: tabStableID, | 747 | stableID: tabStableID, | ||
| 747 | tree: layout.NewTree(id), | 748 | tree: layout.NewTree(id), | ||
| @@ -850,6 +851,7 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) { | |||||
| 850 | if owner.session != sess || owner.tab != tb { | 851 | if owner.session != sess || owner.tab != tb { | ||
| 851 | return | 852 | return | ||
| 852 | } | 853 | } | ||
| 854 | + | d.applyHistoryLimitsToPane(p) | |||
| 853 | sess.mu.Lock() | 855 | sess.mu.Lock() | ||
| 854 | name := sess.name | 856 | name := sess.name | ||
| 855 | sess.mu.Unlock() | 857 | sess.mu.Unlock() | ||
M internal/usecase/daemon/snapshot_restore.go +2 -2
| @@ -270,7 +270,7 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable | |||
| 270 | 270 | } | |
| 271 | 271 | return nil, err | |
| 272 | 272 | } | |
| 273 | - | p := newPaneWithStableIDAndTitle(paneSnap.ID, paneStableID, pty, ptyGeometry.Size, launch.title) | |
| 273 | + | p := newPaneWithStableIDAndTitle(paneSnap.ID, paneStableID, pty, ptyGeometry.Size, launch.title, d.currentHistoryConfig()) | |
| 274 | 274 | if !lifetime.publish(p) { | |
| 275 | 275 | _ = pty.Close() | |
| 276 | 276 | return nil, lifetime.ctx.Err() | |
| @@ -372,7 +372,7 @@ func restorePaneTerminal(p *pane, snap snapcodec.Pane) error { | |||
| 372 | 372 | } | |
| 373 | 373 | p.mu.Lock() | |
| 374 | 374 | defer p.mu.Unlock() | |
| 375 | - | screen, err := vt.NewScreenWithRecoveryTranscript(p.screen.Frame.Width, p.screen.Frame.Height, vt.HistoryConfig{MaxRows: defaultScrollbackRows, MaxCells: defaultScrollbackCells}, snap.SealedChunks, snap.Tail, snap.Transcript) | |
| 375 | + | screen, err := vt.NewScreenWithRecoveryTranscript(p.screen.Columns(), p.screen.Rows(), p.history.Limits(), snap.SealedChunks, snap.Tail, snap.Transcript) | |
| 376 | 376 | if err != nil { | |
| 377 | 377 | return fmt.Errorf("snapshot recovery: %w", err) | |
| 378 | 378 | } | |
| @@ -270,7 +270,7 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable | |||||
| 270 | } | 270 | } | ||
| 271 | return nil, err | 271 | return nil, err | ||
| 272 | } | 272 | } | ||
| 273 | - | p := newPaneWithStableIDAndTitle(paneSnap.ID, paneStableID, pty, ptyGeometry.Size, launch.title) | 273 | + | p := newPaneWithStableIDAndTitle(paneSnap.ID, paneStableID, pty, ptyGeometry.Size, launch.title, d.currentHistoryConfig()) |
| 274 | if !lifetime.publish(p) { | 274 | if !lifetime.publish(p) { | ||
| 275 | _ = pty.Close() | 275 | _ = pty.Close() | ||
| 276 | return nil, lifetime.ctx.Err() | 276 | return nil, lifetime.ctx.Err() | ||
| @@ -372,7 +372,7 @@ func restorePaneTerminal(p *pane, snap snapcodec.Pane) error { | |||||
| 372 | } | 372 | } | ||
| 373 | p.mu.Lock() | 373 | p.mu.Lock() | ||
| 374 | defer p.mu.Unlock() | 374 | defer p.mu.Unlock() | ||
| 375 | - | screen, err := vt.NewScreenWithRecoveryTranscript(p.screen.Frame.Width, p.screen.Frame.Height, vt.HistoryConfig{MaxRows: defaultScrollbackRows, MaxCells: defaultScrollbackCells}, snap.SealedChunks, snap.Tail, snap.Transcript) | 375 | + | screen, err := vt.NewScreenWithRecoveryTranscript(p.screen.Columns(), p.screen.Rows(), p.history.Limits(), snap.SealedChunks, snap.Tail, snap.Transcript) |
| 376 | if err != nil { | 376 | if err != nil { | ||
| 377 | return fmt.Errorf("snapshot recovery: %w", err) | 377 | return fmt.Errorf("snapshot recovery: %w", err) | ||
| 378 | } | 378 | } | ||
M internal/usecase/daemon/snapshot_restore_test.go +13 -7
| @@ -133,7 +133,7 @@ func cloneAcceptanceGeneration(g ports.SnapshotGeneration) ports.SnapshotGenerat | |||
| 133 | 133 | ||
| 134 | 134 | func restoreAcceptanceSession(t *testing.T, name string) snapcodec.Session { | |
| 135 | 135 | t.Helper() | |
| 136 | - | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 8 * 293, ChunkRows: 2}) | |
| 136 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxBytes: 8 * 293 * 32, ChunkRows: 2}) | |
| 137 | 137 | for _, ch := range []rune{'a', 'b', 'c', 'd', 'e'} { | |
| 138 | 138 | row := make([]renderer.Cell, 293) | |
| 139 | 139 | for i := range row { | |
| @@ -242,15 +242,21 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) { | |||
| 242 | 242 | pane.mu.Lock() | |
| 243 | 243 | wantHistory := []string{strings.Repeat("a", 293), strings.Repeat("b", 293), strings.Repeat("c", 293), strings.Repeat("d", 293), strings.Repeat("e", 293), "primary-visible", "alternate-active"} | |
| 244 | 244 | require.Equal(t, wantHistory, snapshotHistoryTexts(pane.history.View()), "bounded history must precede primary and active alternate transcript rows") | |
| 245 | - | for y := range pane.screen.Frame.Height { | |
| 246 | - | for x := range pane.screen.Frame.Width { | |
| 247 | - | require.Truef(t, pane.screen.Frame.At(x, y).Equal(renderer.BlankCell()), "restored frame cell (%d,%d) must be blank", x, y) | |
| 245 | + | for y := range pane.screen.Rows() { | |
| 246 | + | for x := range pane.screen.Columns() { | |
| 247 | + | require.Truef(t, pane.screen.Cell(x, y).Equal(renderer.BlankCell()), "restored frame cell (%d,%d) must be blank", x, y) | |
| 248 | 248 | } | |
| 249 | 249 | } | |
| 250 | 250 | require.Equal(t, 7, pane.history.Len()) | |
| 251 | - | require.Equal(t, 7*293, pane.history.Cells()) | |
| 251 | + | cells := 0 | |
| 252 | + | view := pane.history.View() | |
| 253 | + | for y := range view.Len() { | |
| 254 | + | cells += view.RowWidth(y) | |
| 255 | + | } | |
| 256 | + | require.Equal(t, 7*293, cells) | |
| 252 | 257 | require.Equal(t, defaultScrollbackRows, pane.history.Cap()) | |
| 253 | - | require.Equal(t, defaultScrollbackCells, pane.history.CellCap()) | |
| 258 | + | require.Equal(t, defaultScrollbackBytes, pane.history.ByteCap()) | |
| 259 | + | require.LessOrEqual(t, pane.history.LogicalBytes(), pane.history.ByteCap()) | |
| 254 | 260 | pane.screen.Write([]byte("new-output")) | |
| 255 | 261 | pane.screen.Write([]byte("\x1b[?1049h\x1b[?1049l")) | |
| 256 | 262 | require.Equal(t, wantHistory, snapshotHistoryTexts(pane.history.View()), "new output and redraw must not remove recovered history") | |
| @@ -558,7 +564,7 @@ func TestRestorePaneTerminalRequiresCompleteRecoveryAndInstallsAtomically(t *tes | |||
| 558 | 564 | require.Error(t, err) | |
| 559 | 565 | require.Same(t, oldScreen, p.screen) | |
| 560 | 566 | require.Same(t, oldHistory, p.history) | |
| 561 | - | require.Equal(t, 'o', p.screen.Frame.At(0, 0).Rune, "a failed restore must never install a partial or old persisted frame") | |
| 567 | + | require.Equal(t, 'o', p.screen.Cell(0, 0).Rune, "a failed restore must never install a partial or old persisted frame") | |
| 562 | 568 | require.Equal(t, "old-history", cellsString(p.history.View().Row(0))) | |
| 563 | 569 | }) | |
| 564 | 570 | } | |
| @@ -133,7 +133,7 @@ func cloneAcceptanceGeneration(g ports.SnapshotGeneration) ports.SnapshotGenerat | |||||
| 133 | 133 | ||||
| 134 | func restoreAcceptanceSession(t *testing.T, name string) snapcodec.Session { | 134 | func restoreAcceptanceSession(t *testing.T, name string) snapcodec.Session { | ||
| 135 | t.Helper() | 135 | t.Helper() | ||
| 136 | - | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 8 * 293, ChunkRows: 2}) | 136 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxBytes: 8 * 293 * 32, ChunkRows: 2}) |
| 137 | for _, ch := range []rune{'a', 'b', 'c', 'd', 'e'} { | 137 | for _, ch := range []rune{'a', 'b', 'c', 'd', 'e'} { | ||
| 138 | row := make([]renderer.Cell, 293) | 138 | row := make([]renderer.Cell, 293) | ||
| 139 | for i := range row { | 139 | for i := range row { | ||
| @@ -242,15 +242,21 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) { | |||||
| 242 | pane.mu.Lock() | 242 | pane.mu.Lock() | ||
| 243 | wantHistory := []string{strings.Repeat("a", 293), strings.Repeat("b", 293), strings.Repeat("c", 293), strings.Repeat("d", 293), strings.Repeat("e", 293), "primary-visible", "alternate-active"} | 243 | wantHistory := []string{strings.Repeat("a", 293), strings.Repeat("b", 293), strings.Repeat("c", 293), strings.Repeat("d", 293), strings.Repeat("e", 293), "primary-visible", "alternate-active"} | ||
| 244 | require.Equal(t, wantHistory, snapshotHistoryTexts(pane.history.View()), "bounded history must precede primary and active alternate transcript rows") | 244 | require.Equal(t, wantHistory, snapshotHistoryTexts(pane.history.View()), "bounded history must precede primary and active alternate transcript rows") | ||
| 245 | - | for y := range pane.screen.Frame.Height { | 245 | + | for y := range pane.screen.Rows() { |
| 246 | - | for x := range pane.screen.Frame.Width { | 246 | + | for x := range pane.screen.Columns() { |
| 247 | - | require.Truef(t, pane.screen.Frame.At(x, y).Equal(renderer.BlankCell()), "restored frame cell (%d,%d) must be blank", x, y) | 247 | + | require.Truef(t, pane.screen.Cell(x, y).Equal(renderer.BlankCell()), "restored frame cell (%d,%d) must be blank", x, y) |
| 248 | } | 248 | } | ||
| 249 | } | 249 | } | ||
| 250 | require.Equal(t, 7, pane.history.Len()) | 250 | require.Equal(t, 7, pane.history.Len()) | ||
| 251 | - | require.Equal(t, 7*293, pane.history.Cells()) | 251 | + | cells := 0 |
| 252 | + | view := pane.history.View() | |||
| 253 | + | for y := range view.Len() { | |||
| 254 | + | cells += view.RowWidth(y) | |||
| 255 | + | } | |||
| 256 | + | require.Equal(t, 7*293, cells) | |||
| 252 | require.Equal(t, defaultScrollbackRows, pane.history.Cap()) | 257 | require.Equal(t, defaultScrollbackRows, pane.history.Cap()) | ||
| 253 | - | require.Equal(t, defaultScrollbackCells, pane.history.CellCap()) | 258 | + | require.Equal(t, defaultScrollbackBytes, pane.history.ByteCap()) |
| 259 | + | require.LessOrEqual(t, pane.history.LogicalBytes(), pane.history.ByteCap()) | |||
| 254 | pane.screen.Write([]byte("new-output")) | 260 | pane.screen.Write([]byte("new-output")) | ||
| 255 | pane.screen.Write([]byte("\x1b[?1049h\x1b[?1049l")) | 261 | pane.screen.Write([]byte("\x1b[?1049h\x1b[?1049l")) | ||
| 256 | require.Equal(t, wantHistory, snapshotHistoryTexts(pane.history.View()), "new output and redraw must not remove recovered history") | 262 | require.Equal(t, wantHistory, snapshotHistoryTexts(pane.history.View()), "new output and redraw must not remove recovered history") | ||
| @@ -558,7 +564,7 @@ func TestRestorePaneTerminalRequiresCompleteRecoveryAndInstallsAtomically(t *tes | |||||
| 558 | require.Error(t, err) | 564 | require.Error(t, err) | ||
| 559 | require.Same(t, oldScreen, p.screen) | 565 | require.Same(t, oldScreen, p.screen) | ||
| 560 | require.Same(t, oldHistory, p.history) | 566 | require.Same(t, oldHistory, p.history) | ||
| 561 | - | require.Equal(t, 'o', p.screen.Frame.At(0, 0).Rune, "a failed restore must never install a partial or old persisted frame") | 567 | + | require.Equal(t, 'o', p.screen.Cell(0, 0).Rune, "a failed restore must never install a partial or old persisted frame") |
| 562 | require.Equal(t, "old-history", cellsString(p.history.View().Row(0))) | 568 | require.Equal(t, "old-history", cellsString(p.history.View().Row(0))) | ||
| 563 | }) | 569 | }) | ||
| 564 | } | 570 | } | ||
M internal/usecase/daemon/status_test.go +1 -1
| @@ -90,7 +90,7 @@ func TestStatusCompositionGolden(t *testing.T) { | |||
| 90 | 90 | reset: true, | |
| 91 | 91 | layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 2}, focus: win.focusedPane().id, valid: true}, | |
| 92 | 92 | panes: []capturedPaneRenderState{{ | |
| 93 | - | id: win.focusedPane().id, frame: win.focusedPane().screen.Frame.Clone(), placement: layout.Placement{ID: win.focusedPane().id, Content: domain.Rect{Width: 12, Height: 2}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}, | |
| 93 | + | id: win.focusedPane().id, frame: captureTestFrame(win.focusedPane().screen), placement: layout.Placement{ID: win.focusedPane().id, Content: domain.Rect{Width: 12, Height: 2}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}, | |
| 94 | 94 | }}, | |
| 95 | 95 | bars: barState{status: sess.statusSegmentsFor(ac, true)}, | |
| 96 | 96 | } | |
| @@ -90,7 +90,7 @@ func TestStatusCompositionGolden(t *testing.T) { | |||||
| 90 | reset: true, | 90 | reset: true, | ||
| 91 | layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 2}, focus: win.focusedPane().id, valid: true}, | 91 | layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 2}, focus: win.focusedPane().id, valid: true}, | ||
| 92 | panes: []capturedPaneRenderState{{ | 92 | panes: []capturedPaneRenderState{{ | ||
| 93 | - | id: win.focusedPane().id, frame: win.focusedPane().screen.Frame.Clone(), placement: layout.Placement{ID: win.focusedPane().id, Content: domain.Rect{Width: 12, Height: 2}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}, | 93 | + | id: win.focusedPane().id, frame: captureTestFrame(win.focusedPane().screen), placement: layout.Placement{ID: win.focusedPane().id, Content: domain.Rect{Width: 12, Height: 2}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}, |
| 94 | }}, | 94 | }}, | ||
| 95 | bars: barState{status: sess.statusSegmentsFor(ac, true)}, | 95 | bars: barState{status: sess.statusSegmentsFor(ac, true)}, | ||
| 96 | } | 96 | } | ||
M internal/usecase/daemon/theme_cache_test.go +3 -3
| @@ -74,7 +74,7 @@ func TestAppliedThemeKeepsDimmerOutsideResolvedChrome(t *testing.T) { | |||
| 74 | 74 | } | |
| 75 | 75 | out := composeFrame(state, composeCacheInput{}) | |
| 76 | 76 | require.Equal(t, cell, out.frame.At(0, 1)) | |
| 77 | - | require.Equal(t, themeui.NewDimmer(raw, themeui.WithForegroundDimming(inactivePaneForegroundDimming)).Dim(cell.Style), out.frame.At(2, 1).Style) | |
| 77 | + | require.Equal(t, themeui.NewDimmer(raw, themeui.WithForegroundDimming(inactivePaneForegroundDimming)).Dim(cell.Style).Canonical(), out.frame.At(2, 1).Style.Canonical()) | |
| 78 | 78 | } | |
| 79 | 79 | ||
| 80 | 80 | func TestChromeDimmersUseNeutralInputsOutsideAccentRamp(t *testing.T) { | |
| @@ -83,8 +83,8 @@ func TestChromeDimmersUseNeutralInputsOutsideAccentRamp(t *testing.T) { | |||
| 83 | 83 | neutral := renderer.DefaultStyle() | |
| 84 | 84 | neutral.HasForegroundRGB = true | |
| 85 | 85 | neutral.ForegroundRGB = themeui.Blend(raw.Foreground, raw.Background, 0.40) | |
| 86 | - | want := themeui.NewDimmer(raw).Dim(neutral) | |
| 87 | - | accentDerived := themeui.NewDimmer(raw).Dim(resolved.Styles.BorderMuted) | |
| 86 | + | want := themeui.NewDimmer(raw).Dim(neutral).Canonical() | |
| 87 | + | accentDerived := themeui.NewDimmer(raw).Dim(resolved.Styles.BorderMuted).Canonical() | |
| 88 | 88 | ||
| 89 | 89 | split := cachedSplitState("neutral-divider", "left", layout.Horizontal, raw) | |
| 90 | 90 | split.styles = resolved.Styles | |
| @@ -74,7 +74,7 @@ func TestAppliedThemeKeepsDimmerOutsideResolvedChrome(t *testing.T) { | |||||
| 74 | } | 74 | } | ||
| 75 | out := composeFrame(state, composeCacheInput{}) | 75 | out := composeFrame(state, composeCacheInput{}) | ||
| 76 | require.Equal(t, cell, out.frame.At(0, 1)) | 76 | require.Equal(t, cell, out.frame.At(0, 1)) | ||
| 77 | - | require.Equal(t, themeui.NewDimmer(raw, themeui.WithForegroundDimming(inactivePaneForegroundDimming)).Dim(cell.Style), out.frame.At(2, 1).Style) | 77 | + | require.Equal(t, themeui.NewDimmer(raw, themeui.WithForegroundDimming(inactivePaneForegroundDimming)).Dim(cell.Style).Canonical(), out.frame.At(2, 1).Style.Canonical()) |
| 78 | } | 78 | } | ||
| 79 | 79 | ||||
| 80 | func TestChromeDimmersUseNeutralInputsOutsideAccentRamp(t *testing.T) { | 80 | func TestChromeDimmersUseNeutralInputsOutsideAccentRamp(t *testing.T) { | ||
| @@ -83,8 +83,8 @@ func TestChromeDimmersUseNeutralInputsOutsideAccentRamp(t *testing.T) { | |||||
| 83 | neutral := renderer.DefaultStyle() | 83 | neutral := renderer.DefaultStyle() | ||
| 84 | neutral.HasForegroundRGB = true | 84 | neutral.HasForegroundRGB = true | ||
| 85 | neutral.ForegroundRGB = themeui.Blend(raw.Foreground, raw.Background, 0.40) | 85 | neutral.ForegroundRGB = themeui.Blend(raw.Foreground, raw.Background, 0.40) | ||
| 86 | - | want := themeui.NewDimmer(raw).Dim(neutral) | 86 | + | want := themeui.NewDimmer(raw).Dim(neutral).Canonical() |
| 87 | - | accentDerived := themeui.NewDimmer(raw).Dim(resolved.Styles.BorderMuted) | 87 | + | accentDerived := themeui.NewDimmer(raw).Dim(resolved.Styles.BorderMuted).Canonical() |
| 88 | 88 | ||||
| 89 | split := cachedSplitState("neutral-divider", "left", layout.Horizontal, raw) | 89 | split := cachedSplitState("neutral-divider", "left", layout.Horizontal, raw) | ||
| 90 | split.styles = resolved.Styles | 90 | split.styles = resolved.Styles | ||
M internal/usecase/daemon/toast_damage_test.go +2 -2
| @@ -40,7 +40,7 @@ func TestComposeFrameToastDamageIsIncrementalAndRestoresOldFootprints(t *testing | |||
| 40 | 40 | require.NoError(t, err) | |
| 41 | 41 | require.Less(t, len(incremental)*2, len(full), "one-cell update with a stable toast must stay bounded below full-screen output") | |
| 42 | 42 | terminal.Write(incremental) | |
| 43 | - | require.Equal(t, frameRows(updated.frame), frameRows(terminal.Frame)) | |
| 43 | + | require.Equal(t, frameRows(updated.frame), frameRows(terminal)) | |
| 44 | 44 | ||
| 45 | 45 | // Stacking and overflow stay visually identical while their complete old and | |
| 46 | 46 | // new coverage remains incremental damage. | |
| @@ -129,7 +129,7 @@ func replayToastFrame(t *testing.T, stream *attachmentOutput, terminal *vt.Scree | |||
| 129 | 129 | data, err := stream.render(composed.frame, composed.damage, composed.reset) | |
| 130 | 130 | require.NoError(t, err) | |
| 131 | 131 | terminal.Write(data) | |
| 132 | - | require.Equal(t, frameRows(composed.frame), frameRows(terminal.Frame)) | |
| 132 | + | require.Equal(t, frameRows(composed.frame), frameRows(terminal)) | |
| 133 | 133 | } | |
| 134 | 134 | ||
| 135 | 135 | func requireNoFullRedraw(t *testing.T, damage []renderer.Damage) { | |
| @@ -40,7 +40,7 @@ func TestComposeFrameToastDamageIsIncrementalAndRestoresOldFootprints(t *testing | |||||
| 40 | require.NoError(t, err) | 40 | require.NoError(t, err) | ||
| 41 | require.Less(t, len(incremental)*2, len(full), "one-cell update with a stable toast must stay bounded below full-screen output") | 41 | require.Less(t, len(incremental)*2, len(full), "one-cell update with a stable toast must stay bounded below full-screen output") | ||
| 42 | terminal.Write(incremental) | 42 | terminal.Write(incremental) | ||
| 43 | - | require.Equal(t, frameRows(updated.frame), frameRows(terminal.Frame)) | 43 | + | require.Equal(t, frameRows(updated.frame), frameRows(terminal)) |
| 44 | 44 | ||||
| 45 | // Stacking and overflow stay visually identical while their complete old and | 45 | // Stacking and overflow stay visually identical while their complete old and | ||
| 46 | // new coverage remains incremental damage. | 46 | // new coverage remains incremental damage. | ||
| @@ -129,7 +129,7 @@ func replayToastFrame(t *testing.T, stream *attachmentOutput, terminal *vt.Scree | |||||
| 129 | data, err := stream.render(composed.frame, composed.damage, composed.reset) | 129 | data, err := stream.render(composed.frame, composed.damage, composed.reset) | ||
| 130 | require.NoError(t, err) | 130 | require.NoError(t, err) | ||
| 131 | terminal.Write(data) | 131 | terminal.Write(data) | ||
| 132 | - | require.Equal(t, frameRows(composed.frame), frameRows(terminal.Frame)) | 132 | + | require.Equal(t, frameRows(composed.frame), frameRows(terminal)) |
| 133 | } | 133 | } | ||
| 134 | 134 | ||||
| 135 | func requireNoFullRedraw(t *testing.T, damage []renderer.Damage) { | 135 | func requireNoFullRedraw(t *testing.T, damage []renderer.Damage) { | ||
M internal/usecase/daemon/transactional_resize_test.go +18 -18
| @@ -136,8 +136,8 @@ func TestReplayResizePendingBuffersSuccessFailureAndBatchOrder(t *testing.T) { | |||
| 136 | 136 | p.resizeApplying = true | |
| 137 | 137 | p.resizePending = []byte("A\x1b[1;81HB") | |
| 138 | 138 | d.replayResizePending(sess, tb, p, true, domain.Rect{Width: 120, Height: 23}) | |
| 139 | - | require.Equal(t, 120, p.screen.Frame.Width) | |
| 140 | - | require.Equal(t, 'B', p.screen.Frame.At(80, 0).Rune) | |
| 139 | + | require.Equal(t, 120, p.screen.Columns()) | |
| 140 | + | require.Equal(t, 'B', p.screen.Cell(80, 0).Rune) | |
| 141 | 141 | require.False(t, p.resizeApplying) | |
| 142 | 142 | }) | |
| 143 | 143 | t.Run("failure retains old parser width", func(t *testing.T) { | |
| @@ -147,8 +147,8 @@ func TestReplayResizePendingBuffersSuccessFailureAndBatchOrder(t *testing.T) { | |||
| 147 | 147 | p.resizeApplying = true | |
| 148 | 148 | p.resizePending = []byte("\x1b[1;81HB") | |
| 149 | 149 | d.replayResizePending(sess, tb, p, false, domain.Rect{Width: 120, Height: 23}) | |
| 150 | - | require.Equal(t, 80, p.screen.Frame.Width) | |
| 151 | - | require.Equal(t, 'B', p.screen.Frame.At(79, 0).Rune) | |
| 150 | + | require.Equal(t, 80, p.screen.Columns()) | |
| 151 | + | require.Equal(t, 'B', p.screen.Cell(79, 0).Rune) | |
| 152 | 152 | }) | |
| 153 | 153 | t.Run("batches retain read order", func(t *testing.T) { | |
| 154 | 154 | pty := &transactionalResizePTY{} | |
| @@ -220,7 +220,7 @@ func TestApplySessionLayoutStopsRetryWhenSessionCanceled(t *testing.T) { | |||
| 220 | 220 | tb.mu.Unlock() | |
| 221 | 221 | p.mu.Lock() | |
| 222 | 222 | require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect, "canceled session published a pane rectangle") | |
| 223 | - | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "canceled session published a VT size") | |
| 223 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()}, "canceled session published a VT size") | |
| 224 | 224 | p.mu.Unlock() | |
| 225 | 225 | } | |
| 226 | 226 | ||
| @@ -299,7 +299,7 @@ func TestTransactionalResizePartialFailureCommitsOnlySuccessfulPTYState(t *testi | |||
| 299 | 299 | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"} | |
| 300 | 300 | first := tb.panes["pane-1"] | |
| 301 | 301 | oldFailedRect := second.rect | |
| 302 | - | oldFailedScreen := domain.Size{Cols: second.screen.Frame.Width, Rows: second.screen.Frame.Height} | |
| 302 | + | oldFailedScreen := domain.Size{Cols: second.screen.Columns(), Rows: second.screen.Rows()} | |
| 303 | 303 | tb.mu.Unlock() | |
| 304 | 304 | ||
| 305 | 305 | d.resize(sess, ac, domain.Size{Cols: 60, Rows: 20}) | |
| @@ -310,7 +310,7 @@ func TestTransactionalResizePartialFailureCommitsOnlySuccessfulPTYState(t *testi | |||
| 310 | 310 | "all pane rectangles publish with the committed layout") | |
| 311 | 311 | require.Equal(t, []domain.Size{{Cols: 30, Rows: 18}}, ok.requested()) | |
| 312 | 312 | require.Equal(t, []domain.Size{{Cols: 29, Rows: 18}}, failed.requested()) | |
| 313 | - | require.Equal(t, oldFailedScreen, domain.Size{Cols: second.screen.Frame.Width, Rows: second.screen.Frame.Height}, | |
| 313 | + | require.Equal(t, oldFailedScreen, domain.Size{Cols: second.screen.Columns(), Rows: second.screen.Rows()}, | |
| 314 | 314 | "failed PTY retains its parser/screen size for clipped padded composition") | |
| 315 | 315 | require.NotEqual(t, oldFailedRect, second.rect, "failed PTY rectangle still follows the committed layout") | |
| 316 | 316 | } | |
| @@ -345,7 +345,7 @@ func TestTransactionalResizeRetryMarksNamedSnapshotDirtyOnlyOnSuccess(t *testing | |||
| 345 | 345 | sess.snapshotMu.Unlock() | |
| 346 | 346 | require.Equal(t, tc.wantGeneration, generation) | |
| 347 | 347 | require.True(t, sess.snapDirty.Load()) | |
| 348 | - | require.Equal(t, tc.wantScreen, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}) | |
| 348 | + | require.Equal(t, tc.wantScreen, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()}) | |
| 349 | 349 | }) | |
| 350 | 350 | } | |
| 351 | 351 | } | |
| @@ -371,7 +371,7 @@ func TestTransactionalResizeRetryTargetsNewestCommittedEpoch(t *testing.T) { | |||
| 371 | 371 | // S3 retries 59x32, then commits the parser/screen and requests a reset. | |
| 372 | 372 | require.Equal(t, []domain.Size{{Cols: 49, Rows: 28}, {Cols: 59, Rows: 32}}, failed.requested(), | |
| 373 | 373 | "the failed old apply is historical; only the authoritative newest epoch retries it") | |
| 374 | - | require.Equal(t, domain.Size{Cols: 59, Rows: 32}, domain.Size{Cols: retry.screen.Frame.Width, Rows: retry.screen.Frame.Height}) | |
| 374 | + | require.Equal(t, domain.Size{Cols: 59, Rows: 32}, domain.Size{Cols: retry.screen.Columns(), Rows: retry.screen.Rows()}) | |
| 375 | 375 | require.Equal(t, uint64(2), sess.renderCoordinator().resizeSnapshot().committed, | |
| 376 | 376 | "the retry belongs to the newest committed epoch") | |
| 377 | 377 | } | |
| @@ -422,7 +422,7 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing | |||
| 422 | 422 | p := tb.focusedPane() | |
| 423 | 423 | p.mu.Lock() | |
| 424 | 424 | require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect) | |
| 425 | - | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}) | |
| 425 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()}) | |
| 426 | 426 | p.mu.Unlock() | |
| 427 | 427 | } | |
| 428 | 428 | require.False(t, sess.snapDirty.Load()) | |
| @@ -440,7 +440,7 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing | |||
| 440 | 440 | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size, "rejected epoch published a tab size") | |
| 441 | 441 | p := tb.focusedPane() | |
| 442 | 442 | require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect, "rejected epoch published a pane rectangle") | |
| 443 | - | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "rejected epoch published a VT size") | |
| 443 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()}, "rejected epoch published a VT size") | |
| 444 | 444 | } | |
| 445 | 445 | ||
| 446 | 446 | require.True(t, sess.geometry.runResizeTransaction(d, sess, ac, lease, newer)) | |
| @@ -448,7 +448,7 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing | |||
| 448 | 448 | require.Equal(t, domain.Size{Cols: 120, Rows: 32}, tb.size) | |
| 449 | 449 | p := tb.focusedPane() | |
| 450 | 450 | require.Equal(t, domain.Rect{Width: 120, Height: 32}, p.rect) | |
| 451 | - | require.Equal(t, domain.Size{Cols: 120, Rows: 32}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}) | |
| 451 | + | require.Equal(t, domain.Size{Cols: 120, Rows: 32}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()}) | |
| 452 | 452 | } | |
| 453 | 453 | committed := 0 | |
| 454 | 454 | for _, mark := range observer.marks { | |
| @@ -621,7 +621,7 @@ func TestTransactionalResizeFloatingBreakpointRace(t *testing.T) { | |||
| 621 | 621 | popup.mu.Lock() | |
| 622 | 622 | require.Equal(t, fromGeometry, popup.popupGeometry, "in-flight resize exposed an uncommitted mode") | |
| 623 | 623 | require.Equal(t, fromGeometry.Inner, popup.rect) | |
| 624 | - | require.Equal(t, rectSize(fromGeometry.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | |
| 624 | + | require.Equal(t, rectSize(fromGeometry.Inner), domain.Size{Cols: popup.screen.Columns(), Rows: popup.screen.Rows()}) | |
| 625 | 625 | popup.mu.Unlock() | |
| 626 | 626 | close(release) | |
| 627 | 627 | require.True(t, <-done) | |
| @@ -631,7 +631,7 @@ func TestTransactionalResizeFloatingBreakpointRace(t *testing.T) { | |||
| 631 | 631 | require.Equal(t, tc.wantMode, popup.popupGeometry.Mode) | |
| 632 | 632 | require.Equal(t, toGeometry, popup.popupGeometry) | |
| 633 | 633 | require.Equal(t, toGeometry.Inner, popup.rect) | |
| 634 | - | require.Equal(t, rectSize(toGeometry.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | |
| 634 | + | require.Equal(t, rectSize(toGeometry.Inner), domain.Size{Cols: popup.screen.Columns(), Rows: popup.screen.Rows()}) | |
| 635 | 635 | popup.mu.Unlock() | |
| 636 | 636 | }) | |
| 637 | 637 | } | |
| @@ -677,7 +677,7 @@ func TestApplyVisibleFloatingLayoutPreservesCommittedGeometryOnPTYResizeFailure( | |||
| 677 | 677 | popup.mu.Lock() | |
| 678 | 678 | require.Equal(t, committed.Inner, popup.rect, "failed PTY resize published a speculative rectangle") | |
| 679 | 679 | require.Equal(t, committed, popup.popupGeometry, "failed PTY resize published speculative popup geometry") | |
| 680 | - | require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | |
| 680 | + | require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Columns(), Rows: popup.screen.Rows()}) | |
| 681 | 681 | require.True(t, popup.resizeRetry, "failed PTY resize must remain retryable") | |
| 682 | 682 | require.False(t, popup.resizeApplying, "failed PTY resize must reopen the parser gate") | |
| 683 | 683 | popup.mu.Unlock() | |
| @@ -715,7 +715,7 @@ func TestApplyVisibleFloatingLayoutPublishesSameSizeGeometryWithoutPTYResize(t * | |||
| 715 | 715 | popup.mu.Lock() | |
| 716 | 716 | require.Equal(t, requested.Inner, popup.rect) | |
| 717 | 717 | require.Equal(t, requested, popup.popupGeometry) | |
| 718 | - | require.Equal(t, rectSize(requested.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | |
| 718 | + | require.Equal(t, rectSize(requested.Inner), domain.Size{Cols: popup.screen.Columns(), Rows: popup.screen.Rows()}) | |
| 719 | 719 | require.False(t, popup.resizeRetry) | |
| 720 | 720 | popup.mu.Unlock() | |
| 721 | 721 | } | |
| @@ -747,7 +747,7 @@ func TestZeroInnerFloatingDrawerPublicationDoesNotRecheckCurrent(t *testing.T) { | |||
| 747 | 747 | popup.mu.Lock() | |
| 748 | 748 | require.Equal(t, requested, popup.popupGeometry) | |
| 749 | 749 | require.Equal(t, committed.Inner, popup.rect) | |
| 750 | - | require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | |
| 750 | + | require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Columns(), Rows: popup.screen.Rows()}) | |
| 751 | 751 | popup.mu.Unlock() | |
| 752 | 752 | } | |
| 753 | 753 | ||
| @@ -772,7 +772,7 @@ func TestZeroInnerFloatingDrawerRejectsStaleRequestWithoutPhysicalPublication(t | |||
| 772 | 772 | popup.mu.Lock() | |
| 773 | 773 | require.Equal(t, committed, popup.popupGeometry) | |
| 774 | 774 | require.Equal(t, committed.Inner, popup.rect) | |
| 775 | - | require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | |
| 775 | + | require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Columns(), Rows: popup.screen.Rows()}) | |
| 776 | 776 | popup.mu.Unlock() | |
| 777 | 777 | } | |
| 778 | 778 | ||
| @@ -136,8 +136,8 @@ func TestReplayResizePendingBuffersSuccessFailureAndBatchOrder(t *testing.T) { | |||||
| 136 | p.resizeApplying = true | 136 | p.resizeApplying = true | ||
| 137 | p.resizePending = []byte("A\x1b[1;81HB") | 137 | p.resizePending = []byte("A\x1b[1;81HB") | ||
| 138 | d.replayResizePending(sess, tb, p, true, domain.Rect{Width: 120, Height: 23}) | 138 | d.replayResizePending(sess, tb, p, true, domain.Rect{Width: 120, Height: 23}) | ||
| 139 | - | require.Equal(t, 120, p.screen.Frame.Width) | 139 | + | require.Equal(t, 120, p.screen.Columns()) |
| 140 | - | require.Equal(t, 'B', p.screen.Frame.At(80, 0).Rune) | 140 | + | require.Equal(t, 'B', p.screen.Cell(80, 0).Rune) |
| 141 | require.False(t, p.resizeApplying) | 141 | require.False(t, p.resizeApplying) | ||
| 142 | }) | 142 | }) | ||
| 143 | t.Run("failure retains old parser width", func(t *testing.T) { | 143 | t.Run("failure retains old parser width", func(t *testing.T) { | ||
| @@ -147,8 +147,8 @@ func TestReplayResizePendingBuffersSuccessFailureAndBatchOrder(t *testing.T) { | |||||
| 147 | p.resizeApplying = true | 147 | p.resizeApplying = true | ||
| 148 | p.resizePending = []byte("\x1b[1;81HB") | 148 | p.resizePending = []byte("\x1b[1;81HB") | ||
| 149 | d.replayResizePending(sess, tb, p, false, domain.Rect{Width: 120, Height: 23}) | 149 | d.replayResizePending(sess, tb, p, false, domain.Rect{Width: 120, Height: 23}) | ||
| 150 | - | require.Equal(t, 80, p.screen.Frame.Width) | 150 | + | require.Equal(t, 80, p.screen.Columns()) |
| 151 | - | require.Equal(t, 'B', p.screen.Frame.At(79, 0).Rune) | 151 | + | require.Equal(t, 'B', p.screen.Cell(79, 0).Rune) |
| 152 | }) | 152 | }) | ||
| 153 | t.Run("batches retain read order", func(t *testing.T) { | 153 | t.Run("batches retain read order", func(t *testing.T) { | ||
| 154 | pty := &transactionalResizePTY{} | 154 | pty := &transactionalResizePTY{} | ||
| @@ -220,7 +220,7 @@ func TestApplySessionLayoutStopsRetryWhenSessionCanceled(t *testing.T) { | |||||
| 220 | tb.mu.Unlock() | 220 | tb.mu.Unlock() | ||
| 221 | p.mu.Lock() | 221 | p.mu.Lock() | ||
| 222 | require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect, "canceled session published a pane rectangle") | 222 | require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect, "canceled session published a pane rectangle") | ||
| 223 | - | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "canceled session published a VT size") | 223 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()}, "canceled session published a VT size") |
| 224 | p.mu.Unlock() | 224 | p.mu.Unlock() | ||
| 225 | } | 225 | } | ||
| 226 | 226 | ||||
| @@ -299,7 +299,7 @@ func TestTransactionalResizePartialFailureCommitsOnlySuccessfulPTYState(t *testi | |||||
| 299 | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"} | 299 | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"} | ||
| 300 | first := tb.panes["pane-1"] | 300 | first := tb.panes["pane-1"] | ||
| 301 | oldFailedRect := second.rect | 301 | oldFailedRect := second.rect | ||
| 302 | - | oldFailedScreen := domain.Size{Cols: second.screen.Frame.Width, Rows: second.screen.Frame.Height} | 302 | + | oldFailedScreen := domain.Size{Cols: second.screen.Columns(), Rows: second.screen.Rows()} |
| 303 | tb.mu.Unlock() | 303 | tb.mu.Unlock() | ||
| 304 | 304 | ||||
| 305 | d.resize(sess, ac, domain.Size{Cols: 60, Rows: 20}) | 305 | d.resize(sess, ac, domain.Size{Cols: 60, Rows: 20}) | ||
| @@ -310,7 +310,7 @@ func TestTransactionalResizePartialFailureCommitsOnlySuccessfulPTYState(t *testi | |||||
| 310 | "all pane rectangles publish with the committed layout") | 310 | "all pane rectangles publish with the committed layout") | ||
| 311 | require.Equal(t, []domain.Size{{Cols: 30, Rows: 18}}, ok.requested()) | 311 | require.Equal(t, []domain.Size{{Cols: 30, Rows: 18}}, ok.requested()) | ||
| 312 | require.Equal(t, []domain.Size{{Cols: 29, Rows: 18}}, failed.requested()) | 312 | require.Equal(t, []domain.Size{{Cols: 29, Rows: 18}}, failed.requested()) | ||
| 313 | - | require.Equal(t, oldFailedScreen, domain.Size{Cols: second.screen.Frame.Width, Rows: second.screen.Frame.Height}, | 313 | + | require.Equal(t, oldFailedScreen, domain.Size{Cols: second.screen.Columns(), Rows: second.screen.Rows()}, |
| 314 | "failed PTY retains its parser/screen size for clipped padded composition") | 314 | "failed PTY retains its parser/screen size for clipped padded composition") | ||
| 315 | require.NotEqual(t, oldFailedRect, second.rect, "failed PTY rectangle still follows the committed layout") | 315 | require.NotEqual(t, oldFailedRect, second.rect, "failed PTY rectangle still follows the committed layout") | ||
| 316 | } | 316 | } | ||
| @@ -345,7 +345,7 @@ func TestTransactionalResizeRetryMarksNamedSnapshotDirtyOnlyOnSuccess(t *testing | |||||
| 345 | sess.snapshotMu.Unlock() | 345 | sess.snapshotMu.Unlock() | ||
| 346 | require.Equal(t, tc.wantGeneration, generation) | 346 | require.Equal(t, tc.wantGeneration, generation) | ||
| 347 | require.True(t, sess.snapDirty.Load()) | 347 | require.True(t, sess.snapDirty.Load()) | ||
| 348 | - | require.Equal(t, tc.wantScreen, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}) | 348 | + | require.Equal(t, tc.wantScreen, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()}) |
| 349 | }) | 349 | }) | ||
| 350 | } | 350 | } | ||
| 351 | } | 351 | } | ||
| @@ -371,7 +371,7 @@ func TestTransactionalResizeRetryTargetsNewestCommittedEpoch(t *testing.T) { | |||||
| 371 | // S3 retries 59x32, then commits the parser/screen and requests a reset. | 371 | // S3 retries 59x32, then commits the parser/screen and requests a reset. | ||
| 372 | require.Equal(t, []domain.Size{{Cols: 49, Rows: 28}, {Cols: 59, Rows: 32}}, failed.requested(), | 372 | require.Equal(t, []domain.Size{{Cols: 49, Rows: 28}, {Cols: 59, Rows: 32}}, failed.requested(), | ||
| 373 | "the failed old apply is historical; only the authoritative newest epoch retries it") | 373 | "the failed old apply is historical; only the authoritative newest epoch retries it") | ||
| 374 | - | require.Equal(t, domain.Size{Cols: 59, Rows: 32}, domain.Size{Cols: retry.screen.Frame.Width, Rows: retry.screen.Frame.Height}) | 374 | + | require.Equal(t, domain.Size{Cols: 59, Rows: 32}, domain.Size{Cols: retry.screen.Columns(), Rows: retry.screen.Rows()}) |
| 375 | require.Equal(t, uint64(2), sess.renderCoordinator().resizeSnapshot().committed, | 375 | require.Equal(t, uint64(2), sess.renderCoordinator().resizeSnapshot().committed, | ||
| 376 | "the retry belongs to the newest committed epoch") | 376 | "the retry belongs to the newest committed epoch") | ||
| 377 | } | 377 | } | ||
| @@ -422,7 +422,7 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing | |||||
| 422 | p := tb.focusedPane() | 422 | p := tb.focusedPane() | ||
| 423 | p.mu.Lock() | 423 | p.mu.Lock() | ||
| 424 | require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect) | 424 | require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect) | ||
| 425 | - | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}) | 425 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()}) |
| 426 | p.mu.Unlock() | 426 | p.mu.Unlock() | ||
| 427 | } | 427 | } | ||
| 428 | require.False(t, sess.snapDirty.Load()) | 428 | require.False(t, sess.snapDirty.Load()) | ||
| @@ -440,7 +440,7 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing | |||||
| 440 | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size, "rejected epoch published a tab size") | 440 | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size, "rejected epoch published a tab size") | ||
| 441 | p := tb.focusedPane() | 441 | p := tb.focusedPane() | ||
| 442 | require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect, "rejected epoch published a pane rectangle") | 442 | require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect, "rejected epoch published a pane rectangle") | ||
| 443 | - | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "rejected epoch published a VT size") | 443 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()}, "rejected epoch published a VT size") |
| 444 | } | 444 | } | ||
| 445 | 445 | ||||
| 446 | require.True(t, sess.geometry.runResizeTransaction(d, sess, ac, lease, newer)) | 446 | require.True(t, sess.geometry.runResizeTransaction(d, sess, ac, lease, newer)) | ||
| @@ -448,7 +448,7 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing | |||||
| 448 | require.Equal(t, domain.Size{Cols: 120, Rows: 32}, tb.size) | 448 | require.Equal(t, domain.Size{Cols: 120, Rows: 32}, tb.size) | ||
| 449 | p := tb.focusedPane() | 449 | p := tb.focusedPane() | ||
| 450 | require.Equal(t, domain.Rect{Width: 120, Height: 32}, p.rect) | 450 | require.Equal(t, domain.Rect{Width: 120, Height: 32}, p.rect) | ||
| 451 | - | require.Equal(t, domain.Size{Cols: 120, Rows: 32}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}) | 451 | + | require.Equal(t, domain.Size{Cols: 120, Rows: 32}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()}) |
| 452 | } | 452 | } | ||
| 453 | committed := 0 | 453 | committed := 0 | ||
| 454 | for _, mark := range observer.marks { | 454 | for _, mark := range observer.marks { | ||
| @@ -621,7 +621,7 @@ func TestTransactionalResizeFloatingBreakpointRace(t *testing.T) { | |||||
| 621 | popup.mu.Lock() | 621 | popup.mu.Lock() | ||
| 622 | require.Equal(t, fromGeometry, popup.popupGeometry, "in-flight resize exposed an uncommitted mode") | 622 | require.Equal(t, fromGeometry, popup.popupGeometry, "in-flight resize exposed an uncommitted mode") | ||
| 623 | require.Equal(t, fromGeometry.Inner, popup.rect) | 623 | require.Equal(t, fromGeometry.Inner, popup.rect) | ||
| 624 | - | require.Equal(t, rectSize(fromGeometry.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | 624 | + | require.Equal(t, rectSize(fromGeometry.Inner), domain.Size{Cols: popup.screen.Columns(), Rows: popup.screen.Rows()}) |
| 625 | popup.mu.Unlock() | 625 | popup.mu.Unlock() | ||
| 626 | close(release) | 626 | close(release) | ||
| 627 | require.True(t, <-done) | 627 | require.True(t, <-done) | ||
| @@ -631,7 +631,7 @@ func TestTransactionalResizeFloatingBreakpointRace(t *testing.T) { | |||||
| 631 | require.Equal(t, tc.wantMode, popup.popupGeometry.Mode) | 631 | require.Equal(t, tc.wantMode, popup.popupGeometry.Mode) | ||
| 632 | require.Equal(t, toGeometry, popup.popupGeometry) | 632 | require.Equal(t, toGeometry, popup.popupGeometry) | ||
| 633 | require.Equal(t, toGeometry.Inner, popup.rect) | 633 | require.Equal(t, toGeometry.Inner, popup.rect) | ||
| 634 | - | require.Equal(t, rectSize(toGeometry.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | 634 | + | require.Equal(t, rectSize(toGeometry.Inner), domain.Size{Cols: popup.screen.Columns(), Rows: popup.screen.Rows()}) |
| 635 | popup.mu.Unlock() | 635 | popup.mu.Unlock() | ||
| 636 | }) | 636 | }) | ||
| 637 | } | 637 | } | ||
| @@ -677,7 +677,7 @@ func TestApplyVisibleFloatingLayoutPreservesCommittedGeometryOnPTYResizeFailure( | |||||
| 677 | popup.mu.Lock() | 677 | popup.mu.Lock() | ||
| 678 | require.Equal(t, committed.Inner, popup.rect, "failed PTY resize published a speculative rectangle") | 678 | require.Equal(t, committed.Inner, popup.rect, "failed PTY resize published a speculative rectangle") | ||
| 679 | require.Equal(t, committed, popup.popupGeometry, "failed PTY resize published speculative popup geometry") | 679 | require.Equal(t, committed, popup.popupGeometry, "failed PTY resize published speculative popup geometry") | ||
| 680 | - | require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | 680 | + | require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Columns(), Rows: popup.screen.Rows()}) |
| 681 | require.True(t, popup.resizeRetry, "failed PTY resize must remain retryable") | 681 | require.True(t, popup.resizeRetry, "failed PTY resize must remain retryable") | ||
| 682 | require.False(t, popup.resizeApplying, "failed PTY resize must reopen the parser gate") | 682 | require.False(t, popup.resizeApplying, "failed PTY resize must reopen the parser gate") | ||
| 683 | popup.mu.Unlock() | 683 | popup.mu.Unlock() | ||
| @@ -715,7 +715,7 @@ func TestApplyVisibleFloatingLayoutPublishesSameSizeGeometryWithoutPTYResize(t * | |||||
| 715 | popup.mu.Lock() | 715 | popup.mu.Lock() | ||
| 716 | require.Equal(t, requested.Inner, popup.rect) | 716 | require.Equal(t, requested.Inner, popup.rect) | ||
| 717 | require.Equal(t, requested, popup.popupGeometry) | 717 | require.Equal(t, requested, popup.popupGeometry) | ||
| 718 | - | require.Equal(t, rectSize(requested.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | 718 | + | require.Equal(t, rectSize(requested.Inner), domain.Size{Cols: popup.screen.Columns(), Rows: popup.screen.Rows()}) |
| 719 | require.False(t, popup.resizeRetry) | 719 | require.False(t, popup.resizeRetry) | ||
| 720 | popup.mu.Unlock() | 720 | popup.mu.Unlock() | ||
| 721 | } | 721 | } | ||
| @@ -747,7 +747,7 @@ func TestZeroInnerFloatingDrawerPublicationDoesNotRecheckCurrent(t *testing.T) { | |||||
| 747 | popup.mu.Lock() | 747 | popup.mu.Lock() | ||
| 748 | require.Equal(t, requested, popup.popupGeometry) | 748 | require.Equal(t, requested, popup.popupGeometry) | ||
| 749 | require.Equal(t, committed.Inner, popup.rect) | 749 | require.Equal(t, committed.Inner, popup.rect) | ||
| 750 | - | require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | 750 | + | require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Columns(), Rows: popup.screen.Rows()}) |
| 751 | popup.mu.Unlock() | 751 | popup.mu.Unlock() | ||
| 752 | } | 752 | } | ||
| 753 | 753 | ||||
| @@ -772,7 +772,7 @@ func TestZeroInnerFloatingDrawerRejectsStaleRequestWithoutPhysicalPublication(t | |||||
| 772 | popup.mu.Lock() | 772 | popup.mu.Lock() | ||
| 773 | require.Equal(t, committed, popup.popupGeometry) | 773 | require.Equal(t, committed, popup.popupGeometry) | ||
| 774 | require.Equal(t, committed.Inner, popup.rect) | 774 | require.Equal(t, committed.Inner, popup.rect) | ||
| 775 | - | require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | 775 | + | require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Columns(), Rows: popup.screen.Rows()}) |
| 776 | popup.mu.Unlock() | 776 | popup.mu.Unlock() | ||
| 777 | } | 777 | } | ||
| 778 | 778 | ||||
M internal/usecase/palette/model_test.go +2 -2
| @@ -414,7 +414,7 @@ func TestRenderUsesConfiguredStyles(t *testing.T) { | |||
| 414 | 414 | assert: func(t *testing.T, style renderer.Style) { | |
| 415 | 415 | require.True(t, style.Italic) | |
| 416 | 416 | require.True(t, style.HasForegroundRGB) | |
| 417 | - | require.Equal(t, -1, style.Foreground) | |
| 417 | + | require.Equal(t, 0, style.Foreground, "inactive indexed color is canonicalized") | |
| 418 | 418 | require.Equal(t, renderer.RGB{R: 10, G: 20, B: 30}, style.ForegroundRGB) | |
| 419 | 419 | }, | |
| 420 | 420 | }, | |
| @@ -548,7 +548,7 @@ func TestPaletteDescriptionKeepsInactiveRowSurfaceAcrossFallbacks(t *testing.T) | |||
| 548 | 548 | SelectionDescription: styles.PickerSelectionMuted, | |
| 549 | 549 | }}) | |
| 550 | 550 | inactive := frame.At(4, 1).Style | |
| 551 | - | require.Equal(t, styles.SurfaceInactive.Background, inactive.Background) | |
| 551 | + | require.Equal(t, styles.SurfaceInactive.Canonical().Background, inactive.Background) | |
| 552 | 552 | require.Equal(t, styles.SurfaceInactive.HasBackgroundRGB, inactive.HasBackgroundRGB) | |
| 553 | 553 | require.Equal(t, styles.SurfaceInactive.BackgroundRGB, inactive.BackgroundRGB) | |
| 554 | 554 | if tt.name == "indexed only" { | |
| @@ -414,7 +414,7 @@ func TestRenderUsesConfiguredStyles(t *testing.T) { | |||||
| 414 | assert: func(t *testing.T, style renderer.Style) { | 414 | assert: func(t *testing.T, style renderer.Style) { | ||
| 415 | require.True(t, style.Italic) | 415 | require.True(t, style.Italic) | ||
| 416 | require.True(t, style.HasForegroundRGB) | 416 | require.True(t, style.HasForegroundRGB) | ||
| 417 | - | require.Equal(t, -1, style.Foreground) | 417 | + | require.Equal(t, 0, style.Foreground, "inactive indexed color is canonicalized") |
| 418 | require.Equal(t, renderer.RGB{R: 10, G: 20, B: 30}, style.ForegroundRGB) | 418 | require.Equal(t, renderer.RGB{R: 10, G: 20, B: 30}, style.ForegroundRGB) | ||
| 419 | }, | 419 | }, | ||
| 420 | }, | 420 | }, | ||
| @@ -548,7 +548,7 @@ func TestPaletteDescriptionKeepsInactiveRowSurfaceAcrossFallbacks(t *testing.T) | |||||
| 548 | SelectionDescription: styles.PickerSelectionMuted, | 548 | SelectionDescription: styles.PickerSelectionMuted, | ||
| 549 | }}) | 549 | }}) | ||
| 550 | inactive := frame.At(4, 1).Style | 550 | inactive := frame.At(4, 1).Style | ||
| 551 | - | require.Equal(t, styles.SurfaceInactive.Background, inactive.Background) | 551 | + | require.Equal(t, styles.SurfaceInactive.Canonical().Background, inactive.Background) |
| 552 | require.Equal(t, styles.SurfaceInactive.HasBackgroundRGB, inactive.HasBackgroundRGB) | 552 | require.Equal(t, styles.SurfaceInactive.HasBackgroundRGB, inactive.HasBackgroundRGB) | ||
| 553 | require.Equal(t, styles.SurfaceInactive.BackgroundRGB, inactive.BackgroundRGB) | 553 | require.Equal(t, styles.SurfaceInactive.BackgroundRGB, inactive.BackgroundRGB) | ||
| 554 | if tt.name == "indexed only" { | 554 | if tt.name == "indexed only" { | ||
M internal/usecase/snapshot/codec.go +4 -3
| @@ -22,7 +22,7 @@ var ( | |||
| 22 | 22 | ||
| 23 | 23 | const ( | |
| 24 | 24 | magic = "VEVS" | |
| 25 | - | version = uint16(4) | |
| 25 | + | version = uint16(5) // Compact VTC1 payloads; dense checkpoints are incompatible. | |
| 26 | 26 | maxDecodedBodySize = 256 << 20 | |
| 27 | 27 | maxSnapshotObjects = 1 << 16 | |
| 28 | 28 | maxSnapshotBytes = 256 << 20 | |
| @@ -34,8 +34,9 @@ const ( | |||
| 34 | 34 | maxSnapshotDecodedAllocation = 256 << 20 | |
| 35 | 35 | ) | |
| 36 | 36 | ||
| 37 | - | // Unmarshal validates the complete manifest and all VT blobs without | |
| 38 | - | // allocations first, then decodes the already preflighted data. | |
| 37 | + | // Unmarshal first measures the complete manifest and its VT blobs without | |
| 38 | + | // allocating. After accepting aggregate budgets, it validates references and | |
| 39 | + | // blob semantics before decoding the session. | |
| 39 | 40 | func Unmarshal(b []byte) (Session, error) { | |
| 40 | 41 | if len(b) < 16 { | |
| 41 | 42 | return Session{}, ErrShortPayload | |
| @@ -22,7 +22,7 @@ var ( | |||||
| 22 | 22 | ||||
| 23 | const ( | 23 | const ( | ||
| 24 | magic = "VEVS" | 24 | magic = "VEVS" | ||
| 25 | - | version = uint16(4) | 25 | + | version = uint16(5) // Compact VTC1 payloads; dense checkpoints are incompatible. |
| 26 | maxDecodedBodySize = 256 << 20 | 26 | maxDecodedBodySize = 256 << 20 | ||
| 27 | maxSnapshotObjects = 1 << 16 | 27 | maxSnapshotObjects = 1 << 16 | ||
| 28 | maxSnapshotBytes = 256 << 20 | 28 | maxSnapshotBytes = 256 << 20 | ||
| @@ -34,8 +34,9 @@ const ( | |||||
| 34 | maxSnapshotDecodedAllocation = 256 << 20 | 34 | maxSnapshotDecodedAllocation = 256 << 20 | ||
| 35 | ) | 35 | ) | ||
| 36 | 36 | ||||
| 37 | - | // Unmarshal validates the complete manifest and all VT blobs without | 37 | + | // Unmarshal first measures the complete manifest and its VT blobs without |
| 38 | - | // allocations first, then decodes the already preflighted data. | 38 | + | // allocating. After accepting aggregate budgets, it validates references and |
| 39 | + | // blob semantics before decoding the session. | |||
| 39 | func Unmarshal(b []byte) (Session, error) { | 40 | func Unmarshal(b []byte) (Session, error) { | ||
| 40 | if len(b) < 16 { | 41 | if len(b) < 16 { | ||
| 41 | return Session{}, ErrShortPayload | 42 | return Session{}, ErrShortPayload | ||
M internal/usecase/snapshot/codec_test.go +7 -7
| @@ -140,7 +140,7 @@ func TestActiveTabReferenceMustBeExact(t *testing.T) { | |||
| 140 | 140 | } | |
| 141 | 141 | ||
| 142 | 142 | func TestUnmarshalRejectsV4LegacyVersions(t *testing.T) { | |
| 143 | - | for _, version := range []uint16{1, 2, 3} { | |
| 143 | + | for _, version := range []uint16{1, 2, 3, 4} { | |
| 144 | 144 | if _, err := Unmarshal(v4Envelope(version, 0, nil)); !errors.Is(err, ErrBadVersion) { | |
| 145 | 145 | t.Fatalf("version %d: %v", version, err) | |
| 146 | 146 | } | |
| @@ -227,11 +227,11 @@ func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) { | |||
| 227 | 227 | if len(sealed) < 2 { | |
| 228 | 228 | t.Fatalf("expected at least two sealed chunks, got %d", len(sealed)) | |
| 229 | 229 | } | |
| 230 | - | multiChunk := append([]byte(nil), sealed[0][:17]...) | |
| 231 | - | binary.BigEndian.PutUint32(multiChunk[5:9], 2) | |
| 232 | - | binary.BigEndian.PutUint64(multiChunk[9:17], 4) | |
| 233 | - | multiChunk = append(multiChunk, sealed[0][17:]...) | |
| 234 | - | multiChunk = append(multiChunk, sealed[1][17:]...) | |
| 230 | + | multiChunk := append([]byte(nil), sealed[0][:16]...) | |
| 231 | + | binary.BigEndian.PutUint32(multiChunk[4:8], 2) | |
| 232 | + | binary.BigEndian.PutUint64(multiChunk[8:16], 4) | |
| 233 | + | multiChunk = append(multiChunk, sealed[0][16:]...) | |
| 234 | + | multiChunk = append(multiChunk, sealed[1][16:]...) | |
| 235 | 235 | transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |
| 236 | 236 | emptyTranscript := transcriptBlob(t, nil) | |
| 237 | 237 | ||
| @@ -477,7 +477,7 @@ func transcriptBlob(t *testing.T, rows [][]renderer.Cell) []byte { | |||
| 477 | 477 | } | |
| 478 | 478 | func requireV4Envelope(t *testing.T, data []byte) { | |
| 479 | 479 | t.Helper() | |
| 480 | - | if version != 4 || len(data) < 16 || binary.BigEndian.Uint16(data[4:6]) != 4 || binary.BigEndian.Uint16(data[6:8]) != 0 { | |
| 480 | + | if version != 5 || len(data) < 16 || binary.BigEndian.Uint16(data[4:6]) != 5 || binary.BigEndian.Uint16(data[6:8]) != 0 { | |
| 481 | 481 | t.Fatalf("not v4 envelope: % x", data) | |
| 482 | 482 | } | |
| 483 | 483 | } | |
| @@ -140,7 +140,7 @@ func TestActiveTabReferenceMustBeExact(t *testing.T) { | |||||
| 140 | } | 140 | } | ||
| 141 | 141 | ||||
| 142 | func TestUnmarshalRejectsV4LegacyVersions(t *testing.T) { | 142 | func TestUnmarshalRejectsV4LegacyVersions(t *testing.T) { | ||
| 143 | - | for _, version := range []uint16{1, 2, 3} { | 143 | + | for _, version := range []uint16{1, 2, 3, 4} { |
| 144 | if _, err := Unmarshal(v4Envelope(version, 0, nil)); !errors.Is(err, ErrBadVersion) { | 144 | if _, err := Unmarshal(v4Envelope(version, 0, nil)); !errors.Is(err, ErrBadVersion) { | ||
| 145 | t.Fatalf("version %d: %v", version, err) | 145 | t.Fatalf("version %d: %v", version, err) | ||
| 146 | } | 146 | } | ||
| @@ -227,11 +227,11 @@ func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) { | |||||
| 227 | if len(sealed) < 2 { | 227 | if len(sealed) < 2 { | ||
| 228 | t.Fatalf("expected at least two sealed chunks, got %d", len(sealed)) | 228 | t.Fatalf("expected at least two sealed chunks, got %d", len(sealed)) | ||
| 229 | } | 229 | } | ||
| 230 | - | multiChunk := append([]byte(nil), sealed[0][:17]...) | 230 | + | multiChunk := append([]byte(nil), sealed[0][:16]...) |
| 231 | - | binary.BigEndian.PutUint32(multiChunk[5:9], 2) | 231 | + | binary.BigEndian.PutUint32(multiChunk[4:8], 2) |
| 232 | - | binary.BigEndian.PutUint64(multiChunk[9:17], 4) | 232 | + | binary.BigEndian.PutUint64(multiChunk[8:16], 4) |
| 233 | - | multiChunk = append(multiChunk, sealed[0][17:]...) | 233 | + | multiChunk = append(multiChunk, sealed[0][16:]...) |
| 234 | - | multiChunk = append(multiChunk, sealed[1][17:]...) | 234 | + | multiChunk = append(multiChunk, sealed[1][16:]...) |
| 235 | transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | 235 | transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | ||
| 236 | emptyTranscript := transcriptBlob(t, nil) | 236 | emptyTranscript := transcriptBlob(t, nil) | ||
| 237 | 237 | ||||
| @@ -477,7 +477,7 @@ func transcriptBlob(t *testing.T, rows [][]renderer.Cell) []byte { | |||||
| 477 | } | 477 | } | ||
| 478 | func requireV4Envelope(t *testing.T, data []byte) { | 478 | func requireV4Envelope(t *testing.T, data []byte) { | ||
| 479 | t.Helper() | 479 | t.Helper() | ||
| 480 | - | if version != 4 || len(data) < 16 || binary.BigEndian.Uint16(data[4:6]) != 4 || binary.BigEndian.Uint16(data[6:8]) != 0 { | 480 | + | if version != 5 || len(data) < 16 || binary.BigEndian.Uint16(data[4:6]) != 5 || binary.BigEndian.Uint16(data[6:8]) != 0 { |
| 481 | t.Fatalf("not v4 envelope: % x", data) | 481 | t.Fatalf("not v4 envelope: % x", data) | ||
| 482 | } | 482 | } | ||
| 483 | } | 483 | } | ||
M internal/usecase/snapshot/manifest.go +1 -1
| @@ -16,7 +16,7 @@ const ( | |||
| 16 | 16 | manifestMagic = "VEVM" | |
| 17 | 17 | headMagic = "VEVH" | |
| 18 | 18 | objectMagic = "VEVO" | |
| 19 | - | ManifestVersion = uint16(3) | |
| 19 | + | ManifestVersion = uint16(4) // Reject dense VT checkpoints before loading their objects. | |
| 20 | 20 | manifestHeaderSize = 16 | |
| 21 | 21 | objectEnvelopeBodyPrefixSize = 1 + 4 // object kind and payload length | |
| 22 | 22 | minObjectEnvelopeSize = manifestHeaderSize + objectEnvelopeBodyPrefixSize + 1 | |
| @@ -16,7 +16,7 @@ const ( | |||||
| 16 | manifestMagic = "VEVM" | 16 | manifestMagic = "VEVM" | ||
| 17 | headMagic = "VEVH" | 17 | headMagic = "VEVH" | ||
| 18 | objectMagic = "VEVO" | 18 | objectMagic = "VEVO" | ||
| 19 | - | ManifestVersion = uint16(3) | 19 | + | ManifestVersion = uint16(4) // Reject dense VT checkpoints before loading their objects. |
| 20 | manifestHeaderSize = 16 | 20 | manifestHeaderSize = 16 | ||
| 21 | objectEnvelopeBodyPrefixSize = 1 + 4 // object kind and payload length | 21 | objectEnvelopeBodyPrefixSize = 1 + 4 // object kind and payload length | ||
| 22 | minObjectEnvelopeSize = manifestHeaderSize + objectEnvelopeBodyPrefixSize + 1 | 22 | minObjectEnvelopeSize = manifestHeaderSize + objectEnvelopeBodyPrefixSize + 1 | ||
M internal/usecase/snapshot/preflight.go +11 -5
| @@ -243,7 +243,7 @@ func preflightBlob(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, budg | |||
| 243 | 243 | } | |
| 244 | 244 | blob := r.b[:n] | |
| 245 | 245 | r.b = r.b[n:] | |
| 246 | - | stats, err := vt.PreflightHistoryBlob(blob) | |
| 246 | + | stats, err := vt.MeasureHistoryBlob(blob) | |
| 247 | 247 | if err != nil { | |
| 248 | 248 | return vt.DecodeStats{}, fmt.Errorf("%w: VT blob", ErrInvalidData) | |
| 249 | 249 | } | |
| @@ -361,14 +361,14 @@ func preflightTabReferences(r *payloadReader) error { | |||
| 361 | 361 | return err | |
| 362 | 362 | } | |
| 363 | 363 | for range n { | |
| 364 | - | if err := skipBlob(r); err != nil { | |
| 364 | + | if err := validateBlob(r); err != nil { | |
| 365 | 365 | return err | |
| 366 | 366 | } | |
| 367 | 367 | } | |
| 368 | - | if err := skipBlob(r); err != nil { | |
| 368 | + | if err := validateBlob(r); err != nil { | |
| 369 | 369 | return err | |
| 370 | 370 | } | |
| 371 | - | if err := skipBlob(r); err != nil { | |
| 371 | + | if err := validateBlob(r); err != nil { | |
| 372 | 372 | return err | |
| 373 | 373 | } | |
| 374 | 374 | if err := preflightProcess(r); err != nil { | |
| @@ -386,7 +386,10 @@ func preflightTabReferences(r *payloadReader) error { | |||
| 386 | 386 | return nil | |
| 387 | 387 | } | |
| 388 | 388 | ||
| 389 | - | func skipBlob(r *payloadReader) error { | |
| 389 | + | // validateBlob runs only after the complete collection passes resource sizing. | |
| 390 | + | // Dictionary/identity validation may allocate, but cannot run once per pane in | |
| 391 | + | // an over-budget collection. Sizing alone never authorizes session construction. | |
| 392 | + | func validateBlob(r *payloadReader) error { | |
| 390 | 393 | n, err := r.getUint32() | |
| 391 | 394 | if err != nil { | |
| 392 | 395 | return err | |
| @@ -394,6 +397,9 @@ func skipBlob(r *payloadReader) error { | |||
| 394 | 397 | if uint64(n) > uint64(len(r.b)) { | |
| 395 | 398 | return ErrShortPayload | |
| 396 | 399 | } | |
| 400 | + | if _, err := vt.PreflightHistoryBlob(r.b[:n]); err != nil { | |
| 401 | + | return fmt.Errorf("%w: VT blob", ErrInvalidData) | |
| 402 | + | } | |
| 397 | 403 | r.b = r.b[n:] | |
| 398 | 404 | return nil | |
| 399 | 405 | } | |
| @@ -243,7 +243,7 @@ func preflightBlob(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, budg | |||||
| 243 | } | 243 | } | ||
| 244 | blob := r.b[:n] | 244 | blob := r.b[:n] | ||
| 245 | r.b = r.b[n:] | 245 | r.b = r.b[n:] | ||
| 246 | - | stats, err := vt.PreflightHistoryBlob(blob) | 246 | + | stats, err := vt.MeasureHistoryBlob(blob) |
| 247 | if err != nil { | 247 | if err != nil { | ||
| 248 | return vt.DecodeStats{}, fmt.Errorf("%w: VT blob", ErrInvalidData) | 248 | return vt.DecodeStats{}, fmt.Errorf("%w: VT blob", ErrInvalidData) | ||
| 249 | } | 249 | } | ||
| @@ -361,14 +361,14 @@ func preflightTabReferences(r *payloadReader) error { | |||||
| 361 | return err | 361 | return err | ||
| 362 | } | 362 | } | ||
| 363 | for range n { | 363 | for range n { | ||
| 364 | - | if err := skipBlob(r); err != nil { | 364 | + | if err := validateBlob(r); err != nil { |
| 365 | return err | 365 | return err | ||
| 366 | } | 366 | } | ||
| 367 | } | 367 | } | ||
| 368 | - | if err := skipBlob(r); err != nil { | 368 | + | if err := validateBlob(r); err != nil { |
| 369 | return err | 369 | return err | ||
| 370 | } | 370 | } | ||
| 371 | - | if err := skipBlob(r); err != nil { | 371 | + | if err := validateBlob(r); err != nil { |
| 372 | return err | 372 | return err | ||
| 373 | } | 373 | } | ||
| 374 | if err := preflightProcess(r); err != nil { | 374 | if err := preflightProcess(r); err != nil { | ||
| @@ -386,7 +386,10 @@ func preflightTabReferences(r *payloadReader) error { | |||||
| 386 | return nil | 386 | return nil | ||
| 387 | } | 387 | } | ||
| 388 | 388 | ||||
| 389 | - | func skipBlob(r *payloadReader) error { | 389 | + | // validateBlob runs only after the complete collection passes resource sizing. |
| 390 | + | // Dictionary/identity validation may allocate, but cannot run once per pane in | |||
| 391 | + | // an over-budget collection. Sizing alone never authorizes session construction. | |||
| 392 | + | func validateBlob(r *payloadReader) error { | |||
| 390 | n, err := r.getUint32() | 393 | n, err := r.getUint32() | ||
| 391 | if err != nil { | 394 | if err != nil { | ||
| 392 | return err | 395 | return err | ||
| @@ -394,6 +397,9 @@ func skipBlob(r *payloadReader) error { | |||||
| 394 | if uint64(n) > uint64(len(r.b)) { | 397 | if uint64(n) > uint64(len(r.b)) { | ||
| 395 | return ErrShortPayload | 398 | return ErrShortPayload | ||
| 396 | } | 399 | } | ||
| 400 | + | if _, err := vt.PreflightHistoryBlob(r.b[:n]); err != nil { | |||
| 401 | + | return fmt.Errorf("%w: VT blob", ErrInvalidData) | |||
| 402 | + | } | |||
| 397 | r.b = r.b[n:] | 403 | r.b = r.b[n:] | ||
| 398 | return nil | 404 | return nil | ||
| 399 | } | 405 | } | ||
M internal/usecase/snapshot/vevs_v4_golden_test.go +8 -6
| @@ -9,8 +9,10 @@ import ( | |||
| 9 | 9 | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | - | func TestVEVSV4GoldenRestoreCompatibility(t *testing.T) { | |
| 13 | - | const golden = "5645565300040000000001291707ae820007726573746f7265000000000000007b0000000100037461620050001800000000000000020001700000017000010001700006737461626c6500042f746d700000000100000078565448310300000001000000000000002a0000000100000000000000290000000200000041000000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff00000000000000010000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff000000000000020000000011565448310300000000000000000000002a0000004f565448310300000001000000000000002b00000001000000000000002a000000010000754cff000ffffffffffffffff9000000000000010101020304050605fffffffffffffff7070809000000010100" | |
| 12 | + | const compactVEVSGolden = "564556530005000000000104dbb0765f0007726573746f7265000000000000007b0000000100037461620050001800000000000000020001700000017000010001700006737461626c6500042f746d70000000010000003f5654433100000001000000000000002a0000000200000001000000010000000000000000000000290000000100000000410000000000000000200000000000000000105654433100000000000000000000002a000000645654433100000001000000000000002b000000020000000100000002000000000900000000000000000000ffffffffffffffff010203000000000000000000000000000000000000000000002a00000002000000754c000000010000000000000000010100" | |
| 13 | + | ||
| 14 | + | func TestVEVSV4CompactGoldenRestore(t *testing.T) { | |
| 15 | + | const golden = compactVEVSGolden | |
| 14 | 16 | data, err := hex.DecodeString(golden) | |
| 15 | 17 | if err != nil { | |
| 16 | 18 | t.Fatal(err) | |
| @@ -65,7 +67,7 @@ func TestVEVSV4GoldenRestoreCompatibility(t *testing.T) { | |||
| 65 | 67 | } | |
| 66 | 68 | ||
| 67 | 69 | func TestVEVSV4GoldenRejectsMalformedInput(t *testing.T) { | |
| 68 | - | const golden = "5645565300040000000001291707ae820007726573746f7265000000000000007b0000000100037461620050001800000000000000020001700000017000010001700006737461626c6500042f746d700000000100000078565448310300000001000000000000002a0000000100000000000000290000000200000041000000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff00000000000000010000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff000000000000020000000011565448310300000000000000000000002a0000004f565448310300000001000000000000002b00000001000000000000002a000000010000754cff000ffffffffffffffff9000000000000010101020304050605fffffffffffffff7070809000000010100" | |
| 70 | + | const golden = compactVEVSGolden | |
| 69 | 71 | valid, err := hex.DecodeString(golden) | |
| 70 | 72 | if err != nil { | |
| 71 | 73 | t.Fatal(err) | |
| @@ -88,14 +90,14 @@ func TestVEVSV4GoldenRejectsMalformedInput(t *testing.T) { | |||
| 88 | 90 | } | |
| 89 | 91 | ||
| 90 | 92 | body := append([]byte(nil), valid[16:]...) | |
| 91 | - | if offset := bytes.Index(body, []byte("VTH1")); offset < 0 { | |
| 93 | + | if offset := bytes.Index(body, []byte("VTC1")); offset < 0 { | |
| 92 | 94 | t.Fatal("golden has no VT blob") | |
| 93 | 95 | } else { | |
| 94 | - | body[offset+4] = 2 | |
| 96 | + | body[offset+3] = '2' | |
| 95 | 97 | cases = append(cases, struct { | |
| 96 | 98 | name string | |
| 97 | 99 | data []byte | |
| 98 | - | }{name: "bad nested VT version", data: v4Envelope(4, 0, body)}) | |
| 100 | + | }{name: "bad nested VT version", data: v4Envelope(version, 0, body)}) | |
| 99 | 101 | } | |
| 100 | 102 | ||
| 101 | 103 | for _, test := range cases { | |
| @@ -9,8 +9,10 @@ import ( | |||||
| 9 | "github.com/bnema/vev/internal/usecase/layout" | 9 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | - | func TestVEVSV4GoldenRestoreCompatibility(t *testing.T) { | 12 | + | const compactVEVSGolden = "564556530005000000000104dbb0765f0007726573746f7265000000000000007b0000000100037461620050001800000000000000020001700000017000010001700006737461626c6500042f746d70000000010000003f5654433100000001000000000000002a0000000200000001000000010000000000000000000000290000000100000000410000000000000000200000000000000000105654433100000000000000000000002a000000645654433100000001000000000000002b000000020000000100000002000000000900000000000000000000ffffffffffffffff010203000000000000000000000000000000000000000000002a00000002000000754c000000010000000000000000010100" |
| 13 | - | const golden = "5645565300040000000001291707ae820007726573746f7265000000000000007b0000000100037461620050001800000000000000020001700000017000010001700006737461626c6500042f746d700000000100000078565448310300000001000000000000002a0000000100000000000000290000000200000041000000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff00000000000000010000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff000000000000020000000011565448310300000000000000000000002a0000004f565448310300000001000000000000002b00000001000000000000002a000000010000754cff000ffffffffffffffff9000000000000010101020304050605fffffffffffffff7070809000000010100" | 13 | + | |
| 14 | + | func TestVEVSV4CompactGoldenRestore(t *testing.T) { | |||
| 15 | + | const golden = compactVEVSGolden | |||
| 14 | data, err := hex.DecodeString(golden) | 16 | data, err := hex.DecodeString(golden) | ||
| 15 | if err != nil { | 17 | if err != nil { | ||
| 16 | t.Fatal(err) | 18 | t.Fatal(err) | ||
| @@ -65,7 +67,7 @@ func TestVEVSV4GoldenRestoreCompatibility(t *testing.T) { | |||||
| 65 | } | 67 | } | ||
| 66 | 68 | ||||
| 67 | func TestVEVSV4GoldenRejectsMalformedInput(t *testing.T) { | 69 | func TestVEVSV4GoldenRejectsMalformedInput(t *testing.T) { | ||
| 68 | - | const golden = "5645565300040000000001291707ae820007726573746f7265000000000000007b0000000100037461620050001800000000000000020001700000017000010001700006737461626c6500042f746d700000000100000078565448310300000001000000000000002a0000000100000000000000290000000200000041000000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff00000000000000010000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff000000000000020000000011565448310300000000000000000000002a0000004f565448310300000001000000000000002b00000001000000000000002a000000010000754cff000ffffffffffffffff9000000000000010101020304050605fffffffffffffff7070809000000010100" | 70 | + | const golden = compactVEVSGolden |
| 69 | valid, err := hex.DecodeString(golden) | 71 | valid, err := hex.DecodeString(golden) | ||
| 70 | if err != nil { | 72 | if err != nil { | ||
| 71 | t.Fatal(err) | 73 | t.Fatal(err) | ||
| @@ -88,14 +90,14 @@ func TestVEVSV4GoldenRejectsMalformedInput(t *testing.T) { | |||||
| 88 | } | 90 | } | ||
| 89 | 91 | ||||
| 90 | body := append([]byte(nil), valid[16:]...) | 92 | body := append([]byte(nil), valid[16:]...) | ||
| 91 | - | if offset := bytes.Index(body, []byte("VTH1")); offset < 0 { | 93 | + | if offset := bytes.Index(body, []byte("VTC1")); offset < 0 { |
| 92 | t.Fatal("golden has no VT blob") | 94 | t.Fatal("golden has no VT blob") | ||
| 93 | } else { | 95 | } else { | ||
| 94 | - | body[offset+4] = 2 | 96 | + | body[offset+3] = '2' |
| 95 | cases = append(cases, struct { | 97 | cases = append(cases, struct { | ||
| 96 | name string | 98 | name string | ||
| 97 | data []byte | 99 | data []byte | ||
| 98 | - | }{name: "bad nested VT version", data: v4Envelope(4, 0, body)}) | 100 | + | }{name: "bad nested VT version", data: v4Envelope(version, 0, body)}) |
| 99 | } | 101 | } | ||
| 100 | 102 | ||||
| 101 | for _, test := range cases { | 103 | for _, test := range cases { | ||
M internal/usecase/snapshot/weights_test.go +3 -3
| @@ -86,10 +86,10 @@ func TestSnapshotEnvelopeCompatibility(t *testing.T) { | |||
| 86 | 86 | const ( | |
| 87 | 87 | headGolden = "56455648000100000000002898ebcac200000000000000070100000000000000000000000000000000000000000000000000000000000000" | |
| 88 | 88 | objectGolden = "5645564f00010000000000111cb8f09f030000000c63616e6f6e6963616c2d7674" | |
| 89 | - | manifestGolden = "5645564d0003000000000133d118cc030000000000000001010000000000000000000000000000000000056e616d6564000000000000000000000001000374616200500018000000000000000000036f6e65010000020000036f6e6500000374776f000200036f6e6500036f6e6500042f6f6e65000101010000000000000000000000000000000000000000000000000000000000000000000016020200000000000000000000000000000000000000000000000000000000000000000000160303000000000000000000000000000000000000000000000000000000000000000000001600000374776f000374776f00042f74776f0000020400000000000000000000000000000000000000000000000000000000000000000000160305000000000000000000000000000000000000000000000000000000000000000000001600" | |
| 89 | + | manifestGolden = "5645564d0004000000000133d118cc030000000000000001010000000000000000000000000000000000056e616d6564000000000000000000000001000374616200500018000000000000000000036f6e65010000020000036f6e6500000374776f000200036f6e6500036f6e6500042f6f6e65000101010000000000000000000000000000000000000000000000000000000000000000000016020200000000000000000000000000000000000000000000000000000000000000000000160303000000000000000000000000000000000000000000000000000000000000000000001600000374776f000374776f00042f74776f0000020400000000000000000000000000000000000000000000000000000000000000000000160305000000000000000000000000000000000000000000000000000000000000000000001600" | |
| 90 | 90 | ) | |
| 91 | - | if ManifestVersion != 3 { | |
| 92 | - | t.Fatalf("ManifestVersion = %d, want 3", ManifestVersion) | |
| 91 | + | if ManifestVersion != 4 { | |
| 92 | + | t.Fatalf("ManifestVersion = %d, want 4", ManifestVersion) | |
| 93 | 93 | } | |
| 94 | 94 | ||
| 95 | 95 | head, err := MarshalHead(Head{Generation: 7, ManifestDigest: SnapshotDigest{1}}) | |
| @@ -86,10 +86,10 @@ func TestSnapshotEnvelopeCompatibility(t *testing.T) { | |||||
| 86 | const ( | 86 | const ( | ||
| 87 | headGolden = "56455648000100000000002898ebcac200000000000000070100000000000000000000000000000000000000000000000000000000000000" | 87 | headGolden = "56455648000100000000002898ebcac200000000000000070100000000000000000000000000000000000000000000000000000000000000" | ||
| 88 | objectGolden = "5645564f00010000000000111cb8f09f030000000c63616e6f6e6963616c2d7674" | 88 | objectGolden = "5645564f00010000000000111cb8f09f030000000c63616e6f6e6963616c2d7674" | ||
| 89 | - | manifestGolden = "5645564d0003000000000133d118cc030000000000000001010000000000000000000000000000000000056e616d6564000000000000000000000001000374616200500018000000000000000000036f6e65010000020000036f6e6500000374776f000200036f6e6500036f6e6500042f6f6e65000101010000000000000000000000000000000000000000000000000000000000000000000016020200000000000000000000000000000000000000000000000000000000000000000000160303000000000000000000000000000000000000000000000000000000000000000000001600000374776f000374776f00042f74776f0000020400000000000000000000000000000000000000000000000000000000000000000000160305000000000000000000000000000000000000000000000000000000000000000000001600" | 89 | + | manifestGolden = "5645564d0004000000000133d118cc030000000000000001010000000000000000000000000000000000056e616d6564000000000000000000000001000374616200500018000000000000000000036f6e65010000020000036f6e6500000374776f000200036f6e6500036f6e6500042f6f6e65000101010000000000000000000000000000000000000000000000000000000000000000000016020200000000000000000000000000000000000000000000000000000000000000000000160303000000000000000000000000000000000000000000000000000000000000000000001600000374776f000374776f00042f74776f0000020400000000000000000000000000000000000000000000000000000000000000000000160305000000000000000000000000000000000000000000000000000000000000000000001600" |
| 90 | ) | 90 | ) | ||
| 91 | - | if ManifestVersion != 3 { | 91 | + | if ManifestVersion != 4 { |
| 92 | - | t.Fatalf("ManifestVersion = %d, want 3", ManifestVersion) | 92 | + | t.Fatalf("ManifestVersion = %d, want 4", ManifestVersion) |
| 93 | } | 93 | } | ||
| 94 | 94 | ||||
| 95 | head, err := MarshalHead(Head{Generation: 7, ManifestDigest: SnapshotDigest{1}}) | 95 | head, err := MarshalHead(Head{Generation: 7, ManifestDigest: SnapshotDigest{1}}) | ||
M internal/usecase/ui/separator_test.go +1 -1
| @@ -64,7 +64,7 @@ func TestDrawSeparatorClipsAndRejectsInvalid(t *testing.T) { | |||
| 64 | 64 | } | |
| 65 | 65 | ||
| 66 | 66 | func TestDrawSeparatorInvalidFrame(t *testing.T) { | |
| 67 | - | f := renderer.Frame{Width: 2, Height: 2, Cells: make([]renderer.Cell, 3)} | |
| 67 | + | f := renderer.Frame{Width: 2, Height: 2} | |
| 68 | 68 | DrawSeparator(f, domain.Rect{Width: 2, Height: 1}, SeparatorHorizontal, renderer.DefaultStyle()) | |
| 69 | 69 | } | |
| 70 | 70 | ||
| @@ -64,7 +64,7 @@ func TestDrawSeparatorClipsAndRejectsInvalid(t *testing.T) { | |||||
| 64 | } | 64 | } | ||
| 65 | 65 | ||||
| 66 | func TestDrawSeparatorInvalidFrame(t *testing.T) { | 66 | func TestDrawSeparatorInvalidFrame(t *testing.T) { | ||
| 67 | - | f := renderer.Frame{Width: 2, Height: 2, Cells: make([]renderer.Cell, 3)} | 67 | + | f := renderer.Frame{Width: 2, Height: 2} |
| 68 | DrawSeparator(f, domain.Rect{Width: 2, Height: 1}, SeparatorHorizontal, renderer.DefaultStyle()) | 68 | DrawSeparator(f, domain.Rect{Width: 2, Height: 1}, SeparatorHorizontal, renderer.DefaultStyle()) | ||
| 69 | } | 69 | } | ||
| 70 | 70 | ||||
M scripts/remote-picker-harness/visual_probe.go +5 -5
| @@ -209,7 +209,7 @@ func (p *visualProbe) apply(output protocol.Output) visualOutputResult { | |||
| 209 | 209 | ||
| 210 | 210 | // The client writes side effects as well as state-bearing frames, but only | |
| 211 | 211 | // state-bearing frames advance the dependency chain and receive an ACK. | |
| 212 | - | if p.screen.Frame.Width != output.Size.Cols || p.screen.Frame.Height != output.Size.Rows { | |
| 212 | + | if p.screen.Columns() != output.Size.Cols || p.screen.Rows() != output.Size.Rows { | |
| 213 | 213 | p.screen.Resize(output.Size.Cols, output.Size.Rows) | |
| 214 | 214 | } | |
| 215 | 215 | p.screen.Write(output.Data) | |
| @@ -255,11 +255,11 @@ func visualScreenLines(screen *vt.Screen) []string { | |||
| 255 | 255 | if screen == nil { | |
| 256 | 256 | return nil | |
| 257 | 257 | } | |
| 258 | - | lines := make([]string, screen.Frame.Height) | |
| 259 | - | for y := range screen.Frame.Height { | |
| 258 | + | lines := make([]string, screen.Rows()) | |
| 259 | + | for y := range screen.Rows() { | |
| 260 | 260 | var line strings.Builder | |
| 261 | - | for x := range screen.Frame.Width { | |
| 262 | - | r := screen.Frame.At(x, y).Rune | |
| 261 | + | for x := range screen.Columns() { | |
| 262 | + | r := screen.Cell(x, y).Rune | |
| 263 | 263 | if r == 0 { | |
| 264 | 264 | r = ' ' | |
| 265 | 265 | } | |
| @@ -209,7 +209,7 @@ func (p *visualProbe) apply(output protocol.Output) visualOutputResult { | |||||
| 209 | 209 | ||||
| 210 | // The client writes side effects as well as state-bearing frames, but only | 210 | // The client writes side effects as well as state-bearing frames, but only | ||
| 211 | // state-bearing frames advance the dependency chain and receive an ACK. | 211 | // state-bearing frames advance the dependency chain and receive an ACK. | ||
| 212 | - | if p.screen.Frame.Width != output.Size.Cols || p.screen.Frame.Height != output.Size.Rows { | 212 | + | if p.screen.Columns() != output.Size.Cols || p.screen.Rows() != output.Size.Rows { |
| 213 | p.screen.Resize(output.Size.Cols, output.Size.Rows) | 213 | p.screen.Resize(output.Size.Cols, output.Size.Rows) | ||
| 214 | } | 214 | } | ||
| 215 | p.screen.Write(output.Data) | 215 | p.screen.Write(output.Data) | ||
| @@ -255,11 +255,11 @@ func visualScreenLines(screen *vt.Screen) []string { | |||||
| 255 | if screen == nil { | 255 | if screen == nil { | ||
| 256 | return nil | 256 | return nil | ||
| 257 | } | 257 | } | ||
| 258 | - | lines := make([]string, screen.Frame.Height) | 258 | + | lines := make([]string, screen.Rows()) |
| 259 | - | for y := range screen.Frame.Height { | 259 | + | for y := range screen.Rows() { |
| 260 | var line strings.Builder | 260 | var line strings.Builder | ||
| 261 | - | for x := range screen.Frame.Width { | 261 | + | for x := range screen.Columns() { |
| 262 | - | r := screen.Frame.At(x, y).Rune | 262 | + | r := screen.Cell(x, y).Rune |
| 263 | if r == 0 { | 263 | if r == 0 { | ||
| 264 | r = ' ' | 264 | r = ' ' | ||
| 265 | } | 265 | } | ||
M scripts/remote-picker-harness/visual_probe_test.go +2 -2
| @@ -93,8 +93,8 @@ func TestVisualProbeResizesToAcceptedOutput(t *testing.T) { | |||
| 93 | 93 | }) | |
| 94 | 94 | ||
| 95 | 95 | require.True(t, result.Accepted) | |
| 96 | - | require.Equal(t, outputSize.Cols, probe.screen.Frame.Width) | |
| 97 | - | require.Equal(t, outputSize.Rows, probe.screen.Frame.Height) | |
| 96 | + | require.Equal(t, outputSize.Cols, probe.screen.Columns()) | |
| 97 | + | require.Equal(t, outputSize.Rows, probe.screen.Rows()) | |
| 98 | 98 | require.Len(t, probe.checkpoints, 1) | |
| 99 | 99 | require.Equal(t, outputSize.Cols, probe.checkpoints[0].snapshot.Columns()) | |
| 100 | 100 | require.Equal(t, outputSize.Rows, probe.checkpoints[0].snapshot.Rows()) | |
| @@ -93,8 +93,8 @@ func TestVisualProbeResizesToAcceptedOutput(t *testing.T) { | |||||
| 93 | }) | 93 | }) | ||
| 94 | 94 | ||||
| 95 | require.True(t, result.Accepted) | 95 | require.True(t, result.Accepted) | ||
| 96 | - | require.Equal(t, outputSize.Cols, probe.screen.Frame.Width) | 96 | + | require.Equal(t, outputSize.Cols, probe.screen.Columns()) |
| 97 | - | require.Equal(t, outputSize.Rows, probe.screen.Frame.Height) | 97 | + | require.Equal(t, outputSize.Rows, probe.screen.Rows()) |
| 98 | require.Len(t, probe.checkpoints, 1) | 98 | require.Len(t, probe.checkpoints, 1) | ||
| 99 | require.Equal(t, outputSize.Cols, probe.checkpoints[0].snapshot.Columns()) | 99 | require.Equal(t, outputSize.Cols, probe.checkpoints[0].snapshot.Columns()) | ||
| 100 | require.Equal(t, outputSize.Rows, probe.checkpoints[0].snapshot.Rows()) | 100 | require.Equal(t, outputSize.Rows, probe.checkpoints[0].snapshot.Rows()) | ||
M transport_replay_integration_test.go +5 -6
| @@ -46,7 +46,7 @@ func TestTransportReplayIntegration(t *testing.T) { | |||
| 46 | 46 | } | |
| 47 | 47 | wg.Wait() | |
| 48 | 48 | require.Equal(t, "\x1b[2J\x1b[Hone\r\ntwo\x1b[2;1HTWO", string(bytes)) | |
| 49 | - | require.Equal(t, []string{"one ", "TWO ", " "}, integrationFrameRows(terminal.Frame)) | |
| 49 | + | require.Equal(t, []string{"one ", "TWO ", " "}, integrationFrameRows(terminal)) | |
| 50 | 50 | } | |
| 51 | 51 | ||
| 52 | 52 | // TestThemeGenerationTransportSequences proves the generation's cleared then | |
| @@ -124,12 +124,11 @@ func TestThemeGenerationTransportSequences(t *testing.T) { | |||
| 124 | 124 | } | |
| 125 | 125 | } | |
| 126 | 126 | ||
| 127 | - | func integrationFrameRows(frame renderer.Frame) []string { | |
| 128 | - | rows := make([]string, frame.Height) | |
| 127 | + | func integrationFrameRows(frame renderer.CellSource) []string { | |
| 128 | + | rows := make([]string, frame.Rows()) | |
| 129 | 129 | for y := range rows { | |
| 130 | - | cells := frame.Row(y) | |
| 131 | - | for _, cell := range cells { | |
| 132 | - | rows[y] += string(cell.Rune) | |
| 130 | + | for x := range frame.Columns() { | |
| 131 | + | rows[y] += string(frame.Cell(x, y).Rune) | |
| 133 | 132 | } | |
| 134 | 133 | } | |
| 135 | 134 | return rows | |
| @@ -46,7 +46,7 @@ func TestTransportReplayIntegration(t *testing.T) { | |||||
| 46 | } | 46 | } | ||
| 47 | wg.Wait() | 47 | wg.Wait() | ||
| 48 | require.Equal(t, "\x1b[2J\x1b[Hone\r\ntwo\x1b[2;1HTWO", string(bytes)) | 48 | require.Equal(t, "\x1b[2J\x1b[Hone\r\ntwo\x1b[2;1HTWO", string(bytes)) | ||
| 49 | - | require.Equal(t, []string{"one ", "TWO ", " "}, integrationFrameRows(terminal.Frame)) | 49 | + | require.Equal(t, []string{"one ", "TWO ", " "}, integrationFrameRows(terminal)) |
| 50 | } | 50 | } | ||
| 51 | 51 | ||||
| 52 | // TestThemeGenerationTransportSequences proves the generation's cleared then | 52 | // TestThemeGenerationTransportSequences proves the generation's cleared then | ||
| @@ -124,12 +124,11 @@ func TestThemeGenerationTransportSequences(t *testing.T) { | |||||
| 124 | } | 124 | } | ||
| 125 | } | 125 | } | ||
| 126 | 126 | ||||
| 127 | - | func integrationFrameRows(frame renderer.Frame) []string { | 127 | + | func integrationFrameRows(frame renderer.CellSource) []string { |
| 128 | - | rows := make([]string, frame.Height) | 128 | + | rows := make([]string, frame.Rows()) |
| 129 | for y := range rows { | 129 | for y := range rows { | ||
| 130 | - | cells := frame.Row(y) | 130 | + | for x := range frame.Columns() { |
| 131 | - | for _, cell := range cells { | 131 | + | rows[y] += string(frame.Cell(x, y).Rune) |
| 132 | - | rows[y] += string(cell.Rune) | |||
| 133 | } | 132 | } | ||
| 134 | } | 133 | } | ||
| 135 | return rows | 134 | return rows | ||