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 ea4557f963
6 changed files with 77 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,7 +3,11 @@ use crossterm::event::KeyEvent;
pub(crate) enum AppEvent {
CodexEvent(Event),
Scroll(i32),
Redraw,
KeyEvent(KeyEvent),
/// 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

@@ -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(())