Skip to main content

max / shop

Extract a region of the buffer as text The grid half of the emit primitive (GoingsOn shop e356d861). Addressing a region and handing it over is the one thing only the terminal can do: once a line scrolls off, nothing else has it. Search, paging and opening URLs all fall out as somebody else's program once the bytes are reachable, which is why the earlier in-grid search engine was stripped back to this. Rows are addressed absolutely, not by viewport: row 0 is the oldest row still in scrollback. Viewport rows shift under the user on every line of output, so a range expressed in them would mean something different by the time it was read. Text rules match selection_text, for the same reason it has them: a row that ran off the right edge joins the next with no break, and trailing blanks come off only the row that ends a line. Anything else invents characters the user never saw. The alt screen keeps no history, so a full-buffer emit there hands over the screen, which is all that is reachable anyway. Twelve tests over the cases that make this fiddly: a space straddling a wrap, a row that filled exactly without wrapping, a range ending mid-wrap, blank screen rows under the output, and scrollback ordering ahead of the live screen. The binary half is not here. It needs a key to trigger on, and shop has no keybinding config — every binding is hardcoded in main.rs. Any chord shop consumes never reaches tmux, so taking one by force would take it out from under somebody's prefix with no way to give it back. Blocked on shop f1c36343.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-03 23:20 UTC
Signed with PGP, not checked
Commit: 3007de30fee01a2f30708c9f0a0ea94fc93b1196
Parent: 700cb4a
2 files changed, +221 insertions, -0 deletions
@@ -8,6 +8,7 @@
8 8 //! `vte::Parser` straight into the grid.
9 9
10 10 mod selection;
11 + mod text;
11 12 pub use selection::{Point, Selection, SelectionMode, SelectionSpan};
12 13
13 14 use shop_vt::{Params, Perform};
@@ -1,0 +1,220 @@
1 + //! Getting text out of the grid, for handing a region to another program.
2 + //!
3 + //! Shop's job is to own the buffer; searching, paging and editing it are things
4 + //! any program can do once it has the bytes. This module is the seam: it turns
5 + //! a range of rows into a string, and the binary hands that to whatever the
6 + //! user's runner config names.
7 + //!
8 + //! Rows are addressed ABSOLUTELY, not by viewport: row 0 is the oldest row
9 + //! still kept in scrollback and the last row is the bottom of the live screen.
10 + //! Viewport rows shift under the user every time output arrives, so a range
11 + //! expressed in them would mean something different by the time it was read.
12 + //!
13 + //! The text rules are [`Grid::selection_text`]'s, for the same reasons: a row
14 + //! that ran off the right edge joins the next with no break, and trailing
15 + //! blanks come off only the row that ends the line. Anything else invents
16 + //! characters the user never saw.
17 +
18 + use crate::{Cell, Grid};
19 +
20 + impl Grid {
21 + /// Rows addressable in absolute space: scrollback plus the live screen.
22 + ///
23 + /// The alt screen keeps no history, so on it this is just the screen. A
24 + /// full-buffer emit while `vim` is up hands over what is on screen, which
25 + /// is what the user is looking at and all that is reachable anyway.
26 + pub fn abs_rows(&self) -> usize {
27 + self.abs_base() + self.rows() as usize
28 + }
29 +
30 + /// Absolute index of the live screen's top row.
31 + fn abs_base(&self) -> usize {
32 + if self.on_alt { 0 } else { self.history.len() }
33 + }
34 +
35 + /// The absolute row under visible row `r`.
36 + ///
37 + /// What "emit the visible screen" is expressed with: the viewport covers
38 + /// `abs_row_of_view(0)` up to `abs_row_of_view(0) + rows()`.
39 + pub fn abs_row_of_view(&self, r: u16) -> usize {
40 + // The viewport's top row sits `view_offset` rows above the live screen,
41 + // and `view_offset` never exceeds the history it points into.
42 + self.abs_base() + r as usize - self.view_offset() as usize
43 + }
44 +
45 + /// Absolute rows `start..end` as text, ready to write to a file.
46 + ///
47 + /// `end` is exclusive, and both ends are clamped, so
48 + /// `text_range(0, grid.abs_rows())` is the whole buffer and an over-long
49 + /// range is not an error.
50 + pub fn text_range(&self, start: usize, end: usize) -> String {
51 + let end = end.min(self.abs_rows());
52 + let mut out = String::new();
53 + for r in start..end {
54 + let wrapped = self.abs_row_wrapped(r) && r + 1 < end;
55 + let cells = self.abs_row(r);
56 + if wrapped {
57 + // Trailing blanks on a wrapped row are real cells the text ran
58 + // through. Stripping them would eat the space between two
59 + // words that happened to straddle the edge.
60 + out.extend(cells.iter().map(cell_char));
61 + } else {
62 + let line: String = cells.iter().map(cell_char).collect();
63 + out.push_str(line.trim_end());
64 + out.push('\n');
65 + }
66 + }
67 + out
68 + }
69 +
70 + /// The cells of absolute row `r`, always exactly `cols` of them.
71 + fn abs_row(&self, r: usize) -> &[Cell] {
72 + match r.checked_sub(self.abs_base()) {
73 + Some(live) => {
74 + let start = self.row_start(live as u16);
75 + &self.active_cells()[start..start + self.cols() as usize]
76 + }
77 + None => &self.history[r].cells,
78 + }
79 + }
80 +
81 + /// Whether absolute row `r` ran off the right edge onto the next.
82 + fn abs_row_wrapped(&self, r: usize) -> bool {
83 + match r.checked_sub(self.abs_base()) {
84 + Some(live) => {
85 + let phys = self.phys_row(live as u16) as usize;
86 + let flags = if self.on_alt {
87 + &self.alt_wrapped
88 + } else {
89 + &self.main_wrapped
90 + };
91 + flags.get(phys).copied().unwrap_or(false)
92 + }
93 + None => self.history[r].wrapped,
94 + }
95 + }
96 + }
97 +
98 + /// The character a cell reads as. Unwritten cells hold `\0` and look like
99 + /// blanks on screen, so they emit as blanks.
100 + fn cell_char(cell: &Cell) -> char {
101 + match cell.c() {
102 + '\0' => ' ',
103 + c => c,
104 + }
105 + }
106 +
107 + #[cfg(test)]
108 + mod tests {
109 + use super::*;
110 + use shop_vt::Parser;
111 +
112 + /// A grid fed raw bytes, so tests can drive the deferred wrap and push rows
113 + /// into history the way real output does.
114 + fn grid_fed(cols: u16, rows: u16, bytes: &str) -> Grid {
115 + let mut grid = Grid::new(cols, rows);
116 + let mut parser = Parser::new();
117 + parser.advance(&mut grid, bytes.as_bytes());
118 + grid
119 + }
120 +
121 + fn all(g: &Grid) -> String {
122 + g.text_range(0, g.abs_rows())
123 + }
124 +
125 + #[test]
126 + fn the_live_screen_comes_out_line_by_line() {
127 + let g = grid_fed(20, 3, "one\r\ntwo\r\nthree");
128 + assert_eq!(all(&g), "one\ntwo\nthree\n");
129 + }
130 +
131 + #[test]
132 + fn padding_is_trimmed_but_blank_rows_survive() {
133 + let g = grid_fed(20, 3, "top\r\n\r\nbottom");
134 + assert_eq!(all(&g), "top\n\nbottom\n");
135 + }
136 +
137 + #[test]
138 + fn a_wrapped_line_comes_out_as_one_line() {
139 + let g = grid_fed(6, 3, "abcdefghij");
140 + assert_eq!(g.text_range(0, 2), "abcdefghij\n");
141 + }
142 +
143 + #[test]
144 + fn a_row_that_filled_exactly_still_breaks() {
145 + // Six columns of text ended with CR/LF is not a wrap, even though the
146 + // cursor sat on the right edge.
147 + let g = grid_fed(6, 3, "abcdef\r\nghij");
148 + assert_eq!(g.text_range(0, 2), "abcdef\nghij\n");
149 + }
150 +
151 + #[test]
152 + fn a_space_straddling_the_wrap_survives() {
153 + // "ab" fills the row with real spaces before "cd" wraps onto the next.
154 + let g = grid_fed(6, 3, "ab cd");
155 + assert_eq!(g.text_range(0, 2), "ab cd\n");
156 + }
157 +
158 + #[test]
159 + fn three_rows_of_one_wrapped_line_come_out_as_one() {
160 + let g = grid_fed(4, 4, "0123456789ab");
161 + assert_eq!(g.text_range(0, 3), "0123456789ab\n");
162 + }
163 +
164 + #[test]
165 + fn the_blank_rows_under_the_output_emit_as_blank_lines() {
166 + // Two lines on a four-row screen. The tail is real screen the user is
167 + // looking at, so it comes out rather than being quietly trimmed.
168 + let g = grid_fed(20, 4, "one\r\ntwo");
169 + assert_eq!(all(&g), "one\ntwo\n\n\n");
170 + }
171 +
172 + #[test]
173 + fn scrollback_comes_out_ahead_of_the_screen() {
174 + // Three rows of screen, six lines printed: the first three are history.
175 + let g = grid_fed(20, 3, "one\r\ntwo\r\nthree\r\nfour\r\nfive\r\nsix");
176 + assert_eq!(g.history_len(), 3);
177 + assert_eq!(g.abs_rows(), 6);
178 + assert_eq!(all(&g), "one\ntwo\nthree\nfour\nfive\nsix\n");
179 + }
180 +
181 + #[test]
182 + fn a_range_takes_the_rows_it_names() {
183 + let g = grid_fed(20, 3, "one\r\ntwo\r\nthree\r\nfour\r\nfive\r\nsix");
184 + assert_eq!(g.text_range(2, 4), "three\nfour\n");
185 + assert_eq!(g.text_range(5, 99), "six\n", "an over-long end clamps");
186 + assert_eq!(g.text_range(6, 6), "");
187 + }
188 +
189 + #[test]
190 + fn a_range_ending_mid_wrap_does_not_run_on() {
191 + // The line wraps across rows 0 and 1; asking for row 0 alone must still
192 + // terminate, or the caller gets a file with no final newline.
193 + let g = grid_fed(6, 3, "abcdefghij");
194 + assert_eq!(g.text_range(0, 1), "abcdef\n");
195 + }
196 +
197 + #[test]
198 + fn the_visible_screen_is_a_range_like_any_other() {
199 + let mut g = grid_fed(20, 3, "one\r\ntwo\r\nthree\r\nfour\r\nfive\r\nsix");
200 + let visible = |g: &Grid| {
201 + let top = g.abs_row_of_view(0);
202 + g.text_range(top, top + g.rows() as usize)
203 + };
204 + assert_eq!(visible(&g), "four\nfive\nsix\n");
205 + assert!(g.scroll_view_up(2));
206 + assert_eq!(visible(&g), "two\nthree\nfour\n");
207 + }
208 +
209 + #[test]
210 + fn the_alt_screen_emits_only_itself() {
211 + // Fill history on main, then switch to alt and draw something else.
212 + let g = grid_fed(
213 + 20,
214 + 3,
215 + "main1\r\nmain2\r\nmain3\r\nmain4\x1b[?1049h\x1b[Halt1\r\nalt2",
216 + );
217 + assert_eq!(g.abs_rows(), 3, "history is unreachable from alt");
218 + assert_eq!(all(&g), "alt1\nalt2\n\n");
219 + }
220 + }