From dc7b83666ac32b4c0153924039a099e0a783e93f Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 25 Apr 2025 12:01:52 -0700 Subject: [PATCH] feat(tui-rs): add support for mousewheel scrolling (#641) It is intuitive to try to scroll the conversation history using the mouse in the TUI, but prior to this change, we only supported scrolling via keyboard events. This PR enables mouse capture upon initialization (and disables it on exit) such that we get `ScrollUp` and `ScrollDown` events in `codex-rs/tui/src/app.rs`. I initially mapped each event to scrolling by one line, but that felt sluggish. I decided to introduce `ScrollEventHelper` so we could debounce scroll events and measure the number of scroll events in a 100ms window to determine the "magnitude" of the scroll event. I put in a basic heuristic to start, but perhaps someone more motivated can play with it over time. `ScrollEventHelper` takes care of handling the atomic fields and thread management to ensure an `AppEvent::Scroll` event is pumped back through the event loop at the appropriate time with the accumulated delta. --- codex-rs/tui/src/app.rs | 34 +++++++- codex-rs/tui/src/app_event.rs | 7 ++ codex-rs/tui/src/chatwidget.rs | 17 ++++ .../tui/src/conversation_history_widget.rs | 31 +++++--- codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/scroll_event_helper.rs | 77 +++++++++++++++++++ codex-rs/tui/src/tui.rs | 4 + 7 files changed, 157 insertions(+), 14 deletions(-) create mode 100644 codex-rs/tui/src/scroll_event_helper.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 9aba46ec..26a7074b 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -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, ) -> 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) { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index bb8efb8e..2b320375 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -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, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 149cea42..64cb896d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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> { + // 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) { diff --git a/codex-rs/tui/src/conversation_history_widget.rs b/codex-rs/tui/src/conversation_history_widget.rs index c8f69061..27b5e9b3 100644 --- a/codex-rs/tui/src/conversation_history_widget.rs +++ b/codex-rs/tui/src/conversation_history_widget.rs @@ -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 stick‑to‑bottom mode diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 598d3eaf..7361663b 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -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; diff --git a/codex-rs/tui/src/scroll_event_helper.rs b/codex-rs/tui/src/scroll_event_helper.rs new file mode 100644 index 00000000..7c358157 --- /dev/null +++ b/codex-rs/tui/src/scroll_event_helper.rs @@ -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, + scroll_delta: Arc, + timer_scheduled: Arc, + 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) -> 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); + }); + } +} diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 0753dcb0..8cc54460 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -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>; /// Initialize the terminal pub fn init() -> io::Result { 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(())