feat: make it possible to toggle mouse mode in the Rust TUI (#971)

I did a bit of research to understand why I could not use my mouse to
drag to select text to copy to the clipboard in iTerm.

Apparently https://github.com/openai/codex/pull/641 to enable mousewheel
scrolling broke this functionality. It seems that, unless we put in a
bit of effort, we can have drag-to-select or scrolling, but not both.
Though if you know the trick to hold down `Option` will dragging with
the mouse in iTerm, you can probably get by with this. (I did not know
about this option prior to researching this issue.)

Nevertheless, users may still prefer to disable mouse capture
altogether, so this PR introduces:

* the ability to set `tui.disable_mouse_capture = true` in `config.toml`
to disable mouse capture
* a new command, `/toggle-mouse-mode` to toggle mouse capture
This commit is contained in:
Michael Bolin
2025-05-16 16:16:50 -07:00
committed by GitHub
parent 67ac8ef605
commit 7ca84087e6
10 changed files with 165 additions and 19 deletions

View File

@@ -3,6 +3,7 @@ use crate::app_event_sender::AppEventSender;
use crate::chatwidget::ChatWidget;
use crate::git_warning_screen::GitWarningOutcome;
use crate::git_warning_screen::GitWarningScreen;
use crate::mouse_capture::MouseCapture;
use crate::scroll_event_helper::ScrollEventHelper;
use crate::slash_command::SlashCommand;
use crate::tui;
@@ -122,7 +123,11 @@ impl App<'_> {
self.app_event_tx.clone()
}
pub(crate) fn run(&mut self, terminal: &mut tui::Tui) -> Result<()> {
pub(crate) fn run(
&mut self,
terminal: &mut tui::Tui,
mouse_capture: &mut MouseCapture,
) -> Result<()> {
// Insert an event to trigger the first render.
let app_event_tx = self.app_event_tx.clone();
app_event_tx.send(AppEvent::Redraw);
@@ -176,6 +181,11 @@ impl App<'_> {
SlashCommand::Clear => {
self.chat_widget.clear_conversation_history();
}
SlashCommand::ToggleMouseMode => {
if let Err(e) = mouse_capture.toggle() {
tracing::error!("Failed to toggle mouse mode: {e}");
}
}
SlashCommand::Quit => {
break;
}

View File

@@ -17,6 +17,8 @@ use crate::slash_command::SlashCommand;
use crate::slash_command::built_in_slash_commands;
const MAX_POPUP_ROWS: usize = 5;
/// Ideally this is enough to show the longest command name.
const FIRST_COLUMN_WIDTH: u16 = 20;
use ratatui::style::Modifier;
@@ -176,15 +178,18 @@ impl WidgetRef for CommandPopup {
use ratatui::layout::Constraint;
let table = Table::new(rows, [Constraint::Length(15), Constraint::Min(10)])
.style(style)
.column_spacing(1)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.style(style),
);
let table = Table::new(
rows,
[Constraint::Length(FIRST_COLUMN_WIDTH), Constraint::Min(10)],
)
.style(style)
.column_spacing(1)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.style(style),
);
table.render(area, buf);
}

View File

@@ -27,6 +27,7 @@ mod git_warning_screen;
mod history_cell;
mod log_layer;
mod markdown;
mod mouse_capture;
mod scroll_event_helper;
mod slash_command;
mod status_indicator_widget;
@@ -152,7 +153,7 @@ fn run_ratatui_app(
std::panic::set_hook(Box::new(|info| {
tracing::error!("panic: {info}");
}));
let mut terminal = tui::init()?;
let (mut terminal, mut mouse_capture) = tui::init(&config)?;
terminal.clear()?;
let Cli { prompt, images, .. } = cli;
@@ -168,7 +169,7 @@ fn run_ratatui_app(
});
}
let app_result = app.run(&mut terminal);
let app_result = app.run(&mut terminal, &mut mouse_capture);
restore();
app_result

View File

