scrolling

This commit is contained in:
Gregor Lohaus
2026-09-10 10:24:16 +02:00
parent ca30e9a0ee
commit c21472b2f2
3 changed files with 151 additions and 15 deletions
+21 -2
View File
@@ -1,6 +1,25 @@
use anyhow::Result; use anyhow::Result;
use libghostty_vt::{Terminal, key}; 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<u8>,
mut encode: impl FnMut(&mut [u8]) -> libghostty_vt::error::Result<usize>,
) -> 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 { pub fn mods(m: egui::Modifiers) -> key::Mods {
let mut out = key::Mods::empty(); let mut out = key::Mods::empty();
if m.ctrl { if m.ctrl {
@@ -71,7 +90,7 @@ pub fn encode(terminal: &Terminal<'_, '_>, events: &[egui::Event]) -> Result<Vec
}) })
.set_unshifted_codepoint(ucp) .set_unshifted_codepoint(ucp)
.set_utf8(text); .set_utf8(text);
encoder.encode_to_vec(&event, &mut out)?; append_encoded(&mut out, |buffer| encoder.encode(&event, buffer))?;
} }
egui::Event::Text(text) if !used_text.contains(&index) => { egui::Event::Text(text) if !used_text.contains(&index) => {
event event
@@ -81,7 +100,7 @@ pub fn encode(terminal: &Terminal<'_, '_>, events: &[egui::Event]) -> Result<Vec
.set_consumed_mods(key::Mods::empty()) .set_consumed_mods(key::Mods::empty())
.set_unshifted_codepoint('\0') .set_unshifted_codepoint('\0')
.set_utf8(Some(text.as_str())); .set_utf8(Some(text.as_str()));
encoder.encode_to_vec(&event, &mut out)?; append_encoded(&mut out, |buffer| encoder.encode(&event, buffer))?;
} }
egui::Event::Ime(egui::ImeEvent::Commit(text)) => { egui::Event::Ime(egui::ImeEvent::Commit(text)) => {
out.extend_from_slice(text.as_bytes()) out.extend_from_slice(text.as_bytes())
+82 -13
View File
@@ -31,6 +31,8 @@ pub struct Pane {
callback_error: Rc<RefCell<Option<String>>>, callback_error: Rc<RefCell<Option<String>>>,
mouse_encoder: mouse::Encoder<'static>, mouse_encoder: mouse::Encoder<'static>,
mouse_event: mouse::Event<'static>, mouse_event: mouse::Event<'static>,
scrollback_scroll: ScrollAccumulator,
mouse_scroll: ScrollAccumulator,
selection_start: Option<PointCoordinate>, selection_start: Option<PointCoordinate>,
focused: bool, focused: bool,
saved_session: Option<(Box<Pane>, ScrollbackFile)>, saved_session: Option<(Box<Pane>, ScrollbackFile)>,
@@ -38,6 +40,26 @@ pub struct Pane {
struct ScrollbackFile(PathBuf); 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 { impl ScrollbackFile {
fn new(bytes: &[u8]) -> Result<Self> { fn new(bytes: &[u8]) -> Result<Self> {
use std::{ 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)] #[cfg(test)]
mod scrollback_tests { mod scrollback_tests {
use super::*; use super::*;
@@ -307,6 +364,8 @@ impl Pane {
callback_error, callback_error,
mouse_encoder, mouse_encoder,
mouse_event, mouse_event,
scrollback_scroll: ScrollAccumulator::default(),
mouse_scroll: ScrollAccumulator::default(),
selection_start: None, selection_start: None,
focused: false, focused: false,
saved_session: None, saved_session: None,
@@ -414,6 +473,7 @@ impl Pane {
.try_send(bytes) .try_send(bytes)
.map_err(|e| anyhow::anyhow!("PTY input queue: {e}"))?; .map_err(|e| anyhow::anyhow!("PTY input queue: {e}"))?;
self.terminal.scroll_viewport(ScrollViewport::Bottom); self.terminal.scroll_viewport(ScrollViewport::Bottom);
self.scrollback_scroll = ScrollAccumulator::default();
Ok(()) Ok(())
} }
@@ -531,6 +591,12 @@ impl Pane {
) -> Result<()> { ) -> Result<()> {
let modifiers = ui.input(|i| i.modifiers); let modifiers = ui.input(|i| i.modifiers);
let tracking = self.terminal.is_mouse_tracking()? && !modifiers.shift; 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 let pos = response
.interact_pointer_pos() .interact_pointer_pos()
.or_else(|| ui.input(|i| i.pointer.hover_pos())); .or_else(|| ui.input(|i| i.pointer.hover_pos()));
@@ -579,8 +645,9 @@ impl Pane {
mouse::Action::Release mouse::Action::Release
}) })
.set_button(Some(button)); .set_button(Some(button));
self.mouse_encoder input::append_encoded(&mut data, |buffer| {
.encode_to_vec(&self.mouse_event, &mut data)?; self.mouse_encoder.encode(&self.mouse_event, buffer)
})?;
} }
egui::Event::PointerMoved(_) => { egui::Event::PointerMoved(_) => {
let button = ui.input(|i| { let button = ui.input(|i| {
@@ -595,20 +662,23 @@ impl Pane {
self.mouse_event self.mouse_event
.set_action(mouse::Action::Motion) .set_action(mouse::Action::Motion)
.set_button(button); .set_button(button);
self.mouse_encoder input::append_encoded(&mut data, |buffer| {
.encode_to_vec(&self.mouse_event, &mut data)?; 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 self.mouse_event
.set_action(mouse::Action::Press) .set_action(mouse::Action::Press)
.set_button(Some(if delta.y > 0.0 { .set_button(Some(if rows > 0 {
mouse::Button::Four mouse::Button::Four
} else { } else {
mouse::Button::Five mouse::Button::Five
})); }));
for _ in 0..3 { for _ in 0..rows.unsigned_abs() {
self.mouse_encoder input::append_encoded(&mut data, |buffer| {
.encode_to_vec(&self.mouse_event, &mut data)?; self.mouse_encoder.encode(&self.mouse_event, buffer)
})?;
} }
} }
_ => {} _ => {}
@@ -641,10 +711,9 @@ impl Pane {
} }
if response.hovered() { if response.hovered() {
let scroll = ui.input(|i| i.raw_scroll_delta.y); let scroll = ui.input(|i| i.raw_scroll_delta.y);
if scroll.abs() >= 1.0 { let rows = self.scrollback_scroll.rows(scroll / cell.y);
self.terminal.scroll_viewport(ScrollViewport::Delta( if rows != 0 {
-(scroll / cell.y).round() as isize, self.terminal.scroll_viewport(ScrollViewport::Delta(-rows));
));
} }
} }
} }
+48
View File
@@ -80,6 +80,54 @@ fn terminal_preserves_unicode_colors_and_application_input_modes() {
assert!(plain(&term).contains("hello red")); 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] #[test]
fn queries_return_to_the_pty_and_alternate_screen_restores() { fn queries_return_to_the_pty_and_alternate_screen_restores() {
let mut term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap(); let mut term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap();