feat(tui): preserve owned-screen drag selections

This commit is contained in:
Felipe Coury
2026-07-05 18:54:49 -03:00
parent 25093076bb
commit ba9d8dd59c
13 changed files with 991 additions and 45 deletions
-1
View File
@@ -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();
+37 -5
View File
@@ -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<Rect>,
}
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))
}
}
@@ -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;
+10 -1
View File
@@ -382,7 +382,7 @@ impl CellSelectionProjection {
})
}
fn point_range(&self, row: usize, column: u16) -> Range<usize> {
pub(crate) fn point_range(&self, row: usize, column: u16) -> Range<usize> {
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],
@@ -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<Position>,
direction: Option<AutoscrollDirection>,
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<AutoscrollStep> {
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<Position> {
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<AutoscrollDirection> {
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;
@@ -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<AutoscrollStep> {
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);
}
@@ -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<usize>,
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<CellSelectionProjection>],
layout: &[SelectionCellLayout],
scroll_offset: usize,
) -> Option<Self> {
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<Item = usize> + '_ {
[self.anchor.projected_cell(), self.focus.projected_cell()]
.into_iter()
.flatten()
}
pub(crate) fn restore(
self,
projections: &[Option<CellSelectionProjection>],
layout: &[SelectionCellLayout],
width: u16,
) -> Option<RestoredSelection> {
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<CellSelectionProjection>],
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<usize> {
match self {
Self::Projected { cell, .. } => Some(*cell),
Self::CellRow { .. } | Self::BeforeCell { .. } | Self::AfterContent { .. } => None,
}
}
fn restore(
self,
projections: &[Option<CellSelectionProjection>],
layout: &[SelectionCellLayout],
width: u16,
) -> Option<SelectionPoint> {
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<usize>,
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::<Vec<_>>();
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
}
+163 -8
View File
@@ -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<Vec<Arc<dyn HistoryCell>>>,
deferred_render_mode: Option<HistoryRenderMode>,
selection: ConversationSelection,
selection_pointer: Option<Position>,
selection_projection_cache: Option<SelectionProjectionCache>,
selection_autoscroll: SelectionAutoscroll,
selection_autoscroll_last_tick: Option<Instant>,
}
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<String> {
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<dyn HistoryCell>) {
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<SelectionBookmarks> {
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::<Vec<_>>();
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) {
+150 -30
View File
@@ -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::<String>();
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::<String>();
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::<Vec<_>>()
.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::<String>()
})
.collect::<Vec<_>>()
.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::<Vec<_>>()
.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::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
+2
View File
@@ -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;
+40
View File
@@ -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.
@@ -0,0 +1,12 @@
---
source: tui/src/conversation_viewport_tests.rs
expression: "format!(\"text:\\n{text}\\nselection:\\n{mask}\")"
---
text:
three
selection:
....................
#####...............
....................
@@ -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:
......####
#####.....
..........
..........