feat(tui-rs): add support for mousewheel scrolling

This commit is contained in:
Michael Bolin
2025-04-24 20:10:52 -07:00
parent bfe6fac463
commit 73eb766637
7 changed files with 157 additions and 14 deletions

View File

@@ -2,6 +2,7 @@ use crate::app_event::AppEvent;
use crate::chatwidget::ChatWidget;
use crate::git_warning_screen::GitWarningOutcome;
use crate::git_warning_screen::GitWarningScreen;
use crate::scroll_event_helper::ScrollEventHelper;
use crate::tui;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::Event;
@@ -10,6 +11,8 @@ use codex_core::protocol::SandboxPolicy;
use color_eyre::eyre::Result;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::MouseEvent;
use crossterm::event::MouseEventKind;
use std::sync::mpsc::channel;
use std::sync::mpsc::Receiver;
use std::sync::mpsc::Sender;
@@ -39,6 +42,7 @@ impl App<'_> {
model: Option<String>,
) -> Self {
let (app_event_tx, app_event_rx) = channel();
let scroll_event_helper = ScrollEventHelper::new(app_event_tx.clone());
// Spawn a dedicated thread for reading the crossterm event loop and
// re-publishing the events as AppEvents, as appropriate.
@@ -49,10 +53,21 @@ impl App<'_> {
let app_event = match event {
crossterm::event::Event::Key(key_event) => AppEvent::KeyEvent(key_event),
crossterm::event::Event::Resize(_, _) => AppEvent::Redraw,
crossterm::event::Event::FocusGained
| crossterm::event::Event::FocusLost
| crossterm::event::Event::Mouse(_)
| crossterm::event::Event::Paste(_) => {
crossterm::event::Event::Mouse(MouseEvent {
kind: MouseEventKind::ScrollUp,
..
}) => {
scroll_event_helper.scroll_up();
continue;
}
crossterm::event::Event::Mouse(MouseEvent {
kind: MouseEventKind::ScrollDown,
..
}) => {
scroll_event_helper.scroll_down();
continue;
}
_ => {
continue;
}
};
@@ -125,6 +140,9 @@ impl App<'_> {
}
};
}
AppEvent::Scroll(scroll_delta) => {
self.dispatch_scroll_event(scroll_delta);
}
AppEvent::CodexEvent(event) => {
self.dispatch_codex_event(event);
}
@@ -184,6 +202,14 @@ impl App<'_> {
}
}
fn dispatch_scroll_event(&mut self, scroll_delta: i32) {
if matches!(self.app_state, AppState::Chat) {
if let Err(e) = self.chat_widget.handle_scroll_delta(scroll_delta) {
tracing::error!("SendError: {e}");
}
}
}
fn dispatch_codex_event(&mut self, event: Event) {
if matches!(self.app_state, AppState::Chat) {
if let Err(e) = self.chat_widget.handle_codex_event(event) {

View File

@@ -3,8 +3,15 @@ use crossterm::event::KeyEvent;
pub(crate) enum AppEvent {
CodexEvent(Event),
Redraw,
KeyEvent(KeyEvent),
/// Scroll event with a value representing the "scroll delta" as the net
/// scroll up/down events within a short time window.
Scroll(i32),
/// Request to exit the application gracefully.
ExitRequest,

View File

@@ -364,6 +364,23 @@ impl ChatWidget<'_> {
Ok(())
}
pub(crate) fn handle_scroll_delta(
&mut self,
scroll_delta: i32,
) -> std::result::Result<(), std::sync::mpsc::SendError<AppEvent>> {
// If the user is trying to scroll exactly one line, we let them, but
// otherwise we assume they are trying to scroll in larger increments.
let magnified_scroll_delta = if scroll_delta == 1 {
1
} else {
// Play with this: perhaps it should be non-linear?
scroll_delta * 2
};
self.conversation_history.scroll(magnified_scroll_delta);
self.request_redraw()?;
Ok(())
}
/// Forward an `Op` directly to codex.
pub(crate) fn submit_op(&self, op: Op) {
if let Err(e) = self.codex_op_tx.send(op) {

View File

@@ -40,11 +40,11 @@ impl ConversationHistoryWidget {
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) -> bool {
match key_event.code {
KeyCode::Up | KeyCode::Char('k') => {
self.scroll_up();
self.scroll_up(1);
true
}
KeyCode::Down | KeyCode::Char('j') => {
self.scroll_down();
self.scroll_down(1);
true
}
KeyCode::PageUp | KeyCode::Char('b') | KeyCode::Char('u') | KeyCode::Char('U') => {
@@ -59,9 +59,18 @@ impl ConversationHistoryWidget {
}
}
fn scroll_up(&mut self) {
// If a user is scrolling up from the "stick to bottom" mode, we
// need to scroll them back such that they move just one line up.
/// Negative delta scrolls up; positive delta scrolls down.
pub(crate) fn scroll(&mut self, delta: i32) {
match delta.cmp(&0) {
std::cmp::Ordering::Less => self.scroll_up(-delta as u32),
std::cmp::Ordering::Greater => self.scroll_down(delta as u32),
std::cmp::Ordering::Equal => {}
}
}
fn scroll_up(&mut self, num_lines: u32) {
// If a user is scrolling up from the "stick to bottom" mode, we need to
// map this to a specific scroll position so we can caluate the delta.
// This requires us to care about how tall the screen is.
if self.scroll_position == usize::MAX {
self.scroll_position = self
@@ -70,24 +79,26 @@ impl ConversationHistoryWidget {
.saturating_sub(self.last_viewport_height.get());
}
self.scroll_position = self.scroll_position.saturating_sub(1);
self.scroll_position = self.scroll_position.saturating_sub(num_lines as usize);
}
fn scroll_down(&mut self) {
fn scroll_down(&mut self, num_lines: u32) {
// If we're already pinned to the bottom there's nothing to do.
if self.scroll_position == usize::MAX {
return;
}
let viewport_height = self.last_viewport_height.get().max(1);
let num_lines = self.num_rendered_lines.get();
let num_rendered_lines = self.num_rendered_lines.get();
// Compute the maximum explicit scroll offset that still shows a full
// viewport. This mirrors the calculation in `scroll_page_down()` and
// in the render path.
let max_scroll = num_lines.saturating_sub(viewport_height).saturating_add(1);
let max_scroll = num_rendered_lines
.saturating_sub(viewport_height)
.saturating_add(1);
let new_pos = self.scroll_position.saturating_add(1);
let new_pos = self.scroll_position.saturating_add(num_lines as usize);
if new_pos >= max_scroll {
// Reached (or passed) the bottom switch to sticktobottom mode

View File

@@ -21,6 +21,7 @@ mod exec_command;
mod git_warning_screen;
mod history_cell;
mod log_layer;
mod scroll_event_helper;
mod status_indicator_widget;
mod tui;
mod user_approval_widget;

View File

@@ -0,0 +1,77 @@
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicI32;
use std::sync::atomic::Ordering;
use std::sync::mpsc::Sender;
use std::sync::Arc;
use tokio::runtime::Handle;
use tokio::time::sleep;
use tokio::time::Duration;
use crate::app_event::AppEvent;
pub(crate) struct ScrollEventHelper {
app_event_tx: Sender<AppEvent>,
scroll_delta: Arc<AtomicI32>,
timer_scheduled: Arc<AtomicBool>,
runtime: Handle,
}
/// How long to wait after the first scroll event before sending the
/// accumulated scroll delta to the main thread.
const DEBOUNCE_WINDOW: Duration = Duration::from_millis(100);
/// Utility to debounce scroll events so we can determine the **magnitude** of
/// each scroll burst by accumulating individual wheel events over a short
/// window. The debounce timer now runs on Tokio so we avoid spinning up a new
/// operating-system thread for every burst.
impl ScrollEventHelper {
pub(crate) fn new(app_event_tx: Sender<AppEvent>) -> Self {
Self {
app_event_tx,
scroll_delta: Arc::new(AtomicI32::new(0)),
timer_scheduled: Arc::new(AtomicBool::new(false)),
runtime: Handle::current(),
}
}
pub(crate) fn scroll_up(&self) {
self.scroll_delta.fetch_sub(1, Ordering::Relaxed);
self.schedule_notification();
}
pub(crate) fn scroll_down(&self) {
self.scroll_delta.fetch_add(1, Ordering::Relaxed);
self.schedule_notification();
}
/// Starts a one-shot timer **only once** per burst of wheel events.
fn schedule_notification(&self) {
// If the timer is already scheduled, do nothing.
if self
.timer_scheduled
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return;
}
// Otherwise, schedule a new timer.
let tx = self.app_event_tx.clone();
let delta = Arc::clone(&self.scroll_delta);
let timer_flag = Arc::clone(&self.timer_scheduled);
// Use self.runtime instead of tokio::spawn() because the calling thread
// in app.rs is not part of the Tokio runtime: it is a plain OS thread.
self.runtime.spawn(async move {
sleep(DEBOUNCE_WINDOW).await;
let accumulated = delta.swap(0, Ordering::SeqCst);
if accumulated != 0 {
let _ = tx.send(AppEvent::Scroll(accumulated));
}
timer_flag.store(false, Ordering::SeqCst);
});
}
}

View File

@@ -2,6 +2,8 @@ use std::io::stdout;
use std::io::Stdout;
use std::io::{self};
use crossterm::event::DisableMouseCapture;
use crossterm::event::EnableMouseCapture;
use ratatui::backend::CrosstermBackend;
use ratatui::crossterm::execute;
use ratatui::crossterm::terminal::disable_raw_mode;
@@ -16,6 +18,7 @@ pub type Tui = Terminal<CrosstermBackend<Stdout>>;
/// Initialize the terminal
pub fn init() -> io::Result<Tui> {
execute!(stdout(), EnterAlternateScreen)?;
execute!(stdout(), EnableMouseCapture)?;
enable_raw_mode()?;
set_panic_hook();
Terminal::new(CrosstermBackend::new(stdout()))
@@ -31,6 +34,7 @@ fn set_panic_hook() {
/// Restore the terminal to its original state
pub fn restore() -> io::Result<()> {
execute!(stdout(), DisableMouseCapture)?;
execute!(stdout(), LeaveAlternateScreen)?;
disable_raw_mode()?;
Ok(())