diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 53fecc685c..66ecf09462 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1273,7 +1273,6 @@ See the Codex keymap documentation for supported actions and examples." match &event { TuiEvent::Resize => { self.chat_widget.cancel_owned_screen_split_drag(); - self.cancel_owned_screen_selection(); } TuiEvent::FocusLost => { let split_canceled = self.chat_widget.cancel_owned_screen_split_drag(); diff --git a/codex-rs/tui/src/app/owned_screen.rs b/codex-rs/tui/src/app/owned_screen.rs index c85bc94214..b3b9294e4b 100644 --- a/codex-rs/tui/src/app/owned_screen.rs +++ b/codex-rs/tui/src/app/owned_screen.rs @@ -33,11 +33,13 @@ pub(super) struct OwnedScreen { replay_in_progress: bool, last_pane_area: Rect, last_conversation_area: Rect, + last_selection_viewport_area: Option, } struct RenderedOwnedScreen { cursor: Option<(u16, u16)>, cursor_style: SetCursorStyle, + selection_autoscroll_active: bool, } impl OwnedScreen { @@ -51,6 +53,7 @@ impl OwnedScreen { replay_in_progress: false, last_pane_area: Rect::default(), last_conversation_area: Rect::default(), + last_selection_viewport_area: None, } } @@ -86,12 +89,24 @@ impl OwnedScreen { .sync_live_tail(conversation_area.width, active_key, |width| { chat_widget.active_cell_display_snapshots(width) }); + let now = Instant::now(); + let selection_autoscroll_active = + if self.last_selection_viewport_area.replace(conversation_area) + == Some(conversation_area) + { + self.viewport + .advance_selection_autoscroll(conversation_area, now) + } else { + self.viewport + .prepare_selection_autoscroll(conversation_area, now) + }; self.viewport.render(conversation_area, buffer); bottom_pane.render(bottom_area, buffer); RenderedOwnedScreen { cursor: bottom_pane.cursor_pos(bottom_area), cursor_style: bottom_pane.cursor_style(bottom_area), + selection_autoscroll_active, } } @@ -305,10 +320,20 @@ fn render_layout( buffer, ); render_divider(divider, divider_active, buffer); - match focused { + let selection_autoscroll_active = parent_rendered + .as_ref() + .is_some_and(|rendered| rendered.selection_autoscroll_active) + || side_rendered + .as_ref() + .is_some_and(|rendered| rendered.selection_autoscroll_active); + let focused_rendered = match focused { PaneSlot::Parent => parent_rendered, PaneSlot::Side => side_rendered, - } + }; + focused_rendered.map(|mut rendered| { + rendered.selection_autoscroll_active = selection_autoscroll_active; + rendered + }) } } } @@ -594,17 +619,24 @@ impl App { let has_side = self.chat_widget.has_side(); let split_preference = self.chat_widget.owned_screen_split_preference(); let mut rendered_area = Rect::default(); + let mut selection_autoscroll_active = false; tui.draw(/*height*/ u16::MAX, |frame| { rendered_area = frame.area(); 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 { - frame.set_cursor_style(rendered.cursor_style); - frame.set_cursor_position((x, y)); + selection_autoscroll_active = rendered.selection_autoscroll_active; + if let Some((x, y)) = rendered.cursor { + frame.set_cursor_style(rendered.cursor_style); + frame.set_cursor_position((x, y)); + } } })?; + if selection_autoscroll_active { + tui.frame_requester() + .schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL); + } Ok(Some(rendered_area)) } } diff --git a/codex-rs/tui/src/app/owned_screen_tests.rs b/codex-rs/tui/src/app/owned_screen_tests.rs index 17aeeb3857..65c1107561 100644 --- a/codex-rs/tui/src/app/owned_screen_tests.rs +++ b/codex-rs/tui/src/app/owned_screen_tests.rs @@ -563,6 +563,25 @@ async fn text_drag_crosses_divider_but_divider_press_takes_priority() { .expect("parent owned screen") .selection_is_active() ); + assert!(app.handle_owned_screen_mouse_primary( + &mut tui, + primary_event(MousePrimaryEventKind::Drag, u16::MAX, u16::MAX), + )); + assert!(!app.chat_widget.owned_screen_split_is_dragging()); + assert!( + !app.chat_widget + .by_slot(PaneSlot::Side) + .and_then(|pane| pane.owned_screen.as_ref()) + .expect("side owned screen") + .selection_is_active() + ); + let selected = app + .chat_widget + .by_slot_mut(PaneSlot::Parent) + .and_then(|pane| pane.owned_screen.as_mut()) + .expect("parent owned screen") + .finish_selection(Position::new(/*x*/ u16::MAX, /*y*/ u16::MAX)); + assert_eq!(selected, Some("parent selectable".to_string())); assert!(app.handle_owned_screen_mouse_primary( &mut tui, @@ -586,6 +605,56 @@ async fn text_drag_crosses_divider_but_divider_press_takes_priority() { )); } +#[tokio::test] +async fn edge_selection_schedules_frames_and_survives_resize_events() -> Result<()> { + let mut app = app_with_owned_parent().await; + seed_pane( + &mut app, + PaneSlot::Parent, + "", + &[ + "zero", "one", "two", "three", "four", "five", "six", "seven", + ], + ); + let _terminal = render_app(&mut app, /*width*/ 40, /*height*/ 8); + let area = app + .chat_widget + .owned_screen + .as_ref() + .expect("parent owned screen") + .last_conversation_area; + let mut input_tui = crate::tui::test_support::make_test_tui().expect("create input test TUI"); + assert!(app.handle_owned_screen_mouse_primary( + &mut input_tui, + primary_press(area.x, area.bottom().saturating_sub(/*rhs*/ 1),), + )); + assert!(app.handle_owned_screen_mouse_primary( + &mut input_tui, + primary_event(MousePrimaryEventKind::Drag, area.x, area.y), + )); + let mut tui = crate::tui::test_support::make_test_tui().expect("create render test TUI"); + let mut draw_rx = tui.subscribe_draws_for_test(); + + app.render_owned_screen_frame(&mut tui) + .expect("render owned screen"); + + tokio::time::timeout(Duration::from_secs(/*secs*/ 1), draw_rx.recv()) + .await + .expect("timed out waiting for autoscroll frame") + .expect("draw channel closed"); + let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?; + app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Resize) + .await?; + assert!( + app.chat_widget + .owned_screen + .as_ref() + .expect("parent owned screen") + .selection_is_active() + ); + Ok(()) +} + #[tokio::test] async fn focused_only_layout_cancels_selection_in_the_hidden_pane() { let mut app = app_with_owned_side().await; diff --git a/codex-rs/tui/src/conversation_selection.rs b/codex-rs/tui/src/conversation_selection.rs index 4db980e6ba..677706e2ff 100644 --- a/codex-rs/tui/src/conversation_selection.rs +++ b/codex-rs/tui/src/conversation_selection.rs @@ -382,7 +382,7 @@ impl CellSelectionProjection { }) } - fn point_range(&self, row: usize, column: u16) -> Range { + pub(crate) fn point_range(&self, row: usize, column: u16) -> Range { if let Some(hit) = self.hit(row, column) { return hit; } @@ -642,6 +642,15 @@ impl ConversationSelection { self.anchor.is_some() } + pub(crate) fn endpoints(&self) -> Option<(SelectionPoint, SelectionPoint)> { + Some((self.anchor?, self.focus?)) + } + + pub(crate) fn remap_endpoints(&mut self, anchor: SelectionPoint, focus: SelectionPoint) { + self.anchor = Some(anchor); + self.focus = Some(focus); + } + pub(crate) fn selected_cell_span( &self, layout: &[SelectionCellLayout], diff --git a/codex-rs/tui/src/conversation_selection_autoscroll.rs b/codex-rs/tui/src/conversation_selection_autoscroll.rs new file mode 100644 index 0000000000..aedef5f8a6 --- /dev/null +++ b/codex-rs/tui/src/conversation_selection_autoscroll.rs @@ -0,0 +1,156 @@ +//! Edge-driven vertical autoscroll state for conversation text selection. +//! +//! This module deliberately owns no clock or scrollable content. The viewport supplies elapsed +//! time and applies the returned row delta, which keeps animation tests deterministic and lets the +//! viewport stop the controller when it reaches a content boundary. + +use std::time::Duration; + +use ratatui::layout::Position; +use ratatui::layout::Rect; + +const EDGE_THRESHOLD_ROWS: i64 = 3; +const ROWS_PER_SECOND_SLOW: i128 = 6; +const ROWS_PER_SECOND_MEDIUM: i128 = 36; +const ROWS_PER_SECOND_FAST: i128 = 72; +const NANOS_PER_SECOND: i128 = 1_000_000_000; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum AutoscrollDirection { + Up, + Down, +} + +impl AutoscrollDirection { + fn sign(self) -> i128 { + match self { + Self::Up => -1, + Self::Down => 1, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct AutoscrollStep { + pub(crate) direction: AutoscrollDirection, + pub(crate) rows: usize, + pub(crate) pointer: Position, +} + +/// Accumulates edge-autoscroll motion while retaining the drag's raw terminal position. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct SelectionAutoscroll { + pointer: Option, + direction: Option, + rows_per_second: i128, + fractional_row_nanos: i128, +} + +impl SelectionAutoscroll { + /// Updates the unclamped pointer used both for edge detection and later selection projection. + pub(crate) fn update_pointer(&mut self, area: Rect, pointer: Position) { + self.pointer = Some(pointer); + let Some(direction) = vertical_direction(area, pointer) else { + self.stop(); + return; + }; + + if self.direction.is_none() { + self.fractional_row_nanos = 0; + } + self.direction = Some(direction); + self.rows_per_second = rows_per_second(area, pointer); + } + + /// Advances the controller and returns only complete rows of motion. + pub(crate) fn advance(&mut self, elapsed: Duration) -> Option { + let direction = self.direction?; + let pointer = self.pointer?; + let elapsed_nanos = i128::try_from(elapsed.as_nanos()).unwrap_or(i128::MAX); + let row_nanos = elapsed_nanos + .saturating_mul(self.rows_per_second) + .saturating_mul(direction.sign()); + self.fractional_row_nanos = self.fractional_row_nanos.saturating_add(row_nanos); + + let whole_rows = self.fractional_row_nanos / NANOS_PER_SECOND; + if whole_rows == 0 { + return None; + } + self.fractional_row_nanos = self + .fractional_row_nanos + .saturating_sub(whole_rows.saturating_mul(NANOS_PER_SECOND)); + + let direction = if whole_rows.is_negative() { + AutoscrollDirection::Up + } else { + AutoscrollDirection::Down + }; + let rows = usize::try_from(whole_rows.unsigned_abs()).unwrap_or(usize::MAX); + Some(AutoscrollStep { + direction, + rows, + pointer, + }) + } + + pub(crate) fn needs_frame(&self) -> bool { + self.direction.is_some() + } + + pub(crate) fn pointer(&self) -> Option { + self.pointer + } + + /// Stops motion and discards fractional progress while preserving the last raw pointer. + pub(crate) fn stop(&mut self) { + self.direction = None; + self.rows_per_second = 0; + self.fractional_row_nanos = 0; + } + + /// Clears the gesture entirely so a later unrelated draw cannot re-arm a blocked edge. + pub(crate) fn reset(&mut self) { + self.stop(); + self.pointer = None; + } +} + +fn vertical_direction(area: Rect, pointer: Position) -> Option { + if area.height == 0 { + return None; + } + + let relative_y = i64::from(pointer.y) - i64::from(area.y); + let distance_to_top = relative_y; + let distance_to_bottom = i64::from(area.height) - relative_y; + match ( + distance_to_top <= EDGE_THRESHOLD_ROWS, + distance_to_bottom <= EDGE_THRESHOLD_ROWS, + ) { + (true, true) if distance_to_bottom < distance_to_top => Some(AutoscrollDirection::Down), + (true, _) => Some(AutoscrollDirection::Up), + (_, true) => Some(AutoscrollDirection::Down), + (false, false) => None, + } +} + +fn rows_per_second(area: Rect, pointer: Position) -> i128 { + let relative_x = i64::from(pointer.x) - i64::from(area.x); + let relative_y = i64::from(pointer.y) - i64::from(area.y); + let min_distance = relative_x + .min(i64::from(area.width) - relative_x) + .min(relative_y) + .min(i64::from(area.height) - relative_y); + + if min_distance <= 1 { + ROWS_PER_SECOND_FAST + } else if min_distance <= 2 { + ROWS_PER_SECOND_MEDIUM + } else { + ROWS_PER_SECOND_SLOW + } +} + +#[cfg(test)] +#[path = "conversation_selection_autoscroll_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/conversation_selection_autoscroll_tests.rs b/codex-rs/tui/src/conversation_selection_autoscroll_tests.rs new file mode 100644 index 0000000000..2c9ad6c35f --- /dev/null +++ b/codex-rs/tui/src/conversation_selection_autoscroll_tests.rs @@ -0,0 +1,93 @@ +use std::time::Duration; + +use pretty_assertions::assert_eq; +use ratatui::layout::Position; +use ratatui::layout::Rect; + +use super::*; + +const AREA: Rect = Rect::new( + /*x*/ 10, /*y*/ 20, /*width*/ 40, /*height*/ 20, +); + +fn advance_ms(autoscroll: &mut SelectionAutoscroll, millis: u64) -> Option { + autoscroll.advance(Duration::from_millis(millis)) +} + +#[test] +fn edge_direction_and_speed_match_opentui() { + let cases = [ + (20, Some((AutoscrollDirection::Up, 36))), + (22, Some((AutoscrollDirection::Up, 18))), + (23, Some((AutoscrollDirection::Up, 3))), + (24, None), + (36, None), + (37, Some((AutoscrollDirection::Down, 3))), + (38, Some((AutoscrollDirection::Down, 18))), + (39, Some((AutoscrollDirection::Down, 36))), + ]; + for (y, expected) in cases { + let pointer = Position::new(/*x*/ 30, y); + let mut autoscroll = SelectionAutoscroll::default(); + autoscroll.update_pointer(AREA, pointer); + assert_eq!( + advance_ms(&mut autoscroll, /*millis*/ 500), + expected.map(|(direction, rows)| AutoscrollStep { + direction, + rows, + pointer, + }) + ); + } + + let pointer = Position::new(/*x*/ 55, /*y*/ 7); + let mut outside = SelectionAutoscroll::default(); + outside.update_pointer(AREA, pointer); + assert_eq!(outside.pointer(), Some(pointer)); + assert_eq!( + advance_ms(&mut outside, /*millis*/ 500), + Some(AutoscrollStep { + direction: AutoscrollDirection::Up, + rows: 36, + pointer, + }) + ); + + let short_area = Rect::new( + /*x*/ 10, /*y*/ 20, /*width*/ 40, /*height*/ 3, + ); + let mut short = SelectionAutoscroll::default(); + short.update_pointer(short_area, Position::new(/*x*/ 30, /*y*/ 22)); + assert_eq!( + advance_ms(&mut short, /*millis*/ 500).map(|step| step.direction), + Some(AutoscrollDirection::Down) + ); +} + +#[test] +fn fractional_motion_stops_resets_and_changes_direction() { + let top = Position::new(/*x*/ 30, /*y*/ 23); + let bottom = Position::new(/*x*/ 30, /*y*/ 37); + let mut autoscroll = SelectionAutoscroll::default(); + autoscroll.update_pointer(AREA, top); + assert_eq!(advance_ms(&mut autoscroll, /*millis*/ 100), None); + assert_eq!( + advance_ms(&mut autoscroll, /*millis*/ 67).map(|step| step.rows), + Some(1) + ); + + autoscroll.update_pointer(AREA, bottom); + assert_eq!(advance_ms(&mut autoscroll, /*millis*/ 100), None); + assert_eq!( + advance_ms(&mut autoscroll, /*millis*/ 150).map(|step| step.direction), + Some(AutoscrollDirection::Down) + ); + + autoscroll.update_pointer(AREA, Position::new(/*x*/ 30, /*y*/ 30)); + assert!(!autoscroll.needs_frame()); + autoscroll.update_pointer(AREA, top); + assert_eq!(advance_ms(&mut autoscroll, /*millis*/ 100), None); + autoscroll.reset(); + assert_eq!(autoscroll.pointer(), None); + assert_eq!(autoscroll.advance(Duration::from_secs(/*secs*/ 1)), None); +} diff --git a/codex-rs/tui/src/conversation_selection_reflow.rs b/codex-rs/tui/src/conversation_selection_reflow.rs new file mode 100644 index 0000000000..14e43cea44 --- /dev/null +++ b/codex-rs/tui/src/conversation_selection_reflow.rs @@ -0,0 +1,245 @@ +//! Source-backed endpoint bookmarks for selection-preserving conversation reflow. + +use std::ops::Range; + +use crate::conversation_selection::CellSelectionProjection; +use crate::conversation_selection::ConversationSelection; +use crate::conversation_selection::SelectionCellLayout; +use crate::conversation_selection::SelectionPoint; + +#[derive(Clone, Copy, Eq, PartialEq)] +enum SelectionEndpointEdge { + Start, + End, +} + +pub(crate) struct SelectionBookmarks { + anchor: SelectionBookmark, + focus: SelectionBookmark, + focus_screen_row: usize, +} + +pub(crate) struct RestoredSelection { + pub(crate) anchor: SelectionPoint, + pub(crate) focus: SelectionPoint, + pub(crate) scroll_offset: usize, +} + +enum SelectionBookmark { + Projected { + cell: usize, + source: Range, + edge: SelectionEndpointEdge, + fallback_row: usize, + fallback_column: u16, + }, + CellRow { + cell: usize, + row: usize, + column: u16, + }, + BeforeCell { + cell: usize, + column: u16, + }, + AfterContent { + column: u16, + }, +} + +impl SelectionBookmarks { + pub(crate) fn capture( + selection: &ConversationSelection, + projections: &[Option], + layout: &[SelectionCellLayout], + scroll_offset: usize, + ) -> Option { + let (anchor, focus) = selection.endpoints()?; + let (anchor_edge, focus_edge) = if anchor <= focus { + (SelectionEndpointEdge::Start, SelectionEndpointEdge::End) + } else { + (SelectionEndpointEdge::End, SelectionEndpointEdge::Start) + }; + Some(Self { + anchor: SelectionBookmark::capture(anchor, anchor_edge, projections, layout), + focus: SelectionBookmark::capture(focus, focus_edge, projections, layout), + focus_screen_row: focus.row.saturating_sub(scroll_offset), + }) + } + + pub(crate) fn projected_cells(&self) -> impl Iterator + '_ { + [self.anchor.projected_cell(), self.focus.projected_cell()] + .into_iter() + .flatten() + } + + pub(crate) fn restore( + self, + projections: &[Option], + layout: &[SelectionCellLayout], + width: u16, + ) -> Option { + let anchor = self.anchor.restore(projections, layout, width)?; + let focus = self.focus.restore(projections, layout, width)?; + Some(RestoredSelection { + anchor, + focus, + scroll_offset: focus.row.saturating_sub(self.focus_screen_row), + }) + } +} + +impl SelectionBookmark { + fn capture( + point: SelectionPoint, + edge: SelectionEndpointEdge, + projections: &[Option], + layout: &[SelectionCellLayout], + ) -> Self { + let containing_cell = layout.iter().copied().enumerate().find(|(_, layout)| { + layout.height > 0 + && point.row >= layout.top + && point.row < layout.top.saturating_add(layout.height) + }); + if let Some((cell, cell_layout)) = containing_cell { + let row = point.row.saturating_sub(cell_layout.top); + return if let Some(projection) = projections.get(cell).and_then(Option::as_ref) { + Self::Projected { + cell, + source: projection.point_range(row, point.column), + edge, + fallback_row: row, + fallback_column: point.column, + } + } else { + Self::CellRow { + cell, + row, + column: point.column, + } + }; + } + + if let Some(cell) = layout.iter().position(|layout| layout.top > point.row) { + Self::BeforeCell { + cell, + column: point.column, + } + } else { + Self::AfterContent { + column: point.column, + } + } + } + + fn projected_cell(&self) -> Option { + match self { + Self::Projected { cell, .. } => Some(*cell), + Self::CellRow { .. } | Self::BeforeCell { .. } | Self::AfterContent { .. } => None, + } + } + + fn restore( + self, + projections: &[Option], + layout: &[SelectionCellLayout], + width: u16, + ) -> Option { + let max_column = width.saturating_sub(1); + match self { + Self::Projected { + cell, + source, + edge, + fallback_row, + fallback_column, + } => { + let cell_layout = *layout.get(cell)?; + let (row, column) = projections + .get(cell) + .and_then(Option::as_ref) + .and_then(|projection| { + point_for_source_range(projection, &source, edge, width, fallback_row) + }) + .unwrap_or(( + fallback_row.min(cell_layout.height.saturating_sub(1)), + fallback_column.min(max_column), + )); + Some(SelectionPoint { + row: cell_layout.top.saturating_add(row), + column, + }) + } + Self::CellRow { cell, row, column } => { + let cell_layout = *layout.get(cell)?; + Some(SelectionPoint { + row: cell_layout + .top + .saturating_add(row.min(cell_layout.height.saturating_sub(1))), + column: column.min(max_column), + }) + } + Self::BeforeCell { cell, column } => Some(SelectionPoint { + row: layout.get(cell)?.top.saturating_sub(1), + column: column.min(max_column), + }), + Self::AfterContent { column } => Some(SelectionPoint { + row: layout + .last() + .map(|layout| layout.top.saturating_add(layout.height)) + .unwrap_or_default(), + column: column.min(max_column), + }), + } + } +} + +fn point_for_source_range( + projection: &CellSelectionProjection, + source: &Range, + edge: SelectionEndpointEdge, + width: u16, + preferred_row: usize, +) -> Option<(usize, u16)> { + if !source.is_empty() { + return projection + .rows() + .iter() + .enumerate() + .find_map(|(row, selection_row)| { + selection_row.segments.iter().find_map(|segment| { + (segment.bytes.start <= source.start && segment.bytes.end >= source.end) + .then_some((row, segment.columns.start)) + }) + }); + } + + let boundary = source.start; + let mut rows = (0..projection.rows().len()).collect::>(); + rows.sort_by_key(|row| row.abs_diff(preferred_row)); + for row in rows { + let mut columns = vec![0, width.saturating_sub(1)]; + for pair in projection.rows()[row].segments.windows(2) { + let [left, right] = pair else { + continue; + }; + columns.push(left.columns.end); + columns.push(right.columns.start.saturating_sub(1)); + } + columns.sort_unstable(); + columns.dedup(); + if edge == SelectionEndpointEdge::End { + columns.reverse(); + } + if let Some(column) = columns.into_iter().find(|column| { + let range = projection.point_range(row, *column); + match edge { + SelectionEndpointEdge::Start => range.start == boundary, + SelectionEndpointEdge::End => range.end == boundary, + } + }) { + return Some((row, column)); + } + } + None +} diff --git a/codex-rs/tui/src/conversation_viewport.rs b/codex-rs/tui/src/conversation_viewport.rs index 6b7b1f2002..e4723b5df4 100644 --- a/codex-rs/tui/src/conversation_viewport.rs +++ b/codex-rs/tui/src/conversation_viewport.rs @@ -7,6 +7,8 @@ use std::cell::Cell; use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; use ratatui::buffer::Buffer; use ratatui::crossterm::event::KeyEvent; @@ -24,6 +26,9 @@ use crate::conversation_selection::CellSelectionProjection; use crate::conversation_selection::ConversationSelection; use crate::conversation_selection::SelectionCellLayout; use crate::conversation_selection::SelectionPoint; +use crate::conversation_selection_autoscroll::AutoscrollDirection; +use crate::conversation_selection_autoscroll::SelectionAutoscroll; +use crate::conversation_selection_reflow::SelectionBookmarks; use crate::history_cell::HistoryCell; use crate::history_cell::HistoryRenderMode; use crate::keymap::PagerKeymap; @@ -46,7 +51,10 @@ pub(crate) struct ConversationViewport { deferred_cells: Option>>, deferred_render_mode: Option, selection: ConversationSelection, + selection_pointer: Option, selection_projection_cache: Option, + selection_autoscroll: SelectionAutoscroll, + selection_autoscroll_last_tick: Option, } struct SelectionProjectionCache { @@ -81,7 +89,10 @@ impl ConversationViewport { deferred_cells: None, deferred_render_mode: None, selection: ConversationSelection::default(), + selection_pointer: None, selection_projection_cache: None, + selection_autoscroll: SelectionAutoscroll::default(), + selection_autoscroll_last_tick: None, } } @@ -91,9 +102,11 @@ impl ConversationViewport { } else { BottomFollowMode::Enabled }; - self.content.render(area, buf, bottom_follow); if self.selection_projection_cache.is_some() { self.ensure_selection_projections(area.width); + } + self.content.render(area, buf, bottom_follow); + if self.selection_projection_cache.is_some() { self.ensure_selected_projections(); self.render_selection(area, buf); } @@ -117,7 +130,7 @@ impl ConversationViewport { direction: MouseScrollDirection, position: Position, ) { - self.content.handle_mouse_scroll(direction); + self.content.scroll_mouse_wheel_rows(direction); self.update_selection(area, position); } @@ -125,6 +138,8 @@ impl ConversationViewport { let Some(point) = self.selection_point(area, position, /*clamp*/ false) else { return false; }; + self.stop_selection_autoscroll(); + self.selection_pointer = None; self.selection.start(point); true } @@ -133,11 +148,29 @@ impl ConversationViewport { let Some(point) = self.selection_point(area, position, /*clamp*/ true) else { return false; }; - self.selection.update(point) + let updated = self.selection.update(point); + if updated { + self.selection_pointer = Some(position); + let was_active = self.selection_autoscroll.needs_frame(); + self.selection_autoscroll.update_pointer(area, position); + match (was_active, self.selection_autoscroll.needs_frame()) { + (false, true) => self.selection_autoscroll_last_tick = Some(Instant::now()), + (_, false) => self.selection_autoscroll_last_tick = None, + (true, true) => {} + } + } + updated } pub(crate) fn finish_selection(&mut self, area: Rect, position: Position) -> Option { - let point = self.selection_point(area, position, /*clamp*/ true); + let release_matches_last_drag = self.selection_pointer == Some(position); + self.stop_selection_autoscroll(); + self.selection_pointer = None; + let point = if release_matches_last_drag { + None + } else { + self.selection_point(area, position, /*clamp*/ true) + }; self.selection.set_release_point(point); self.ensure_selected_projections(); let selected = if let Some(cache) = self.selection_projection_cache.as_ref() { @@ -151,6 +184,8 @@ impl ConversationViewport { } pub(crate) fn cancel_selection(&mut self) { + self.stop_selection_autoscroll(); + self.selection_pointer = None; self.selection.cancel(); self.apply_deferred_state(); } @@ -159,6 +194,62 @@ impl ConversationViewport { self.selection.is_active() } + pub(crate) fn advance_selection_autoscroll(&mut self, area: Rect, now: Instant) -> bool { + let Some(pointer) = self.selection_autoscroll.pointer() else { + return false; + }; + self.selection_autoscroll.update_pointer(area, pointer); + if !self.selection_autoscroll.needs_frame() { + self.selection_autoscroll_last_tick = None; + return false; + } + let elapsed = self + .selection_autoscroll_last_tick + .replace(now) + .map_or(Duration::ZERO, |last_tick| { + now.saturating_duration_since(last_tick) + }); + self.advance_selection_autoscroll_by(area, elapsed) + } + + pub(crate) fn prepare_selection_autoscroll(&mut self, area: Rect, now: Instant) -> bool { + let Some(pointer) = self.selection_autoscroll.pointer() else { + return false; + }; + self.selection_autoscroll.update_pointer(area, pointer); + let needs_frame = self.selection_autoscroll.needs_frame(); + self.selection_autoscroll_last_tick = needs_frame.then_some(now); + needs_frame + } + + fn advance_selection_autoscroll_by(&mut self, area: Rect, elapsed: Duration) -> bool { + let Some(step) = self.selection_autoscroll.advance(elapsed) else { + return self.selection_autoscroll.needs_frame(); + }; + let direction = match step.direction { + AutoscrollDirection::Up => MouseScrollDirection::Up, + AutoscrollDirection::Down => MouseScrollDirection::Down, + }; + let moved = self.content.scroll_rows(direction, step.rows); + if moved == 0 { + self.stop_selection_autoscroll(); + return false; + } + if let Some(point) = self.selection_point(area, step.pointer, /*clamp*/ true) { + self.selection.update(point); + } + if moved < step.rows { + self.stop_selection_autoscroll(); + return false; + } + true + } + + fn stop_selection_autoscroll(&mut self) { + self.selection_autoscroll.reset(); + self.selection_autoscroll_last_tick = None; + } + pub(crate) fn push_cell(&mut self, cell: Arc) { if self.selection.is_active() { self.deferred_cells @@ -240,7 +331,17 @@ impl ConversationViewport { if width_is_stable { return; } - self.cancel_selection(); + match (self.live_tail_key, active_key) { + (None, _) => return, + (Some(current), Some(next)) + if current.revision == next.revision + && current.is_stream_continuation == next.is_stream_continuation => + { + // The semantic live tail is unchanged, so it is safe to re-render it at the + // new width before remapping the selection's source-backed endpoints. + } + _ => self.cancel_selection(), + } } let next_key = active_key.map(|key| LiveTailKey { width, @@ -252,9 +353,11 @@ impl ConversationViewport { return; } - let follow_bottom = self.content.is_following_bottom(); + let follow_bottom = !self.selection.is_active() && self.content.is_following_bottom(); self.take_live_tail_renderables(); - self.selection_projection_cache = None; + if !self.selection.is_active() { + self.selection_projection_cache = None; + } self.live_tail_key = next_key; self.live_cells.clear(); if next_key.is_some() { @@ -381,6 +484,50 @@ impl ConversationViewport { } } + fn selection_bookmarks(&mut self) -> Option { + let (anchor, focus) = self.selection.endpoints()?; + for point in [anchor, focus] { + let cell = self + .selection_projection_cache + .as_ref()? + .layout + .iter() + .position(|layout| { + layout.height > 0 + && point.row >= layout.top + && point.row < layout.top.saturating_add(layout.height) + }); + if let Some(cell) = cell { + self.ensure_cell_projection(cell); + } + } + let cache = self.selection_projection_cache.as_ref()?; + SelectionBookmarks::capture( + &self.selection, + &cache.projections, + &cache.layout, + self.content.scroll_offset(), + ) + } + + fn restore_selection_bookmarks(&mut self, bookmarks: SelectionBookmarks) -> bool { + let projected_cells = bookmarks.projected_cells().collect::>(); + for cell in projected_cells { + self.ensure_cell_projection(cell); + } + let Some(restored) = self + .selection_projection_cache + .as_ref() + .and_then(|cache| bookmarks.restore(&cache.projections, &cache.layout, cache.width)) + else { + return false; + }; + self.selection + .remap_endpoints(restored.anchor, restored.focus); + self.content.set_scroll_offset(restored.scroll_offset); + true + } + fn ensure_selection_projections(&mut self, width: u16) { let current_cell_count = self.cells.len().saturating_add(self.live_cells.len()); let cache_matches = self @@ -394,7 +541,10 @@ impl ConversationViewport { if cache_matches { return; } - self.cancel_selection(); + let selection_was_active = self.selection.is_active(); + let bookmarks = selection_was_active + .then(|| self.selection_bookmarks()) + .flatten(); let selection_cell_count = self.cells.len().saturating_add(self.live_cells.len()); let renderable_heights = self.content.renderable_heights(width); let mut content_top = 0usize; @@ -451,6 +601,11 @@ impl ConversationViewport { computed, layout, }); + if selection_was_active + && !bookmarks.is_some_and(|bookmarks| self.restore_selection_bookmarks(bookmarks)) + { + self.cancel_selection(); + } } fn ensure_cell_projection(&mut self, index: usize) { diff --git a/codex-rs/tui/src/conversation_viewport_tests.rs b/codex-rs/tui/src/conversation_viewport_tests.rs index b55a0d77f6..8a656a554b 100644 --- a/codex-rs/tui/src/conversation_viewport_tests.rs +++ b/codex-rs/tui/src/conversation_viewport_tests.rs @@ -12,6 +12,7 @@ use ratatui::style::Color; use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::text::Line; +use std::time::Duration; use super::*; use crate::history_cell::AgentMarkdownCell; @@ -743,6 +744,58 @@ fn mouse_wheel_extends_an_active_selection_into_offscreen_history() { ); } +#[test] +fn holding_the_top_edge_autoscrolls_selection_into_offscreen_history() { + let mut viewport = viewport(vec![ + cell("zero"), + cell("one"), + cell("two"), + cell("three"), + cell("four"), + cell("five"), + cell("six"), + cell("seven"), + ]); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 20, /*height*/ 3, + ); + viewport.render(area, &mut Buffer::empty(area)); + assert_eq!(viewport.content.scroll_offset(), 12); + + let anchor = Position::new(/*x*/ 4, /*y*/ 2); + let edge = Position::new(/*x*/ 0, /*y*/ 0); + assert!(viewport.begin_selection(area, anchor)); + assert!(viewport.update_selection(area, edge)); + + assert!(viewport.advance_selection_autoscroll_by(area, Duration::from_millis(/*millis*/ 100),)); + assert_eq!(viewport.content.scroll_offset(), 5); + + let mut highlighted = Buffer::empty(area); + viewport.render(area, &mut highlighted); + let text = trimmed_buffer_text(&highlighted, area); + let mask = selection_mask(&highlighted, area); + assert_snapshot!(format!("text:\n{text}\nselection:\n{mask}")); + assert_eq!( + viewport.finish_selection(area, edge), + Some("three\n\nfour\n\nfive\n\nsix\n\nseven".to_string()) + ); +} + +#[test] +fn edge_autoscroll_stops_requesting_frames_at_history_boundary() { + let mut viewport = viewport(vec![cell("only")]); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 20, /*height*/ 3, + ); + viewport.render(area, &mut Buffer::empty(area)); + let edge = Position::new(/*x*/ 0, /*y*/ 0); + assert!(viewport.begin_selection(area, edge)); + assert!(viewport.update_selection(area, edge)); + + assert!(!viewport.advance_selection_autoscroll_by(area, Duration::from_secs(/*secs*/ 1),)); + assert!(!viewport.selection_autoscroll.needs_frame()); +} + #[test] fn selection_hit_testing_accounts_for_scroll_offset_and_cell_spacing() { let mut viewport = viewport(vec![cell("oldest"), cell("LATEST")]); @@ -781,6 +834,9 @@ fn selection_can_start_in_right_padding_and_drag_backward() { assert!(viewport.begin_selection(area, Position::new(/*x*/ 19, /*y*/ 0))); assert!(viewport.update_selection(area, Position::new(/*x*/ 1, /*y*/ 0))); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 10, /*height*/ 1, + ); let mut highlighted = Buffer::empty(area); viewport.render(area, &mut highlighted); let mask = (area.x..area.right()) @@ -795,7 +851,7 @@ fn selection_can_start_in_right_padding_and_drag_backward() { } }) .collect::(); - assert_snapshot!(mask, @".####..............."); + assert_snapshot!(mask, @".####....."); assert_eq!( viewport.finish_selection(area, Position::new(/*x*/ 1, /*y*/ 0)), Some("ello".to_string()) @@ -884,7 +940,7 @@ fn replacing_cells_is_deferred_until_active_selection_finishes() { } #[test] -fn width_change_safely_applies_a_deferred_shrinking_replacement() { +fn width_change_preserves_selection_before_applying_a_deferred_replacement() { let mut viewport = viewport(vec![cell("first"), cell("second")]); let wide = Rect::new( /*x*/ 0, /*y*/ 0, /*width*/ 20, /*height*/ 3, @@ -899,13 +955,48 @@ fn width_change_safely_applies_a_deferred_shrinking_replacement() { ); viewport.render(narrow, &mut Buffer::empty(narrow)); - assert!(!viewport.selection_is_active()); + assert!(viewport.selection_is_active()); + assert_eq!(viewport.committed_cell_count(), 2); + assert_eq!( + viewport.finish_selection(narrow, Position::new(/*x*/ 4, /*y*/ 0)), + Some("first".to_string()) + ); assert_eq!(viewport.committed_cell_count(), 1); let mut updated = Buffer::empty(narrow); viewport.render(narrow, &mut updated); assert!(buffer_text(&updated, narrow).starts_with("new")); } +#[test] +fn width_change_remaps_selection_to_the_same_source_range() { + let source = "alpha beta gamma delta epsilon"; + let mut viewport = viewport(vec![cell("012345678901234567890123456789"), cell(source)]); + let wide = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 60, /*height*/ 2, + ); + viewport.render(wide, &mut Buffer::empty(wide)); + assert_eq!(viewport.content.scroll_offset(), 1); + assert!(viewport.begin_selection(wide, Position::new(/*x*/ 6, /*y*/ 1))); + assert!(viewport.update_selection(wide, Position::new(/*x*/ 15, /*y*/ 1))); + + let narrow = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 10, /*height*/ 4, + ); + let mut highlighted = Buffer::empty(narrow); + viewport.render(narrow, &mut highlighted); + + assert!(viewport.selection_is_active()); + assert_eq!(viewport.content.scroll_offset(), 4); + let text = trimmed_buffer_text(&highlighted, narrow); + let mask = selection_mask(&highlighted, narrow); + assert_snapshot!(format!("text:\n{text}\nselection:\n{mask}")); + + assert_eq!( + viewport.finish_selection(narrow, Position::new(/*x*/ 15, /*y*/ 1)), + Some("beta gamma".to_string()) + ); +} + #[test] fn committed_stream_chunks_are_deferred_until_active_selection_finishes() { let mut viewport = viewport(vec![cell("stable")]); @@ -933,7 +1024,8 @@ fn committed_stream_chunks_are_deferred_until_active_selection_finishes() { #[test] fn live_tail_updates_are_frozen_while_selecting_live_text() { - let mut viewport = viewport(vec![cell("committed")]); + let live_text = "live one wraps across several terminal rows"; + let mut viewport = viewport(vec![cell("older"), cell("012345678901234567890123456789")]); viewport.sync_live_tail( /*width*/ 20, Some(ActiveCellRenderKey { @@ -943,7 +1035,7 @@ fn live_tail_updates_are_frozen_while_selecting_live_text() { }), |width| { Some(vec![live_cell( - width, "live one", /*is_stream_continuation*/ false, + width, live_text, /*is_stream_continuation*/ false, )]) }, ); @@ -951,10 +1043,30 @@ fn live_tail_updates_are_frozen_while_selecting_live_text() { /*x*/ 0, /*y*/ 0, /*width*/ 20, /*height*/ 4, ); viewport.render(area, &mut Buffer::empty(area)); - assert!(viewport.begin_selection(area, Position::new(/*x*/ 0, /*y*/ 2),)); + assert!(viewport.begin_selection(area, Position::new(/*x*/ 0, /*y*/ 1),)); + + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 10, /*height*/ 4, + ); + viewport.sync_live_tail( + area.width, + Some(ActiveCellRenderKey { + revision: 1, + is_stream_continuation: false, + animation_tick: Some(1), + }), + |width| { + Some(vec![live_cell( + width, live_text, /*is_stream_continuation*/ false, + )]) + }, + ); + viewport.render(area, &mut Buffer::empty(area)); + assert!(viewport.selection_is_active()); + assert_eq!(viewport.content.scroll_offset(), 5); viewport.sync_live_tail( - /*width*/ 20, + area.width, Some(ActiveCellRenderKey { revision: 1, is_stream_continuation: false, @@ -974,12 +1086,12 @@ fn live_tail_updates_are_frozen_while_selecting_live_text() { assert!(viewport.selection_is_active()); assert!(buffer_text(&frozen, area).contains("live one")); assert!(!buffer_text(&frozen, area).contains("live two")); - assert!(viewport.update_selection(area, Position::new(/*x*/ 7, /*y*/ 2),)); + assert!(viewport.update_selection(area, Position::new(/*x*/ 7, /*y*/ 1),)); let mut highlighted = Buffer::empty(area); viewport.render(area, &mut highlighted); let mask = (area.x..area.right()) .map(|x| { - if highlighted[(x, area.y.saturating_add(/*rhs*/ 2))] + if highlighted[(x, area.y.saturating_add(/*rhs*/ 1))] .modifier .contains(Modifier::REVERSED) { @@ -989,9 +1101,9 @@ fn live_tail_updates_are_frozen_while_selecting_live_text() { } }) .collect::(); - assert_snapshot!(mask, @"########............"); + assert_snapshot!(mask, @"########.."); assert_eq!( - viewport.finish_selection(area, Position::new(/*x*/ 7, /*y*/ 2)), + viewport.finish_selection(area, Position::new(/*x*/ 7, /*y*/ 1)), Some("live one".to_string()) ); } @@ -1367,25 +1479,8 @@ fn selected_user_prompt_highlights_source_glyphs_without_chrome_or_padding() { let mut highlighted = Buffer::empty(area); viewport.render(area, &mut highlighted); - let text = buffer_text(&highlighted, area) - .lines() - .map(str::trim_end) - .collect::>() - .join("\n"); - let mask = (area.y..area.bottom()) - .map(|y| { - (area.x..area.right()) - .map(|x| { - if highlighted[(x, y)].modifier.contains(Modifier::REVERSED) { - '#' - } else { - '.' - } - }) - .collect::() - }) - .collect::>() - .join("\n"); + let text = trimmed_buffer_text(&highlighted, area); + let mask = selection_mask(&highlighted, area); assert_snapshot!(format!("text:\n{text}\nselection:\n{mask}")); assert_eq!( @@ -1405,3 +1500,28 @@ fn buffer_text(buffer: &Buffer, area: Rect) -> String { } rows.join("\n") } + +fn trimmed_buffer_text(buffer: &Buffer, area: Rect) -> String { + buffer_text(buffer, area) + .lines() + .map(str::trim_end) + .collect::>() + .join("\n") +} + +fn selection_mask(buffer: &Buffer, area: Rect) -> String { + (area.y..area.bottom()) + .map(|y| { + (area.x..area.right()) + .map(|x| { + if buffer[(x, y)].modifier.contains(Modifier::REVERSED) { + '#' + } else { + '.' + } + }) + .collect::() + }) + .collect::>() + .join("\n") +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 4966a8d625..64fee0ff87 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -106,6 +106,8 @@ mod collaboration_modes; mod color; mod config_update; mod conversation_selection; +mod conversation_selection_autoscroll; +mod conversation_selection_reflow; mod conversation_viewport; pub(crate) mod custom_terminal; mod pets; diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 557b913cf1..7087587e31 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -520,9 +520,49 @@ impl PagerContent { self.view.apply_mouse_scroll(direction); } + pub(crate) fn scroll_mouse_wheel_rows(&mut self, direction: MouseScrollDirection) -> usize { + self.scroll_rows(direction, MOUSE_SCROLL_ROWS) + } + + /// Scrolls by an explicit number of rows and returns the distance actually travelled. + /// + /// Unlike the pager's ordinary input handlers, this clamps immediately against the most + /// recently rendered content. Selection autoscroll needs the resolved offset before it maps + /// the stationary pointer back into content coordinates. + pub(crate) fn scroll_rows(&mut self, direction: MouseScrollDirection, rows: usize) -> usize { + if let (Some(content_height), Some(viewport_height)) = ( + self.view.last_rendered_height, + self.view.last_content_height, + ) { + self.view + .resolve_scroll_offset(content_height, viewport_height); + } + let before = self.view.scroll_offset; + match direction { + MouseScrollDirection::Up => self.view.scroll_up_rows(rows), + MouseScrollDirection::Down => self.view.scroll_down_rows(rows), + } + if let (Some(content_height), Some(viewport_height)) = ( + self.view.last_rendered_height, + self.view.last_content_height, + ) { + self.view + .resolve_scroll_offset(content_height, viewport_height); + } + match direction { + MouseScrollDirection::Up => before.saturating_sub(self.view.scroll_offset), + MouseScrollDirection::Down => self.view.scroll_offset.saturating_sub(before), + } + } + pub(crate) fn scroll_offset(&self) -> usize { self.view.scroll_offset } + + pub(crate) fn set_scroll_offset(&mut self, scroll_offset: usize) { + self.view.scroll_offset = scroll_offset; + self.view.pending_rows_from_bottom = 0; + } } /// A renderable that caches its desired height. diff --git a/codex-rs/tui/src/snapshots/codex_tui__conversation_viewport__tests__holding_the_top_edge_autoscrolls_selection_into_offscreen_history.snap b/codex-rs/tui/src/snapshots/codex_tui__conversation_viewport__tests__holding_the_top_edge_autoscrolls_selection_into_offscreen_history.snap new file mode 100644 index 0000000000..ad02847154 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__conversation_viewport__tests__holding_the_top_edge_autoscrolls_selection_into_offscreen_history.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/conversation_viewport_tests.rs +expression: "format!(\"text:\\n{text}\\nselection:\\n{mask}\")" +--- +text: + +three + +selection: +.................... +#####............... +.................... diff --git a/codex-rs/tui/src/snapshots/codex_tui__conversation_viewport__tests__width_change_remaps_selection_to_the_same_source_range.snap b/codex-rs/tui/src/snapshots/codex_tui__conversation_viewport__tests__width_change_remaps_selection_to_the_same_source_range.snap new file mode 100644 index 0000000000..6fd8bd7da7 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__conversation_viewport__tests__width_change_remaps_selection_to_the_same_source_range.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/conversation_viewport_tests.rs +expression: "format!(\"text:\\n{text}\\nselection:\\n{mask}\")" +--- +text: +alpha beta +gamma +delta +epsilon +selection: +......#### +#####..... +.......... +..........