Skip to main content

max / shop

Keep scrollback, and give it the wheel and Shift+Page The rio swap made this a regression rather than a gap: the image ships one terminal now, so every build log longer than a screen was unrecoverable. Rows leaving the top of the main screen go into a bounded history and the viewport can sit back in it, with row() and row_wrapped() resolving through the offset so the renderer, selection and word boundaries see one flat screen. Main only: the alt screen is a canvas an application repaints, so a row leaving its top is overdraw. A partial scroll region is the same argument one level down. No reflow, decided rather than deferred by accident. A resize clips or pads history to the new width, which keeps the invariant every reader leans on -- a row is cols cells -- in one place, at the cost of truncating old wide lines when the window narrows. Rewrapping is its own work. The ring's O(1) origin bump survives: history takes a memcpy per scrolled row into a recycled buffer, and the incremental damage path is untouched while the viewport is at the bottom. Reading history falls back to full rebuilds, which is affordable because it is not the throughput case. 10000 rows by default, scrollback_lines in config.toml to change it.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-01 15:40 UTC
Signed with PGP, not checked
Commit: 07fb4e70fb98f2965fd9e37e59fc221209ab837d
Parent: 5d8e209
4 files changed, +542 insertions, -16 deletions
M README.md +28 -6
@@ -14,12 +14,13 @@
14 14 its output through `shop-vt`, which is wired in, so escape sequences are
15 15 interpreted rather than printed. Working: the Kitty graphics protocol,
16 16 truecolor, cursor shapes via DECSCUSR, a palette resolved from a makeover
17 - theme, mouse selection with clipboard and primary selection, and key encoding
18 - through `shop-xkb`.
17 + theme, mouse selection with clipboard and primary selection, key encoding
18 + through `shop-xkb`, an I-beam pointer over the grid, and scrollback with the
19 + wheel and Shift+Page Up / Shift+Page Down.
19 20
20 - Absent: scrollback, and with it the scroll wheel and scrollback search. Also
21 - absent: sixel (Kitty graphics covers the same ground), hyperlinks, and an
22 - I-beam pointer over the grid.
21 + Absent: scrollback search, reflow (a resize clips scrollback to the new width
22 + rather than rewrapping it), sixel (Kitty graphics covers the same ground), and
23 + hyperlinks.
23 24
24 25 `-e PROGRAM [ARGS...]` runs a program instead of the login shell, as every
25 26 terminal does. `--exec 'CMD'` is the shell-string form, for benchmarks.
@@ -36,7 +37,7 @@
36 37
37 38 ## Config
38 39
39 - `~/.config/shop/config.toml`, two keys, both optional:
40 + `~/.config/shop/config.toml`, three keys, all optional:
40 41
41 42 ```toml
42 43 # A theme id, without the .toml. Defaults to akari-night, which shop carries
@@ -47,6 +48,11 @@
47 48 # An extra directory to search first. An Alloy machine points this at the
48 49 # desktop's own set; shop does not otherwise know Alloy exists.
49 50 themes = "/usr/share/alloy/themes"
51 +
52 + # Rows of scrollback to keep. Defaults to 10000. Costs lines x columns x 12
53 + # bytes, so the default is about 24 MB per window at 200 columns. 0 turns
54 + # scrollback off.
55 + scrollback_lines = 10000
50 56 ```
51 57
52 58 Themes are [makeover](https://makenot.work/git/max/makeover) TOML files, which
@@ -58,6 +64,22 @@
58 64
59 65 `shop --theme ID` overrides the file for one run.
60 66
67 + ## Scrollback
68 +
69 + The wheel scrolls three rows a notch; Shift+Page Up and Shift+Page Down move a
70 + screen at a time, less a row of overlap. Typing snaps back to the bottom.
71 +
72 + The alt screen has none, by definition: a program that takes the whole window
73 + repaints it, so a row leaving the top is overdraw rather than history. A wheel
74 + turn inside vim or htop does nothing for now — translating it into arrow keys
75 + is a separate question.
76 +
77 + A resize does not reflow. Scrollback is clipped or padded to the new width, so
78 + narrowing the window truncates old wide lines for good. Rewrapping history is
79 + the right answer for text someone is reading and it is also the classic source
80 + of terminal bugs, so it is deliberate future work rather than a side effect of
81 + having scrollback at all.
82 +
61 83 ## Non-goals
62 84
63 85 - Cross-platform. Wayland Linux only. No X11, no macOS, no Windows.
@@ -11,6 +11,7 @@
11 11 pub use selection::{Point, Selection, SelectionMode, SelectionSpan};
12 12
13 13 use shop_vt::{Params, Perform};
14 + use std::collections::VecDeque;
14 15 use tracing::trace;
15 16
16 17 /// A terminal color.
@@ -212,6 +213,13 @@
212 213 pub screen_swapped: bool,
213 214 /// Grid was resized. Same effect as `screen_swapped` on the renderer.
214 215 pub resized: bool,
216 + /// The viewport moved into or within scrollback, or content arrived under
217 + /// it. Same effect as `screen_swapped` on the renderer: the cache describes
218 + /// rows that are no longer the rows on screen. `dirty_rows` carries the
219 + /// whole screen when this is set, and `scroll` is zero — a cache rotation
220 + /// would be wrong, since the visible rows did not shift by a knowable
221 + /// amount.
222 + pub view_moved: bool,
215 223 }
216 224
217 225 /// What the terminal answers about itself.
@@ -268,6 +276,25 @@
268 276 pub wrap_next: bool,
269 277 }
270 278
279 + /// One row that has scrolled off the top of the main screen.
280 + ///
281 + /// Held at the width it was authored at, which `resize` then normalizes to the
282 + /// grid's width — see [`Grid::history`].
283 + #[derive(Clone, Debug)]
284 + struct HistoryRow {
285 + cells: Vec<Cell>,
286 + /// Whether it ran off the right edge and continued on the row below, so a
287 + /// copy spanning the two joins them without a newline.
288 + wrapped: bool,
289 + }
290 +
291 + /// Rows of scrollback a grid keeps unless told otherwise.
292 + ///
293 + /// Ten thousand is the common default and costs `lines * cols * 12` bytes —
294 + /// about 24 MB at 200 columns. [`Grid::set_history_limit`] is what a config
295 + /// key drives.
296 + pub const DEFAULT_HISTORY_LIMIT: usize = 10_000;
297 +
271 298 pub struct Grid {
272 299 cols: u16,
273 300 rows: u16,
@@ -288,6 +315,25 @@
288 315 // the distinction that decides whether copied text gets a newline here.
289 316 main_wrapped: Vec<bool>,
290 317 alt_wrapped: Vec<bool>,
318 + /// Rows that have scrolled off the top of the main screen, oldest first.
319 + ///
320 + /// Main only: the alt screen is a fixed canvas an application repaints, so
321 + /// a row leaving its top is overdraw rather than history, and every
322 + /// terminal that keeps scrollback keeps none for it.
323 + ///
324 + /// Every row here is exactly `cols` wide. `resize` clips or pads the whole
325 + /// buffer to the new width rather than reflowing it, so the invariant every
326 + /// reader depends on — `row()` yields `cols` cells — holds for history rows
327 + /// as much as for live ones.
328 + history: VecDeque<HistoryRow>,
329 + /// How many rows `history` keeps before dropping its oldest.
330 + history_limit: usize,
331 + /// How far back the viewport sits, in rows. Zero is live. Never exceeds
332 + /// `history.len()`, and forced to zero on the alt screen.
333 + view_offset: u16,
334 + /// The viewport moved, or moved under content, since the last damage
335 + /// drain. Every cached row is suspect, so the renderer rebuilds.
336 + view_dirty: bool,
291 337 on_alt: bool,
292 338 main_origin: u16,
293 339 alt_origin: u16,
@@ -359,6 +405,10 @@
359 405 alt: vec![Cell::default(); cell_count],
360 406 main_wrapped: vec![false; rows as usize],
361 407 alt_wrapped: vec![false; rows as usize],
408 + history: VecDeque::new(),
409 + history_limit: DEFAULT_HISTORY_LIMIT,
410 + view_offset: 0,
411 + view_dirty: false,
362 412 on_alt: false,
363 413 main_origin: 0,
364 414 alt_origin: 0,
@@ -401,6 +451,7 @@
401 451 let scroll = std::mem::take(&mut self.pending_scroll);
402 452 let screen_swapped = std::mem::take(&mut self.pending_screen_swap);
403 453 let resized = std::mem::take(&mut self.pending_resize);
454 + let view_dirty = std::mem::take(&mut self.view_dirty);
404 455 let mut dirty_rows: Vec<u16> = Vec::new();
405 456 for (i, d) in self.row_dirty.iter_mut().enumerate() {
406 457 if *d {
@@ -408,11 +459,29 @@
408 459 *d = false;
409 460 }
410 461 }
462 + // A viewport back in history breaks every assumption the incremental
463 + // path makes: `scroll` describes the live screen moving, and a dirty
464 + // logical row is not the visible row it would be at offset zero. So
465 + // once the user is reading history, any change at all rebuilds the
466 + // screen. That is affordable precisely because it is not the hot path
467 + // — the throughput case is a viewport pinned to the bottom.
468 + let stale = view_dirty
469 + || (self.view_offset > 0 && (scroll != 0 || !dirty_rows.is_empty() || screen_swapped));
470 + if stale && !resized {
471 + return Damage {
472 + scroll: 0,
473 + dirty_rows: (0..self.rows).collect(),
474 + screen_swapped,
475 + resized,
476 + view_moved: true,
477 + };
478 + }
411 479 Damage {
412 480 scroll,
413 481 dirty_rows,
414 482 screen_swapped,
415 483 resized,
484 + view_moved: false,
416 485 }
417 486 }
418 487
@@ -500,12 +569,159 @@
500 569 self.cursor
501 570 }
502 571
572 + /// Where the cursor sits on screen, or `None` when the viewport is far
573 + /// enough back that it has scrolled off the bottom.
574 + ///
575 + /// The cursor's own row is a live row. Drawing it at that row while the
576 + /// user reads history would put a blinking block on unrelated text.
577 + pub fn cursor_view_row(&self) -> Option<u16> {
578 + let r = self.cursor.row.checked_add(self.view_offset)?;
579 + (r < self.rows).then_some(r)
580 + }
581 +
582 + /// The cells of visible row `r`, always exactly `cols` of them.
583 + ///
584 + /// While the viewport sits back in history the top `view_offset` rows come
585 + /// from [`Grid::history`] and the rest from the live screen, so every
586 + /// reader — the renderer, selection, word boundaries — sees one flat
587 + /// screen and needs to know nothing about where it came from.
503 588 pub fn row(&self, r: u16) -> &[Cell] {
504 - let start = self.row_start(r);
589 + if let Some(h) = self.history_row(r) {
590 + return &h.cells;
591 + }
592 + let start = self.row_start(self.live_row(r));
505 593 let end = start + self.cols as usize;
506 594 &self.active_cells()[start..end]
507 595 }
508 596
597 + /// The history row backing visible row `r`, if the viewport is far enough
598 + /// back that `r` falls in it.
599 + fn history_row(&self, r: u16) -> Option<&HistoryRow> {
600 + if r >= self.view_offset {
601 + return None;
602 + }
603 + // The viewport's top row is `view_offset` rows above the live screen,
604 + // so it is that far from the end of history.
605 + let back = (self.view_offset - r) as usize;
606 + self.history
607 + .len()
608 + .checked_sub(back)
609 + .map(|i| &self.history[i])
610 + }
611 +
612 + /// The live logical row under visible row `r`. Only meaningful once `r` is
613 + /// known not to fall in history.
614 + fn live_row(&self, r: u16) -> u16 {
615 + r - self.view_offset
616 + }
617 +
618 + /// How far back the viewport sits, in rows. Zero is live.
619 + pub fn view_offset(&self) -> u16 {
620 + self.view_offset
621 + }
622 +
623 + /// Rows currently in scrollback.
624 + pub fn history_len(&self) -> usize {
625 + self.history.len()
626 + }
627 +
628 + /// Set how many rows of scrollback to keep, dropping the oldest if the new
629 + /// limit is smaller. Zero disables scrollback.
630 + pub fn set_history_limit(&mut self, limit: usize) {
631 + self.history_limit = limit;
632 + while self.history.len() > limit {
633 + self.history.pop_front();
634 + }
635 + // The viewport cannot point past what is left.
636 + self.set_view_offset(self.view_offset.min(self.history_len_u16()));
637 + }
638 +
639 + /// Move the viewport back into history by `n` rows, stopping at the oldest
640 + /// row kept. Returns whether it moved.
641 + pub fn scroll_view_up(&mut self, n: u16) -> bool {
642 + let want = self
643 + .view_offset
644 + .saturating_add(n)
645 + .min(self.history_len_u16());
646 + self.set_view_offset(want)
647 + }
648 +
649 + /// Move the viewport toward the live screen by `n` rows. Returns whether
650 + /// it moved.
651 + pub fn scroll_view_down(&mut self, n: u16) -> bool {
652 + let want = self.view_offset.saturating_sub(n);
653 + self.set_view_offset(want)
654 + }
655 +
656 + /// Snap the viewport back to the live screen. Returns whether it moved.
657 + ///
658 + /// This is what typing does: input goes to a program whose output is at the
659 + /// bottom, so leaving the user reading history while their keystrokes land
660 + /// somewhere off-screen would be a lie about where they are.
661 + pub fn scroll_view_to_bottom(&mut self) -> bool {
662 + self.set_view_offset(0)
663 + }
664 +
665 + fn set_view_offset(&mut self, want: u16) -> bool {
666 + // The alt screen has no history, so there is nowhere to go.
667 + let want = if self.on_alt { 0 } else { want };
668 + if want == self.view_offset {
669 + return false;
670 + }
671 + self.view_offset = want;
672 + self.view_dirty = true;
673 + true
674 + }
675 +
676 + fn history_len_u16(&self) -> u16 {
677 + self.history.len().min(u16::MAX as usize) as u16
678 + }
679 +
680 + /// Push the row about to be overwritten into history, and keep the viewport
681 + /// looking at the same content if it is back in history.
682 + ///
683 + /// Called only from the fullscreen main-screen scroll. A partial scroll
684 + /// region is an application drawing inside a box — the row leaving the top
685 + /// of that box has not left the screen — and the alt screen keeps none.
686 + fn push_history(&mut self, phys: u16) {
687 + if self.on_alt || self.history_limit == 0 {
688 + return;
689 + }
690 + let cols = self.cols as usize;
691 + let start = phys as usize * cols;
692 + let wrapped = self
693 + .main_wrapped
694 + .get(phys as usize)
695 + .copied()
696 + .unwrap_or(false);
697 + // Recycle the evicted row's buffer rather than freeing one and
698 + // allocating another. Scrolling is the throughput case the ring layout
699 + // exists for, and once history is full — which a long build log reaches
700 + // in seconds — this makes the steady state a memcpy with no allocator
701 + // traffic behind it.
702 + let mut cells = if self.history.len() == self.history_limit {
703 + let recycled = self.history.pop_front().map(|row| row.cells);
704 + // The oldest row is gone, so a viewport anchored to it has to give
705 + // up a row rather than silently show different text.
706 + self.view_offset = self.view_offset.saturating_sub(1);
707 + recycled.unwrap_or_default()
708 + } else {
709 + Vec::new()
710 + };
711 + cells.clear();
712 + cells.extend_from_slice(&self.main[start..start + cols]);
713 + self.history.push_back(HistoryRow { cells, wrapped });
714 + // Pin the view: new output below should not drag what the user is
715 + // reading up the screen.
716 + if self.view_offset > 0 {
717 + self.view_offset = self
718 + .view_offset
719 + .saturating_add(1)
720 + .min(self.history_len_u16());
721 + self.view_dirty = true;
722 + }
723 + }
724 +
509 725 fn active_cells(&self) -> &[Cell] {
510 726 if self.on_alt { &self.alt } else { &self.main }
511 727 }
@@ -560,7 +776,10 @@
560 776 /// spanning the two rows should join them without a newline. A row that
561 777 /// filled exactly and then got an explicit CR/LF reads false.
562 778 pub fn row_wrapped(&self, r: u16) -> bool {
563 - let phys = self.phys_row(r) as usize;
779 + if let Some(h) = self.history_row(r) {
780 + return h.wrapped;
781 + }
782 + let phys = self.phys_row(self.live_row(r)) as usize;
564 783 let flags = if self.on_alt {
565 784 &self.alt_wrapped
566 785 } else {
@@ -677,6 +896,25 @@
677 896 // rather than carry wrong ones into a copy.
678 897 self.main_wrapped = vec![false; rows as usize];
679 898 self.alt_wrapped = vec![false; rows as usize];
899 + // Scrollback is clipped or padded to the new width, never reflowed.
900 + // Reflow is the right answer for a history someone is reading and it is
901 + // also the classic source of terminal bugs, so it is a separate,
902 + // deliberate piece of work rather than a side effect of this one.
903 + //
904 + // Normalizing here rather than at render keeps the invariant every
905 + // reader leans on — a row is `cols` cells — in one place. The cost is
906 + // that narrowing the window truncates old wide lines for good.
907 + if cols != self.cols {
908 + for row in &mut self.history {
909 + row.cells.resize(cols as usize, Cell::default());
910 + // A wrap point recorded at the old width no longer says where
911 + // the text runs off the edge, same argument as the live rows.
912 + row.wrapped = false;
913 + }
914 + }
915 + // The viewport survives a height change, but it cannot point further
916 + // back than history goes.
917 + self.view_offset = self.view_offset.min(self.history_len_u16());
680 918 self.main_origin = 0;
681 919 self.alt_origin = 0;
682 920 self.region_origin = 0;
@@ -942,6 +1180,9 @@
942 1180 self.advance_origin(n as i32);
943 1181 for k in 0..n {
944 1182 let phys = (old_origin as u32 + k as u32) % self.rows as u32;
1183 + // Before blanking, not after: this row is leaving the screen
1184 + // and the copy into history is the only thing that keeps it.
1185 + self.push_history(phys as u16);
945 1186 self.blank_physical_row(phys as u16);
946 1187 }
947 1188 self.pending_scroll = self.pending_scroll.saturating_add(n as i16);
@@ -1045,6 +1286,11 @@
1045 1286 // region_origin belongs to the currently-active screen; unroll before
1046 1287 // switching so the other screen starts with region_origin = 0.
1047 1288 self.unroll_region();
1289 + // An application taking the alt screen is taking the whole window, so
1290 + // a viewport parked in main's history has nothing left to show. Going
1291 + // the other way, the user is put back where the shell is, not where
1292 + // they were reading before vim opened.
1293 + self.view_offset = 0;
1048 1294 if to_alt {
1049 1295 self.saved_main_cursor = self.cursor;
1050 1296 self.on_alt = true;
@@ -2094,4 +2340,179 @@
2094 2340 assert_eq!(row_str(&g, 1), "four");
2095 2341 assert_eq!(row_str(&g, 2), "fi");
2096 2342 }
2343 +
2344 + #[test]
2345 + fn rows_that_scroll_off_the_top_land_in_history() {
2346 + let mut g = Grid::new(6, 3);
2347 + feed(&mut g, b"one\r\ntwo\r\nthree\r\nfour\r\nfive");
2348 + assert_eq!(g.history_len(), 2);
2349 + // Still live, so the screen reads as it did before scrollback existed.
2350 + assert_eq!(row_str(&g, 0), "three");
2351 + }
2352 +
2353 + #[test]
2354 + fn scrolling_back_shows_the_rows_that_left() {
2355 + let mut g = Grid::new(6, 3);
2356 + feed(&mut g, b"one\r\ntwo\r\nthree\r\nfour\r\nfive");
2357 + assert!(g.scroll_view_up(2));
2358 + assert_eq!(g.view_offset(), 2);
2359 + assert_eq!(row_str(&g, 0), "one");
2360 + assert_eq!(row_str(&g, 1), "two");
2361 + assert_eq!(row_str(&g, 2), "three");
2362 + }
2363 +
2364 + #[test]
2365 + fn the_viewport_stops_at_the_oldest_row_kept() {
2366 + let mut g = Grid::new(6, 3);
2367 + feed(&mut g, b"one\r\ntwo\r\nthree\r\nfour\r\nfive");
2368 + assert!(g.scroll_view_up(999));
2369 + assert_eq!(g.view_offset(), 2);
2370 + // Already at the top: no move, so nothing asks for a redraw.
2371 + assert!(!g.scroll_view_up(1));
2372 + }
2373 +
2374 + #[test]
2375 + fn output_under_a_scrolled_back_viewport_does_not_drag_it() {
2376 + let mut g = Grid::new(6, 3);
2377 + feed(&mut g, b"one\r\ntwo\r\nthree\r\nfour\r\nfive");
2378 + g.scroll_view_up(2);
2379 + assert_eq!(row_str(&g, 0), "one");
2380 + feed(&mut g, b"\r\nsix\r\nseven");
2381 + // The reader is still looking at the same text, one row further back.
2382 + assert_eq!(row_str(&g, 0), "one");
2383 + assert_eq!(g.view_offset(), 4);
2384 + }
2385 +
2386 + #[test]
2387 + fn the_oldest_row_falls_off_at_the_limit() {
2388 + let mut g = Grid::new(6, 3);
2389 + g.set_history_limit(2);
2390 + feed(&mut g, b"one\r\ntwo\r\nthree\r\nfour\r\nfive\r\nsix");
2391 + assert_eq!(g.history_len(), 2);
2392 + g.scroll_view_up(2);
2393 + // "one" is gone; the oldest kept row is what the top shows.
2394 + assert_eq!(row_str(&g, 0), "two");
2395 + }
2396 +
2397 + #[test]
2398 + fn a_zero_limit_keeps_no_history() {
2399 + let mut g = Grid::new(6, 3);
2400 + g.set_history_limit(0);
2401 + feed(&mut g, b"one\r\ntwo\r\nthree\r\nfour\r\nfive");
2402 + assert_eq!(g.history_len(), 0);
2403 + assert!(!g.scroll_view_up(1));
2404 + }
2405 +
2406 + #[test]
2407 + fn the_alt_screen_neither_feeds_history_nor_scrolls_back() {
2408 + let mut g = Grid::new(6, 3);
2409 + feed(&mut g, b"one\r\ntwo\r\nthree\r\nfour\r\nfive");
2410 + let before = g.history_len();
2411 + feed(&mut g, b"\x1b[?1049h"); // enter alt
2412 + feed(&mut g, b"a\r\nb\r\nc\r\nd\r\ne");
2413 + assert_eq!(g.history_len(), before, "alt screen wrote to history");
2414 + assert!(!g.scroll_view_up(1));
2415 + assert_eq!(g.view_offset(), 0);
2416 + }
2417 +
2418 + #[test]
2419 + fn taking_the_alt_screen_puts_the_viewport_back_at_the_bottom() {
2420 + let mut g = Grid::new(6, 3);
2421 + feed(&mut g, b"one\r\ntwo\r\nthree\r\nfour\r\nfive");
2422 + g.scroll_view_up(2);
2423 + feed(&mut g, b"\x1b[?1049h");
2424 + assert_eq!(g.view_offset(), 0);
2425 + feed(&mut g, b"\x1b[?1049l"); // and back
2426 + assert_eq!(g.view_offset(), 0);
2427 + }
2428 +
2429 + #[test]
2430 + fn a_partial_scroll_region_does_not_feed_history() {
2431 + let mut g = Grid::new(6, 4);
2432 + // DECSTBM rows 1-3: an application drawing in a box, so a row leaving
2433 + // the top of that box has not left the screen.
2434 + feed(&mut g, b"\x1b[1;3r");
2435 + feed(&mut g, b"one\r\ntwo\r\nthree\r\nfour\r\nfive");
2436 + assert_eq!(g.history_len(), 0);
2437 + }
2438 +
2439 + #[test]
2440 + fn history_is_clipped_to_the_new_width_rather_than_reflowed() {
2441 + let mut g = Grid::new(8, 2);
2442 + feed(&mut g, b"abcdefgh\r\nsecond\r\nthird");
2443 + g.scroll_view_up(1);
2444 + assert_eq!(row_str(&g, 0), "abcdefgh");
2445 + g.scroll_view_down(1);
2446 + g.resize(4, 2);
2447 + g.scroll_view_up(1);
2448 + // Narrowed, so the tail is gone for good. That is the cost of not
2449 + // reflowing, and it is the stated tradeoff rather than a bug.
2450 + assert_eq!(row_str(&g, 0), "abcd");
2451 + assert_eq!(g.row(0).len(), 4, "history rows must be `cols` wide");
2452 + }
2453 +
2454 + #[test]
2455 + fn widening_pads_history_so_every_row_is_cols_wide() {
2456 + let mut g = Grid::new(4, 2);
2457 + // Exactly one row off the top, so offset 1 is unambiguously "abcd".
2458 + feed(&mut g, b"abcd\r\nxy\r\nz");
2459 + g.resize(8, 2);
2460 + g.scroll_view_up(1);
2461 + assert_eq!(g.row(0).len(), 8);
2462 + assert_eq!(row_str(&g, 0), "abcd");
2463 + }
2464 +
2465 + #[test]
2466 + fn the_viewport_cannot_outlive_the_history_a_resize_leaves() {
2467 + let mut g = Grid::new(6, 3);
2468 + feed(&mut g, b"one\r\ntwo\r\nthree\r\nfour\r\nfive");
2469 + g.scroll_view_up(2);
2470 + g.set_history_limit(1);
2471 + assert_eq!(g.view_offset(), 1, "viewport pointed past the oldest row");
2472 + }
2473 +
2474 + #[test]
2475 + fn the_cursor_hides_when_the_viewport_leaves_it_behind() {
2476 + let mut g = Grid::new(6, 3);
2477 + feed(&mut g, b"one\r\ntwo\r\nthree\r\nfour\r\nfive");
2478 + assert_eq!(g.cursor_view_row(), Some(g.cursor().row));
2479 + // The cursor sits on the bottom row after that output, so one row of
2480 + // scrollback is already enough to push it off the screen.
2481 + g.scroll_view_up(1);
2482 + assert_eq!(g.cursor_view_row(), None);
2483 + }
2484 +
2485 + #[test]
2486 + fn a_viewport_move_asks_for_a_full_rebuild() {
2487 + let mut g = Grid::new(6, 3);
2488 + feed(&mut g, b"one\r\ntwo\r\nthree\r\nfour\r\nfive");
2489 + let _ = g.take_damage();
2490 + g.scroll_view_up(1);
2491 + let d = g.take_damage();
2492 + assert!(d.view_moved);
2493 + assert_eq!(d.scroll, 0, "a cache rotation would be wrong here");
2494 + assert_eq!(d.dirty_rows.len(), 3);
2495 + }
2496 +
2497 + #[test]
2498 + fn a_still_viewport_at_the_bottom_keeps_the_incremental_path() {
2499 + let mut g = Grid::new(6, 3);
2500 + feed(&mut g, b"one\r\ntwo\r\nthree");
2501 + let _ = g.take_damage();
2502 + feed(&mut g, b"\r\nfour");
2503 + let d = g.take_damage();
2504 + assert!(!d.view_moved);
2505 + assert_eq!(d.scroll, 1, "the O(1) scroll path must survive scrollback");
2506 + }
2507 +
Lines truncated
@@ -75,6 +75,9 @@
75 75 /// prompt stays steady. The colour is the theme's `action.primary`; only the
76 76 /// alpha is shop's, because how much of the cell the cursor covers is a
77 77 /// property of this terminal and not of the palette.
78 + /// Rows one wheel notch moves the viewport. Three is the near-universal
79 + /// default and is what makes a turn feel like a turn rather than a nudge.
80 + const WHEEL_LINES: u16 = 3;
78 81 const CURSOR_ALPHA_ON: f32 = 0.85;
79 82 const CURSOR_ALPHA_DIM: f32 = 0.28;
80 83 /// How much of a selected cell the wash covers.
@@ -140,7 +143,9 @@
140 143 .iter()
141 144 .position(|a| a == "--theme")
142 145 .and_then(|i| args.get(i + 1).cloned());
143 - let palette = Palette::load(&Config::load().with_theme(theme_arg));
146 + let config = Config::load().with_theme(theme_arg);
147 + let scrollback_lines = config.scrollback_lines;
148 + let palette = Palette::load(&config);
144 149 let (spawn_cmd, spawn_args) =
145 150 spawn_target(exec_argv, exec_cmd.clone(), std::env::var("SHELL").ok());
146 151 let spawn_args_refs: Vec<&str> = spawn_args.iter().map(String::as_str).collect();
@@ -234,7 +239,10 @@
234 239 let text = TextRenderer::new(&device, &queue, format, font_data, FONT_PX)?;
235 240 let images = ImageRenderer::new(&device, format);
236 241
237 - let grid = Grid::new(cols_initial, rows_initial);
242 + let mut grid = Grid::new(cols_initial, rows_initial);
243 + if let Some(lines) = scrollback_lines {
244 + grid.set_history_limit(lines);
245 + }
238 246 let parser = shop_vt::Parser::new();
239 247
240 248 // Both selection protocols are optional. A compositor without them costs
@@ -827,7 +835,7 @@
827 835 let damage = app.grid.take_damage();
828 836 app.apply_damage_to_selection(&damage);
829 837 app.text.ensure_rows(app.grid.rows());
830 - if damage.screen_swapped || damage.resized {
838 + if damage.screen_swapped || damage.resized || damage.view_moved {
831 839 app.text.clear_rows();
832 840 }
833 841 if damage.scroll != 0 {
@@ -919,9 +927,11 @@
919 927 }
920 928 }
921 929
922 - if cursor.visible {
930 + if let Some(cursor_row) = app.grid.cursor_view_row()
931 + && cursor.visible
932 + {
923 933 let cx = pad_x_px + cursor.col as f32 * cell_w_px;
924 - let cy = pad_y_px + cursor.row as f32 * cell_h_px;
934 + let cy = pad_y_px + cursor_row as f32 * cell_h_px;
925 935 let [r, g, b, _] = app.palette.cursor;
926 936 let alpha = if app.cursor_phase {
927 937 CURSOR_ALPHA_ON
@@ -1494,6 +1504,34 @@
1494 1504 self.last_serial = serial;
1495 1505 self.paste_primary();
1496 1506 }
1507 + PointerEventKind::Axis { vertical, .. } => {
1508 + // The wheel moves the viewport through scrollback. On the
1509 + // alt screen there is none, and translating the wheel into
1510 + // arrow keys for full-screen programs is a separate
1511 + // question (GO shop task da46cb19) — until it is settled,
1512 + // a wheel turn in vim does nothing rather than something
1513 + // surprising.
1514 + //
1515 + // `discrete` is notches where the compositor reports them
1516 + // and zero on a touchpad, where `absolute` carries a
1517 + // continuous distance; take the notch count when there is
1518 + // one and fall back to the sign of the distance.
1519 + let notches = if vertical.discrete != 0 {
1520 + vertical.discrete
1521 + } else if vertical.absolute > 0.0 {
1522 + 1
1523 + } else if vertical.absolute < 0.0 {
1524 + -1
1525 + } else {
1526 + 0
1527 + };
1528 + let lines = notches.unsigned_abs() as u16 * WHEEL_LINES;
1529 + if notches < 0 {
1530 + self.scroll_view(|g| g.scroll_view_up(lines));
1531 + } else if notches > 0 {
1532 + self.scroll_view(|g| g.scroll_view_down(lines));
1533 + }
1534 + }
1497 1535 PointerEventKind::Leave { .. } => {
1498 1536 // Keep the drag alive: the pointer leaving the window
1499 1537 // during a selection is normal, and the release will find
@@ -1701,6 +1739,25 @@
1701 1739 ///
1702 1740 /// Returns true when the key was consumed.
1703 1741 fn handle_binding(&mut self, event: &KeyEvent) -> bool {
1742 + // Shift+Page is the conventional scrollback binding, and shop consumes
1743 + // it rather than forwarding it: the shell has no use for it, and a
1744 + // terminal that passed it through would have no way to reach its own
1745 + // history. A page is one screen less a row, so a line of context
1746 + // carries over and the reader can stitch the two screens together.
1747 + if self.modifiers.shift && !self.modifiers.ctrl {
1748 + let page = self.grid.rows().saturating_sub(1).max(1);
1749 + match event.keysym {
1750 + Keysym::Page_Up => {
1751 + self.scroll_view(|g| g.scroll_view_up(page));
1752 + return true;
1753 + }
1754 + Keysym::Page_Down => {
1755 + self.scroll_view(|g| g.scroll_view_down(page));
1756 + return true;
1757 + }
1758 + _ => {}
1759 + }
1760 + }
1704 1761 if !(self.modifiers.ctrl && self.modifiers.shift) {
1705 1762 return false;
1706 1763 }
@@ -1724,6 +1781,16 @@
1724 1781 }
1725 1782 }
1726 1783
1784 + /// Run a viewport move and ask for a frame if it went anywhere.
1785 + ///
1786 + /// The redraw has to be explicit: moving the viewport changes no cell, so
1787 + /// nothing else in the loop would notice that the screen is now wrong.
1788 + fn scroll_view(&mut self, mv: impl FnOnce(&mut Grid) -> bool) {
1789 + if mv(&mut self.grid) {
1790 + self.dirty = true;
1791 + }
1792 + }
1793 +
1727 1794 /// Reconcile the selection with what just happened to the grid.
1728 1795 ///
1729 1796 /// A selection is a claim about which cells hold which text, and a scroll
@@ -1734,7 +1801,11 @@
1734 1801 if self.selection.is_none() {
1735 1802 return;
1736 1803 }
1737 - if damage.resized || damage.screen_swapped {
1804 + // A viewport move is in the same class as a resize here. The selection
1805 + // is anchored to visible rows, and moving the view puts different text
1806 + // under them; carrying the anchors across would silently reselect
1807 + // something the user never dragged over.
1808 + if damage.resized || damage.screen_swapped || damage.view_moved {
1738 1809 self.selection = None;
1739 1810 self.dragging = false;
1740 1811 } else if damage.scroll != 0 {
@@ -1770,6 +1841,10 @@
1770 1841 if bytes.is_empty() {
1771 1842 return;
1772 1843 }
1844 + // Typing goes to a program whose output is at the bottom, so the
1845 + // screen follows the keystrokes back down. Anything that reached here
1846 + // produced bytes, which means it was input and not a shop binding.
1847 + self.scroll_view(shop_grid::Grid::scroll_view_to_bottom);
1773 1848 if let Err(e) = self.pty.write(&bytes) {
1774 1849 warn!("pty write: {e}");
1775 1850 }
@@ -171,9 +171,11 @@
171 171
172 172 /// What shop reads out of `~/.config/shop/config.toml`.
173 173 ///
174 - /// Deliberately two keys. A terminal's config file grows without limit if you
174 + /// Deliberately few keys. A terminal's config file grows without limit if you
175 175 /// let it, and everything else shop needs so far is either a compile-time
176 - /// constant or a command-line flag for one run.
176 + /// constant or a command-line flag for one run. A key earns its place by being
177 + /// a property of the machine rather than a preference: where the themes live,
178 + /// and how much memory scrollback may have.
177 179 #[derive(Debug, Clone, Default)]
178 180 pub(crate) struct Config {
179 181 /// The theme id to load, without `.toml`.
@@ -184,6 +186,13 @@
184 186 /// Alloy machine points it at `/usr/share/alloy/themes` and gets the
185 187 /// desktop's own set, and shop does not have to know that Alloy exists.
186 188 pub themes: Option<PathBuf>,
189 + /// Rows of scrollback to keep, or `None` for the grid's default.
190 + ///
191 + /// Configurable because the cost is per window and the right answer is a
192 + /// property of the machine, not of the terminal: `lines * cols * 12` bytes,
193 + /// so ten thousand rows is about 24 MB at 200 columns. Zero turns
194 + /// scrollback off.
195 + pub scrollback_lines: Option<usize>,
187 196 }
188 197
189 198 impl Config {
@@ -223,6 +232,13 @@
223 232 .get("themes")
224 233 .and_then(toml::Value::as_str)
225 234 .map(PathBuf::from),
235 + // A negative count is not a smaller history, it is a typo, so it
236 + // falls back to the default rather than being clamped to zero and
237 + // silently costing the user their scrollback.
238 + scrollback_lines: table
239 + .get("scrollback_lines")
240 + .and_then(toml::Value::as_integer)
241 + .and_then(|n| usize::try_from(n).ok()),
226 242 }
227 243 }
228 244
@@ -313,6 +329,7 @@
313 329 let config = Config {
314 330 theme: Some("no-such-theme".into()),
315 331 themes: None,
332 + scrollback_lines: None,
316 333 };
317 334 let palette = Palette::load(&config);
318 335 let want = embedded(DEFAULT_THEME).unwrap();
@@ -341,6 +358,7 @@
341 358 let unchanged = Config {
342 359 theme: Some("akari-night".into()),
343 360 themes: None,
361 + scrollback_lines: None,
344 362 }
345 363 .with_theme(None);
346 364 assert_eq!(unchanged.theme.as_deref(), Some("akari-night"));