clone
ssh://git@git.notashelf.dev:33/notashelf/beer.gitCommit 3dd953b75a22
tarballunverified4 files changed+201-20
@@ -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.@@ -714,6 +783,25 @@ mod tests { } #[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); g.print('世');@@ -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@@ -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() }@@ -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);