@@ -0,0 +1,69 @@
use crossterm::event::DisableMouseCapture;
use crossterm::event::EnableMouseCapture;
use ratatui::crossterm::execute;
use std::io::Result;
use std::io::stdout;
pub(crate) struct MouseCapture {
mouse_capture_is_active: bool,
}
impl MouseCapture {
pub(crate) fn new_with_capture(mouse_capture_is_active: bool) -> Result<Self> {
if mouse_capture_is_active {
enable_capture()?;
}
Ok(Self {
mouse_capture_is_active,
})
}
}
impl MouseCapture {
/// Idempotent method to set the mouse capture state.
pub fn set_active(&mut self, is_active: bool) -> Result<()> {
match (self.mouse_capture_is_active, is_active) {
(true, true) => {}
(false, false) => {}
(true, false) => {
disable_capture()?;
self.mouse_capture_is_active = false;
}
(false, true) => {
enable_capture()?;
self.mouse_capture_is_active = true;
}
}
Ok(())
}
pub(crate) fn toggle(&mut self) -> Result<()> {
self.set_active(!self.mouse_capture_is_active)
}
pub(crate) fn disable(&mut self) -> Result<()> {
if self.mouse_capture_is_active {
disable_capture()?;
self.mouse_capture_is_active = false;
}
Ok(())
}
}
impl Drop for MouseCapture {
fn drop(&mut self) {
if self.disable().is_err() {
// The user is likely shutting down, so ignore any errors so the
// shutdown process can complete.
}
}
}
fn enable_capture() -> Result<()> {
execute!(stdout(), EnableMouseCapture)
}
fn disable_capture() -> Result<()> {
execute!(stdout(), DisableMouseCapture)
}

View File

@@ -13,6 +13,7 @@ use strum_macros::IntoStaticStr;
#[strum(serialize_all = "kebab-case")]
pub enum SlashCommand {
Clear,
ToggleMouseMode,
Quit,
}
@@ -21,6 +22,9 @@ impl SlashCommand {
pub fn description(self) -> &'static str {
match self {
SlashCommand::Clear => "Clear the chat history.",
SlashCommand::ToggleMouseMode => {
"Toggle mouse mode (enable for scrolling, disable for text selection)"
}
SlashCommand::Quit => "Exit the application.",
}
}

View File

@@ -1,11 +1,11 @@
use std::io::Result;
use std::io::Stdout;
use std::io::stdout;
use std::io::{self};
use codex_core::config::Config;
use crossterm::event::DisableBracketedPaste;
use crossterm::event::DisableMouseCapture;
use crossterm::event::EnableBracketedPaste;
use crossterm::event::EnableMouseCapture;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::crossterm::execute;
@@ -14,17 +14,21 @@ use ratatui::crossterm::terminal::LeaveAlternateScreen;
use ratatui::crossterm::terminal::disable_raw_mode;
use ratatui::crossterm::terminal::enable_raw_mode;
use crate::mouse_capture::MouseCapture;
/// A type alias for the terminal type used in this application
pub type Tui = Terminal<CrosstermBackend<Stdout>>;
/// Initialize the terminal
pub fn init() -> io::Result<Tui> {
pub fn init(config: &Config) -> Result<(Tui, MouseCapture)> {
execute!(stdout(), EnterAlternateScreen)?;
execute!(stdout(), EnableMouseCapture)?;
execute!(stdout(), EnableBracketedPaste)?;
let mouse_capture = MouseCapture::new_with_capture(!config.tui.disable_mouse_capture)?;
enable_raw_mode()?;
set_panic_hook();
Terminal::new(CrosstermBackend::new(stdout()))
let tui = Terminal::new(CrosstermBackend::new(stdout()))?;
Ok((tui, mouse_capture))
}
fn set_panic_hook() {
@@ -36,8 +40,13 @@ fn set_panic_hook() {
}
/// Restore the terminal to its original state
pub fn restore() -> io::Result<()> {
execute!(stdout(), DisableMouseCapture)?;
pub fn restore() -> Result<()> {
// We are shutting down, and we cannot reference the `MouseCapture`, so we
// categorically disable mouse capture just to be safe.
if execute!(stdout(), DisableMouseCapture).is_err() {
// It is possible that `DisableMouseCapture` is written more than once
// on shutdown, so ignore the error in this case.
}
execute!(stdout(), DisableBracketedPaste)?;
execute!(stdout(), LeaveAlternateScreen)?;
disable_raw_mode()?;