Express TUI mouse capture as an explicit policy (#57284)

## What changed

Replace screen-specific `OverlayInput` variants with `MouseCapture::{Inherit, Enabled, Disabled}` and rename the setter to `set_mouse_capture`.

Preserve existing capture behavior: transcript and usage overlays enable capture, onboarding and static pagers disable it, and the inherited policy follows screen ownership. Update the alternate-screen and lifecycle tests to use the new policy.

GitOrigin-RevId: 965a3bb6d0854e289d2b0dbeedbf2b252b98293d
This commit is contained in:
Ian MacLeod
2026-10-26 22:03:39 +00:00
committed by copyberry
parent efab30992b
commit a3eabbbbf9
8 changed files with 102 additions and 110 deletions
+1 -1
View File
@@ -1296,7 +1296,7 @@ See the Codex keymap documentation for supported actions and examples."
if !tui.is_owned_screen() && tui.is_alt_screen_active() {
app.close_transcript_overlay(tui);
} else if let Err(err) =
tui.set_overlay_input(crate::tui::OverlayInput::Default)
tui.set_mouse_capture(crate::tui::MouseCapture::Inherit)
{
break Err(err.into());
}
+2 -2
View File
@@ -415,7 +415,7 @@ impl App {
{
let was_select_mode = t.is_select_mode();
let pending_follow_before = t.pending_follow_id();
tui.set_overlay_input(tui::OverlayInput::Transcript)?;
tui.set_mouse_capture(tui::MouseCapture::Enabled)?;
t.motion = crate::motion::MotionMode::from_animations_enabled(
self.local_settings.tui.animations && self.local_settings.tui.effects.shimmer,
);
@@ -442,7 +442,7 @@ impl App {
let result =
if input { t.draw(tui) } else { Ok(()) }.and_then(|()| t.handle_event(tui, event));
if result.is_err() {
let _ = tui.set_overlay_input(tui::OverlayInput::Default);
let _ = tui.set_mouse_capture(tui::MouseCapture::Inherit);
}
let completed_copy = result?;
let close_overlay = t.is_done();
@@ -52,7 +52,7 @@ use crate::onboarding::trust_directory::TrustDirectoryWidget;
use crate::onboarding::welcome::WelcomeWidget;
use crate::startup_draft::StartupCancelled;
use crate::tui::FrameRequester;
use crate::tui::OverlayInput;
use crate::tui::MouseCapture;
use crate::tui::Tui;
use crate::tui::TuiEvent;
use color_eyre::eyre::Result;
@@ -599,8 +599,8 @@ async fn run_onboarding_screen(
tui: &mut Tui,
) -> Result<OnboardingResult> {
// Let the terminal select and copy login URLs and device codes, even in fullscreen mode.
let previous_input = tui.overlay_input;
let result = match tui.set_overlay_input(OverlayInput::Onboarding) {
let previous_mouse_capture = tui.mouse_capture;
let result = match tui.set_mouse_capture(MouseCapture::Disabled) {
Ok(()) => {
run_onboarding_screen_inner(
onboarding_screen,
@@ -612,7 +612,7 @@ async fn run_onboarding_screen(
}
Err(error) => Err(error.into()),
};
let restore = tui.set_overlay_input(previous_input);
let restore = tui.set_mouse_capture(previous_mouse_capture);
result.and_then(|result| restore.map(|()| result).map_err(Into::into))
}
+5 -6
View File
@@ -85,19 +85,18 @@ impl Overlay {
}
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
let input = match self {
Overlay::Transcript(_) => tui::OverlayInput::Transcript,
Overlay::Static(_) => tui::OverlayInput::StaticPager,
Overlay::Analytics(_) => tui::OverlayInput::Usage,
let mouse_capture = match self {
Overlay::Transcript(_) | Overlay::Analytics(_) => tui::MouseCapture::Enabled,
Overlay::Static(_) => tui::MouseCapture::Disabled,
};
tui.set_overlay_input(input)?;
tui.set_mouse_capture(mouse_capture)?;
let result = match self {
Overlay::Transcript(o) => o.handle_event(tui, event).map(drop),
Overlay::Static(o) => o.handle_event(tui, event),
Overlay::Analytics(o) => o.handle_event(tui, event),
};
if result.is_err() || self.is_done() {
let restore = tui.set_overlay_input(tui::OverlayInput::Default);
let restore = tui.set_mouse_capture(tui::MouseCapture::Inherit);
return result.and(restore);
}
result
+24 -24
View File
@@ -642,23 +642,23 @@ pub enum TuiEvent {
FocusLost,
}
/// The current screen's pointer policy; ordinary pickers retain alternate-scroll input.
#[derive(Clone, Copy, Default, PartialEq, Eq)]
pub(crate) enum OverlayInput {
/// Controls whether the TUI requests mouse events from the terminal.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum MouseCapture {
#[default]
Default,
Onboarding,
Transcript,
StaticPager,
Usage,
/// Follow the session default: capture only when the TUI owns the screen.
Inherit,
Enabled,
/// Let the terminal handle gestures, including selection and opening links.
Disabled,
}
impl OverlayInput {
impl MouseCapture {
fn captures_mouse(self, owned_screen: bool) -> bool {
match self {
Self::Default => owned_screen,
Self::Onboarding | Self::StaticPager => false,
Self::Transcript | Self::Usage => true,
Self::Inherit => owned_screen,
Self::Enabled => true,
Self::Disabled => false,
}
}
}
@@ -693,7 +693,7 @@ pub struct Tui {
alt_screen_enabled: bool,
// Keep the alternate screen alive when an overlay closes.
owned_screen: bool,
pub(crate) overlay_input: OverlayInput,
pub(crate) mouse_capture: MouseCapture,
// Copies and native ownership survive closing an overlay or startup picker.
pub(crate) clipboard: crate::clipboard_copy::worker::ClipboardWorker,
// Keeps unmanaged process stderr writes out of the inline viewport.
@@ -767,7 +767,7 @@ impl Tui {
scrollback,
alt_screen_enabled: true,
owned_screen: false,
overlay_input: OverlayInput::Default,
mouse_capture: MouseCapture::Inherit,
clipboard: Default::default(),
_stderr_guard: stderr_guard,
}
@@ -831,18 +831,18 @@ impl Tui {
crate::transcript_mode::TranscriptMode::resolve(self.owned_screen, self.alt_screen_enabled)
}
/// Retain the overlay input request across temporary editor, suspend and panic handoffs.
/// Retain the mouse capture policy across temporary editor, suspend and panic handoffs.
/// Actual terminal capture is tracked by AlternateScreen so partial writes remain cleanable.
pub(crate) fn set_overlay_input(&mut self, input: OverlayInput) -> Result<()> {
pub(crate) fn set_mouse_capture(&mut self, mouse_capture: MouseCapture) -> Result<()> {
if self.alt_screen_enabled && self.is_alt_screen_active() {
self.overlay_input.apply(
self.mouse_capture.apply(
&ALTERNATE_SCREEN,
self.terminal.backend_mut(),
input,
mouse_capture,
self.owned_screen,
)
} else {
self.overlay_input = input;
self.mouse_capture = mouse_capture;
Ok(())
}
}
@@ -902,7 +902,7 @@ impl Tui {
set_modes()?;
self._stderr_guard.recover_after_caught_panic()?;
self.terminal.invalidate_cursor_state();
if self.is_owned_screen() || self.overlay_input != OverlayInput::Default {
if self.is_owned_screen() || self.mouse_capture != MouseCapture::Inherit {
let saved = self.alt_saved_viewport;
self.alt_screen_active
.store(/*val*/ false, Ordering::Relaxed);
@@ -1096,7 +1096,7 @@ impl Tui {
}
let result = ALTERNATE_SCREEN.enter(
self.terminal.backend_mut(),
self.overlay_input.captures_mouse(self.owned_screen),
self.mouse_capture.captures_mouse(self.owned_screen),
);
self.alt_screen_active
.store(ALTERNATE_SCREEN.is_active(), Ordering::Relaxed);
@@ -1123,7 +1123,7 @@ impl Tui {
/// Close an overlay, preserving the alternate screen when the session owns it.
pub fn leave_alt_screen(&mut self) -> Result<()> {
let input_result = self.set_overlay_input(OverlayInput::Default);
let input_result = self.set_mouse_capture(MouseCapture::Inherit);
if self.is_owned_screen() {
self.terminal.invalidate_viewport();
return input_result;
@@ -1313,7 +1313,7 @@ impl Tui {
&mut self.terminal,
screen_size,
self.owned_screen,
self.overlay_input.captures_mouse(self.owned_screen),
self.mouse_capture.captures_mouse(self.owned_screen),
)?;
}
@@ -1462,7 +1462,7 @@ impl Tui {
&mut self.terminal,
screen_size,
self.owned_screen,
self.overlay_input.captures_mouse(self.owned_screen),
self.mouse_capture.captures_mouse(self.owned_screen),
)?;
}
+4 -4
View File
@@ -24,7 +24,7 @@ use super::DisableAlternateScroll;
use super::EnableAlternateScroll;
use super::KeyboardRestore;
use super::keyboard_modes;
use super::tmux::MouseCapture;
use super::tmux;
// Panic and exit cleanup cannot borrow Tui. Track the actual screen independently of its owner.
pub(super) static ALTERNATE_SCREEN: AlternateScreen = AlternateScreen {
@@ -98,7 +98,7 @@ impl AlternateScreen {
Ordering::Relaxed,
);
self.mouse_capture_disabled.store(
mouse_capture == MouseCapture::DisabledByTmux,
mouse_capture == tmux::MouseCapture::DisabledByTmux,
Ordering::Relaxed,
);
self.configure_input(writer, capture_mouse)
@@ -227,7 +227,7 @@ impl AlternateScreen {
}
}
impl super::OverlayInput {
impl super::MouseCapture {
/// Roll back a partial overlay setup immediately; callers still receive the original error.
/// The fallback keeps capture when the session owns the screen, otherwise restores the picker.
pub(super) fn apply(
@@ -242,7 +242,7 @@ impl super::OverlayInput {
}
*self = next;
if let Err(error) = screen.configure_input(writer, next.captures_mouse(owned)) {
*self = Self::Default;
*self = Self::Inherit;
let _ = screen.configure_input(writer, owned);
return Err(error);
}
+50 -57
View File
@@ -2,6 +2,7 @@
#![cfg(unix)]
use super::super::MouseCapture;
use super::*;
use crossterm::event::KeyboardEnhancementFlags;
use crossterm::event::PushKeyboardEnhancementFlags;
@@ -16,15 +17,13 @@ struct KeyboardScreens {
#[test]
fn refreshed_mouse_policy_applies_to_fullscreen_and_overlays() {
use super::super::OverlayInput;
for owned in [false, true] {
let screen = AlternateScreen::default();
let mut output = Vec::new();
let mut terminal = vt100::Parser::new(
/*rows*/ 24, /*cols*/ 80, /*scrollback_len*/ 0,
);
let mut input = OverlayInput::Default;
let mut input = MouseCapture::Inherit;
screen
.enter(&mut output, input.captures_mouse(owned))
.unwrap();
@@ -33,18 +32,16 @@ fn refreshed_mouse_policy_applies_to_fullscreen_and_overlays() {
.mouse_capture_disabled
.store(disabled, Ordering::Relaxed);
for next in [
OverlayInput::Transcript,
OverlayInput::Usage,
OverlayInput::Onboarding,
OverlayInput::Default,
OverlayInput::StaticPager,
MouseCapture::Enabled,
MouseCapture::Inherit,
MouseCapture::Disabled,
] {
input.apply(&screen, &mut output, next, owned).unwrap();
terminal.process(&std::mem::take(&mut output));
let capture = match next {
OverlayInput::Default => owned,
OverlayInput::Transcript | OverlayInput::Usage => true,
OverlayInput::StaticPager | OverlayInput::Onboarding => false,
MouseCapture::Inherit => owned,
MouseCapture::Enabled => true,
MouseCapture::Disabled => false,
};
let expected = if !disabled && capture {
vt100::MouseProtocolMode::AnyMotion
@@ -432,16 +429,12 @@ fn pager_capture_restores_picker_input_without_a_screen_transition() {
terminal.screen().mouse_protocol_mode(),
vt100::MouseProtocolMode::None
);
let mut input = super::super::OverlayInput::Default;
for requested in [
super::super::OverlayInput::Transcript,
super::super::OverlayInput::StaticPager,
super::super::OverlayInput::Usage,
] {
let mut input = MouseCapture::Inherit;
for requested in [MouseCapture::Enabled, MouseCapture::Disabled] {
input
.apply(&screen, &mut output, requested, /*owned*/ false)
.unwrap();
let capture = requested != super::super::OverlayInput::StaticPager;
let capture = requested != MouseCapture::Disabled;
assert_eq!(
output
.windows(b"\x1b[?1003h".len())
@@ -473,7 +466,7 @@ fn pager_capture_restores_picker_input_without_a_screen_transition() {
.apply(
&screen,
&mut output,
super::super::OverlayInput::Default,
MouseCapture::Inherit,
/*owned*/ false,
)
.unwrap();
@@ -489,40 +482,40 @@ fn pager_capture_restores_picker_input_without_a_screen_transition() {
#[cfg(not(windows))]
#[test]
fn failed_pager_capture_setup_restores_requested_and_actual_picker_policy() {
for requested in [
super::super::OverlayInput::Transcript,
super::super::OverlayInput::Usage,
] {
let screen = AlternateScreen::default();
let mut output = Vec::new();
enter_with_mouse_enabled(&screen, &mut output, /*capture_mouse*/ false).unwrap();
let mut writer = FailOnce {
output,
sequence: b"\x1b[?1000h\x1b[?1002h\x1b[?1003h",
failed: false,
};
let mut input = super::super::OverlayInput::Default;
let error = input
.apply(&screen, &mut writer, requested, /*owned*/ false)
.unwrap_err();
assert_eq!(error.to_string(), "injected cleanup write failure");
assert!(input == super::super::OverlayInput::Default);
assert!(writer.failed);
assert!(!screen.mouse_active.load(Ordering::Relaxed));
let mut terminal = vt100::Parser::new(
/*rows*/ 12, /*cols*/ 40, /*scrollback_len*/ 0,
);
terminal.process(&writer.output);
assert_eq!(
(
terminal.screen().alternate_screen(),
terminal.screen().mouse_protocol_mode()
),
(true, vt100::MouseProtocolMode::None)
);
assert!(writer.output.ends_with(b"\x1b[?1007h"));
screen.leave(&mut writer).unwrap();
}
let screen = AlternateScreen::default();
let mut output = Vec::new();
enter_with_mouse_enabled(&screen, &mut output, /*capture_mouse*/ false).unwrap();
let mut writer = FailOnce {
output,
sequence: b"\x1b[?1000h\x1b[?1002h\x1b[?1003h",
failed: false,
};
let mut input = MouseCapture::Inherit;
let error = input
.apply(
&screen,
&mut writer,
MouseCapture::Enabled,
/*owned*/ false,
)
.unwrap_err();
assert_eq!(error.to_string(), "injected cleanup write failure");
assert!(input == MouseCapture::Inherit);
assert!(writer.failed);
assert!(!screen.mouse_active.load(Ordering::Relaxed));
let mut terminal = vt100::Parser::new(
/*rows*/ 12, /*cols*/ 40, /*scrollback_len*/ 0,
);
terminal.process(&writer.output);
assert_eq!(
(
terminal.screen().alternate_screen(),
terminal.screen().mouse_protocol_mode()
),
(true, vt100::MouseProtocolMode::None)
);
assert!(writer.output.ends_with(b"\x1b[?1007h"));
screen.leave(&mut writer).unwrap();
}
#[cfg(not(windows))]
@@ -560,22 +553,22 @@ fn identical_default_request_retries_after_failed_fallback_cleanup() {
failed_cleanup: false,
};
enter_with_mouse_enabled(&screen, &mut writer, /*capture_mouse*/ false).unwrap();
let mut input = super::super::OverlayInput::Default;
let mut input = MouseCapture::Inherit;
let result = input.apply(
&screen,
&mut writer,
super::super::OverlayInput::Transcript,
MouseCapture::Enabled,
/*owned*/ false,
);
assert_eq!(result.unwrap_err().to_string(), "partial setup");
assert!(input == super::super::OverlayInput::Default);
assert!(input == MouseCapture::Inherit);
assert!(screen.mouse_active.load(Ordering::Relaxed));
assert!(!screen.input_configured.load(Ordering::Relaxed));
input
.apply(
&screen,
&mut writer,
super::super::OverlayInput::Default,
MouseCapture::Inherit,
/*owned*/ false,
)
.unwrap();
+12 -12
View File
@@ -2,7 +2,7 @@
#![cfg(unix)]
use super::OverlayInput;
use super::MouseCapture;
use super::TuiEvent;
use crate::pager_overlay::Overlay;
use crossterm::event::KeyCode;
@@ -59,9 +59,9 @@ async fn owned_screen_lifecycle_child() {
tui.terminal.set_viewport_area(inline);
tui.set_alt_screen_enabled(/*enabled*/ false);
tui.set_owned_screen(/*owned*/ true).expect("honor no-alt");
tui.set_overlay_input(OverlayInput::Transcript)
tui.set_mouse_capture(MouseCapture::Enabled)
.expect("no-alt overlay");
tui.set_overlay_input(OverlayInput::Default)
tui.set_mouse_capture(MouseCapture::Inherit)
.expect("close no-alt overlay");
assert_eq!(
(
@@ -80,7 +80,7 @@ async fn owned_screen_lifecycle_child() {
pager
.handle_event(&mut tui, TuiEvent::FocusLost)
.expect("request no-alt static input");
assert!(tui.overlay_input == OverlayInput::StaticPager);
assert!(tui.mouse_capture == MouseCapture::Disabled);
assert!(!tui.is_alt_screen_active());
pager
.handle_event(
@@ -89,20 +89,20 @@ async fn owned_screen_lifecycle_child() {
)
.expect("close no-alt static pager");
assert!(pager.is_done());
assert!(tui.overlay_input == OverlayInput::Default);
assert!(tui.mouse_capture == MouseCapture::Inherit);
assert_eq!(tui.terminal.viewport_area, inline);
tui.set_alt_screen_enabled(/*enabled*/ true);
// A startup overlay may already hold the alternate screen when App selects ownership.
tui.enter_alt_screen().expect("startup overlay");
tui.set_overlay_input(OverlayInput::Transcript)
tui.set_mouse_capture(MouseCapture::Enabled)
.expect("nested transcript input");
tui.leave_alt_screen_for_handoff()
.expect("yield transcript to editor");
assert!(tui.overlay_input == OverlayInput::Transcript);
assert!(tui.mouse_capture == MouseCapture::Enabled);
tui.enter_alt_screen()
.expect("restore transcript after editor");
tui.set_overlay_input(OverlayInput::Default)
tui.set_mouse_capture(MouseCapture::Inherit)
.expect("return to picker input");
assert!(tui.is_alt_screen_active());
let mut pager = Overlay::new_static_with_lines(
@@ -113,10 +113,10 @@ async fn owned_screen_lifecycle_child() {
pager
.handle_event(&mut tui, TuiEvent::FocusLost)
.expect("request native static input");
assert!(tui.overlay_input == OverlayInput::StaticPager);
assert!(tui.mouse_capture == MouseCapture::Disabled);
tui.leave_alt_screen_for_handoff()
.expect("yield static pager to editor");
assert!(tui.overlay_input == OverlayInput::StaticPager);
assert!(tui.mouse_capture == MouseCapture::Disabled);
assert_eq!(tui.terminal.viewport_area, inline);
tui.enter_alt_screen().expect("restore static pager");
super::ALTERNATE_SCREEN
@@ -127,7 +127,7 @@ async fn owned_screen_lifecycle_child() {
&mut tui.terminal,
Size::new(/*width*/ 80, /*height*/ 24),
/*owned*/ false,
tui.overlay_input.captures_mouse(/*owned_screen*/ false),
tui.mouse_capture.captures_mouse(/*owned_screen*/ false),
)
.expect("resume static pager");
pager
@@ -137,7 +137,7 @@ async fn owned_screen_lifecycle_child() {
)
.expect("close static pager back to picker");
assert!(pager.is_done());
assert!(tui.overlay_input == OverlayInput::Default);
assert!(tui.mouse_capture == MouseCapture::Inherit);
assert!(tui.is_alt_screen_active());
tui.set_owned_screen(/*owned*/ true)
.expect("promote overlay");