From c21472b2f217b1c99b5ad542c451b0fc538f48e9 Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Thu, 10 Sep 2026 10:24:16 +0200 Subject: [PATCH] scrolling --- src/input.rs | 23 +++++++++++-- src/pane.rs | 95 +++++++++++++++++++++++++++++++++++++++++++++------- src/tests.rs | 48 ++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 15 deletions(-) diff --git a/src/input.rs b/src/input.rs index 5d8b53d..5b47f48 100644 --- a/src/input.rs +++ b/src/input.rs @@ -1,6 +1,25 @@ use anyhow::Result; use libghostty_vt::{Terminal, key}; +/// Avoid libghostty-vt 0.2.1's encode_to_vec capacity calculation, which can +/// reserve too little when the destination already has some spare capacity. +pub fn append_encoded( + out: &mut Vec, + mut encode: impl FnMut(&mut [u8]) -> libghostty_vt::error::Result, +) -> Result<()> { + let mut buffer = [0; 128]; + match encode(&mut buffer) { + Ok(written) => out.extend_from_slice(&buffer[..written]), + Err(libghostty_vt::Error::OutOfSpace { required }) => { + let mut buffer = vec![0; required]; + let written = encode(&mut buffer)?; + out.extend_from_slice(&buffer[..written]); + } + Err(error) => return Err(error.into()), + } + Ok(()) +} + pub fn mods(m: egui::Modifiers) -> key::Mods { let mut out = key::Mods::empty(); if m.ctrl { @@ -71,7 +90,7 @@ pub fn encode(terminal: &Terminal<'_, '_>, events: &[egui::Event]) -> Result { event @@ -81,7 +100,7 @@ pub fn encode(terminal: &Terminal<'_, '_>, events: &[egui::Event]) -> Result { out.extend_from_slice(text.as_bytes()) diff --git a/src/pane.rs b/src/pane.rs index daa7710..01d6106 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -31,6 +31,8 @@ pub struct Pane { callback_error: Rc>>, mouse_encoder: mouse::Encoder<'static>, mouse_event: mouse::Event<'static>, + scrollback_scroll: ScrollAccumulator, + mouse_scroll: ScrollAccumulator, selection_start: Option, focused: bool, saved_session: Option<(Box, ScrollbackFile)>, @@ -38,6 +40,26 @@ pub struct Pane { struct ScrollbackFile(PathBuf); +#[derive(Default)] +struct ScrollAccumulator(f32); + +impl ScrollAccumulator { + fn rows(&mut self, delta: f32) -> isize { + self.0 += delta; + let rows = self.0.trunc(); + self.0 -= rows; + rows as isize + } + + fn wheel(&mut self, unit: egui::MouseWheelUnit, delta: f32, cell: f32, page: u16) -> isize { + self.rows(match unit { + egui::MouseWheelUnit::Point => delta / cell, + egui::MouseWheelUnit::Line => delta * 3.0, + egui::MouseWheelUnit::Page => delta * f32::from(page), + }) + } +} + impl ScrollbackFile { fn new(bytes: &[u8]) -> Result { use std::{ @@ -71,6 +93,41 @@ impl ScrollbackFile { } } +#[cfg(test)] +mod scroll_tests { + use super::*; + + #[test] + fn precise_scrolling_preserves_distance_across_frames() { + let mut scroll = ScrollAccumulator::default(); + // Sub-pixel events from a slow gesture must eventually move a row. + let rows: isize = (0..128).map(|_| scroll.rows(0.25 / 16.0)).sum(); + assert_eq!(rows, 2); + assert_eq!(scroll.0, 0.0); + + let mut batched = ScrollAccumulator::default(); + assert_eq!(batched.rows(32.0 / 16.0), rows); + assert_eq!(scroll.rows(1.75), 1); + assert_eq!(scroll.rows(0.25), 1); + assert_eq!(scroll.rows(-0.75), 0); + assert_eq!(scroll.rows(-0.25), -1); + assert_eq!(scroll.rows(0.75), 0); + assert_eq!(scroll.rows(-0.75), 0); + } + + #[test] + fn mouse_tracking_respects_precise_distance_and_wheel_units() { + use egui::MouseWheelUnit::{Line, Page, Point}; + let mut scroll = ScrollAccumulator::default(); + assert_eq!(scroll.wheel(Point, 0.0, 16.0, 24), 0); + assert_eq!(scroll.wheel(Point, 4.0, 16.0, 24), 0); + assert_eq!(scroll.wheel(Point, 12.0, 16.0, 24), 1); + assert_eq!(scroll.wheel(Point, -32.0, 16.0, 24), -2); + assert_eq!(scroll.wheel(Line, 1.0, 16.0, 24), 3); + assert_eq!(scroll.wheel(Page, -1.0, 16.0, 24), -24); + } +} + #[cfg(test)] mod scrollback_tests { use super::*; @@ -307,6 +364,8 @@ impl Pane { callback_error, mouse_encoder, mouse_event, + scrollback_scroll: ScrollAccumulator::default(), + mouse_scroll: ScrollAccumulator::default(), selection_start: None, focused: false, saved_session: None, @@ -414,6 +473,7 @@ impl Pane { .try_send(bytes) .map_err(|e| anyhow::anyhow!("PTY input queue: {e}"))?; self.terminal.scroll_viewport(ScrollViewport::Bottom); + self.scrollback_scroll = ScrollAccumulator::default(); Ok(()) } @@ -531,6 +591,12 @@ impl Pane { ) -> Result<()> { let modifiers = ui.input(|i| i.modifiers); let tracking = self.terminal.is_mouse_tracking()? && !modifiers.shift; + if !response.hovered() || tracking { + self.scrollback_scroll = ScrollAccumulator::default(); + } + if !response.hovered() || !tracking { + self.mouse_scroll = ScrollAccumulator::default(); + } let pos = response .interact_pointer_pos() .or_else(|| ui.input(|i| i.pointer.hover_pos())); @@ -579,8 +645,9 @@ impl Pane { mouse::Action::Release }) .set_button(Some(button)); - self.mouse_encoder - .encode_to_vec(&self.mouse_event, &mut data)?; + input::append_encoded(&mut data, |buffer| { + self.mouse_encoder.encode(&self.mouse_event, buffer) + })?; } egui::Event::PointerMoved(_) => { let button = ui.input(|i| { @@ -595,20 +662,23 @@ impl Pane { self.mouse_event .set_action(mouse::Action::Motion) .set_button(button); - self.mouse_encoder - .encode_to_vec(&self.mouse_event, &mut data)?; + input::append_encoded(&mut data, |buffer| { + self.mouse_encoder.encode(&self.mouse_event, buffer) + })?; } - egui::Event::MouseWheel { delta, .. } => { + egui::Event::MouseWheel { unit, delta, .. } => { + let rows = self.mouse_scroll.wheel(unit, delta.y, cell.y, self.size.1); self.mouse_event .set_action(mouse::Action::Press) - .set_button(Some(if delta.y > 0.0 { + .set_button(Some(if rows > 0 { mouse::Button::Four } else { mouse::Button::Five })); - for _ in 0..3 { - self.mouse_encoder - .encode_to_vec(&self.mouse_event, &mut data)?; + for _ in 0..rows.unsigned_abs() { + input::append_encoded(&mut data, |buffer| { + self.mouse_encoder.encode(&self.mouse_event, buffer) + })?; } } _ => {} @@ -641,10 +711,9 @@ impl Pane { } if response.hovered() { let scroll = ui.input(|i| i.raw_scroll_delta.y); - if scroll.abs() >= 1.0 { - self.terminal.scroll_viewport(ScrollViewport::Delta( - -(scroll / cell.y).round() as isize, - )); + let rows = self.scrollback_scroll.rows(scroll / cell.y); + if rows != 0 { + self.terminal.scroll_viewport(ScrollViewport::Delta(-rows)); } } } diff --git a/src/tests.rs b/src/tests.rs index 6155263..671f17d 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -80,6 +80,54 @@ fn terminal_preserves_unicode_colors_and_application_input_modes() { assert!(plain(&term).contains("hello red")); } +#[test] +fn input_encoding_grows_buffers_without_losing_events() { + let term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap(); + let mut events = vec![Event::Text("a".into()), Event::Text("123456789".into())]; + let long_text = "界".repeat(200); + events.push(Event::Text(long_text.clone())); + for _ in 0..100 { + events.push(key(Key::ArrowUp, Modifiers::NONE)); + } + let expected = format!("a123456789{long_text}{}", "\x1b[A".repeat(100)); + assert_eq!(input::encode(&term, &events).unwrap(), expected.as_bytes()); +} + +#[test] +fn mouse_encoding_appends_when_destination_has_insufficient_spare_capacity() { + use libghostty_vt::mouse; + + let mut term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap(); + term.vt_write(b"\x1b[?1000h\x1b[?1006h"); + let mut encoder = mouse::Encoder::new().unwrap(); + encoder + .set_options_from_terminal(&term) + .set_size(mouse::EncoderSize { + screen_width: 800, + screen_height: 600, + cell_width: 8, + cell_height: 16, + padding_top: 0, + padding_bottom: 0, + padding_left: 0, + padding_right: 0, + }); + let mut event = mouse::Event::new().unwrap(); + event + .set_action(mouse::Action::Press) + .set_button(Some(mouse::Button::Left)) + .set_position(mouse::Position { x: 0.0, y: 0.0 }); + let mut out = Vec::with_capacity(16); + out.resize(9, b'x'); + for _ in 0..100 { + input::append_encoded(&mut out, |buffer| encoder.encode(&event, buffer)).unwrap(); + } + assert_eq!( + out, + format!("xxxxxxxxx{}", "\x1b[<0;1;1M".repeat(100)).as_bytes() + ); +} + #[test] fn queries_return_to_the_pty_and_alternate_screen_restores() { let mut term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap();