Skip to main content

max / shop

Give the alt screen's wheel cursor keys, gated on 1007 The wheel got scrollback in 07fb4e7, which left only the alt screen unanswered: there is no history there, so a turn in less or man did nothing. Translate notches into cursor keys instead, one arrow per row the viewport would have moved, so a turn covers the same distance on either screen. DECSET 1007 is how a program declines it; on by default. Mouse reporting stays out. Nothing in the workspace handles 1000/1002/ 1006, so no program is receiving mouse events today and none can be confused by arrows. Whoever adds it makes reporting win over this path and leaves 1007 as the off switch. The notch arithmetic moves out of the pointer handler into wheel_action, which is the part worth testing and does not need a compositor.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-02 13:42 UTC
Signed with PGP, not checked
Commit: 700cb4a2b640a89439fd695765a43669471ee956
Parent: 28e2cb0
2 files changed, +219 insertions, -13 deletions
@@ -374,6 +374,12 @@
374 374 // them — shop-xkb is what reads them.
375 375 cursor_keys_application: bool,
376 376 keypad_application: bool,
377 + // DECSET 1007: alternate scroll. On the alt screen there is no history to
378 + // move through, so the wheel is translated into cursor keys instead —
379 + // which is what makes `less` and `man` scroll. On by default, and a
380 + // program that wants the wheel to mean something else clears it. Same
381 + // division as 2004: the grid tracks the mode, the binary acts on it.
382 + alternate_scroll: bool,
377 383 pending_title: Option<String>,
378 384 identity: Identity,
379 385 // Bytes the terminal owes the program, from queries it answered. The grid
@@ -432,6 +438,7 @@
432 438 bracketed_paste: false,
433 439 cursor_keys_application: false,
434 440 keypad_application: false,
441 + alternate_scroll: true,
435 442 pending_title: None,
436 443 identity: Identity::default(),
437 444 pending_replies: Vec::new(),
@@ -531,6 +538,21 @@
531 538 self.keypad_application
532 539 }
533 540
541 + /// DECSET 1007: the wheel may be translated into cursor keys on the alt
542 + /// screen. On unless a program clears it, and only consulted there — off
543 + /// the alt screen the wheel has real history to move through.
544 + pub fn alternate_scroll(&self) -> bool {
545 + self.alternate_scroll
546 + }
547 +
548 + /// Whether the alt screen is the active one.
549 + ///
550 + /// The alt screen keeps no history, which is why anything deciding what a
551 + /// scroll means has to ask.
552 + pub fn on_alt(&self) -> bool {
553 + self.on_alt
554 + }
555 +
534 556 /// Consume any window title set by the shell via OSC 0/2 since the last
535 557 /// call. Binary polls after each `parser.advance` and forwards to
536 558 /// `xdg_window.set_title`.
@@ -1615,6 +1637,7 @@
1615 1637 // it. Grid just tracks the state — the binary
1616 1638 // is what actually defers the redraw.
1617 1639 1 => self.cursor_keys_application = action == 'h',
1640 + 1007 => self.alternate_scroll = action == 'h',
1618 1641 2004 => self.bracketed_paste = action == 'h',
1619 1642 2026 => self.sync_update = action == 'h',
1620 1643 _ => {}
@@ -2249,6 +2272,37 @@
2249 2272 assert!(g.bracketed_paste());
2250 2273 }
2251 2274
2275 + #[test]
2276 + fn alternate_scroll_toggles_on_1007() {
2277 + let mut g = Grid::new(10, 3);
2278 + assert!(g.alternate_scroll(), "on until a program says otherwise");
2279 + feed(&mut g, b"\x1b[?1007l");
2280 + assert!(!g.alternate_scroll());
2281 + feed(&mut g, b"\x1b[?1007h");
2282 + assert!(g.alternate_scroll());
2283 + }
2284 +
2285 + #[test]
2286 + fn alternate_scroll_survives_an_alt_screen_round_trip() {
2287 + // The mode is the user's answer to what the wheel means, not the alt
2288 + // screen's, so entering and leaving one does not restore the default.
2289 + let mut g = Grid::new(10, 3);
2290 + feed(&mut g, b"\x1b[?1007l");
2291 + feed(&mut g, b"\x1b[?1049h");
2292 + feed(&mut g, b"\x1b[?1049l");
2293 + assert!(!g.alternate_scroll());
2294 + }
2295 +
2296 + #[test]
2297 + fn on_alt_follows_1049() {
2298 + let mut g = Grid::new(10, 3);
2299 + assert!(!g.on_alt());
2300 + feed(&mut g, b"\x1b[?1049h");
2301 + assert!(g.on_alt());
2302 + feed(&mut g, b"\x1b[?1049l");
2303 + assert!(!g.on_alt());
2304 + }
2305 +
2252 2306 // ---- wrapped-row flag ----------------------------------------------
2253 2307
2254 2308 #[test]
@@ -1505,13 +1505,6 @@
1505 1505 self.paste_primary();
1506 1506 }
1507 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 1508 // `discrete` is notches where the compositor reports them
1516 1509 // and zero on a touchpad, where `absolute` carries a
1517 1510 // continuous distance; take the notch count when there is
@@ -1525,12 +1518,7 @@
1525 1518 } else {
1526 1519 0
1527 1520 };
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 - }
1521 + self.apply_wheel(notches);
1534 1522 }
1535 1523 PointerEventKind::Leave { .. } => {
1536 1524 // Keep the drag alive: the pointer leaving the window
@@ -1791,6 +1779,32 @@
1791 1779 }
1792 1780 }
1793 1781
1782 + /// Act on a wheel turn of `notches`, negative for up.
1783 + fn apply_wheel(&mut self, notches: i32) {
1784 + let modes = shop_xkb::Modes {
1785 + cursor_keys_application: self.grid.cursor_keys_application(),
1786 + keypad_application: self.grid.keypad_application(),
1787 + };
1788 + match wheel_action(
1789 + notches,
1790 + self.grid.on_alt(),
1791 + self.grid.alternate_scroll(),
1792 + modes,
1793 + ) {
1794 + WheelAction::Nothing => {}
1795 + WheelAction::Up(lines) => self.scroll_view(|g| g.scroll_view_up(lines)),
1796 + WheelAction::Down(lines) => self.scroll_view(|g| g.scroll_view_down(lines)),
1797 + // No snap back to the bottom here, unlike typing: the viewport is
1798 + // already pinned at zero on the alt screen, so there is nothing
1799 + // for a reset to do.
1800 + WheelAction::Keys(bytes) => {
1801 + if let Err(e) = self.pty.write(&bytes) {
1802 + warn!("pty write: {e}");
1803 + }
1804 + }
1805 + }
1806 + }
1807 +
1794 1808 /// Reconcile the selection with what just happened to the grid.
1795 1809 ///
1796 1810 /// A selection is a claim about which cells hold which text, and a scroll
@@ -1851,6 +1865,66 @@
1851 1865 }
1852 1866 }
1853 1867
1868 + /// What a wheel turn amounts to, once the screen and the modes are known.
1869 + ///
1870 + /// Split out from the pointer handler so the decision is testable without a
1871 + /// compositor: the arithmetic and the alt-screen rule are the parts worth
1872 + /// getting right, and neither of them needs a window.
1873 + #[derive(Debug, PartialEq, Eq)]
1874 + enum WheelAction {
1875 + /// Move the viewport back into history by this many rows.
1876 + Up(u16),
1877 + /// Move the viewport toward the live screen by this many rows.
1878 + Down(u16),
1879 + /// Write these bytes to the PTY.
1880 + Keys(Vec<u8>),
1881 + Nothing,
1882 + }
1883 +
1884 + /// Decide what a wheel turn of `notches` does. Negative is up, toward older
1885 + /// output.
1886 + ///
1887 + /// Off the alt screen the wheel moves the viewport through scrollback. On it
1888 + /// there is no history to move through — the offset is pinned at zero — so the
1889 + /// turn becomes cursor keys instead, which is what makes `less`, `man` and
1890 + /// `git log` scroll. DECSET 1007 is the program's way of declining that.
1891 + fn wheel_action(
1892 + notches: i32,
1893 + on_alt: bool,
1894 + alternate_scroll: bool,
1895 + modes: shop_xkb::Modes,
1896 + ) -> WheelAction {
1897 + if notches == 0 {
1898 + return WheelAction::Nothing;
1899 + }
1900 + let lines = notches
1901 + .unsigned_abs()
1902 + .saturating_mul(u32::from(WHEEL_LINES))
1903 + .min(u32::from(u16::MAX)) as u16;
1904 + let up = notches < 0;
1905 +
1906 + if !on_alt {
1907 + return if up {
1908 + WheelAction::Up(lines)
1909 + } else {
1910 + WheelAction::Down(lines)
1911 + };
1912 + }
1913 + if !alternate_scroll {
1914 + return WheelAction::Nothing;
1915 + }
1916 + // One arrow per row the viewport would have moved, so a turn covers the
1917 + // same distance whichever screen it lands on. `encode` is the only place
1918 + // that knows whether DECCKM makes that `CSI B` or `SS3 B`.
1919 + let keysym = if up { Keysym::Up } else { Keysym::Down };
1920 + let one = shop_xkb::encode(keysym, "", shop_xkb::Mods::default(), modes);
1921 + let mut bytes = Vec::with_capacity(one.len() * lines as usize);
1922 + for _ in 0..lines {
1923 + bytes.extend_from_slice(&one);
1924 + }
1925 + WheelAction::Keys(bytes)
1926 + }
1927 +
1854 1928 /// SCTK's modifier set as shop-xkb's.
1855 1929 ///
1856 1930 /// Two structurally identical types, kept apart on purpose: shop-xkb does not
@@ -1964,6 +2038,84 @@
1964 2038 items.iter().map(|s| (*s).to_string()).collect()
1965 2039 }
1966 2040
2041 + // ---- the wheel ------------------------------------------------------
2042 +
2043 + const NORMAL: shop_xkb::Modes = shop_xkb::Modes {
2044 + cursor_keys_application: false,
2045 + keypad_application: false,
2046 + };
2047 +
2048 + fn keys(action: &WheelAction) -> &str {
2049 + match action {
2050 + WheelAction::Keys(b) => std::str::from_utf8(b).expect("arrow bytes are ascii"),
2051 + other => panic!("expected keys, got {other:?}"),
2052 + }
2053 + }
2054 +
2055 + #[test]
2056 + fn on_the_main_screen_the_wheel_moves_the_viewport() {
2057 + assert_eq!(
2058 + wheel_action(-1, false, true, NORMAL),
2059 + WheelAction::Up(WHEEL_LINES)
2060 + );
2061 + assert_eq!(
2062 + wheel_action(2, false, true, NORMAL),
2063 + WheelAction::Down(WHEEL_LINES * 2)
2064 + );
2065 + }
2066 +
2067 + #[test]
2068 + fn the_main_screen_ignores_1007() {
2069 + // The mode only answers what to do where there is no history; it is
2070 + // not an off switch for scrolling.
2071 + assert_eq!(
2072 + wheel_action(-1, false, false, NORMAL),
2073 + WheelAction::Up(WHEEL_LINES)
2074 + );
2075 + }
2076 +
2077 + #[test]
2078 + fn on_the_alt_screen_the_wheel_becomes_arrows() {
2079 + // One arrow per row the viewport would have moved, so `less` covers
2080 + // the same distance as the scrollback would have.
2081 + assert_eq!(
2082 + keys(&wheel_action(-1, true, true, NORMAL)),
2083 + "\x1b[A".repeat(3)
2084 + );
2085 + assert_eq!(
2086 + keys(&wheel_action(1, true, true, NORMAL)),
2087 + "\x1b[B".repeat(3)
2088 + );
2089 + assert_eq!(
2090 + keys(&wheel_action(3, true, true, NORMAL)),
2091 + "\x1b[B".repeat(9)
2092 + );
2093 + }
2094 +
2095 + #[test]
2096 + fn decckm_switches_the_arrows_to_their_application_form() {
2097 + let modes = shop_xkb::Modes {
2098 + cursor_keys_application: true,
2099 + ..NORMAL
2100 + };
2101 + assert_eq!(
2102 + keys(&wheel_action(1, true, true, modes)),
2103 + "\x1bOB".repeat(3)
2104 + );
2105 + }
2106 +
2107 + #[test]
2108 + fn clearing_1007_leaves_the_alt_screen_wheel_dead() {
2109 + assert_eq!(wheel_action(1, true, false, NORMAL), WheelAction::Nothing);
2110 + assert_eq!(wheel_action(-1, true, false, NORMAL), WheelAction::Nothing);
2111 + }
2112 +
2113 + #[test]
2114 + fn no_notches_is_no_action() {
2115 + assert_eq!(wheel_action(0, false, true, NORMAL), WheelAction::Nothing);
2116 + assert_eq!(wheel_action(0, true, true, NORMAL), WheelAction::Nothing);
2117 + }
2118 +
1967 2119 #[test]
1968 2120 fn with_no_flags_the_login_shell_runs() {
1969 2121 let (cmd, args) = spawn_target(None, None, Some("/usr/bin/nu".into()));