mirror of
https://github.com/openai/codex.git
synced 2026-09-15 12:08:01 +00:00
feat(tui-rs): add support for mousewheel scrolling
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
77
codex-rs/tui/src/scroll_event_helper.rs
Normal file
77
codex-rs/tui/src/scroll_event_helper.rs
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
|
||||
Reference in New Issue
Block a user