grid: scrollback with mouse-wheel and Shift+PageUp scrolling

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I12b2ed33a705eb3474a7d14a295e021d6a6a6964
This commit is contained in:
raf 2026-06-24 14:59:05 +03:00
commit 3dd953b75a
No known key found for this signature in database
GPG key ID: 29D95B64378DB4BF
4 changed files with 201 additions and 20 deletions

View file

@ -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<bool>,
/// Saved primary screen while the alternate screen is active.
alt_saved: Option<Vec<Vec<Cell>>>,
/// Lines that have scrolled off the top of the main screen, newest last.
scrollback: VecDeque<Vec<Cell>>,
/// 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);

View file

@ -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

View file

@ -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()
}

View file

@ -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<ExitCode> {
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<wl_keyboard::WlKeyboard>,
pointer: Option<wl_pointer::WlPointer>,
modifiers: Modifiers,
/// `None` until the first configure spawns the shell.
session: Option<Session>,
@ -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<Self>,
_: &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);