mirror of
https://github.com/openai/codex.git
synced 2026-10-27 18:48:37 +00:00
feat(tui): add owned screen wheel scrolling
This commit is contained in:
@@ -1298,6 +1298,21 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
let pasted = pasted.replace("\r", "\n");
|
||||
self.chat_widget.handle_paste(pasted);
|
||||
}
|
||||
TuiEvent::MouseScroll(event) => {
|
||||
let handled = self.handle_owned_screen_mouse_scroll(tui, event);
|
||||
if !handled && !self.chat_widget.no_modal_or_popup_active() {
|
||||
let key_code = match event.direction {
|
||||
tui::MouseScrollDirection::Up => KeyCode::Up,
|
||||
tui::MouseScrollDirection::Down => KeyCode::Down,
|
||||
};
|
||||
self.handle_key_event(
|
||||
tui,
|
||||
app_server,
|
||||
KeyEvent::new(key_code, KeyModifiers::NONE),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
TuiEvent::Draw | TuiEvent::Resize => {
|
||||
if self.backtrack_render_pending {
|
||||
self.rebuild_transcript_after_backtrack(tui)?;
|
||||
|
||||
@@ -8,12 +8,14 @@ use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Position;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::Clear;
|
||||
use ratatui::widgets::Widget;
|
||||
|
||||
use super::*;
|
||||
use crate::AltScreenBehavior;
|
||||
use crate::tui::MouseScrollEvent;
|
||||
|
||||
pub(super) struct OwnedScreen {
|
||||
viewport: ConversationViewport,
|
||||
@@ -92,6 +94,17 @@ impl OwnedScreen {
|
||||
self.viewport
|
||||
.handle_navigation_key(self.last_conversation_area, key_event)
|
||||
}
|
||||
|
||||
fn handle_mouse_scroll(&mut self, event: MouseScrollEvent) -> bool {
|
||||
if !self
|
||||
.last_conversation_area
|
||||
.contains(Position::new(event.column, event.row))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.viewport.handle_mouse_scroll(event.direction);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -153,6 +166,25 @@ impl App {
|
||||
handled
|
||||
}
|
||||
|
||||
pub(super) fn handle_owned_screen_mouse_scroll(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
event: MouseScrollEvent,
|
||||
) -> bool {
|
||||
if !self.chat_widget.no_modal_or_popup_active() {
|
||||
return false;
|
||||
}
|
||||
let handled = self
|
||||
.owned_screen
|
||||
.as_mut()
|
||||
.is_some_and(|screen| screen.handle_mouse_scroll(event));
|
||||
if handled {
|
||||
tui.frame_requester()
|
||||
.schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL);
|
||||
}
|
||||
handled
|
||||
}
|
||||
|
||||
pub(crate) fn sync_owned_screen_cells(&mut self) {
|
||||
if let Some(screen) = &mut self.owned_screen {
|
||||
screen.viewport.replace_cells(self.transcript_cells.clone());
|
||||
|
||||
@@ -10,6 +10,8 @@ use tokio::sync::broadcast::error::TryRecvError;
|
||||
|
||||
use super::*;
|
||||
use crate::chatwidget::tests::make_chatwidget_manual_with_sender;
|
||||
use crate::tui::MouseScrollDirection;
|
||||
use crate::tui::MouseScrollEvent;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TestCell(&'static str);
|
||||
@@ -147,3 +149,60 @@ async fn navigation_does_not_steal_printable_or_draft_input() {
|
||||
KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE),
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mouse_wheel_scrolls_transcript_without_changing_draft() {
|
||||
let (mut chat_widget, _app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await;
|
||||
chat_widget.set_composer_text("draft sentinel".to_string(), Vec::new(), Vec::new());
|
||||
let mut screen = OwnedScreen::new(&chat_widget, crate::keymap::RuntimeKeymap::defaults().pager);
|
||||
for text in ["oldest", "older", "middle", "newer", "LATEST"] {
|
||||
screen.viewport.push_cell(Arc::new(TestCell(text)));
|
||||
}
|
||||
let mut terminal =
|
||||
Terminal::new(TestBackend::new(/*width*/ 40, /*height*/ 8)).expect("create terminal");
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
screen.render(&chat_widget, frame.area(), frame.buffer_mut());
|
||||
})
|
||||
.expect("render bottom");
|
||||
|
||||
assert!(screen.handle_mouse_scroll(MouseScrollEvent {
|
||||
direction: MouseScrollDirection::Up,
|
||||
column: 2,
|
||||
row: 2,
|
||||
}));
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
screen.render(&chat_widget, frame.area(), frame.buffer_mut());
|
||||
})
|
||||
.expect("render scrolled");
|
||||
|
||||
assert_snapshot!(terminal.backend(), @r###"
|
||||
" "
|
||||
"middle "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
"› draft sentinel "
|
||||
" "
|
||||
" gpt-5.5 default · /tmp/project "
|
||||
"###);
|
||||
assert!(!screen.viewport.is_following_bottom());
|
||||
assert!(!screen.handle_mouse_scroll(MouseScrollEvent {
|
||||
direction: MouseScrollDirection::Up,
|
||||
column: 2,
|
||||
row: 7,
|
||||
}));
|
||||
|
||||
assert!(screen.handle_mouse_scroll(MouseScrollEvent {
|
||||
direction: MouseScrollDirection::Down,
|
||||
column: 2,
|
||||
row: 2,
|
||||
}));
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
screen.render(&chat_widget, frame.area(), frame.buffer_mut());
|
||||
})
|
||||
.expect("render restored bottom");
|
||||
assert!(screen.viewport.is_following_bottom());
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ use crate::render::renderable::Renderable;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use crate::terminal_hyperlinks::mark_buffer_hyperlinks;
|
||||
use crate::terminal_hyperlinks::visible_lines;
|
||||
use crate::tui::MouseScrollDirection;
|
||||
|
||||
pub(crate) struct ConversationViewport {
|
||||
content: PagerContent,
|
||||
@@ -66,6 +67,10 @@ impl ConversationViewport {
|
||||
self.content.handle_navigation_key(area, key_event)
|
||||
}
|
||||
|
||||
pub(crate) fn handle_mouse_scroll(&mut self, direction: MouseScrollDirection) {
|
||||
self.content.handle_mouse_scroll(direction);
|
||||
}
|
||||
|
||||
pub(crate) fn push_cell(&mut self, cell: Arc<dyn HistoryCell>) {
|
||||
let follow_bottom = self.content.is_following_bottom();
|
||||
let had_prior_cells = !self.cells.is_empty();
|
||||
|
||||
@@ -11,6 +11,7 @@ use ratatui::text::Line;
|
||||
|
||||
use super::*;
|
||||
use crate::history_cell::AgentMarkdownCell;
|
||||
use crate::tui::MouseScrollDirection;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TestCell {
|
||||
@@ -274,6 +275,59 @@ LATEST
|
||||
"###);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_wheel_leaves_and_restores_bottom_follow() {
|
||||
let mut viewport = viewport(vec![
|
||||
cell("oldest"),
|
||||
cell("older"),
|
||||
cell("middle"),
|
||||
cell("newer"),
|
||||
cell("LATEST"),
|
||||
]);
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 20, /*height*/ 3,
|
||||
);
|
||||
let mut bottom = Buffer::empty(area);
|
||||
viewport.render(area, &mut bottom);
|
||||
|
||||
viewport.handle_mouse_scroll(MouseScrollDirection::Up);
|
||||
let mut scrolled = Buffer::empty(area);
|
||||
viewport.render(area, &mut scrolled);
|
||||
assert!(!viewport.is_following_bottom());
|
||||
|
||||
viewport.handle_mouse_scroll(MouseScrollDirection::Down);
|
||||
let mut restored = Buffer::empty(area);
|
||||
viewport.render(area, &mut restored);
|
||||
assert!(viewport.is_following_bottom());
|
||||
|
||||
let trim_rows = |buffer: &Buffer| {
|
||||
buffer_text(buffer, area)
|
||||
.lines()
|
||||
.map(str::trim_end)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
};
|
||||
assert_snapshot!(format!(
|
||||
"bottom:\n{}\nscrolled:\n{}\nrestored:\n{}",
|
||||
trim_rows(&bottom),
|
||||
trim_rows(&scrolled),
|
||||
trim_rows(&restored),
|
||||
), @r###"
|
||||
bottom:
|
||||
newer
|
||||
|
||||
LATEST
|
||||
scrolled:
|
||||
|
||||
middle
|
||||
|
||||
restored:
|
||||
newer
|
||||
|
||||
LATEST
|
||||
"###);
|
||||
}
|
||||
|
||||
fn buffer_text(buffer: &Buffer, area: Rect) -> String {
|
||||
let mut rows = Vec::new();
|
||||
for y in area.y..area.bottom() {
|
||||
|
||||
@@ -96,7 +96,7 @@ pub(crate) async fn run_cwd_selection_prompt(
|
||||
if let Some(event) = events.next().await {
|
||||
match event {
|
||||
TuiEvent::Key(key_event) => screen.handle_key(key_event),
|
||||
TuiEvent::Paste(_) => {}
|
||||
TuiEvent::Paste(_) | TuiEvent::MouseScroll(_) => {}
|
||||
TuiEvent::Draw | TuiEvent::Resize => {
|
||||
tui.draw(u16::MAX, |frame| {
|
||||
frame.render_widget_ref(&screen, frame.area());
|
||||
|
||||
@@ -99,7 +99,7 @@ pub(crate) async fn run_external_agent_config_migration_prompt(
|
||||
if let Some(event) = events.next().await {
|
||||
match event {
|
||||
TuiEvent::Key(key_event) => screen.handle_key(key_event),
|
||||
TuiEvent::Paste(_) => {}
|
||||
TuiEvent::Paste(_) | TuiEvent::MouseScroll(_) => {}
|
||||
TuiEvent::Draw | TuiEvent::Resize => {
|
||||
let _ = tui.draw(u16::MAX, |frame| {
|
||||
frame.render_widget_ref(&screen, frame.area());
|
||||
|
||||
@@ -1787,7 +1787,7 @@ async fn run_ratatui_app(
|
||||
};
|
||||
|
||||
if alt_screen_behavior == AltScreenBehavior::Owned {
|
||||
tui.enter_alt_screen()?;
|
||||
tui.enter_owned_alt_screen()?;
|
||||
}
|
||||
let app_result = App::run(
|
||||
&mut tui,
|
||||
|
||||
@@ -152,7 +152,7 @@ pub(crate) async fn run_model_migration_prompt(
|
||||
if let Some(event) = events.next().await {
|
||||
match event {
|
||||
TuiEvent::Key(key_event) => screen.handle_key(key_event),
|
||||
TuiEvent::Paste(_) => {}
|
||||
TuiEvent::Paste(_) | TuiEvent::MouseScroll(_) => {}
|
||||
TuiEvent::Draw | TuiEvent::Resize => {
|
||||
let _ = alt.tui.draw(u16::MAX, |frame| {
|
||||
frame.render_widget_ref(&screen, frame.area());
|
||||
|
||||
@@ -509,6 +509,7 @@ pub(crate) async fn run_onboarding_app(
|
||||
TuiEvent::Paste(text) => {
|
||||
onboarding_screen.handle_paste(text);
|
||||
}
|
||||
TuiEvent::MouseScroll(_) => {}
|
||||
TuiEvent::Draw | TuiEvent::Resize => {
|
||||
if !did_full_clear_after_success
|
||||
&& onboarding_screen.steps.iter().any(|step| {
|
||||
|
||||
@@ -33,6 +33,8 @@ use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use crate::terminal_hyperlinks::mark_buffer_hyperlinks;
|
||||
use crate::terminal_hyperlinks::visible_lines;
|
||||
use crate::tui;
|
||||
use crate::tui::MouseScrollDirection;
|
||||
use crate::tui::MouseScrollEvent;
|
||||
use crate::tui::TuiEvent;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
@@ -95,6 +97,8 @@ fn first_or_empty(bindings: &[KeyBinding]) -> Vec<KeyBinding> {
|
||||
bindings.first().copied().into_iter().collect()
|
||||
}
|
||||
|
||||
const MOUSE_SCROLL_ROWS: usize = 3;
|
||||
|
||||
// Render a single line of key hints from (key(s), description) pairs.
|
||||
fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(Vec<KeyBinding>, &str)]) {
|
||||
let mut spans: Vec<Span<'static>> = vec![" ".into()];
|
||||
@@ -120,6 +124,8 @@ fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(Vec<KeyBinding>, &st
|
||||
struct PagerView {
|
||||
renderables: Vec<Box<dyn Renderable>>,
|
||||
scroll_offset: usize,
|
||||
/// Rows to move upward from the freshly measured bottom on the next render.
|
||||
pending_rows_from_bottom: usize,
|
||||
title: String,
|
||||
keymap: PagerKeymap,
|
||||
last_content_height: Option<usize>,
|
||||
@@ -138,6 +144,7 @@ impl PagerView {
|
||||
Self {
|
||||
renderables,
|
||||
scroll_offset,
|
||||
pending_rows_from_bottom: 0,
|
||||
title,
|
||||
keymap,
|
||||
last_content_height: None,
|
||||
@@ -165,9 +172,7 @@ impl PagerView {
|
||||
if let Some(idx) = self.pending_scroll_chunk.take() {
|
||||
self.ensure_chunk_visible(idx, content_area);
|
||||
}
|
||||
self.scroll_offset = self
|
||||
.scroll_offset
|
||||
.min(content_height.saturating_sub(content_area.height as usize));
|
||||
self.resolve_scroll_offset(content_height, content_area.height as usize);
|
||||
|
||||
self.render_content(content_area, buf, /*empty_row_marker*/ Some('~'));
|
||||
|
||||
@@ -264,34 +269,35 @@ impl PagerView {
|
||||
fn apply_key_event(&mut self, viewport_area: Rect, key_event: KeyEvent) -> bool {
|
||||
match key_event {
|
||||
e if self.keymap.scroll_up.is_pressed(e) => {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(1);
|
||||
self.scroll_up_rows(/*rows*/ 1);
|
||||
}
|
||||
e if self.keymap.scroll_down.is_pressed(e) => {
|
||||
self.scroll_offset = self.scroll_offset.saturating_add(1);
|
||||
self.scroll_down_rows(/*rows*/ 1);
|
||||
}
|
||||
e if self.keymap.page_up.is_pressed(e) => {
|
||||
let page_height = self.page_height(viewport_area);
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(page_height);
|
||||
self.scroll_up_rows(page_height);
|
||||
}
|
||||
e if self.keymap.page_down.is_pressed(e) => {
|
||||
let page_height = self.page_height(viewport_area);
|
||||
self.scroll_offset = self.scroll_offset.saturating_add(page_height);
|
||||
self.scroll_down_rows(page_height);
|
||||
}
|
||||
e if self.keymap.half_page_down.is_pressed(e) => {
|
||||
let area = self.content_area(viewport_area);
|
||||
let half_page = (area.height as usize).saturating_add(1) / 2;
|
||||
self.scroll_offset = self.scroll_offset.saturating_add(half_page);
|
||||
self.scroll_down_rows(half_page);
|
||||
}
|
||||
e if self.keymap.half_page_up.is_pressed(e) => {
|
||||
let area = self.content_area(viewport_area);
|
||||
let half_page = (area.height as usize).saturating_add(1) / 2;
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(half_page);
|
||||
self.scroll_up_rows(half_page);
|
||||
}
|
||||
e if self.keymap.jump_top.is_pressed(e) => {
|
||||
self.scroll_offset = 0;
|
||||
self.pending_rows_from_bottom = 0;
|
||||
}
|
||||
e if self.keymap.jump_bottom.is_pressed(e) => {
|
||||
self.scroll_offset = usize::MAX;
|
||||
self.scroll_to_bottom();
|
||||
}
|
||||
_ => {
|
||||
return false;
|
||||
@@ -300,6 +306,51 @@ impl PagerView {
|
||||
true
|
||||
}
|
||||
|
||||
fn handle_mouse_scroll(&mut self, tui: &mut tui::Tui, event: MouseScrollEvent) -> Result<()> {
|
||||
self.apply_mouse_scroll(event.direction);
|
||||
tui.frame_requester()
|
||||
.schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_mouse_scroll(&mut self, direction: MouseScrollDirection) {
|
||||
match direction {
|
||||
MouseScrollDirection::Up => self.scroll_up_rows(MOUSE_SCROLL_ROWS),
|
||||
MouseScrollDirection::Down => self.scroll_down_rows(MOUSE_SCROLL_ROWS),
|
||||
}
|
||||
}
|
||||
|
||||
fn scroll_up_rows(&mut self, rows: usize) {
|
||||
if self.scroll_offset == usize::MAX {
|
||||
self.pending_rows_from_bottom = self.pending_rows_from_bottom.saturating_add(rows);
|
||||
} else {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(rows);
|
||||
}
|
||||
}
|
||||
|
||||
fn scroll_down_rows(&mut self, rows: usize) {
|
||||
if self.scroll_offset == usize::MAX {
|
||||
self.pending_rows_from_bottom = self.pending_rows_from_bottom.saturating_sub(rows);
|
||||
} else {
|
||||
self.scroll_offset = self.scroll_offset.saturating_add(rows);
|
||||
}
|
||||
}
|
||||
|
||||
fn scroll_to_bottom(&mut self) {
|
||||
self.scroll_offset = usize::MAX;
|
||||
self.pending_rows_from_bottom = 0;
|
||||
}
|
||||
|
||||
fn resolve_scroll_offset(&mut self, content_height: usize, viewport_height: usize) {
|
||||
let max_scroll = content_height.saturating_sub(viewport_height);
|
||||
if self.scroll_offset == usize::MAX {
|
||||
self.scroll_offset = max_scroll.saturating_sub(self.pending_rows_from_bottom);
|
||||
self.pending_rows_from_bottom = 0;
|
||||
} else {
|
||||
self.scroll_offset = self.scroll_offset.min(max_scroll);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the height of one page in content rows.
|
||||
///
|
||||
/// Prefers the last rendered content height (excluding header/footer chrome);
|
||||
@@ -335,11 +386,9 @@ impl PagerView {
|
||||
if let Some(idx) = self.pending_scroll_chunk.take() {
|
||||
self.ensure_chunk_visible(idx, area);
|
||||
} else if follow_bottom {
|
||||
self.scroll_offset = usize::MAX;
|
||||
self.scroll_to_bottom();
|
||||
}
|
||||
self.scroll_offset = self
|
||||
.scroll_offset
|
||||
.min(content_height.saturating_sub(area.height as usize));
|
||||
self.resolve_scroll_offset(content_height, area.height as usize);
|
||||
self.render_content(area, buf, /*empty_row_marker*/ None);
|
||||
}
|
||||
}
|
||||
@@ -347,7 +396,7 @@ impl PagerView {
|
||||
impl PagerView {
|
||||
fn is_scrolled_to_bottom(&self) -> bool {
|
||||
if self.scroll_offset == usize::MAX {
|
||||
return true;
|
||||
return self.pending_rows_from_bottom == 0;
|
||||
}
|
||||
let Some(height) = self.last_content_height else {
|
||||
return false;
|
||||
@@ -368,6 +417,7 @@ impl PagerView {
|
||||
/// Request that the given text chunk index be scrolled into view on next render.
|
||||
fn scroll_chunk_into_view(&mut self, chunk_index: usize) {
|
||||
self.pending_scroll_chunk = Some(chunk_index);
|
||||
self.pending_rows_from_bottom = 0;
|
||||
}
|
||||
|
||||
fn ensure_chunk_visible(&mut self, idx: usize, area: Rect) {
|
||||
@@ -433,7 +483,7 @@ impl PagerContent {
|
||||
}
|
||||
|
||||
pub(crate) fn scroll_to_bottom(&mut self) {
|
||||
self.view.scroll_offset = usize::MAX;
|
||||
self.view.scroll_to_bottom();
|
||||
}
|
||||
|
||||
pub(crate) fn handle_navigation_key(
|
||||
@@ -443,6 +493,10 @@ impl PagerContent {
|
||||
) -> bool {
|
||||
self.view.apply_key_event(viewport_area, key_event)
|
||||
}
|
||||
|
||||
pub(crate) fn handle_mouse_scroll(&mut self, direction: MouseScrollDirection) {
|
||||
self.view.apply_mouse_scroll(direction);
|
||||
}
|
||||
}
|
||||
|
||||
/// A renderable that caches its desired height.
|
||||
@@ -640,7 +694,7 @@ impl TranscriptOverlay {
|
||||
self.view.renderables.push(tail);
|
||||
}
|
||||
if follow_bottom {
|
||||
self.view.scroll_offset = usize::MAX;
|
||||
self.view.scroll_to_bottom();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -660,7 +714,7 @@ impl TranscriptOverlay {
|
||||
}
|
||||
self.rebuild_renderables();
|
||||
if follow_bottom {
|
||||
self.view.scroll_offset = usize::MAX;
|
||||
self.view.scroll_to_bottom();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -703,7 +757,7 @@ impl TranscriptOverlay {
|
||||
self.rebuild_renderables();
|
||||
}
|
||||
if follow_bottom {
|
||||
self.view.scroll_offset = usize::MAX;
|
||||
self.view.scroll_to_bottom();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -751,7 +805,7 @@ impl TranscriptOverlay {
|
||||
}
|
||||
}
|
||||
if follow_bottom {
|
||||
self.view.scroll_offset = usize::MAX;
|
||||
self.view.scroll_to_bottom();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -876,6 +930,7 @@ impl TranscriptOverlay {
|
||||
}
|
||||
other => self.view.handle_key_event(tui, other),
|
||||
},
|
||||
TuiEvent::MouseScroll(event) => self.view.handle_mouse_scroll(tui, event),
|
||||
TuiEvent::Draw | TuiEvent::Resize => {
|
||||
tui.draw(u16::MAX, |frame| {
|
||||
self.render(frame.area(), frame.buffer);
|
||||
@@ -979,6 +1034,7 @@ impl StaticOverlay {
|
||||
}
|
||||
other => self.view.handle_key_event(tui, other),
|
||||
},
|
||||
TuiEvent::MouseScroll(event) => self.view.handle_mouse_scroll(tui, event),
|
||||
TuiEvent::Draw | TuiEvent::Resize => {
|
||||
tui.draw(u16::MAX, |frame| {
|
||||
self.render(frame.area(), frame.buffer);
|
||||
@@ -1362,6 +1418,66 @@ mod tests {
|
||||
assert_eq!(overlay.view.scroll_offset, usize::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_mouse_wheel_scrolls_active_pager() {
|
||||
let mut overlay = transcript_overlay(
|
||||
(0..20)
|
||||
.map(|i| {
|
||||
Arc::new(TestCell {
|
||||
lines: vec![Line::from(format!("line{i}"))],
|
||||
}) as Arc<dyn HistoryCell>
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 12,
|
||||
);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
overlay.render(area, &mut buffer);
|
||||
let bottom_offset = overlay.view.scroll_offset;
|
||||
|
||||
overlay.view.apply_mouse_scroll(MouseScrollDirection::Up);
|
||||
assert_eq!(
|
||||
overlay.view.scroll_offset,
|
||||
bottom_offset.saturating_sub(MOUSE_SCROLL_ROWS)
|
||||
);
|
||||
|
||||
overlay.view.apply_mouse_scroll(MouseScrollDirection::Down);
|
||||
overlay.render(area, &mut buffer);
|
||||
assert!(overlay.view.is_scrolled_to_bottom());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_scroll_after_followed_append_uses_updated_bottom() {
|
||||
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
|
||||
lines: vec!["initial".into()],
|
||||
})]);
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 12,
|
||||
);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
overlay.render(area, &mut buffer);
|
||||
overlay.insert_cell(Arc::new(TestCell {
|
||||
lines: (0..20)
|
||||
.map(|line| Line::from(format!("tail-{line:02}")))
|
||||
.collect(),
|
||||
}));
|
||||
|
||||
overlay.view.apply_mouse_scroll(MouseScrollDirection::Up);
|
||||
overlay.render(area, &mut buffer);
|
||||
|
||||
let max_scroll = overlay
|
||||
.view
|
||||
.last_rendered_height
|
||||
.expect("rendered height")
|
||||
.saturating_sub(overlay.view.last_content_height.expect("content height"));
|
||||
assert_eq!(
|
||||
overlay.view.scroll_offset,
|
||||
max_scroll.saturating_sub(MOUSE_SCROLL_ROWS)
|
||||
);
|
||||
assert!(overlay.view.scroll_offset > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_preserves_manual_scroll_position() {
|
||||
let mut overlay = transcript_overlay(
|
||||
|
||||
@@ -475,6 +475,7 @@ async fn run_session_picker_with_loader(
|
||||
TuiEvent::Paste(pasted) => {
|
||||
state.handle_paste(pasted);
|
||||
}
|
||||
TuiEvent::MouseScroll(_) => {}
|
||||
TuiEvent::Draw | TuiEvent::Resize => {
|
||||
if let Ok(size) = alt.tui.terminal.size() {
|
||||
let list_height =
|
||||
|
||||
@@ -166,7 +166,7 @@ async fn run_startup_hooks_review_app(
|
||||
}
|
||||
}
|
||||
}
|
||||
TuiEvent::Paste(_) => {}
|
||||
TuiEvent::Paste(_) | TuiEvent::MouseScroll(_) => {}
|
||||
TuiEvent::Draw | TuiEvent::Resize => draw_view(tui, &view)?,
|
||||
}
|
||||
}
|
||||
|
||||
+22
-3
@@ -300,9 +300,8 @@ pub(super) fn reapply_raw_mode_after_resume() -> Result<()> {
|
||||
/// Uses a stronger keyboard reset than [`restore`] so the parent shell recovers even if a
|
||||
/// terminal missed the stack pop that normally pairs with [`set_modes`].
|
||||
pub fn restore_after_exit() -> Result<()> {
|
||||
let mut first_error =
|
||||
restore_common(RawModeRestore::Disable, KeyboardRestore::ResetAfterExit).err();
|
||||
if let Err(err) = screen_session::restore_physical_alt_screen(&mut stdout()) {
|
||||
let mut first_error = screen_session::restore_physical_alt_screen(&mut stdout()).err();
|
||||
if let Err(err) = restore_common(RawModeRestore::Disable, KeyboardRestore::ResetAfterExit) {
|
||||
first_error.get_or_insert(err);
|
||||
}
|
||||
if let Err(err) = terminal_stderr::finish() {
|
||||
@@ -518,6 +517,8 @@ pub enum TuiEvent {
|
||||
Key(KeyEvent),
|
||||
/// A bracketed paste payload normalized by the app layer before it reaches the composer.
|
||||
Paste(String),
|
||||
/// A vertical mouse-wheel event reported while an owned screen has mouse capture.
|
||||
MouseScroll(MouseScrollEvent),
|
||||
/// A terminal size notification that should be handled as resize-sensitive draw work.
|
||||
///
|
||||
/// Resize is separate from `Draw` so the app can run feature-gated pre-render logic without
|
||||
@@ -527,6 +528,19 @@ pub enum TuiEvent {
|
||||
Draw,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct MouseScrollEvent {
|
||||
pub direction: MouseScrollDirection,
|
||||
pub column: u16,
|
||||
pub row: u16,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MouseScrollDirection {
|
||||
Up,
|
||||
Down,
|
||||
}
|
||||
|
||||
pub struct Tui {
|
||||
frame_requester: FrameRequester,
|
||||
draw_tx: broadcast::Sender<()>,
|
||||
@@ -739,6 +753,11 @@ impl Tui {
|
||||
self.screen_session.enter(&mut self.terminal)
|
||||
}
|
||||
|
||||
/// Enter an application-owned alternate screen with direct mouse input.
|
||||
pub fn enter_owned_alt_screen(&mut self) -> Result<()> {
|
||||
self.screen_session.enter_owned(&mut self.terminal)
|
||||
}
|
||||
|
||||
/// Leave alternate screen and restore the previously saved inline viewport, if any.
|
||||
pub fn leave_alt_screen(&mut self) -> Result<()> {
|
||||
self.screen_session.leave(&mut self.terminal)
|
||||
|
||||
@@ -26,6 +26,7 @@ use std::task::Context;
|
||||
use std::task::Poll;
|
||||
|
||||
use crossterm::event::Event;
|
||||
use crossterm::event::MouseEventKind;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::watch;
|
||||
use tokio_stream::Stream;
|
||||
@@ -33,10 +34,14 @@ use tokio_stream::wrappers::BroadcastStream;
|
||||
use tokio_stream::wrappers::WatchStream;
|
||||
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
|
||||
|
||||
use super::MouseScrollDirection;
|
||||
use super::MouseScrollEvent;
|
||||
use super::TuiEvent;
|
||||
#[cfg(unix)]
|
||||
use super::screen_session::ScreenSession;
|
||||
|
||||
const MAX_SKIPPED_EVENTS_PER_POLL: usize = 32;
|
||||
|
||||
/// Result type produced by an event source.
|
||||
pub type EventResult = std::io::Result<Event>;
|
||||
|
||||
@@ -174,13 +179,13 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
|
||||
|
||||
/// Poll the shared crossterm stream for the next mapped `TuiEvent`.
|
||||
///
|
||||
/// This skips events we don't use (mouse events, etc.) and keeps polling until it yields
|
||||
/// This skips events we don't use (mouse clicks, focus loss, etc.) and keeps polling until it yields
|
||||
/// a mapped event, hits `Pending`, or sees EOF/error. When the broker is paused, it drops
|
||||
/// the underlying stream and returns `Pending` to fully release stdin.
|
||||
pub fn poll_crossterm_event(&mut self, cx: &mut Context<'_>) -> Poll<Option<TuiEvent>> {
|
||||
// Some crossterm events map to None (e.g. FocusLost, mouse); loop so we keep polling
|
||||
// Some crossterm events map to None (e.g. FocusLost, mouse clicks); loop so we keep polling
|
||||
// until we return a mapped event, hit Pending, or see EOF/error.
|
||||
loop {
|
||||
for _ in 0..MAX_SKIPPED_EVENTS_PER_POLL {
|
||||
let poll_result = {
|
||||
let mut state = self
|
||||
.broker
|
||||
@@ -221,6 +226,8 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
|
||||
return Poll::Ready(Some(mapped));
|
||||
}
|
||||
}
|
||||
cx.waker().wake_by_ref();
|
||||
Poll::Pending
|
||||
}
|
||||
|
||||
/// Poll the draw broadcast stream for the next draw event. Draw events are used to trigger a redraw of the TUI.
|
||||
@@ -235,7 +242,7 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a crossterm event to a [`TuiEvent`], skipping events we don't use (mouse events, etc.).
|
||||
/// Map a crossterm event to a [`TuiEvent`], skipping events we don't use.
|
||||
fn map_crossterm_event(&mut self, event: Event) -> Option<TuiEvent> {
|
||||
match event {
|
||||
Event::Key(key_event) => {
|
||||
@@ -257,6 +264,23 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
|
||||
}
|
||||
Event::Resize(_, _) => Some(TuiEvent::Resize),
|
||||
Event::Paste(pasted) => Some(TuiEvent::Paste(pasted)),
|
||||
Event::Mouse(mouse_event) => {
|
||||
let direction = match mouse_event.kind {
|
||||
MouseEventKind::ScrollUp => MouseScrollDirection::Up,
|
||||
MouseEventKind::ScrollDown => MouseScrollDirection::Down,
|
||||
MouseEventKind::Down(_)
|
||||
| MouseEventKind::Up(_)
|
||||
| MouseEventKind::Drag(_)
|
||||
| MouseEventKind::Moved
|
||||
| MouseEventKind::ScrollLeft
|
||||
| MouseEventKind::ScrollRight => return None,
|
||||
};
|
||||
Some(TuiEvent::MouseScroll(MouseScrollEvent {
|
||||
direction,
|
||||
column: mouse_event.column,
|
||||
row: mouse_event.row,
|
||||
}))
|
||||
}
|
||||
Event::FocusGained => {
|
||||
self.terminal_focused.store(true, Ordering::Relaxed);
|
||||
crate::terminal_palette::requery_default_colors();
|
||||
@@ -266,7 +290,6 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
|
||||
self.terminal_focused.store(false, Ordering::Relaxed);
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -308,6 +331,8 @@ mod tests {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use crossterm::event::MouseButton;
|
||||
use crossterm::event::MouseEvent;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::task::Context;
|
||||
use std::task::Poll;
|
||||
@@ -473,6 +498,62 @@ mod tests {
|
||||
assert!(matches!(next, Some(TuiEvent::Resize)));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn vertical_mouse_wheel_maps_to_scroll_and_ignored_events_yield_to_draws() {
|
||||
let (broker, handle, draw_tx, draw_rx, terminal_focused) = setup();
|
||||
let mut stream = make_stream(broker, draw_rx, terminal_focused);
|
||||
stream.poll_draw_first = false;
|
||||
|
||||
handle.send(Ok(Event::Mouse(MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: 3,
|
||||
row: 4,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
})));
|
||||
for column in 0..=MAX_SKIPPED_EVENTS_PER_POLL {
|
||||
handle.send(Ok(Event::Mouse(MouseEvent {
|
||||
kind: MouseEventKind::Moved,
|
||||
column: column as u16,
|
||||
row: 0,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
})));
|
||||
}
|
||||
handle.send(Ok(Event::Mouse(MouseEvent {
|
||||
kind: MouseEventKind::ScrollUp,
|
||||
column: 5,
|
||||
row: 6,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
})));
|
||||
handle.send(Ok(Event::Mouse(MouseEvent {
|
||||
kind: MouseEventKind::ScrollDown,
|
||||
column: 7,
|
||||
row: 8,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
})));
|
||||
draw_tx.send(()).expect("send draw");
|
||||
|
||||
let first = stream.next().await;
|
||||
let second = stream.next().await;
|
||||
let third = stream.next().await;
|
||||
assert!(matches!(first, Some(TuiEvent::Draw)));
|
||||
assert!(matches!(
|
||||
second,
|
||||
Some(TuiEvent::MouseScroll(MouseScrollEvent {
|
||||
direction: MouseScrollDirection::Up,
|
||||
column: 5,
|
||||
row: 6,
|
||||
}))
|
||||
));
|
||||
assert!(matches!(
|
||||
third,
|
||||
Some(TuiEvent::MouseScroll(MouseScrollEvent {
|
||||
direction: MouseScrollDirection::Down,
|
||||
column: 7,
|
||||
row: 8,
|
||||
}))
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn error_or_eof_ends_stream() {
|
||||
let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup();
|
||||
|
||||
@@ -9,6 +9,8 @@ use std::sync::PoisonError;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use crossterm::event::DisableMouseCapture;
|
||||
use crossterm::event::EnableMouseCapture;
|
||||
use crossterm::terminal::EnterAlternateScreen;
|
||||
use crossterm::terminal::LeaveAlternateScreen;
|
||||
use ratatui::crossterm::execute;
|
||||
@@ -23,34 +25,75 @@ static PHYSICAL_ALT_SCREEN: PhysicalAltScreenTracker = PhysicalAltScreenTracker:
|
||||
|
||||
struct PhysicalAltScreenTracker {
|
||||
active: AtomicBool,
|
||||
alternate_scroll_active: AtomicBool,
|
||||
mouse_capture_active: AtomicBool,
|
||||
}
|
||||
|
||||
impl PhysicalAltScreenTracker {
|
||||
const fn new() -> Self {
|
||||
Self {
|
||||
active: AtomicBool::new(false),
|
||||
active: AtomicBool::new(/*v*/ false),
|
||||
alternate_scroll_active: AtomicBool::new(/*v*/ false),
|
||||
mouse_capture_active: AtomicBool::new(/*v*/ false),
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_entered(&self) {
|
||||
self.active.store(true, Ordering::Release);
|
||||
self.active.store(/*val*/ true, Ordering::Release);
|
||||
}
|
||||
|
||||
fn mark_left(&self) {
|
||||
self.active.store(false, Ordering::Release);
|
||||
self.active.store(/*val*/ false, Ordering::Release);
|
||||
}
|
||||
|
||||
fn mark_alternate_scroll_enabled(&self) {
|
||||
self.alternate_scroll_active
|
||||
.store(/*val*/ true, Ordering::Release);
|
||||
}
|
||||
|
||||
fn mark_alternate_scroll_disabled(&self) {
|
||||
self.alternate_scroll_active
|
||||
.store(/*val*/ false, Ordering::Release);
|
||||
}
|
||||
|
||||
fn mark_mouse_capture_enabled(&self) {
|
||||
self.mouse_capture_active
|
||||
.store(/*val*/ true, Ordering::Release);
|
||||
}
|
||||
|
||||
fn mark_mouse_capture_disabled(&self) {
|
||||
self.mouse_capture_active
|
||||
.store(/*val*/ false, Ordering::Release);
|
||||
}
|
||||
|
||||
fn restore(&self, writer: &mut impl Write) -> io::Result<()> {
|
||||
if !self.active.load(Ordering::Acquire) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let disable_result = execute!(writer, DisableAlternateScroll);
|
||||
let leave_result = execute!(writer, LeaveAlternateScreen);
|
||||
let mouse_result = if self.mouse_capture_active.load(Ordering::Acquire) {
|
||||
let result = execute!(writer, DisableMouseCapture);
|
||||
if result.is_ok() {
|
||||
self.mark_mouse_capture_disabled();
|
||||
}
|
||||
result
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
let scroll_result = if self.alternate_scroll_active.load(Ordering::Acquire) {
|
||||
let result = execute!(writer, DisableAlternateScroll);
|
||||
if result.is_ok() {
|
||||
self.mark_alternate_scroll_disabled();
|
||||
}
|
||||
result
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
let leave_result = if self.active.load(Ordering::Acquire) {
|
||||
execute!(writer, LeaveAlternateScreen)
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
if leave_result.is_ok() {
|
||||
self.mark_left();
|
||||
}
|
||||
merge_results(disable_result, leave_result)
|
||||
merge_results(mouse_result, merge_results(scroll_result, leave_result))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +120,7 @@ enum ScreenOwnership {
|
||||
owners: NonZeroUsize,
|
||||
saved_viewport: Rect,
|
||||
physical: PhysicalAltScreen,
|
||||
input_mode: AltScreenInputMode,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -86,6 +130,28 @@ enum PhysicalAltScreen {
|
||||
Suspended,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum AltScreenInputMode {
|
||||
AlternateScroll,
|
||||
MouseCapture,
|
||||
}
|
||||
|
||||
impl AltScreenInputMode {
|
||||
fn enable(self, commands: &mut impl ScreenCommands) -> io::Result<()> {
|
||||
match self {
|
||||
Self::AlternateScroll => commands.enable_alternate_scroll(),
|
||||
Self::MouseCapture => commands.enable_mouse_capture(),
|
||||
}
|
||||
}
|
||||
|
||||
fn disable(self, commands: &mut impl ScreenCommands) -> io::Result<()> {
|
||||
match self {
|
||||
Self::AlternateScroll => commands.disable_alternate_scroll(),
|
||||
Self::MouseCapture => commands.disable_mouse_capture(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ScreenSession {
|
||||
pub(super) fn new() -> Self {
|
||||
Self {
|
||||
@@ -131,9 +197,21 @@ impl ScreenSession {
|
||||
}
|
||||
|
||||
pub(super) fn enter(&self, terminal: &mut Terminal) -> io::Result<()> {
|
||||
self.enter_with_input_mode(terminal, AltScreenInputMode::AlternateScroll)
|
||||
}
|
||||
|
||||
pub(super) fn enter_owned(&self, terminal: &mut Terminal) -> io::Result<()> {
|
||||
self.enter_with_input_mode(terminal, AltScreenInputMode::MouseCapture)
|
||||
}
|
||||
|
||||
fn enter_with_input_mode(
|
||||
&self,
|
||||
terminal: &mut Terminal,
|
||||
input_mode: AltScreenInputMode,
|
||||
) -> io::Result<()> {
|
||||
let was_active = self.is_active();
|
||||
let saved_viewport = terminal.viewport_area;
|
||||
let command_result = self.acquire(terminal, saved_viewport);
|
||||
let command_result = self.acquire(terminal, saved_viewport, input_mode);
|
||||
let layout_result = if !was_active && self.is_active() {
|
||||
expand_to_full_screen(terminal)
|
||||
} else {
|
||||
@@ -193,7 +271,12 @@ impl ScreenSession {
|
||||
merge_results(command_result, layout_result)
|
||||
}
|
||||
|
||||
fn acquire(&self, commands: &mut impl ScreenCommands, saved_viewport: Rect) -> io::Result<()> {
|
||||
fn acquire(
|
||||
&self,
|
||||
commands: &mut impl ScreenCommands,
|
||||
saved_viewport: Rect,
|
||||
input_mode: AltScreenInputMode,
|
||||
) -> io::Result<()> {
|
||||
let mut inner = self.lock();
|
||||
if inner.availability == AltScreenAvailability::Disabled {
|
||||
return Ok(());
|
||||
@@ -205,9 +288,10 @@ impl ScreenSession {
|
||||
owners: NonZeroUsize::MIN,
|
||||
saved_viewport,
|
||||
physical: PhysicalAltScreen::Active,
|
||||
input_mode,
|
||||
};
|
||||
if let Err(enable_error) = commands.enable_alternate_scroll() {
|
||||
let disable_result = commands.disable_alternate_scroll();
|
||||
if let Err(enable_error) = input_mode.enable(commands) {
|
||||
let disable_result = input_mode.disable(commands);
|
||||
let leave_result = commands.leave_alternate_screen();
|
||||
if leave_result.is_ok() {
|
||||
inner.ownership = ScreenOwnership::Inline;
|
||||
@@ -234,7 +318,10 @@ impl ScreenSession {
|
||||
fn release(&self, commands: &mut impl ScreenCommands) -> io::Result<()> {
|
||||
let mut inner = self.lock();
|
||||
let ScreenOwnership::Alternate {
|
||||
owners, physical, ..
|
||||
owners,
|
||||
physical,
|
||||
input_mode,
|
||||
..
|
||||
} = &mut inner.ownership
|
||||
else {
|
||||
return Ok(());
|
||||
@@ -250,7 +337,7 @@ impl ScreenSession {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let disable_result = commands.disable_alternate_scroll();
|
||||
let disable_result = input_mode.disable(commands);
|
||||
let leave_result = commands.leave_alternate_screen();
|
||||
if leave_result.is_ok() {
|
||||
inner.ownership = ScreenOwnership::Inline;
|
||||
@@ -260,7 +347,12 @@ impl ScreenSession {
|
||||
|
||||
fn finish_commands(&self, commands: &mut impl ScreenCommands) -> io::Result<()> {
|
||||
let mut inner = self.lock();
|
||||
let ScreenOwnership::Alternate { physical, .. } = inner.ownership else {
|
||||
let ScreenOwnership::Alternate {
|
||||
physical,
|
||||
input_mode,
|
||||
..
|
||||
} = inner.ownership
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
if physical == PhysicalAltScreen::Suspended {
|
||||
@@ -268,7 +360,7 @@ impl ScreenSession {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let disable_result = commands.disable_alternate_scroll();
|
||||
let disable_result = input_mode.disable(commands);
|
||||
let leave_result = commands.leave_alternate_screen();
|
||||
if leave_result.is_ok() {
|
||||
inner.ownership = ScreenOwnership::Inline;
|
||||
@@ -278,13 +370,18 @@ impl ScreenSession {
|
||||
|
||||
fn suspend_commands(&self, commands: &mut impl ScreenCommands) -> io::Result<()> {
|
||||
let mut inner = self.lock();
|
||||
let ScreenOwnership::Alternate { physical, .. } = &mut inner.ownership else {
|
||||
let ScreenOwnership::Alternate {
|
||||
physical,
|
||||
input_mode,
|
||||
..
|
||||
} = &mut inner.ownership
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
if *physical == PhysicalAltScreen::Suspended {
|
||||
return Ok(());
|
||||
}
|
||||
let disable_result = commands.disable_alternate_scroll();
|
||||
let disable_result = input_mode.disable(commands);
|
||||
let leave_result = commands.leave_alternate_screen();
|
||||
if leave_result.is_ok() {
|
||||
*physical = PhysicalAltScreen::Suspended;
|
||||
@@ -294,7 +391,12 @@ impl ScreenSession {
|
||||
|
||||
fn resume_commands(&self, commands: &mut impl ScreenCommands) -> io::Result<()> {
|
||||
let mut inner = self.lock();
|
||||
let ScreenOwnership::Alternate { physical, .. } = &mut inner.ownership else {
|
||||
let ScreenOwnership::Alternate {
|
||||
physical,
|
||||
input_mode,
|
||||
..
|
||||
} = &mut inner.ownership
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
if *physical == PhysicalAltScreen::Active {
|
||||
@@ -302,8 +404,8 @@ impl ScreenSession {
|
||||
}
|
||||
commands.enter_alternate_screen()?;
|
||||
*physical = PhysicalAltScreen::Active;
|
||||
if let Err(enable_error) = commands.enable_alternate_scroll() {
|
||||
let disable_result = commands.disable_alternate_scroll();
|
||||
if let Err(enable_error) = input_mode.enable(commands) {
|
||||
let disable_result = input_mode.disable(commands);
|
||||
let leave_result = commands.leave_alternate_screen();
|
||||
if leave_result.is_ok() {
|
||||
*physical = PhysicalAltScreen::Suspended;
|
||||
@@ -327,6 +429,8 @@ trait ScreenCommands {
|
||||
fn leave_alternate_screen(&mut self) -> io::Result<()>;
|
||||
fn enable_alternate_scroll(&mut self) -> io::Result<()>;
|
||||
fn disable_alternate_scroll(&mut self) -> io::Result<()>;
|
||||
fn enable_mouse_capture(&mut self) -> io::Result<()>;
|
||||
fn disable_mouse_capture(&mut self) -> io::Result<()>;
|
||||
}
|
||||
|
||||
pub(super) fn restore_physical_alt_screen(writer: &mut impl Write) -> io::Result<()> {
|
||||
@@ -351,11 +455,35 @@ impl ScreenCommands for Terminal {
|
||||
}
|
||||
|
||||
fn enable_alternate_scroll(&mut self) -> io::Result<()> {
|
||||
execute!(self.backend_mut(), EnableAlternateScroll)
|
||||
let result = execute!(self.backend_mut(), EnableAlternateScroll);
|
||||
if result.is_ok() {
|
||||
PHYSICAL_ALT_SCREEN.mark_alternate_scroll_enabled();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn disable_alternate_scroll(&mut self) -> io::Result<()> {
|
||||
execute!(self.backend_mut(), DisableAlternateScroll)
|
||||
let result = execute!(self.backend_mut(), DisableAlternateScroll);
|
||||
if result.is_ok() {
|
||||
PHYSICAL_ALT_SCREEN.mark_alternate_scroll_disabled();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn enable_mouse_capture(&mut self) -> io::Result<()> {
|
||||
let result = execute!(self.backend_mut(), EnableMouseCapture);
|
||||
if result.is_ok() {
|
||||
PHYSICAL_ALT_SCREEN.mark_mouse_capture_enabled();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn disable_mouse_capture(&mut self) -> io::Result<()> {
|
||||
let result = execute!(self.backend_mut(), DisableMouseCapture);
|
||||
if result.is_ok() {
|
||||
PHYSICAL_ALT_SCREEN.mark_mouse_capture_disabled();
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,11 +507,35 @@ impl<W: Write> ScreenCommands for WriterCommands<'_, W> {
|
||||
}
|
||||
|
||||
fn enable_alternate_scroll(&mut self) -> io::Result<()> {
|
||||
execute!(self.0, EnableAlternateScroll)
|
||||
let result = execute!(self.0, EnableAlternateScroll);
|
||||
if result.is_ok() {
|
||||
PHYSICAL_ALT_SCREEN.mark_alternate_scroll_enabled();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn disable_alternate_scroll(&mut self) -> io::Result<()> {
|
||||
execute!(self.0, DisableAlternateScroll)
|
||||
let result = execute!(self.0, DisableAlternateScroll);
|
||||
if result.is_ok() {
|
||||
PHYSICAL_ALT_SCREEN.mark_alternate_scroll_disabled();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn enable_mouse_capture(&mut self) -> io::Result<()> {
|
||||
let result = execute!(self.0, EnableMouseCapture);
|
||||
if result.is_ok() {
|
||||
PHYSICAL_ALT_SCREEN.mark_mouse_capture_enabled();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn disable_mouse_capture(&mut self) -> io::Result<()> {
|
||||
let result = execute!(self.0, DisableMouseCapture);
|
||||
if result.is_ok() {
|
||||
PHYSICAL_ALT_SCREEN.mark_mouse_capture_disabled();
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::io;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
use super::AltScreenInputMode;
|
||||
use super::PhysicalAltScreenTracker;
|
||||
use super::ScreenCommands;
|
||||
use super::ScreenSession;
|
||||
@@ -13,6 +14,8 @@ enum Effect {
|
||||
Leave,
|
||||
EnableScroll,
|
||||
DisableScroll,
|
||||
EnableMouse,
|
||||
DisableMouse,
|
||||
}
|
||||
|
||||
const ENTER_AND_LEAVE: &[Effect] = &[
|
||||
@@ -22,6 +25,12 @@ const ENTER_AND_LEAVE: &[Effect] = &[
|
||||
Effect::Leave,
|
||||
];
|
||||
const LEAVE: &[Effect] = &[Effect::DisableScroll, Effect::Leave];
|
||||
const OWNED_ENTER_AND_LEAVE: &[Effect] = &[
|
||||
Effect::Enter,
|
||||
Effect::EnableMouse,
|
||||
Effect::DisableMouse,
|
||||
Effect::Leave,
|
||||
];
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeCommands {
|
||||
@@ -63,6 +72,14 @@ impl ScreenCommands for FakeCommands {
|
||||
fn disable_alternate_scroll(&mut self) -> io::Result<()> {
|
||||
self.record(Effect::DisableScroll)
|
||||
}
|
||||
|
||||
fn enable_mouse_capture(&mut self) -> io::Result<()> {
|
||||
self.record(Effect::EnableMouse)
|
||||
}
|
||||
|
||||
fn disable_mouse_capture(&mut self) -> io::Result<()> {
|
||||
self.record(Effect::DisableMouse)
|
||||
}
|
||||
}
|
||||
|
||||
fn acquire(session: &ScreenSession, commands: &mut FakeCommands) {
|
||||
@@ -75,10 +92,17 @@ fn acquire(session: &ScreenSession, commands: &mut FakeCommands) {
|
||||
width: 80,
|
||||
height: 12,
|
||||
},
|
||||
AltScreenInputMode::AlternateScroll,
|
||||
)
|
||||
.expect("acquire");
|
||||
}
|
||||
|
||||
fn acquire_owned(session: &ScreenSession, commands: &mut FakeCommands) {
|
||||
session
|
||||
.acquire(commands, Rect::default(), AltScreenInputMode::MouseCapture)
|
||||
.expect("acquire owned");
|
||||
}
|
||||
|
||||
fn assert_effects(commands: &FakeCommands, expected: &[Effect]) {
|
||||
assert_eq!(commands.effects, expected);
|
||||
}
|
||||
@@ -116,6 +140,20 @@ fn finish_clears_all_nested_owners_with_one_physical_transition() {
|
||||
assert_effects(&commands, LEAVE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_root_keeps_mouse_capture_across_nested_overlay_ownership() {
|
||||
let session = ScreenSession::new();
|
||||
let mut commands = FakeCommands::default();
|
||||
acquire_owned(&session, &mut commands);
|
||||
acquire(&session, &mut commands);
|
||||
|
||||
session.release(&mut commands).expect("nested release");
|
||||
session.release(&mut commands).expect("owned release");
|
||||
|
||||
assert!(!session.is_active());
|
||||
assert_effects(&commands, OWNED_ENTER_AND_LEAVE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emergency_restore_only_leaves_a_physically_owned_screen() {
|
||||
let tracker = PhysicalAltScreenTracker::new();
|
||||
@@ -126,6 +164,7 @@ fn emergency_restore_only_leaves_a_physically_owned_screen() {
|
||||
assert_eq!(inactive_output, Vec::<u8>::new());
|
||||
|
||||
tracker.mark_entered();
|
||||
tracker.mark_alternate_scroll_enabled();
|
||||
|
||||
let mut restore_output = Vec::new();
|
||||
tracker
|
||||
@@ -146,6 +185,27 @@ fn emergency_restore_only_leaves_a_physically_owned_screen() {
|
||||
assert_eq!(restore_output, expected_output);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn emergency_restore_disables_tracked_mouse_capture() {
|
||||
let tracker = PhysicalAltScreenTracker::new();
|
||||
tracker.mark_entered();
|
||||
tracker.mark_mouse_capture_enabled();
|
||||
|
||||
let mut restore_output = Vec::new();
|
||||
tracker
|
||||
.restore(&mut restore_output)
|
||||
.expect("restore mouse-captured screen");
|
||||
let mut expected_output = Vec::new();
|
||||
ratatui::crossterm::execute!(
|
||||
expected_output,
|
||||
crossterm::event::DisableMouseCapture,
|
||||
crossterm::terminal::LeaveAlternateScreen
|
||||
)
|
||||
.expect("write expected restore sequence");
|
||||
assert_eq!(restore_output, expected_output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporary_suspend_preserves_nested_ownership_until_resume() {
|
||||
let session = ScreenSession::new();
|
||||
@@ -180,6 +240,30 @@ fn temporary_suspend_preserves_nested_ownership_until_resume() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_suspend_and_resume_restore_mouse_capture() {
|
||||
let session = ScreenSession::new();
|
||||
let mut commands = FakeCommands::default();
|
||||
acquire_owned(&session, &mut commands);
|
||||
commands.effects.clear();
|
||||
|
||||
session.suspend_commands(&mut commands).expect("suspend");
|
||||
session.resume_commands(&mut commands).expect("resume");
|
||||
session.release(&mut commands).expect("release");
|
||||
|
||||
assert_effects(
|
||||
&commands,
|
||||
&[
|
||||
Effect::DisableMouse,
|
||||
Effect::Leave,
|
||||
Effect::Enter,
|
||||
Effect::EnableMouse,
|
||||
Effect::DisableMouse,
|
||||
Effect::Leave,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_session_ignores_ownership_requests() {
|
||||
let session = ScreenSession::new();
|
||||
@@ -199,13 +283,29 @@ fn disabled_session_ignores_ownership_requests() {
|
||||
fn partial_acquire_rolls_back_or_retains_ownership_for_retry() {
|
||||
let session = ScreenSession::new();
|
||||
let mut commands = FakeCommands::failing(vec![Effect::EnableScroll]);
|
||||
assert!(session.acquire(&mut commands, Rect::default()).is_err());
|
||||
assert!(
|
||||
session
|
||||
.acquire(
|
||||
&mut commands,
|
||||
Rect::default(),
|
||||
AltScreenInputMode::AlternateScroll,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(!session.is_active());
|
||||
assert_effects(&commands, ENTER_AND_LEAVE);
|
||||
|
||||
let session = ScreenSession::new();
|
||||
let mut commands = FakeCommands::failing(vec![Effect::EnableScroll, Effect::Leave]);
|
||||
assert!(session.acquire(&mut commands, Rect::default()).is_err());
|
||||
assert!(
|
||||
session
|
||||
.acquire(
|
||||
&mut commands,
|
||||
Rect::default(),
|
||||
AltScreenInputMode::AlternateScroll,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(session.is_active());
|
||||
commands.effects.clear();
|
||||
session.release(&mut commands).expect("retry cleanup");
|
||||
@@ -213,6 +313,24 @@ fn partial_acquire_rolls_back_or_retains_ownership_for_retry() {
|
||||
assert_effects(&commands, LEAVE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_owned_acquire_disables_mouse_before_leaving() {
|
||||
let session = ScreenSession::new();
|
||||
let mut commands = FakeCommands::failing(vec![Effect::EnableMouse]);
|
||||
|
||||
assert!(
|
||||
session
|
||||
.acquire(
|
||||
&mut commands,
|
||||
Rect::default(),
|
||||
AltScreenInputMode::MouseCapture,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(!session.is_active());
|
||||
assert_effects(&commands, OWNED_ENTER_AND_LEAVE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_attempts_all_cleanup_and_retains_ownership_when_leave_fails() {
|
||||
let session = ScreenSession::new();
|
||||
|
||||
@@ -58,7 +58,7 @@ pub(crate) async fn run_update_prompt_if_needed(
|
||||
if let Some(event) = events.next().await {
|
||||
match event {
|
||||
TuiEvent::Key(key_event) => screen.handle_key(key_event),
|
||||
TuiEvent::Paste(_) => {}
|
||||
TuiEvent::Paste(_) | TuiEvent::MouseScroll(_) => {}
|
||||
TuiEvent::Draw | TuiEvent::Resize => {
|
||||
tui.draw(u16::MAX, |frame| {
|
||||
frame.render_widget_ref(&screen, frame.area());
|
||||
|
||||
Reference in New Issue
Block a user