From 3dd953b75a224129f1cdb7a8029c5902d88098ec Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 24 Jun 2026 14:59:05 +0300 Subject: [PATCH] grid: scrollback with mouse-wheel and Shift+PageUp scrolling Signed-off-by: NotAShelf Change-Id: I12b2ed33a705eb3474a7d14a295e021d6a6a6964 --- src/grid.rs | 88 +++++++++++++++++++++++++++++++++++++++++ src/render.rs | 16 +++++--- src/vt.rs | 13 +++++++ src/wayland.rs | 104 ++++++++++++++++++++++++++++++++++++++++++------- 4 files changed, 201 insertions(+), 20 deletions(-) diff --git a/src/grid.rs b/src/grid.rs index 78c0132..fdd3703 100644 --- a/src/grid.rs +++ b/src/grid.rs @@ -1,8 +1,13 @@ //! The terminal screen: a grid of styled cells, a cursor, and the editing //! operations the VT parser drives. +use std::collections::VecDeque; + use unicode_width::UnicodeWidthChar; +/// Maximum scrollback lines retained for the main screen. +const SCROLLBACK_CAP: usize = 10_000; + /// A cell colour: terminal default, a palette index, or direct RGB. #[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] pub enum Color { @@ -122,6 +127,10 @@ pub struct Grid { tabs: Vec, /// Saved primary screen while the alternate screen is active. alt_saved: Option>>, + /// Lines that have scrolled off the top of the main screen, newest last. + scrollback: VecDeque>, + /// How many lines the viewport is scrolled back from the live bottom. + view_offset: usize, cursor_shape: CursorShape, cursor_visible: bool, /// Application cursor-keys mode (DECCKM): arrows send SS3 instead of CSI. @@ -153,6 +162,8 @@ impl Grid { wrap_pending: false, tabs: default_tabs(cols), alt_saved: None, + scrollback: VecDeque::new(), + view_offset: 0, cursor_shape: CursorShape::default(), cursor_visible: true, cursor_color: None, @@ -184,6 +195,9 @@ impl Grid { self.cursor.x = self.cursor.x.min(cols - 1); self.cursor.y = self.cursor.y.min(rows - 1); self.wrap_pending = false; + // Scrollback lines keep their old width (no reflow); snap to the live + // screen so the viewport never indexes a stale-width line. + self.view_offset = 0; } pub fn cursor(&self) -> (usize, usize) { @@ -475,6 +489,21 @@ impl Grid { pub fn scroll_up(&mut self, n: usize) { let n = n.min(self.bottom - self.top + 1); + // Lines leaving the top of the *whole* main screen become scrollback; + // a DECSTBM region scroll (top > 0) or the alt screen does not. + if self.top == 0 && self.alt_saved.is_none() { + for y in 0..n { + let line = std::mem::replace(&mut self.lines[y], vec![Cell::default(); self.cols]); + self.scrollback.push_back(line); + } + while self.scrollback.len() > SCROLLBACK_CAP { + self.scrollback.pop_front(); + } + // Keep a scrolled-back viewport anchored to the same content. + if self.view_offset > 0 { + self.view_offset = (self.view_offset + n).min(self.scrollback.len()); + } + } for y in self.top..=self.bottom { if y + n <= self.bottom { self.lines.swap(y, y + n); @@ -618,6 +647,7 @@ impl Grid { if self.alt_saved.is_some() { return; } + self.view_offset = 0; let blank = vec![vec![Cell::default(); self.cols]; self.rows]; self.alt_saved = Some(std::mem::replace(&mut self.lines, blank)); } @@ -628,6 +658,45 @@ impl Grid { } } + // --- scrollback viewport --- + + /// Scroll the viewport by `delta` lines: positive = back into history, + /// negative = toward the live screen. No-op on the alternate screen. + pub fn scroll_view(&mut self, delta: isize) { + if self.alt_saved.is_some() { + return; + } + let max = self.scrollback.len() as isize; + self.view_offset = (self.view_offset as isize + delta).clamp(0, max) as usize; + } + + pub fn scroll_to_bottom(&mut self) { + self.view_offset = 0; + } + + /// Whether the viewport is showing the live screen (not scrolled back). + pub fn view_at_bottom(&self) -> bool { + self.view_offset == 0 + } + + /// One page (a screenful) of lines, for page-scroll bindings. + pub fn page(&self) -> usize { + self.rows.max(1) + } + + /// The cells shown at viewport row `y` (0 = top of the window), accounting + /// for the scrollback offset. May differ from `cols` in width if the line + /// predates a resize (no reflow yet), so callers must not assume length. + pub fn view_row(&self, y: usize) -> &[Cell] { + let start = self.scrollback.len() - self.view_offset; + let idx = start + y; + if idx < self.scrollback.len() { + &self.scrollback[idx] + } else { + &self.lines[idx - self.scrollback.len()] + } + } + // --- inspection (logging + tests) --- /// The visible text of one row, trailing blanks trimmed. @@ -713,6 +782,25 @@ mod tests { assert_eq!(g.row_text(0), "a def"); } + #[test] + fn scrollback_captures_scrolled_lines() { + let mut g = Grid::new(8, 2); + for c in ['1', '2', '3'] { + g.print(c); + g.next_line(); + } + // Live screen: newest line on top, cleared line below. + assert_eq!(g.view_row(0)[0].c, '3'); + assert!(g.view_at_bottom()); + // Scroll back to reveal the two captured lines. + g.scroll_view(2); + assert!(!g.view_at_bottom()); + assert_eq!(g.view_row(0)[0].c, '1'); + assert_eq!(g.view_row(1)[0].c, '2'); + g.scroll_to_bottom(); + assert_eq!(g.view_row(0)[0].c, '3'); + } + #[test] fn wide_char_occupies_two_columns() { let mut g = Grid::new(6, 1); diff --git a/src/render.rs b/src/render.rs index 9ea9bb4..b3ca7a7 100644 --- a/src/render.rs +++ b/src/render.rs @@ -126,12 +126,14 @@ impl Renderer { canvas.clear(DEFAULT_BG); let m = self.fonts.metrics(); + let cols = grid.cols(); // Cell backgrounds: only paint cells that differ from the cleared - // default - most of a screen is default background. + // default - most of a screen is default background. Rows come through + // the scrollback viewport and may be shorter than `cols` after a resize. for y in 0..grid.rows() { - for x in 0..grid.cols() { - let (_, bg) = cell_colors(grid.cell(x, y)); + for (x, cell) in grid.view_row(y).iter().take(cols).enumerate() { + let (_, bg) = cell_colors(cell); if bg != DEFAULT_BG { let (px, py) = (x as i32 * m.width as i32, y as i32 * m.height as i32); canvas.fill_rect(px, py, m.width, m.height, bg); @@ -140,8 +142,7 @@ impl Renderer { } for y in 0..grid.rows() { - for x in 0..grid.cols() { - let cell = grid.cell(x, y); + for (x, cell) in grid.view_row(y).iter().take(cols).enumerate() { if cell.flags.contains(Flags::WIDE_CONT) { continue; } @@ -162,7 +163,10 @@ impl Renderer { } } - self.draw_cursor(&mut canvas, grid, m, focused); + // The cursor belongs to the live screen; hide it while scrolled back. + if grid.view_at_bottom() { + self.draw_cursor(&mut canvas, grid, m, focused); + } } /// Draw the cursor: a solid block/underline/beam when focused, a hollow diff --git a/src/vt.rs b/src/vt.rs index ae7e847..b698824 100644 --- a/src/vt.rs +++ b/src/vt.rs @@ -123,6 +123,19 @@ impl Term { self.grid.resize(cols, rows); } + pub fn scroll_view(&mut self, delta: isize) { + self.grid.scroll_view(delta); + } + + pub fn scroll_to_bottom(&mut self) { + self.grid.scroll_to_bottom(); + } + + /// Lines per page, for page-scroll bindings. + pub fn page(&self) -> usize { + self.grid.page() + } + pub fn title(&self) -> Option<&str> { self.title.as_deref() } diff --git a/src/wayland.rs b/src/wayland.rs index 358a44b..afc9e86 100644 --- a/src/wayland.rs +++ b/src/wayland.rs @@ -18,14 +18,15 @@ use crate::render::Renderer; use crate::vt::Term; use smithay_client_toolkit::{ compositor::{CompositorHandler, CompositorState}, - delegate_compositor, delegate_keyboard, delegate_output, delegate_registry, delegate_seat, - delegate_shm, delegate_xdg_shell, delegate_xdg_window, + delegate_compositor, delegate_keyboard, delegate_output, delegate_pointer, delegate_registry, + delegate_seat, delegate_shm, delegate_xdg_shell, delegate_xdg_window, output::{OutputHandler, OutputState}, registry::{ProvidesRegistryState, RegistryState}, registry_handlers, seat::{ Capability, SeatHandler, SeatState, keyboard::{KeyEvent, KeyboardHandler, Keysym, Modifiers, RawModifiers, RepeatInfo}, + pointer::{PointerEvent, PointerEventKind, PointerHandler}, }, shell::{ WaylandSurface, @@ -39,7 +40,7 @@ use smithay_client_toolkit::{ use wayland_client::{ Connection, QueueHandle, globals::registry_queue_init, - protocol::{wl_keyboard, wl_output, wl_seat, wl_shm, wl_surface}, + protocol::{wl_keyboard, wl_output, wl_pointer, wl_seat, wl_shm, wl_surface}, }; /// Default window size in pixels before the compositor suggests one. @@ -90,6 +91,7 @@ pub fn run() -> anyhow::Result { renderer, loop_handle: event_loop.handle(), keyboard: None, + pointer: None, modifiers: Modifiers::default(), // The PTY is spawned on the first configure, once the real window size // is known, so the shell starts at the final size and is not hit by a @@ -158,6 +160,7 @@ struct App { renderer: Renderer, loop_handle: LoopHandle<'static, App>, keyboard: Option, + pointer: Option, modifiers: Modifiers, /// `None` until the first configure spawns the shell. session: Option, @@ -233,17 +236,36 @@ impl App { }); } - /// Encode a key event and write it to the shell. - fn send_key(&self, event: &KeyEvent) { + /// Handle a key (initial press or repeat): Shift+PageUp/PageDown scroll the + /// viewport locally; anything else is encoded to the shell and snaps the + /// viewport back to the live screen. + fn handle_key(&mut self, event: &KeyEvent) { + if self.modifiers.shift && matches!(event.keysym, Keysym::Page_Up | Keysym::Page_Down) { + if let Some(session) = self.session.as_mut() { + let page = session.term.page() as isize; + let delta = if event.keysym == Keysym::Page_Up { + page + } else { + -page + }; + session.term.scroll_view(delta); + self.dirty = true; + } + return; + } + let app_cursor = self .session .as_ref() .is_some_and(|s| s.term.grid().app_cursor()); if let Some(bytes) = crate::input::encode(event, self.modifiers, app_cursor) - && let Some(session) = self.session.as_ref() - && let Err(err) = write_all(session.pty.master(), &bytes) + && let Some(session) = self.session.as_mut() { - tracing::warn!("write key to pty: {err}"); + session.term.scroll_to_bottom(); + self.dirty = true; + if let Err(err) = write_all(session.pty.master(), &bytes) { + tracing::warn!("write key to pty: {err}"); + } } } @@ -439,13 +461,19 @@ impl SeatHandler for App { &seat, None, loop_handle, - Box::new(|app: &mut App, _kbd, event| app.send_key(&event)), + Box::new(|app: &mut App, _kbd, event| app.handle_key(&event)), ); match keyboard { Ok(keyboard) => self.keyboard = Some(keyboard), Err(err) => tracing::warn!("get keyboard: {err}"), } } + if capability == Capability::Pointer && self.pointer.is_none() { + match self.seat_state.get_pointer(qh, &seat) { + Ok(pointer) => self.pointer = Some(pointer), + Err(err) => tracing::warn!("get pointer: {err}"), + } + } } fn remove_capability( @@ -455,10 +483,18 @@ impl SeatHandler for App { _: wl_seat::WlSeat, capability: Capability, ) { - if capability == Capability::Keyboard - && let Some(keyboard) = self.keyboard.take() - { - keyboard.release(); + match capability { + Capability::Keyboard => { + if let Some(keyboard) = self.keyboard.take() { + keyboard.release(); + } + } + Capability::Pointer => { + if let Some(pointer) = self.pointer.take() { + pointer.release(); + } + } + _ => {} } } @@ -500,7 +536,7 @@ impl KeyboardHandler for App { _: u32, event: KeyEvent, ) { - self.send_key(&event); + self.handle_key(&event); } fn repeat_key( @@ -548,6 +584,45 @@ impl KeyboardHandler for App { } } +impl PointerHandler for App { + fn pointer_frame( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_pointer::WlPointer, + events: &[PointerEvent], + ) { + let cell_h = f64::from(self.renderer.metrics().height); + for event in events { + let PointerEventKind::Axis { vertical, .. } = &event.kind else { + continue; + }; + // Wheel notches arrive as value120 (รท120) or legacy discrete steps, + // ~3 lines each; touchpads send pixels, mapped per cell height. + let (raw, scale) = if vertical.value120 != 0 { + (f64::from(vertical.value120) / 120.0, 3.0) + } else if vertical.discrete != 0 { + (f64::from(vertical.discrete), 3.0) + } else if cell_h > 0.0 { + (vertical.absolute / cell_h, 1.0) + } else { + continue; + }; + if raw == 0.0 { + continue; + } + // Positive axis = scroll down (toward live); we scroll the viewport + // the opposite way (negative offset delta). + let lines = (raw.abs() * scale).ceil().max(1.0) as isize; + let delta = if raw < 0.0 { lines } else { -lines }; + if let Some(session) = self.session.as_mut() { + session.term.scroll_view(delta); + self.dirty = true; + } + } + } +} + impl OutputHandler for App { fn output_state(&mut self) -> &mut OutputState { &mut self.output_state @@ -572,6 +647,7 @@ delegate_output!(App); delegate_shm!(App); delegate_seat!(App); delegate_keyboard!(App); +delegate_pointer!(App); delegate_xdg_shell!(App); delegate_xdg_window!(App); delegate_registry!(App);