forked from NotAShelf/beer
grid: scrollback with mouse-wheel and Shift+PageUp scrolling
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I12b2ed33a705eb3474a7d14a295e021d6a6a6964
This commit is contained in:
parent
ba8f8d7144
commit
3dd953b75a
4 changed files with 201 additions and 20 deletions
88
src/grid.rs
88
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<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);
|
||||
|
|
|
|||
|
|
@ -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,8 +163,11 @@ impl Renderer {
|
|||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
/// outline when not.
|
||||
|
|
|
|||
13
src/vt.rs
13
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()
|
||||
}
|
||||
|
|
|
|||
100
src/wayland.rs
100
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<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,19 +236,38 @@ 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()
|
||||
{
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// After parsing child output: send any replies, sync the title, repaint.
|
||||
fn after_feed(&mut self) {
|
||||
|
|
@ -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,12 +483,20 @@ impl SeatHandler for App {
|
|||
_: wl_seat::WlSeat,
|
||||
capability: Capability,
|
||||
) {
|
||||
if capability == Capability::Keyboard
|
||||
&& let Some(keyboard) = self.keyboard.take()
|
||||
{
|
||||
match capability {
|
||||
Capability::Keyboard => {
|
||||
if let Some(keyboard) = self.keyboard.take() {
|
||||
keyboard.release();
|
||||
}
|
||||
}
|
||||
Capability::Pointer => {
|
||||
if let Some(pointer) = self.pointer.take() {
|
||||
pointer.release();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_seat::WlSeat) {}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue