mirror of
https://github.com/openai/codex.git
synced 2026-11-03 19:53:25 +00:00
feat(tui): focus owned panes on click
This commit is contained in:
@@ -1303,6 +1303,9 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
.await;
|
||||
}
|
||||
}
|
||||
TuiEvent::MousePrimaryPress(event) => {
|
||||
self.handle_owned_screen_mouse_primary_press(tui, event);
|
||||
}
|
||||
TuiEvent::Draw | TuiEvent::Resize => {
|
||||
if self.backtrack_render_pending {
|
||||
self.rebuild_transcript_after_backtrack(tui)?;
|
||||
|
||||
@@ -102,9 +102,6 @@ impl App {
|
||||
key_event: KeyEvent,
|
||||
) {
|
||||
if self.handle_conversation_pane_focus_key(key_event) {
|
||||
if self.backtrack.primed {
|
||||
self.reset_backtrack_state();
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
return;
|
||||
}
|
||||
@@ -276,13 +273,26 @@ impl App {
|
||||
};
|
||||
}
|
||||
|
||||
fn handle_conversation_pane_focus_key(&mut self, key_event: KeyEvent) -> bool {
|
||||
pub(super) fn focus_conversation_pane(&mut self, target: PaneSlot) -> bool {
|
||||
if self.overlay.is_some()
|
||||
|| self.chat_widget.by_slot(PaneSlot::Side).is_none()
|
||||
|| !self.chat_widget.no_modal_or_popup_active()
|
||||
|| self.chat_widget.by_slot(target).is_none()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if self.backtrack.primed {
|
||||
self.reset_backtrack_state();
|
||||
}
|
||||
if self.chat_widget.focused_slot() == target {
|
||||
return true;
|
||||
}
|
||||
self.chat_widget.focus(target)
|
||||
}
|
||||
|
||||
fn handle_conversation_pane_focus_key(&mut self, key_event: KeyEvent) -> bool {
|
||||
if self.chat_widget.by_slot(PaneSlot::Side).is_none() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let composer_is_empty = self.chat_widget.composer_text_with_pending().is_empty();
|
||||
let allow_word_motion_fallback = !self.enhanced_keys_supported && composer_is_empty;
|
||||
@@ -299,7 +309,7 @@ impl App {
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
self.chat_widget.focus(target)
|
||||
self.focus_conversation_pane(target)
|
||||
}
|
||||
|
||||
pub(super) fn should_handle_backtrack_esc(&self, key_event: KeyEvent) -> bool {
|
||||
@@ -395,6 +405,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pane_focus_key_clears_primed_backtrack_before_switching() {
|
||||
let mut app = make_test_app().await;
|
||||
install_side_pane(&mut app).await;
|
||||
app.backtrack.primed = true;
|
||||
app.chat_widget.show_esc_backtrack_hint();
|
||||
|
||||
assert!(app.handle_conversation_pane_focus_key(KeyEvent::new(
|
||||
KeyCode::Char('2'),
|
||||
KeyModifiers::ALT,
|
||||
)));
|
||||
|
||||
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Side);
|
||||
assert!(!app.backtrack.primed);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn pane_focus_does_not_steal_word_motion_from_nonempty_draft() {
|
||||
|
||||
@@ -21,6 +21,7 @@ use ratatui::widgets::Widget;
|
||||
use super::*;
|
||||
use crate::AltScreenBehavior;
|
||||
use crate::key_hint::is_plain_text_key_event;
|
||||
use crate::tui::MousePrimaryPressEvent;
|
||||
use crate::tui::MouseScrollEvent;
|
||||
|
||||
const MIN_SPLIT_PANE_WIDTH: u16 = 41;
|
||||
@@ -31,6 +32,7 @@ const PANE_HEADER_HEIGHT: u16 = 1;
|
||||
pub(super) struct OwnedScreen {
|
||||
viewport: ConversationViewport,
|
||||
replay_in_progress: bool,
|
||||
last_pane_area: Rect,
|
||||
last_conversation_area: Rect,
|
||||
}
|
||||
|
||||
@@ -101,6 +103,7 @@ impl OwnedScreen {
|
||||
keymap,
|
||||
),
|
||||
replay_in_progress: false,
|
||||
last_pane_area: Rect::default(),
|
||||
last_conversation_area: Rect::default(),
|
||||
}
|
||||
}
|
||||
@@ -177,7 +180,8 @@ impl OwnedScreen {
|
||||
true
|
||||
}
|
||||
|
||||
fn clear_last_conversation_area(&mut self) {
|
||||
fn clear_last_render_areas(&mut self) {
|
||||
self.last_pane_area = Rect::default();
|
||||
self.last_conversation_area = Rect::default();
|
||||
}
|
||||
}
|
||||
@@ -251,6 +255,7 @@ fn render_pane(
|
||||
let pane = panes.by_slot_mut(slot)?;
|
||||
pane.chat_widget.update_owned_screen_width(body_area.width);
|
||||
let screen = pane.owned_screen.as_mut()?;
|
||||
screen.last_pane_area = area;
|
||||
Some(screen.render(&pane.chat_widget, body_area, buffer))
|
||||
}
|
||||
|
||||
@@ -266,7 +271,7 @@ fn render_layout(
|
||||
.by_slot_mut(slot)
|
||||
.and_then(|pane| pane.owned_screen.as_mut())
|
||||
{
|
||||
screen.clear_last_conversation_area();
|
||||
screen.clear_last_render_areas();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,6 +397,36 @@ impl App {
|
||||
false
|
||||
}
|
||||
|
||||
pub(super) fn handle_owned_screen_mouse_primary_press(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
event: MousePrimaryPressEvent,
|
||||
) -> bool {
|
||||
if !self.chat_widget.has_side() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let position = Position::new(event.column, event.row);
|
||||
let target = [PaneSlot::Parent, PaneSlot::Side].into_iter().find(|slot| {
|
||||
self.chat_widget
|
||||
.by_slot(*slot)
|
||||
.and_then(|pane| pane.owned_screen.as_ref())
|
||||
.is_some_and(|screen| screen.last_pane_area.contains(position))
|
||||
});
|
||||
let Some(target) = target else {
|
||||
return false;
|
||||
};
|
||||
let focus_changed = self.chat_widget.focused_slot() != target;
|
||||
let backtrack_was_primed = self.backtrack.primed;
|
||||
if !self.focus_conversation_pane(target) {
|
||||
return false;
|
||||
}
|
||||
if focus_changed || backtrack_was_primed {
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn sync_owned_screen_cells(&mut self) {
|
||||
let cells = self.chat_widget.transcript_cells.clone();
|
||||
if let Some(screen) = &mut self.chat_widget.owned_screen {
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::chatwidget::tests::constructor::make_chatwidget_for_pane;
|
||||
use crate::chatwidget::tests::constructor::make_chatwidget_for_pane_with_sender;
|
||||
use crate::chatwidget::tests::make_chatwidget_manual_with_sender;
|
||||
use crate::file_search::FileSearchManager;
|
||||
use crate::tui::MousePrimaryPressEvent;
|
||||
use crate::tui::MouseScrollDirection;
|
||||
use crate::tui::MouseScrollEvent;
|
||||
use codex_app_server_protocol::ConfigWarningNotification;
|
||||
@@ -362,6 +363,60 @@ async fn renders_closed_parent_read_only_while_side_remains_focused() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn primary_press_focuses_closed_parent_without_enabling_input() {
|
||||
let mut app = app_with_owned_side().await;
|
||||
seed_pane(&mut app, PaneSlot::Parent, "", &["parent transcript"]);
|
||||
seed_pane(&mut app, PaneSlot::Side, "side draft", &["side transcript"]);
|
||||
app.chat_widget
|
||||
.by_slot_mut(PaneSlot::Parent)
|
||||
.expect("parent pane")
|
||||
.mark_thread_closed();
|
||||
assert!(app.chat_widget.focus(PaneSlot::Side));
|
||||
let _terminal = render_app(&mut app, /*width*/ 83, /*height*/ 12);
|
||||
|
||||
let parent_area = app
|
||||
.chat_widget
|
||||
.by_slot(PaneSlot::Parent)
|
||||
.and_then(|pane| pane.owned_screen.as_ref())
|
||||
.expect("parent screen")
|
||||
.last_pane_area;
|
||||
let mut tui = crate::tui::test_support::make_test_tui().expect("create test TUI");
|
||||
assert!(app.handle_owned_screen_mouse_primary_press(
|
||||
&mut tui,
|
||||
MousePrimaryPressEvent {
|
||||
column: parent_area.x,
|
||||
row: parent_area.y,
|
||||
},
|
||||
));
|
||||
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent);
|
||||
let mut rendered_cursor = None;
|
||||
let mut terminal =
|
||||
Terminal::new(TestBackend::new(/*width*/ 83, /*height*/ 12)).expect("create terminal");
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
rendered_cursor = Some(
|
||||
render_layout(
|
||||
&mut app.chat_widget,
|
||||
OwnedScreenLayout::new(frame.area(), /*has_side*/ true, PaneSlot::Parent),
|
||||
PaneSlot::Parent,
|
||||
frame.buffer_mut(),
|
||||
)
|
||||
.expect("render closed parent")
|
||||
.cursor,
|
||||
);
|
||||
})
|
||||
.expect("render owned panes");
|
||||
assert_eq!(rendered_cursor, Some(None));
|
||||
assert_eq!(
|
||||
app.chat_widget
|
||||
.by_slot(PaneSlot::Side)
|
||||
.expect("side pane")
|
||||
.composer_text_with_pending(),
|
||||
"side draft"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn narrow_layout_renders_only_the_focused_side() {
|
||||
let mut app = app_with_owned_side().await;
|
||||
@@ -372,6 +427,7 @@ async fn narrow_layout_renders_only_the_focused_side() {
|
||||
&["PARENT MUST BE HIDDEN"],
|
||||
);
|
||||
seed_pane(&mut app, PaneSlot::Side, "side draft", &["side transcript"]);
|
||||
let _wide = render_app(&mut app, /*width*/ 83, /*height*/ 14);
|
||||
assert!(app.chat_widget.focus(PaneSlot::Side));
|
||||
|
||||
let terminal = render_app(&mut app, /*width*/ 82, /*height*/ 14);
|
||||
@@ -381,9 +437,16 @@ async fn narrow_layout_renders_only_the_focused_side() {
|
||||
app.chat_widget
|
||||
.by_slot(PaneSlot::Parent)
|
||||
.and_then(|pane| pane.owned_screen.as_ref())
|
||||
.map(|screen| screen.last_conversation_area),
|
||||
Some(Rect::default())
|
||||
.map(|screen| (screen.last_pane_area, screen.last_conversation_area)),
|
||||
Some((Rect::default(), Rect::default()))
|
||||
);
|
||||
|
||||
let mut tui = crate::tui::test_support::make_test_tui().expect("create test TUI");
|
||||
assert!(app.handle_owned_screen_mouse_primary_press(
|
||||
&mut tui,
|
||||
MousePrimaryPressEvent { column: 2, row: 2 },
|
||||
));
|
||||
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Side);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -402,6 +465,259 @@ async fn terminal_cursor_tracks_only_the_focused_pane() {
|
||||
assert!(side_cursor.x > 41);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn primary_press_focuses_visible_pane_regions() {
|
||||
let mut app = app_with_owned_side().await;
|
||||
seed_pane(
|
||||
&mut app,
|
||||
PaneSlot::Parent,
|
||||
"parent draft",
|
||||
&["parent transcript"],
|
||||
);
|
||||
seed_pane(&mut app, PaneSlot::Side, "side draft", &["side transcript"]);
|
||||
let _terminal = render_app(&mut app, /*width*/ 83, /*height*/ 16);
|
||||
let (parent_area, parent_conversation_area, side_area, side_conversation_area) = {
|
||||
let parent = app
|
||||
.chat_widget
|
||||
.by_slot(PaneSlot::Parent)
|
||||
.and_then(|pane| pane.owned_screen.as_ref())
|
||||
.expect("parent screen");
|
||||
let side = app
|
||||
.chat_widget
|
||||
.by_slot(PaneSlot::Side)
|
||||
.and_then(|pane| pane.owned_screen.as_ref())
|
||||
.expect("side screen");
|
||||
(
|
||||
parent.last_pane_area,
|
||||
parent.last_conversation_area,
|
||||
side.last_pane_area,
|
||||
side.last_conversation_area,
|
||||
)
|
||||
};
|
||||
let region_pairs = [
|
||||
((parent_area.x, parent_area.y), (side_area.x, side_area.y)),
|
||||
(
|
||||
(parent_conversation_area.x, parent_conversation_area.y),
|
||||
(side_conversation_area.x, side_conversation_area.y),
|
||||
),
|
||||
(
|
||||
(parent_area.x, parent_conversation_area.bottom()),
|
||||
(side_area.x, side_conversation_area.bottom()),
|
||||
),
|
||||
(
|
||||
(parent_area.x, parent_area.bottom() - 1),
|
||||
(side_area.x, side_area.bottom() - 1),
|
||||
),
|
||||
];
|
||||
let mut tui = crate::tui::test_support::make_test_tui().expect("create test TUI");
|
||||
|
||||
for (parent_position, side_position) in region_pairs {
|
||||
app.backtrack.primed = true;
|
||||
assert!(app.handle_owned_screen_mouse_primary_press(
|
||||
&mut tui,
|
||||
MousePrimaryPressEvent {
|
||||
column: side_position.0,
|
||||
row: side_position.1,
|
||||
},
|
||||
));
|
||||
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Side);
|
||||
assert!(!app.backtrack.primed);
|
||||
assert!(app.handle_owned_screen_mouse_primary_press(
|
||||
&mut tui,
|
||||
MousePrimaryPressEvent {
|
||||
column: parent_position.0,
|
||||
row: parent_position.1,
|
||||
},
|
||||
));
|
||||
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent);
|
||||
}
|
||||
|
||||
app.backtrack.primed = true;
|
||||
assert!(app.handle_owned_screen_mouse_primary_press(
|
||||
&mut tui,
|
||||
MousePrimaryPressEvent {
|
||||
column: parent_area.x,
|
||||
row: parent_area.y,
|
||||
},
|
||||
));
|
||||
assert!(!app.backtrack.primed);
|
||||
assert!(!app.handle_owned_screen_mouse_primary_press(
|
||||
&mut tui,
|
||||
MousePrimaryPressEvent {
|
||||
column: parent_area.right(),
|
||||
row: parent_area.y,
|
||||
},
|
||||
));
|
||||
assert!(!app.handle_owned_screen_mouse_primary_press(
|
||||
&mut tui,
|
||||
MousePrimaryPressEvent {
|
||||
column: side_area.right(),
|
||||
row: side_area.y,
|
||||
},
|
||||
));
|
||||
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn primary_press_preserves_pane_drafts_and_cursors() {
|
||||
let mut app = app_with_owned_side().await;
|
||||
seed_pane(
|
||||
&mut app,
|
||||
PaneSlot::Parent,
|
||||
"parent draft",
|
||||
&["parent transcript"],
|
||||
);
|
||||
seed_pane(&mut app, PaneSlot::Side, "side draft", &["side transcript"]);
|
||||
for (slot, cursor_moves) in [(PaneSlot::Parent, 1), (PaneSlot::Side, 2)] {
|
||||
for _ in 0..cursor_moves {
|
||||
app.chat_widget
|
||||
.by_slot_mut(slot)
|
||||
.expect("installed pane")
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
|
||||
}
|
||||
}
|
||||
|
||||
let mut parent_terminal = render_app(&mut app, /*width*/ 83, /*height*/ 16);
|
||||
let parent_cursor = parent_terminal
|
||||
.get_cursor_position()
|
||||
.expect("parent cursor");
|
||||
let (parent_area, parent_conversation_area, side_area, side_conversation_area) = {
|
||||
let parent = app
|
||||
.chat_widget
|
||||
.by_slot(PaneSlot::Parent)
|
||||
.and_then(|pane| pane.owned_screen.as_ref())
|
||||
.expect("parent screen");
|
||||
let side = app
|
||||
.chat_widget
|
||||
.by_slot(PaneSlot::Side)
|
||||
.and_then(|pane| pane.owned_screen.as_ref())
|
||||
.expect("side screen");
|
||||
(
|
||||
parent.last_pane_area,
|
||||
parent.last_conversation_area,
|
||||
side.last_pane_area,
|
||||
side.last_conversation_area,
|
||||
)
|
||||
};
|
||||
assert!(app.chat_widget.focus(PaneSlot::Side));
|
||||
let mut side_terminal_before_click =
|
||||
render_app(&mut app, /*width*/ 83, /*height*/ 16);
|
||||
let side_cursor_before_click = side_terminal_before_click
|
||||
.get_cursor_position()
|
||||
.expect("side cursor before click");
|
||||
assert!(app.chat_widget.focus(PaneSlot::Parent));
|
||||
let _parent_terminal = render_app(&mut app, /*width*/ 83, /*height*/ 16);
|
||||
let mut tui = crate::tui::test_support::make_test_tui().expect("create test TUI");
|
||||
|
||||
assert!(app.handle_owned_screen_mouse_primary_press(
|
||||
&mut tui,
|
||||
MousePrimaryPressEvent {
|
||||
column: side_area.x,
|
||||
row: side_conversation_area.bottom(),
|
||||
},
|
||||
));
|
||||
let mut side_terminal = render_app(&mut app, /*width*/ 83, /*height*/ 16);
|
||||
let side_cursor = side_terminal.get_cursor_position().expect("side cursor");
|
||||
assert_eq!(side_cursor, side_cursor_before_click);
|
||||
assert_snapshot!(
|
||||
"owned_screen_wide_split_side_focused_by_click",
|
||||
side_terminal.backend()
|
||||
);
|
||||
let buffer = side_terminal.backend().buffer();
|
||||
assert!(
|
||||
buffer[(parent_area.x + 1, parent_area.y)]
|
||||
.style()
|
||||
.add_modifier
|
||||
.contains(ratatui::style::Modifier::DIM)
|
||||
);
|
||||
assert!(
|
||||
buffer[(side_area.x + 1, side_area.y)]
|
||||
.style()
|
||||
.add_modifier
|
||||
.contains(ratatui::style::Modifier::BOLD)
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
app.chat_widget
|
||||
.by_slot(PaneSlot::Parent)
|
||||
.expect("parent pane")
|
||||
.composer_text_with_pending(),
|
||||
app.chat_widget
|
||||
.by_slot(PaneSlot::Side)
|
||||
.expect("side pane")
|
||||
.composer_text_with_pending(),
|
||||
),
|
||||
("parent draft".to_string(), "side draft".to_string())
|
||||
);
|
||||
|
||||
assert!(app.handle_owned_screen_mouse_primary_press(
|
||||
&mut tui,
|
||||
MousePrimaryPressEvent {
|
||||
column: parent_area.x,
|
||||
row: parent_conversation_area.bottom(),
|
||||
},
|
||||
));
|
||||
let mut parent_terminal = render_app(&mut app, /*width*/ 83, /*height*/ 16);
|
||||
assert_eq!(
|
||||
parent_terminal
|
||||
.get_cursor_position()
|
||||
.expect("restored parent cursor"),
|
||||
parent_cursor
|
||||
);
|
||||
assert!(app.handle_owned_screen_mouse_primary_press(
|
||||
&mut tui,
|
||||
MousePrimaryPressEvent {
|
||||
column: side_area.x,
|
||||
row: side_conversation_area.bottom(),
|
||||
},
|
||||
));
|
||||
let mut side_terminal = render_app(&mut app, /*width*/ 83, /*height*/ 16);
|
||||
assert_eq!(
|
||||
side_terminal
|
||||
.get_cursor_position()
|
||||
.expect("restored side cursor"),
|
||||
side_cursor
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn primary_press_does_not_switch_behind_overlay_or_popup() {
|
||||
let mut app = app_with_owned_side().await;
|
||||
let _terminal = render_app(&mut app, /*width*/ 83, /*height*/ 12);
|
||||
let side_area = app
|
||||
.chat_widget
|
||||
.by_slot(PaneSlot::Side)
|
||||
.and_then(|pane| pane.owned_screen.as_ref())
|
||||
.expect("side screen")
|
||||
.last_pane_area;
|
||||
let mut tui = crate::tui::test_support::make_test_tui().expect("create test TUI");
|
||||
|
||||
app.overlay = Some(Overlay::new_transcript(
|
||||
Vec::new(),
|
||||
app.keymap.pager.clone(),
|
||||
));
|
||||
assert!(!app.handle_owned_screen_mouse_primary_press(
|
||||
&mut tui,
|
||||
MousePrimaryPressEvent {
|
||||
column: side_area.x,
|
||||
row: side_area.y,
|
||||
},
|
||||
));
|
||||
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent);
|
||||
|
||||
app.overlay = None;
|
||||
let keymap = app.keymap.clone();
|
||||
app.chat_widget.open_keymap_debug(&keymap);
|
||||
assert!(!app.handle_owned_screen_mouse_primary_press(
|
||||
&mut tui,
|
||||
MousePrimaryPressEvent {
|
||||
column: side_area.x,
|
||||
row: side_area.y,
|
||||
},
|
||||
));
|
||||
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mouse_wheel_routes_by_pointer_without_changing_focus() {
|
||||
let mut app = app_with_owned_side().await;
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
---
|
||||
source: tui/src/app/owned_screen_tests.rs
|
||||
expression: side_terminal.backend()
|
||||
---
|
||||
" Parent │ Side ⌥ + 1 / ⌥ + 2 focus "
|
||||
"parent transcript │side transcript "
|
||||
" │ "
|
||||
" │╭───────────────────────────────────────╮"
|
||||
" ││ >_ OpenAI Codex (v0.0.0) │"
|
||||
" ││ │"
|
||||
" ││ model: loading /model to change │"
|
||||
" ││ directory: /tmp/project │"
|
||||
" │╰───────────────────────────────────────╯"
|
||||
" │ "
|
||||
" │ "
|
||||
" │ "
|
||||
" │ "
|
||||
"› parent draft │› side draft "
|
||||
" │ "
|
||||
" gpt-5.5 default · /tmp/project │ gpt-5.5 default · /tmp/project "
|
||||
@@ -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::MouseScroll(_) => {}
|
||||
TuiEvent::Paste(_) | TuiEvent::MouseScroll(_) | TuiEvent::MousePrimaryPress(_) => {}
|
||||
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::MouseScroll(_) => {}
|
||||
TuiEvent::Paste(_) | TuiEvent::MouseScroll(_) | TuiEvent::MousePrimaryPress(_) => {}
|
||||
TuiEvent::Draw | TuiEvent::Resize => {
|
||||
let _ = tui.draw(u16::MAX, |frame| {
|
||||
frame.render_widget_ref(&screen, frame.area());
|
||||
|
||||
@@ -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::MouseScroll(_) => {}
|
||||
TuiEvent::Paste(_) | TuiEvent::MouseScroll(_) | TuiEvent::MousePrimaryPress(_) => {}
|
||||
TuiEvent::Draw | TuiEvent::Resize => {
|
||||
let _ = alt.tui.draw(u16::MAX, |frame| {
|
||||
frame.render_widget_ref(&screen, frame.area());
|
||||
|
||||
@@ -509,7 +509,7 @@ pub(crate) async fn run_onboarding_app(
|
||||
TuiEvent::Paste(text) => {
|
||||
onboarding_screen.handle_paste(text);
|
||||
}
|
||||
TuiEvent::MouseScroll(_) => {}
|
||||
TuiEvent::MouseScroll(_) | TuiEvent::MousePrimaryPress(_) => {}
|
||||
TuiEvent::Draw | TuiEvent::Resize => {
|
||||
if !did_full_clear_after_success
|
||||
&& onboarding_screen.steps.iter().any(|step| {
|
||||
|
||||
@@ -475,7 +475,7 @@ async fn run_session_picker_with_loader(
|
||||
TuiEvent::Paste(pasted) => {
|
||||
state.handle_paste(pasted);
|
||||
}
|
||||
TuiEvent::MouseScroll(_) => {}
|
||||
TuiEvent::MouseScroll(_) | TuiEvent::MousePrimaryPress(_) => {}
|
||||
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::MouseScroll(_) => {}
|
||||
TuiEvent::Paste(_) | TuiEvent::MouseScroll(_) | TuiEvent::MousePrimaryPress(_) => {}
|
||||
TuiEvent::Draw | TuiEvent::Resize => draw_view(tui, &view)?,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,6 +519,8 @@ pub enum TuiEvent {
|
||||
Paste(String),
|
||||
/// A vertical mouse-wheel event reported while an owned screen has mouse capture.
|
||||
MouseScroll(MouseScrollEvent),
|
||||
/// A primary mouse-button press with terminal coordinates.
|
||||
MousePrimaryPress(MousePrimaryPressEvent),
|
||||
/// 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
|
||||
@@ -535,6 +537,12 @@ pub struct MouseScrollEvent {
|
||||
pub row: u16,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct MousePrimaryPressEvent {
|
||||
pub column: u16,
|
||||
pub row: u16,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MouseScrollDirection {
|
||||
Up,
|
||||
|
||||
@@ -34,6 +34,7 @@ use tokio_stream::wrappers::BroadcastStream;
|
||||
use tokio_stream::wrappers::WatchStream;
|
||||
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
|
||||
|
||||
use super::MousePrimaryPressEvent;
|
||||
use super::MouseScrollDirection;
|
||||
use super::MouseScrollEvent;
|
||||
use super::TuiEvent;
|
||||
@@ -179,11 +180,11 @@ 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 clicks, focus loss, etc.) and keeps polling until it yields
|
||||
/// This skips events we don't use (mouse movement, 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 clicks); loop so we keep polling
|
||||
// Some crossterm events map to None (e.g. FocusLost, mouse movement); loop so we keep polling
|
||||
// until we return a mapped event, hit Pending, or see EOF/error.
|
||||
for _ in 0..MAX_SKIPPED_EVENTS_PER_POLL {
|
||||
let poll_result = {
|
||||
@@ -264,23 +265,30 @@ 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,
|
||||
Event::Mouse(mouse_event) => match mouse_event.kind {
|
||||
MouseEventKind::ScrollUp => Some(TuiEvent::MouseScroll(MouseScrollEvent {
|
||||
direction: MouseScrollDirection::Up,
|
||||
column: mouse_event.column,
|
||||
row: mouse_event.row,
|
||||
}))
|
||||
}
|
||||
})),
|
||||
MouseEventKind::ScrollDown => Some(TuiEvent::MouseScroll(MouseScrollEvent {
|
||||
direction: MouseScrollDirection::Down,
|
||||
column: mouse_event.column,
|
||||
row: mouse_event.row,
|
||||
})),
|
||||
MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
|
||||
Some(TuiEvent::MousePrimaryPress(MousePrimaryPressEvent {
|
||||
column: mouse_event.column,
|
||||
row: mouse_event.row,
|
||||
}))
|
||||
}
|
||||
MouseEventKind::Down(_)
|
||||
| MouseEventKind::Up(_)
|
||||
| MouseEventKind::Drag(_)
|
||||
| MouseEventKind::Moved
|
||||
| MouseEventKind::ScrollLeft
|
||||
| MouseEventKind::ScrollRight => None,
|
||||
},
|
||||
Event::FocusGained => {
|
||||
self.terminal_focused.store(true, Ordering::Relaxed);
|
||||
crate::terminal_palette::requery_default_colors();
|
||||
@@ -498,6 +506,27 @@ mod tests {
|
||||
assert!(matches!(next, Some(TuiEvent::Resize)));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn primary_mouse_press_maps_to_event() {
|
||||
let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup();
|
||||
let mut stream = make_stream(broker, draw_rx, terminal_focused);
|
||||
|
||||
handle.send(Ok(Event::Mouse(MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: 3,
|
||||
row: 4,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
})));
|
||||
|
||||
assert!(matches!(
|
||||
stream.next().await,
|
||||
Some(TuiEvent::MousePrimaryPress(MousePrimaryPressEvent {
|
||||
column: 3,
|
||||
row: 4,
|
||||
}))
|
||||
));
|
||||
}
|
||||
|
||||
#[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();
|
||||
@@ -505,11 +534,23 @@ mod tests {
|
||||
stream.poll_draw_first = false;
|
||||
|
||||
handle.send(Ok(Event::Mouse(MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
kind: MouseEventKind::Down(MouseButton::Right),
|
||||
column: 3,
|
||||
row: 4,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
})));
|
||||
handle.send(Ok(Event::Mouse(MouseEvent {
|
||||
kind: MouseEventKind::Up(MouseButton::Left),
|
||||
column: 3,
|
||||
row: 4,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
})));
|
||||
handle.send(Ok(Event::Mouse(MouseEvent {
|
||||
kind: MouseEventKind::Drag(MouseButton::Left),
|
||||
column: 4,
|
||||
row: 4,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
})));
|
||||
for column in 0..=MAX_SKIPPED_EVENTS_PER_POLL {
|
||||
handle.send(Ok(Event::Mouse(MouseEvent {
|
||||
kind: MouseEventKind::Moved,
|
||||
|
||||
@@ -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::MouseScroll(_) => {}
|
||||
TuiEvent::Paste(_) | TuiEvent::MouseScroll(_) | TuiEvent::MousePrimaryPress(_) => {}
|
||||
TuiEvent::Draw | TuiEvent::Resize => {
|
||||
tui.draw(u16::MAX, |frame| {
|
||||
frame.render_widget_ref(&screen, frame.area());
|
||||
|
||||
Reference in New Issue
Block a user