mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
feat(tui): resize owned conversation panes
This commit is contained in:
+22
-2
@@ -215,6 +215,7 @@ mod history_ui;
|
||||
mod input;
|
||||
mod loaded_threads;
|
||||
mod owned_screen;
|
||||
mod owned_screen_resize;
|
||||
mod pending_interactive_replay;
|
||||
mod pets;
|
||||
mod platform_actions;
|
||||
@@ -1269,11 +1270,29 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
app_server: &mut AppServerSession,
|
||||
event: TuiEvent,
|
||||
) -> Result<AppRunControl> {
|
||||
match &event {
|
||||
TuiEvent::Resize => {
|
||||
self.chat_widget.cancel_owned_screen_split_drag();
|
||||
}
|
||||
TuiEvent::FocusLost => {
|
||||
if self.chat_widget.cancel_owned_screen_split_drag() {
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
}
|
||||
TuiEvent::Key(_)
|
||||
| TuiEvent::Paste(_)
|
||||
| TuiEvent::MouseScroll(_)
|
||||
| TuiEvent::MousePrimary(_)
|
||||
| TuiEvent::Draw => {}
|
||||
}
|
||||
if matches!(event, TuiEvent::Draw | TuiEvent::Resize) {
|
||||
self.handle_draw_pre_render(tui)?;
|
||||
}
|
||||
|
||||
if self.overlay.is_some() {
|
||||
if self.chat_widget.cancel_owned_screen_split_drag() {
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
let _ = self.handle_backtrack_overlay_event(tui, event).await?;
|
||||
} else {
|
||||
match event {
|
||||
@@ -1303,9 +1322,10 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
.await;
|
||||
}
|
||||
}
|
||||
TuiEvent::MousePrimaryPress(event) => {
|
||||
self.handle_owned_screen_mouse_primary_press(tui, event);
|
||||
TuiEvent::MousePrimary(event) => {
|
||||
self.handle_owned_screen_mouse_primary(tui, event);
|
||||
}
|
||||
TuiEvent::FocusLost => {}
|
||||
TuiEvent::Draw | TuiEvent::Resize => {
|
||||
if self.backtrack_render_pending {
|
||||
self.rebuild_transcript_after_backtrack(tui)?;
|
||||
|
||||
@@ -16,6 +16,9 @@ use tokio::sync::mpsc;
|
||||
|
||||
use super::InitialHistoryReplayBuffer;
|
||||
use super::owned_screen::OwnedScreen;
|
||||
use super::owned_screen_resize::OwnedScreenLayout;
|
||||
use super::owned_screen_resize::OwnedScreenSplitState;
|
||||
use super::owned_screen_resize::PaneSplitPreference;
|
||||
use super::thread_events::ThreadBufferedEvent;
|
||||
use crate::app_event::ConversationOrigin;
|
||||
use crate::app_event::PaneSlot;
|
||||
@@ -25,6 +28,7 @@ use crate::history_cell::HistoryCell;
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::transcript_reflow::TranscriptReflowState;
|
||||
use crate::tui::MousePrimaryEvent;
|
||||
|
||||
pub(super) fn parent_pane_shortcut() -> KeyBinding {
|
||||
key_hint::alt(KeyCode::Char('1'))
|
||||
@@ -125,6 +129,7 @@ pub(crate) struct ConversationPanes {
|
||||
side: Option<ConversationPane>,
|
||||
focused: PaneSlot,
|
||||
dispatch: Option<PaneSlot>,
|
||||
owned_screen_split: OwnedScreenSplitState,
|
||||
}
|
||||
|
||||
// Rendering and `/side` lifecycle consume the remaining APIs in the next stack layers.
|
||||
@@ -136,6 +141,7 @@ impl ConversationPanes {
|
||||
side: None,
|
||||
focused: PaneSlot::Parent,
|
||||
dispatch: None,
|
||||
owned_screen_split: OwnedScreenSplitState::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -173,6 +179,7 @@ impl ConversationPanes {
|
||||
}
|
||||
|
||||
pub(super) fn take_side(&mut self) -> Option<ConversationPane> {
|
||||
self.owned_screen_split.clear_rendered_layout();
|
||||
if self.focused == PaneSlot::Side {
|
||||
self.focus(PaneSlot::Parent);
|
||||
}
|
||||
@@ -186,6 +193,26 @@ impl ConversationPanes {
|
||||
self.side.take()
|
||||
}
|
||||
|
||||
pub(super) fn owned_screen_split_preference(&self) -> PaneSplitPreference {
|
||||
self.owned_screen_split.preference()
|
||||
}
|
||||
|
||||
pub(super) fn record_owned_screen_layout(&mut self, layout: OwnedScreenLayout) {
|
||||
self.owned_screen_split.record_layout(layout);
|
||||
}
|
||||
|
||||
pub(super) fn handle_owned_screen_split_mouse(&mut self, event: MousePrimaryEvent) -> bool {
|
||||
self.owned_screen_split.handle_mouse(event)
|
||||
}
|
||||
|
||||
pub(super) fn owned_screen_split_is_dragging(&self) -> bool {
|
||||
self.owned_screen_split.is_dragging()
|
||||
}
|
||||
|
||||
pub(super) fn cancel_owned_screen_split_drag(&mut self) -> bool {
|
||||
self.owned_screen_split.cancel_drag()
|
||||
}
|
||||
|
||||
pub(super) fn by_slot(&self, slot: PaneSlot) -> Option<&ConversationPane> {
|
||||
match slot {
|
||||
PaneSlot::Parent => Some(&self.parent),
|
||||
|
||||
@@ -18,15 +18,14 @@ use ratatui::widgets::Clear;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
|
||||
use super::owned_screen_resize::OwnedScreenLayout;
|
||||
use super::*;
|
||||
use crate::AltScreenBehavior;
|
||||
use crate::key_hint::is_plain_text_key_event;
|
||||
use crate::tui::MousePrimaryPressEvent;
|
||||
use crate::tui::MousePrimaryEvent;
|
||||
use crate::tui::MousePrimaryEventKind;
|
||||
use crate::tui::MouseScrollEvent;
|
||||
|
||||
const MIN_SPLIT_PANE_WIDTH: u16 = 41;
|
||||
const SPLIT_DIVIDER_WIDTH: u16 = 1;
|
||||
const MIN_SPLIT_WIDTH: u16 = MIN_SPLIT_PANE_WIDTH * 2 + SPLIT_DIVIDER_WIDTH;
|
||||
const PANE_HEADER_HEIGHT: u16 = 1;
|
||||
|
||||
pub(super) struct OwnedScreen {
|
||||
@@ -41,59 +40,6 @@ struct RenderedOwnedScreen {
|
||||
cursor_style: SetCursorStyle,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum OwnedScreenLayout {
|
||||
Single {
|
||||
slot: PaneSlot,
|
||||
area: Rect,
|
||||
show_header: bool,
|
||||
},
|
||||
Split {
|
||||
area: Rect,
|
||||
parent: Rect,
|
||||
divider: Rect,
|
||||
side: Rect,
|
||||
},
|
||||
}
|
||||
|
||||
impl OwnedScreenLayout {
|
||||
fn new(area: Rect, has_side: bool, focused: PaneSlot) -> Self {
|
||||
if !has_side {
|
||||
return Self::Single {
|
||||
slot: PaneSlot::Parent,
|
||||
area,
|
||||
show_header: false,
|
||||
};
|
||||
}
|
||||
if area.width < MIN_SPLIT_WIDTH {
|
||||
return Self::Single {
|
||||
slot: focused,
|
||||
area,
|
||||
show_header: true,
|
||||
};
|
||||
}
|
||||
|
||||
let pane_width = area.width.saturating_sub(SPLIT_DIVIDER_WIDTH);
|
||||
let parent_width = (pane_width + 1) / 2;
|
||||
let side_width = pane_width.saturating_sub(parent_width);
|
||||
let parent = Rect::new(area.x, area.y, parent_width, area.height);
|
||||
let divider = Rect::new(parent.right(), area.y, SPLIT_DIVIDER_WIDTH, area.height);
|
||||
let side = Rect::new(divider.right(), area.y, side_width, area.height);
|
||||
Self::Split {
|
||||
area,
|
||||
parent,
|
||||
divider,
|
||||
side,
|
||||
}
|
||||
}
|
||||
|
||||
fn area(self) -> Rect {
|
||||
match self {
|
||||
Self::Single { area, .. } | Self::Split { area, .. } => area,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OwnedScreen {
|
||||
fn new(chat_widget: &ChatWidget, keymap: crate::keymap::PagerKeymap) -> Self {
|
||||
Self {
|
||||
@@ -231,14 +177,19 @@ fn render_pane_header(slot: PaneSlot, focused: bool, area: Rect, buffer: &mut Bu
|
||||
);
|
||||
}
|
||||
|
||||
fn render_divider(area: Rect, buffer: &mut Buffer) {
|
||||
fn render_divider(area: Rect, active: bool, buffer: &mut Buffer) {
|
||||
if area.width == 0 {
|
||||
return;
|
||||
}
|
||||
let style = if active {
|
||||
Style::default().cyan().bold()
|
||||
} else {
|
||||
Style::default().dim()
|
||||
};
|
||||
for y in area.y..area.bottom() {
|
||||
buffer[(area.x, y)]
|
||||
.set_symbol("│")
|
||||
.set_style(Style::default().dim());
|
||||
.set_symbol(if active { "┃" } else { "│" })
|
||||
.set_style(style);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,6 +216,8 @@ fn render_layout(
|
||||
focused: PaneSlot,
|
||||
buffer: &mut Buffer,
|
||||
) -> Option<RenderedOwnedScreen> {
|
||||
panes.record_owned_screen_layout(layout);
|
||||
let divider_active = panes.owned_screen_split_is_dragging();
|
||||
Clear.render(layout.area(), buffer);
|
||||
for slot in [PaneSlot::Parent, PaneSlot::Side] {
|
||||
if let Some(screen) = panes
|
||||
@@ -303,7 +256,7 @@ fn render_layout(
|
||||
focused,
|
||||
buffer,
|
||||
);
|
||||
render_divider(divider, buffer);
|
||||
render_divider(divider, divider_active, buffer);
|
||||
match focused {
|
||||
PaneSlot::Parent => parent_rendered,
|
||||
PaneSlot::Side => side_rendered,
|
||||
@@ -380,6 +333,9 @@ impl App {
|
||||
tui: &mut tui::Tui,
|
||||
event: MouseScrollEvent,
|
||||
) -> bool {
|
||||
if self.chat_widget.owned_screen_split_is_dragging() {
|
||||
return true;
|
||||
}
|
||||
for slot in [PaneSlot::Parent, PaneSlot::Side] {
|
||||
let handled = self.chat_widget.by_slot_mut(slot).is_some_and(|pane| {
|
||||
pane.chat_widget.no_modal_or_popup_active()
|
||||
@@ -397,12 +353,33 @@ impl App {
|
||||
false
|
||||
}
|
||||
|
||||
pub(super) fn handle_owned_screen_mouse_primary_press(
|
||||
pub(super) fn handle_owned_screen_mouse_primary(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
event: MousePrimaryPressEvent,
|
||||
event: MousePrimaryEvent,
|
||||
) -> bool {
|
||||
if !self.chat_widget.has_side() {
|
||||
if self.overlay.is_some()
|
||||
|| !self.chat_widget.has_side()
|
||||
|| !self.chat_widget.no_modal_or_popup_active()
|
||||
{
|
||||
if self.chat_widget.cancel_owned_screen_split_drag() {
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if self.chat_widget.handle_owned_screen_split_mouse(event) {
|
||||
match event.kind {
|
||||
MousePrimaryEventKind::Drag => tui
|
||||
.frame_requester()
|
||||
.schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL),
|
||||
MousePrimaryEventKind::Press | MousePrimaryEventKind::Release => {
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if event.kind != MousePrimaryEventKind::Press {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -474,12 +451,16 @@ impl App {
|
||||
if !self.has_owned_screen() {
|
||||
return Ok(None);
|
||||
}
|
||||
if !self.chat_widget.no_modal_or_popup_active() {
|
||||
self.chat_widget.cancel_owned_screen_split_drag();
|
||||
}
|
||||
let focused = self.chat_widget.focused_slot();
|
||||
let has_side = self.chat_widget.has_side();
|
||||
let split_preference = self.chat_widget.owned_screen_split_preference();
|
||||
let mut rendered_area = Rect::default();
|
||||
tui.draw(/*height*/ u16::MAX, |frame| {
|
||||
rendered_area = frame.area();
|
||||
let layout = OwnedScreenLayout::new(rendered_area, has_side, focused);
|
||||
let layout = OwnedScreenLayout::new(rendered_area, has_side, focused, split_preference);
|
||||
if let Some(rendered) =
|
||||
render_layout(&mut self.chat_widget, layout, focused, frame.buffer)
|
||||
&& let Some((x, y)) = rendered.cursor
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
//! Layout and pointer interaction state for application-owned conversation panes.
|
||||
|
||||
use ratatui::layout::Position;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
use crate::app_event::PaneSlot;
|
||||
use crate::tui::MousePrimaryEvent;
|
||||
use crate::tui::MousePrimaryEventKind;
|
||||
|
||||
const MIN_SPLIT_PANE_WIDTH: u16 = 41;
|
||||
const SPLIT_DIVIDER_WIDTH: u16 = 1;
|
||||
const MIN_SPLIT_WIDTH: u16 = MIN_SPLIT_PANE_WIDTH * 2 + SPLIT_DIVIDER_WIDTH;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(super) struct PaneSplitPreference {
|
||||
parent_width: u16,
|
||||
available_width: u16,
|
||||
}
|
||||
|
||||
impl Default for PaneSplitPreference {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
parent_width: 1,
|
||||
available_width: 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PaneSplitPreference {
|
||||
fn parent_width(self, available_width: u16) -> u16 {
|
||||
let denominator = u32::from(self.available_width.max(/*other*/ 1));
|
||||
let numerator = u32::from(self.parent_width) * u32::from(available_width);
|
||||
((numerator + denominator / 2) / denominator) as u16
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(super) enum OwnedScreenLayout {
|
||||
Single {
|
||||
slot: PaneSlot,
|
||||
area: Rect,
|
||||
show_header: bool,
|
||||
},
|
||||
Split {
|
||||
area: Rect,
|
||||
parent: Rect,
|
||||
divider: Rect,
|
||||
side: Rect,
|
||||
},
|
||||
}
|
||||
|
||||
impl OwnedScreenLayout {
|
||||
pub(super) fn new(
|
||||
area: Rect,
|
||||
has_side: bool,
|
||||
focused: PaneSlot,
|
||||
preference: PaneSplitPreference,
|
||||
) -> Self {
|
||||
if !has_side {
|
||||
return Self::Single {
|
||||
slot: PaneSlot::Parent,
|
||||
area,
|
||||
show_header: false,
|
||||
};
|
||||
}
|
||||
if area.width < MIN_SPLIT_WIDTH {
|
||||
return Self::Single {
|
||||
slot: focused,
|
||||
area,
|
||||
show_header: true,
|
||||
};
|
||||
}
|
||||
|
||||
let available_width = area.width.saturating_sub(SPLIT_DIVIDER_WIDTH);
|
||||
let max_parent_width = available_width.saturating_sub(MIN_SPLIT_PANE_WIDTH);
|
||||
let parent_width = preference
|
||||
.parent_width(available_width)
|
||||
.clamp(MIN_SPLIT_PANE_WIDTH, max_parent_width);
|
||||
let side_width = available_width.saturating_sub(parent_width);
|
||||
let parent = Rect::new(area.x, area.y, parent_width, area.height);
|
||||
let divider = Rect::new(parent.right(), area.y, SPLIT_DIVIDER_WIDTH, area.height);
|
||||
let side = Rect::new(divider.right(), area.y, side_width, area.height);
|
||||
Self::Split {
|
||||
area,
|
||||
parent,
|
||||
divider,
|
||||
side,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn area(self) -> Rect {
|
||||
match self {
|
||||
Self::Single { area, .. } | Self::Split { area, .. } => area,
|
||||
}
|
||||
}
|
||||
|
||||
fn split_geometry(self) -> Option<SplitGeometry> {
|
||||
match self {
|
||||
Self::Single { .. } => None,
|
||||
Self::Split { area, divider, .. } => Some(SplitGeometry { area, divider }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
struct SplitGeometry {
|
||||
area: Rect,
|
||||
divider: Rect,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
enum SplitInteraction {
|
||||
#[default]
|
||||
Idle,
|
||||
Dragging {
|
||||
origin_column: u16,
|
||||
initial_preference: PaneSplitPreference,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct OwnedScreenSplitState {
|
||||
preference: PaneSplitPreference,
|
||||
interaction: SplitInteraction,
|
||||
geometry: Option<SplitGeometry>,
|
||||
}
|
||||
|
||||
impl OwnedScreenSplitState {
|
||||
pub(super) fn preference(&self) -> PaneSplitPreference {
|
||||
self.preference
|
||||
}
|
||||
|
||||
pub(super) fn record_layout(&mut self, layout: OwnedScreenLayout) {
|
||||
let next_geometry = layout.split_geometry();
|
||||
let area_changed = self
|
||||
.geometry
|
||||
.zip(next_geometry)
|
||||
.is_some_and(|(current, next)| current.area != next.area);
|
||||
if next_geometry.is_none() || area_changed {
|
||||
self.interaction = SplitInteraction::Idle;
|
||||
}
|
||||
self.geometry = next_geometry;
|
||||
}
|
||||
|
||||
pub(super) fn clear_rendered_layout(&mut self) {
|
||||
self.interaction = SplitInteraction::Idle;
|
||||
self.geometry = None;
|
||||
}
|
||||
|
||||
pub(super) fn is_dragging(&self) -> bool {
|
||||
matches!(self.interaction, SplitInteraction::Dragging { .. })
|
||||
}
|
||||
|
||||
pub(super) fn cancel_drag(&mut self) -> bool {
|
||||
let was_dragging = self.is_dragging();
|
||||
self.interaction = SplitInteraction::Idle;
|
||||
was_dragging
|
||||
}
|
||||
|
||||
pub(super) fn handle_mouse(&mut self, event: MousePrimaryEvent) -> bool {
|
||||
match event.kind {
|
||||
MousePrimaryEventKind::Press => {
|
||||
self.interaction = SplitInteraction::Idle;
|
||||
let position = Position::new(event.column, event.row);
|
||||
if self.geometry.is_some_and(|geometry| {
|
||||
geometry.area.width > MIN_SPLIT_WIDTH && geometry.divider.contains(position)
|
||||
}) {
|
||||
self.interaction = SplitInteraction::Dragging {
|
||||
origin_column: event.column,
|
||||
initial_preference: self.preference,
|
||||
};
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
MousePrimaryEventKind::Drag if self.is_dragging() => {
|
||||
self.update_preference(event.column);
|
||||
true
|
||||
}
|
||||
MousePrimaryEventKind::Release if self.is_dragging() => {
|
||||
let SplitInteraction::Dragging {
|
||||
origin_column,
|
||||
initial_preference,
|
||||
} = self.interaction
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if event.column == origin_column {
|
||||
self.preference = initial_preference;
|
||||
} else {
|
||||
self.update_preference(event.column);
|
||||
}
|
||||
self.interaction = SplitInteraction::Idle;
|
||||
true
|
||||
}
|
||||
MousePrimaryEventKind::Drag | MousePrimaryEventKind::Release => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn update_preference(&mut self, column: u16) {
|
||||
let Some(geometry) = self.geometry else {
|
||||
return;
|
||||
};
|
||||
let available_width = geometry.area.width.saturating_sub(SPLIT_DIVIDER_WIDTH);
|
||||
let max_parent_width = available_width.saturating_sub(MIN_SPLIT_PANE_WIDTH);
|
||||
let parent_width = column
|
||||
.saturating_sub(geometry.area.x)
|
||||
.clamp(MIN_SPLIT_PANE_WIDTH, max_parent_width);
|
||||
self.preference = PaneSplitPreference {
|
||||
parent_width,
|
||||
available_width,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "owned_screen_resize_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,194 @@
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn split_widths(layout: OwnedScreenLayout) -> (u16, u16, u16) {
|
||||
let OwnedScreenLayout::Split {
|
||||
parent,
|
||||
divider,
|
||||
side,
|
||||
..
|
||||
} = layout
|
||||
else {
|
||||
panic!("expected split layout");
|
||||
};
|
||||
(parent.width, divider.x, side.width)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_uses_threshold_parent_bias_and_nonzero_origin() {
|
||||
let preference = PaneSplitPreference::default();
|
||||
let narrow = Rect::new(
|
||||
/*x*/ 7, /*y*/ 3, /*width*/ 82, /*height*/ 20,
|
||||
);
|
||||
assert_eq!(
|
||||
OwnedScreenLayout::new(narrow, /*has_side*/ true, PaneSlot::Side, preference),
|
||||
OwnedScreenLayout::Single {
|
||||
slot: PaneSlot::Side,
|
||||
area: narrow,
|
||||
show_header: true,
|
||||
}
|
||||
);
|
||||
|
||||
for (width, expected) in [(83, (41, 48, 41)), (84, (42, 49, 41))] {
|
||||
let area = Rect::new(/*x*/ 7, /*y*/ 3, width, /*height*/ 20);
|
||||
assert_eq!(
|
||||
split_widths(OwnedScreenLayout::new(
|
||||
area,
|
||||
/*has_side*/ true,
|
||||
PaneSlot::Parent,
|
||||
preference,
|
||||
)),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_lifecycle_clamps_and_preserves_the_preferred_ratio() {
|
||||
let mut state = OwnedScreenSplitState::default();
|
||||
let initial_preference = state.preference();
|
||||
let wide = Rect::new(
|
||||
/*x*/ 10, /*y*/ 2, /*width*/ 120, /*height*/ 20,
|
||||
);
|
||||
state.record_layout(OwnedScreenLayout::new(
|
||||
wide,
|
||||
/*has_side*/ true,
|
||||
PaneSlot::Parent,
|
||||
state.preference(),
|
||||
));
|
||||
assert!(!state.handle_mouse(MousePrimaryEvent {
|
||||
kind: MousePrimaryEventKind::Drag,
|
||||
column: 80,
|
||||
row: 4,
|
||||
}));
|
||||
assert!(!state.handle_mouse(MousePrimaryEvent {
|
||||
kind: MousePrimaryEventKind::Press,
|
||||
column: 20,
|
||||
row: 4,
|
||||
}));
|
||||
|
||||
assert!(state.handle_mouse(MousePrimaryEvent {
|
||||
kind: MousePrimaryEventKind::Press,
|
||||
column: 70,
|
||||
row: 4,
|
||||
}));
|
||||
assert!(state.handle_mouse(MousePrimaryEvent {
|
||||
kind: MousePrimaryEventKind::Drag,
|
||||
column: 10,
|
||||
row: 4,
|
||||
}));
|
||||
assert_eq!(
|
||||
split_widths(OwnedScreenLayout::new(
|
||||
wide,
|
||||
/*has_side*/ true,
|
||||
PaneSlot::Parent,
|
||||
state.preference(),
|
||||
)),
|
||||
(41, 51, 78)
|
||||
);
|
||||
assert!(state.handle_mouse(MousePrimaryEvent {
|
||||
kind: MousePrimaryEventKind::Release,
|
||||
column: 70,
|
||||
row: 4,
|
||||
}));
|
||||
assert_eq!(state.preference(), initial_preference);
|
||||
|
||||
assert!(state.handle_mouse(MousePrimaryEvent {
|
||||
kind: MousePrimaryEventKind::Press,
|
||||
column: 70,
|
||||
row: 4,
|
||||
}));
|
||||
assert!(state.handle_mouse(MousePrimaryEvent {
|
||||
kind: MousePrimaryEventKind::Drag,
|
||||
column: 80,
|
||||
row: 4,
|
||||
}));
|
||||
assert!(state.handle_mouse(MousePrimaryEvent {
|
||||
kind: MousePrimaryEventKind::Release,
|
||||
column: 80,
|
||||
row: u16::MAX,
|
||||
}));
|
||||
assert_eq!(
|
||||
split_widths(OwnedScreenLayout::new(
|
||||
wide,
|
||||
/*has_side*/ true,
|
||||
PaneSlot::Parent,
|
||||
state.preference(),
|
||||
)),
|
||||
(70, 80, 49)
|
||||
);
|
||||
assert!(!state.handle_mouse(MousePrimaryEvent {
|
||||
kind: MousePrimaryEventKind::Drag,
|
||||
column: 70,
|
||||
row: 4,
|
||||
}));
|
||||
|
||||
let preference = state.preference();
|
||||
let constrained = Rect::new(
|
||||
/*x*/ 10, /*y*/ 2, /*width*/ 90, /*height*/ 20,
|
||||
);
|
||||
let constrained_layout = OwnedScreenLayout::new(
|
||||
constrained,
|
||||
/*has_side*/ true,
|
||||
PaneSlot::Parent,
|
||||
preference,
|
||||
);
|
||||
assert_eq!(split_widths(constrained_layout), (48, 58, 41));
|
||||
state.record_layout(constrained_layout);
|
||||
assert!(state.handle_mouse(MousePrimaryEvent {
|
||||
kind: MousePrimaryEventKind::Press,
|
||||
column: 58,
|
||||
row: 4,
|
||||
}));
|
||||
assert!(state.handle_mouse(MousePrimaryEvent {
|
||||
kind: MousePrimaryEventKind::Release,
|
||||
column: 58,
|
||||
row: 4,
|
||||
}));
|
||||
assert_eq!(state.preference(), preference);
|
||||
|
||||
assert!(state.handle_mouse(MousePrimaryEvent {
|
||||
kind: MousePrimaryEventKind::Press,
|
||||
column: 58,
|
||||
row: 4,
|
||||
}));
|
||||
let narrow = Rect::new(
|
||||
/*x*/ 10, /*y*/ 2, /*width*/ 82, /*height*/ 20,
|
||||
);
|
||||
state.record_layout(OwnedScreenLayout::new(
|
||||
narrow,
|
||||
/*has_side*/ true,
|
||||
PaneSlot::Parent,
|
||||
state.preference(),
|
||||
));
|
||||
assert!(!state.is_dragging());
|
||||
assert_eq!(state.preference(), preference);
|
||||
assert_eq!(
|
||||
split_widths(OwnedScreenLayout::new(
|
||||
wide,
|
||||
/*has_side*/ true,
|
||||
PaneSlot::Parent,
|
||||
state.preference(),
|
||||
)),
|
||||
(70, 80, 49)
|
||||
);
|
||||
|
||||
let fixed = Rect::new(
|
||||
/*x*/ 10,
|
||||
/*y*/ 2,
|
||||
MIN_SPLIT_WIDTH,
|
||||
/*height*/ 20,
|
||||
);
|
||||
state.record_layout(OwnedScreenLayout::new(
|
||||
fixed,
|
||||
/*has_side*/ true,
|
||||
PaneSlot::Parent,
|
||||
state.preference(),
|
||||
));
|
||||
assert!(!state.handle_mouse(MousePrimaryEvent {
|
||||
kind: MousePrimaryEventKind::Press,
|
||||
column: 51,
|
||||
row: 4,
|
||||
}));
|
||||
}
|
||||
@@ -17,7 +17,8 @@ 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::MousePrimaryEvent;
|
||||
use crate::tui::MousePrimaryEventKind;
|
||||
use crate::tui::MouseScrollDirection;
|
||||
use crate::tui::MouseScrollEvent;
|
||||
use codex_app_server_protocol::ConfigWarningNotification;
|
||||
@@ -95,10 +96,11 @@ fn seed_pane(app: &mut App, slot: PaneSlot, draft: &str, cells: &[&'static str])
|
||||
fn render_app(app: &mut App, width: u16, height: u16) -> Terminal<TestBackend> {
|
||||
let focused = app.chat_widget.focused_slot();
|
||||
let has_side = app.chat_widget.has_side();
|
||||
let split_preference = app.chat_widget.owned_screen_split_preference();
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal");
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
let layout = OwnedScreenLayout::new(frame.area(), has_side, focused);
|
||||
let layout = OwnedScreenLayout::new(frame.area(), has_side, focused, split_preference);
|
||||
if let Some(rendered) =
|
||||
render_layout(&mut app.chat_widget, layout, focused, frame.buffer_mut())
|
||||
&& let Some((x, y)) = rendered.cursor
|
||||
@@ -119,51 +121,12 @@ fn is_following_bottom(app: &App, slot: PaneSlot) -> bool {
|
||||
.is_following_bottom()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responsive_layout_uses_expected_threshold_and_parent_bias() {
|
||||
let area_narrow = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 82, /*height*/ 20,
|
||||
);
|
||||
let narrow = OwnedScreenLayout::new(area_narrow, /*has_side*/ true, PaneSlot::Side);
|
||||
assert!(matches!(
|
||||
narrow,
|
||||
OwnedScreenLayout::Single {
|
||||
slot: PaneSlot::Side,
|
||||
show_header: true,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert_eq!(
|
||||
OwnedScreenLayout::new(area_narrow, /*has_side*/ false, PaneSlot::Side),
|
||||
OwnedScreenLayout::Single {
|
||||
slot: PaneSlot::Parent,
|
||||
area: area_narrow,
|
||||
show_header: false,
|
||||
}
|
||||
);
|
||||
for (width, expected_parent_width) in [(83, 41), (84, 42)] {
|
||||
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 20);
|
||||
let OwnedScreenLayout::Split {
|
||||
parent,
|
||||
divider,
|
||||
side,
|
||||
..
|
||||
} = OwnedScreenLayout::new(area, /*has_side*/ true, PaneSlot::Parent)
|
||||
else {
|
||||
panic!("width {width} should split");
|
||||
};
|
||||
assert_eq!(parent.width, expected_parent_width);
|
||||
assert_eq!(
|
||||
divider,
|
||||
Rect::new(
|
||||
parent.right(),
|
||||
/*y*/ 0,
|
||||
/*width*/ 1,
|
||||
/*height*/ 20
|
||||
)
|
||||
);
|
||||
assert_eq!(side.width, 41);
|
||||
}
|
||||
fn primary_event(kind: MousePrimaryEventKind, column: u16, row: u16) -> MousePrimaryEvent {
|
||||
MousePrimaryEvent { kind, column, row }
|
||||
}
|
||||
|
||||
fn primary_press(column: u16, row: u16) -> MousePrimaryEvent {
|
||||
primary_event(MousePrimaryEventKind::Press, column, row)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -382,14 +345,14 @@ async fn primary_press_focuses_closed_parent_without_enabling_input() {
|
||||
.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!(
|
||||
app.handle_owned_screen_mouse_primary(
|
||||
&mut tui,
|
||||
primary_press(parent_area.x, parent_area.y),
|
||||
)
|
||||
);
|
||||
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent);
|
||||
let split_preference = app.chat_widget.owned_screen_split_preference();
|
||||
let mut rendered_cursor = None;
|
||||
let mut terminal =
|
||||
Terminal::new(TestBackend::new(/*width*/ 83, /*height*/ 12)).expect("create terminal");
|
||||
@@ -398,7 +361,12 @@ async fn primary_press_focuses_closed_parent_without_enabling_input() {
|
||||
rendered_cursor = Some(
|
||||
render_layout(
|
||||
&mut app.chat_widget,
|
||||
OwnedScreenLayout::new(frame.area(), /*has_side*/ true, PaneSlot::Parent),
|
||||
OwnedScreenLayout::new(
|
||||
frame.area(),
|
||||
/*has_side*/ true,
|
||||
PaneSlot::Parent,
|
||||
split_preference,
|
||||
),
|
||||
PaneSlot::Parent,
|
||||
frame.buffer_mut(),
|
||||
)
|
||||
@@ -442,10 +410,9 @@ async fn narrow_layout_renders_only_the_focused_side() {
|
||||
);
|
||||
|
||||
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!(
|
||||
app.handle_owned_screen_mouse_primary(&mut tui, primary_press(/*column*/ 2, /*row*/ 2),)
|
||||
);
|
||||
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Side);
|
||||
}
|
||||
|
||||
@@ -513,47 +480,34 @@ async fn primary_press_focuses_visible_pane_regions() {
|
||||
|
||||
for (parent_position, side_position) in region_pairs {
|
||||
app.backtrack.primed = true;
|
||||
assert!(app.handle_owned_screen_mouse_primary_press(
|
||||
assert!(app.handle_owned_screen_mouse_primary(
|
||||
&mut tui,
|
||||
MousePrimaryPressEvent {
|
||||
column: side_position.0,
|
||||
row: side_position.1,
|
||||
},
|
||||
primary_press(side_position.0, side_position.1),
|
||||
));
|
||||
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Side);
|
||||
assert!(!app.backtrack.primed);
|
||||
assert!(app.handle_owned_screen_mouse_primary_press(
|
||||
assert!(app.handle_owned_screen_mouse_primary(
|
||||
&mut tui,
|
||||
MousePrimaryPressEvent {
|
||||
column: parent_position.0,
|
||||
row: parent_position.1,
|
||||
},
|
||||
primary_press(parent_position.0, 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.handle_owned_screen_mouse_primary(
|
||||
&mut tui,
|
||||
primary_press(parent_area.x, parent_area.y),
|
||||
)
|
||||
);
|
||||
assert!(!app.backtrack.primed);
|
||||
assert!(!app.handle_owned_screen_mouse_primary_press(
|
||||
assert!(!app.handle_owned_screen_mouse_primary(
|
||||
&mut tui,
|
||||
MousePrimaryPressEvent {
|
||||
column: parent_area.right(),
|
||||
row: parent_area.y,
|
||||
},
|
||||
primary_press(parent_area.right(), parent_area.y),
|
||||
));
|
||||
assert!(!app.handle_owned_screen_mouse_primary_press(
|
||||
assert!(!app.handle_owned_screen_mouse_primary(
|
||||
&mut tui,
|
||||
MousePrimaryPressEvent {
|
||||
column: side_area.right(),
|
||||
row: side_area.y,
|
||||
},
|
||||
primary_press(side_area.right(), side_area.y),
|
||||
));
|
||||
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent);
|
||||
}
|
||||
@@ -609,12 +563,9 @@ async fn primary_press_preserves_pane_drafts_and_cursors() {
|
||||
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(
|
||||
assert!(app.handle_owned_screen_mouse_primary(
|
||||
&mut tui,
|
||||
MousePrimaryPressEvent {
|
||||
column: side_area.x,
|
||||
row: side_conversation_area.bottom(),
|
||||
},
|
||||
primary_press(side_area.x, 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");
|
||||
@@ -650,12 +601,9 @@ async fn primary_press_preserves_pane_drafts_and_cursors() {
|
||||
("parent draft".to_string(), "side draft".to_string())
|
||||
);
|
||||
|
||||
assert!(app.handle_owned_screen_mouse_primary_press(
|
||||
assert!(app.handle_owned_screen_mouse_primary(
|
||||
&mut tui,
|
||||
MousePrimaryPressEvent {
|
||||
column: parent_area.x,
|
||||
row: parent_conversation_area.bottom(),
|
||||
},
|
||||
primary_press(parent_area.x, parent_conversation_area.bottom()),
|
||||
));
|
||||
let mut parent_terminal = render_app(&mut app, /*width*/ 83, /*height*/ 16);
|
||||
assert_eq!(
|
||||
@@ -664,12 +612,9 @@ async fn primary_press_preserves_pane_drafts_and_cursors() {
|
||||
.expect("restored parent cursor"),
|
||||
parent_cursor
|
||||
);
|
||||
assert!(app.handle_owned_screen_mouse_primary_press(
|
||||
assert!(app.handle_owned_screen_mouse_primary(
|
||||
&mut tui,
|
||||
MousePrimaryPressEvent {
|
||||
column: side_area.x,
|
||||
row: side_conversation_area.bottom(),
|
||||
},
|
||||
primary_press(side_area.x, side_conversation_area.bottom()),
|
||||
));
|
||||
let mut side_terminal = render_app(&mut app, /*width*/ 83, /*height*/ 16);
|
||||
assert_eq!(
|
||||
@@ -696,28 +641,113 @@ async fn primary_press_does_not_switch_behind_overlay_or_popup() {
|
||||
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!(
|
||||
!app.handle_owned_screen_mouse_primary(&mut tui, primary_press(side_area.x, 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!(
|
||||
!app.handle_owned_screen_mouse_primary(&mut tui, primary_press(side_area.x, side_area.y),)
|
||||
);
|
||||
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn primary_drag_resizes_panes_without_changing_pane_state() {
|
||||
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 _initial = render_app(&mut app, /*width*/ 120, /*height*/ 12);
|
||||
let initial_parent = app
|
||||
.chat_widget
|
||||
.by_slot(PaneSlot::Parent)
|
||||
.and_then(|pane| pane.owned_screen.as_ref())
|
||||
.expect("parent screen")
|
||||
.last_pane_area;
|
||||
let initial_side = app
|
||||
.chat_widget
|
||||
.by_slot(PaneSlot::Side)
|
||||
.and_then(|pane| pane.owned_screen.as_ref())
|
||||
.expect("side screen")
|
||||
.last_pane_area;
|
||||
assert_eq!((initial_parent.width, initial_side.width), (60, 59));
|
||||
|
||||
let mut tui = crate::tui::test_support::make_test_tui().expect("create test TUI");
|
||||
app.backtrack.primed = true;
|
||||
assert!(app.handle_owned_screen_mouse_primary(
|
||||
&mut tui,
|
||||
primary_press(
|
||||
initial_parent.right(),
|
||||
initial_parent.y.saturating_add(/*rhs*/ 2)
|
||||
),
|
||||
));
|
||||
assert!(app.chat_widget.owned_screen_split_is_dragging());
|
||||
assert!(app.handle_owned_screen_mouse_primary(
|
||||
&mut tui,
|
||||
primary_event(MousePrimaryEventKind::Drag, /*column*/ 70, u16::MAX,),
|
||||
));
|
||||
|
||||
let active = render_app(&mut app, /*width*/ 120, /*height*/ 12);
|
||||
let parent = app
|
||||
.chat_widget
|
||||
.by_slot(PaneSlot::Parent)
|
||||
.and_then(|pane| pane.owned_screen.as_ref())
|
||||
.expect("parent screen")
|
||||
.last_pane_area;
|
||||
let side = app
|
||||
.chat_widget
|
||||
.by_slot(PaneSlot::Side)
|
||||
.and_then(|pane| pane.owned_screen.as_ref())
|
||||
.expect("side screen")
|
||||
.last_pane_area;
|
||||
assert_eq!((parent.width, side.width), (70, 49));
|
||||
assert_eq!(active.backend().buffer()[(parent.right(), 2)].symbol(), "┃");
|
||||
assert!(
|
||||
active.backend().buffer()[(parent.right(), 2)]
|
||||
.style()
|
||||
.add_modifier
|
||||
.contains(ratatui::style::Modifier::BOLD)
|
||||
);
|
||||
assert_snapshot!(
|
||||
"owned_screen_resized_parent_wide_dragging",
|
||||
active.backend()
|
||||
);
|
||||
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent);
|
||||
assert!(app.backtrack.primed);
|
||||
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(
|
||||
&mut tui,
|
||||
primary_event(MousePrimaryEventKind::Release, /*column*/ 70, u16::MAX,),
|
||||
));
|
||||
assert!(!app.chat_widget.owned_screen_split_is_dragging());
|
||||
let settled = render_app(&mut app, /*width*/ 120, /*height*/ 12);
|
||||
assert_eq!(
|
||||
settled.backend().buffer()[(parent.right(), 2)].symbol(),
|
||||
"│"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mouse_wheel_routes_by_pointer_without_changing_focus() {
|
||||
let mut app = app_with_owned_side().await;
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
---
|
||||
source: tui/src/app/owned_screen_tests.rs
|
||||
expression: active.backend()
|
||||
---
|
||||
" Parent ⌥ + 1 / ⌥ + 2 focus ┃ Side "
|
||||
"parent 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,10 @@ 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::MousePrimaryPress(_) => {}
|
||||
TuiEvent::Paste(_)
|
||||
| TuiEvent::MouseScroll(_)
|
||||
| TuiEvent::MousePrimary(_)
|
||||
| TuiEvent::FocusLost => {}
|
||||
TuiEvent::Draw | TuiEvent::Resize => {
|
||||
tui.draw(u16::MAX, |frame| {
|
||||
frame.render_widget_ref(&screen, frame.area());
|
||||
|
||||
@@ -99,7 +99,10 @@ 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::MousePrimaryPress(_) => {}
|
||||
TuiEvent::Paste(_)
|
||||
| TuiEvent::MouseScroll(_)
|
||||
| TuiEvent::MousePrimary(_)
|
||||
| TuiEvent::FocusLost => {}
|
||||
TuiEvent::Draw | TuiEvent::Resize => {
|
||||
let _ = tui.draw(u16::MAX, |frame| {
|
||||
frame.render_widget_ref(&screen, frame.area());
|
||||
|
||||
@@ -152,7 +152,10 @@ 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::MousePrimaryPress(_) => {}
|
||||
TuiEvent::Paste(_)
|
||||
| TuiEvent::MouseScroll(_)
|
||||
| TuiEvent::MousePrimary(_)
|
||||
| TuiEvent::FocusLost => {}
|
||||
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::MousePrimaryPress(_) => {}
|
||||
TuiEvent::MouseScroll(_) | TuiEvent::MousePrimary(_) | TuiEvent::FocusLost => {}
|
||||
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::MousePrimaryPress(_) => {}
|
||||
TuiEvent::MouseScroll(_) | TuiEvent::MousePrimary(_) | TuiEvent::FocusLost => {}
|
||||
TuiEvent::Draw | TuiEvent::Resize => {
|
||||
if let Ok(size) = alt.tui.terminal.size() {
|
||||
let list_height =
|
||||
|
||||
@@ -166,7 +166,10 @@ async fn run_startup_hooks_review_app(
|
||||
}
|
||||
}
|
||||
}
|
||||
TuiEvent::Paste(_) | TuiEvent::MouseScroll(_) | TuiEvent::MousePrimaryPress(_) => {}
|
||||
TuiEvent::Paste(_)
|
||||
| TuiEvent::MouseScroll(_)
|
||||
| TuiEvent::MousePrimary(_)
|
||||
| TuiEvent::FocusLost => {}
|
||||
TuiEvent::Draw | TuiEvent::Resize => draw_view(tui, &view)?,
|
||||
}
|
||||
}
|
||||
|
||||
+13
-3
@@ -519,8 +519,10 @@ 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 primary mouse-button lifecycle event with terminal coordinates.
|
||||
MousePrimary(MousePrimaryEvent),
|
||||
/// Notification that the terminal stopped receiving keyboard and mouse input.
|
||||
FocusLost,
|
||||
/// 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
|
||||
@@ -538,11 +540,19 @@ pub struct MouseScrollEvent {
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct MousePrimaryPressEvent {
|
||||
pub struct MousePrimaryEvent {
|
||||
pub kind: MousePrimaryEventKind,
|
||||
pub column: u16,
|
||||
pub row: u16,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MousePrimaryEventKind {
|
||||
Press,
|
||||
Drag,
|
||||
Release,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MouseScrollDirection {
|
||||
Up,
|
||||
|
||||
@@ -34,7 +34,8 @@ use tokio_stream::wrappers::BroadcastStream;
|
||||
use tokio_stream::wrappers::WatchStream;
|
||||
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
|
||||
|
||||
use super::MousePrimaryPressEvent;
|
||||
use super::MousePrimaryEvent;
|
||||
use super::MousePrimaryEventKind;
|
||||
use super::MouseScrollDirection;
|
||||
use super::MouseScrollEvent;
|
||||
use super::TuiEvent;
|
||||
@@ -184,7 +185,7 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
|
||||
/// 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 movement); loop so we keep polling
|
||||
// Some crossterm events map to None (for example, 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 = {
|
||||
@@ -277,7 +278,22 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
|
||||
row: mouse_event.row,
|
||||
})),
|
||||
MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
|
||||
Some(TuiEvent::MousePrimaryPress(MousePrimaryPressEvent {
|
||||
Some(TuiEvent::MousePrimary(MousePrimaryEvent {
|
||||
kind: MousePrimaryEventKind::Press,
|
||||
column: mouse_event.column,
|
||||
row: mouse_event.row,
|
||||
}))
|
||||
}
|
||||
MouseEventKind::Drag(crossterm::event::MouseButton::Left) => {
|
||||
Some(TuiEvent::MousePrimary(MousePrimaryEvent {
|
||||
kind: MousePrimaryEventKind::Drag,
|
||||
column: mouse_event.column,
|
||||
row: mouse_event.row,
|
||||
}))
|
||||
}
|
||||
MouseEventKind::Up(crossterm::event::MouseButton::Left) => {
|
||||
Some(TuiEvent::MousePrimary(MousePrimaryEvent {
|
||||
kind: MousePrimaryEventKind::Release,
|
||||
column: mouse_event.column,
|
||||
row: mouse_event.row,
|
||||
}))
|
||||
@@ -296,7 +312,7 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
|
||||
}
|
||||
Event::FocusLost => {
|
||||
self.terminal_focused.store(false, Ordering::Relaxed);
|
||||
None
|
||||
Some(TuiEvent::FocusLost)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -433,23 +449,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn key_event_skips_unmapped() {
|
||||
async fn focus_lost_maps_to_event_and_updates_terminal_state() {
|
||||
let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup();
|
||||
let mut stream = make_stream(broker, draw_rx, terminal_focused);
|
||||
let mut stream = make_stream(broker, draw_rx, terminal_focused.clone());
|
||||
|
||||
handle.send(Ok(Event::FocusLost));
|
||||
handle.send(Ok(Event::Key(KeyEvent::new(
|
||||
KeyCode::Char('a'),
|
||||
KeyModifiers::NONE,
|
||||
))));
|
||||
|
||||
let next = stream.next().await.unwrap();
|
||||
match next {
|
||||
TuiEvent::Key(key) => {
|
||||
assert_eq!(key, KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
|
||||
}
|
||||
other => panic!("expected key event, got {other:?}"),
|
||||
}
|
||||
assert!(matches!(stream.next().await, Some(TuiEvent::FocusLost)));
|
||||
assert!(!terminal_focused.load(Ordering::Relaxed));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
@@ -507,7 +514,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn primary_mouse_press_maps_to_event() {
|
||||
async fn primary_mouse_lifecycle_maps_to_events() {
|
||||
let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup();
|
||||
let mut stream = make_stream(broker, draw_rx, terminal_focused);
|
||||
|
||||
@@ -517,14 +524,43 @@ mod tests {
|
||||
row: 4,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
})));
|
||||
handle.send(Ok(Event::Mouse(MouseEvent {
|
||||
kind: MouseEventKind::Drag(MouseButton::Left),
|
||||
column: 5,
|
||||
row: 6,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
})));
|
||||
handle.send(Ok(Event::Mouse(MouseEvent {
|
||||
kind: MouseEventKind::Up(MouseButton::Left),
|
||||
column: 7,
|
||||
row: 8,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
})));
|
||||
|
||||
assert!(matches!(
|
||||
stream.next().await,
|
||||
Some(TuiEvent::MousePrimaryPress(MousePrimaryPressEvent {
|
||||
Some(TuiEvent::MousePrimary(MousePrimaryEvent {
|
||||
kind: MousePrimaryEventKind::Press,
|
||||
column: 3,
|
||||
row: 4,
|
||||
}))
|
||||
));
|
||||
assert!(matches!(
|
||||
stream.next().await,
|
||||
Some(TuiEvent::MousePrimary(MousePrimaryEvent {
|
||||
kind: MousePrimaryEventKind::Drag,
|
||||
column: 5,
|
||||
row: 6,
|
||||
}))
|
||||
));
|
||||
assert!(matches!(
|
||||
stream.next().await,
|
||||
Some(TuiEvent::MousePrimary(MousePrimaryEvent {
|
||||
kind: MousePrimaryEventKind::Release,
|
||||
column: 7,
|
||||
row: 8,
|
||||
}))
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
@@ -540,13 +576,25 @@ mod tests {
|
||||
modifiers: KeyModifiers::NONE,
|
||||
})));
|
||||
handle.send(Ok(Event::Mouse(MouseEvent {
|
||||
kind: MouseEventKind::Up(MouseButton::Left),
|
||||
kind: MouseEventKind::Down(MouseButton::Middle),
|
||||
column: 3,
|
||||
row: 4,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
})));
|
||||
handle.send(Ok(Event::Mouse(MouseEvent {
|
||||
kind: MouseEventKind::Drag(MouseButton::Left),
|
||||
kind: MouseEventKind::Drag(MouseButton::Right),
|
||||
column: 3,
|
||||
row: 4,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
})));
|
||||
handle.send(Ok(Event::Mouse(MouseEvent {
|
||||
kind: MouseEventKind::Up(MouseButton::Middle),
|
||||
column: 3,
|
||||
row: 4,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
})));
|
||||
handle.send(Ok(Event::Mouse(MouseEvent {
|
||||
kind: MouseEventKind::ScrollLeft,
|
||||
column: 4,
|
||||
row: 4,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
|
||||
@@ -58,7 +58,10 @@ 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::MousePrimaryPress(_) => {}
|
||||
TuiEvent::Paste(_)
|
||||
| TuiEvent::MouseScroll(_)
|
||||
| TuiEvent::MousePrimary(_)
|
||||
| TuiEvent::FocusLost => {}
|
||||
TuiEvent::Draw | TuiEvent::Resize => {
|
||||
tui.draw(u16::MAX, |frame| {
|
||||
frame.render_widget_ref(&screen, frame.area());
|
||||
|
||||
Reference in New Issue
Block a user