feat: make it possible to toggle mouse mode in the Rust TUI

This commit is contained in:
Michael Bolin
2025-05-16 15:42:04 -07:00
parent 67ac8ef605
commit 04a069b938
8 changed files with 145 additions and 19 deletions

1
.codespellignore Normal file
View File

@@ -0,0 +1 @@
iTerm

View File

@@ -88,6 +88,9 @@ pub struct Config {
/// Optional URI-based file opener. If set, citations to files in the model
/// output will be hyperlinked using the specified URI scheme.
pub file_opener: UriBasedFileOpener,
/// Collection of settings that are specific to the TUI.
pub tui: Tui,
}
/// Settings that govern if and what will be written to `~/.codex/history.jsonl`.
@@ -111,6 +114,23 @@ pub enum HistoryPersistence {
None,
}
/// Collection of settings that are specific to the TUI.
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
pub struct Tui {
/// By default, mouse capture is enabled in the TUI so that it is possible
/// to scroll the conversation history with a mouse. This comes at the cost
/// of not being able to use the mouse to select text in the TUI.
/// (Most terminals support a modifier key to allow this. For example,
/// text selection works in iTerm if you hold down the `Option` key while
/// clicking and dragging.)
///
/// Setting this option to `true` disables mouse capture, so scrolling with
/// the mouse is not possible, though the keyboard shortcuts e.g. `b` and
/// `space` still work. This allows the user to select text in the TUI
/// using the mouse without needing to hold down a modifier key.
pub disable_mouse_capture: bool,
}
#[derive(Deserialize, Debug, Copy, Clone, PartialEq)]
pub enum UriBasedFileOpener {
#[serde(rename = "vscode")]
@@ -197,6 +217,9 @@ pub struct ConfigToml {
/// Optional URI-based file opener. If set, citations to files in the model
/// output will be hyperlinked using the specified URI scheme.
pub file_opener: Option<UriBasedFileOpener>,
/// Collection of settings that are specific to the TUI.
pub tui: Option<Tui>,
}
impl ConfigToml {
@@ -391,6 +414,7 @@ impl Config {
codex_home,
history,
file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode),
tui: cfg.tui.unwrap_or_default(),
};
Ok(config)
}
@@ -727,6 +751,7 @@ disable_response_storage = true
codex_home: fixture.codex_home(),
history: History::default(),
file_opener: UriBasedFileOpener::VsCode,
tui: Tui::default(),
},
o3_profile_config
);
@@ -763,6 +788,7 @@ disable_response_storage = true
codex_home: fixture.codex_home(),
history: History::default(),
file_opener: UriBasedFileOpener::VsCode,
tui: Tui::default(),
};
assert_eq!(expected_gpt3_profile_config, gpt3_profile_config);
@@ -814,6 +840,7 @@ disable_response_storage = true
codex_home: fixture.codex_home(),
history: History::default(),
file_opener: UriBasedFileOpener::VsCode,
tui: Tui::default(),
};
assert_eq!(expected_zdr_profile_config, zdr_profile_config);

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()?;