feat(tui): add owned screen wheel scrolling

This commit is contained in:
Felipe Coury
2026-07-04 13:24:36 -03:00
parent df5fdcf5bf
commit bcafbd142c
18 changed files with 716 additions and 63 deletions
+15
View File
@@ -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)?;
+32
View File
@@ -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() {
+1 -1
View File
@@ -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());
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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| {
+136 -20
View File
@@ -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(
+1
View File
@@ -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 =
+1 -1
View File
@@ -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
View File
@@ -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)
+86 -5
View File
@@ -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();
+179 -27
View File
@@ -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
}
}
+120 -2
View File
@@ -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();
+1 -1
View File
@@ -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());