mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
feat(tui): preserve owned-screen drag selections
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
---
|
||||
source: tui/src/conversation_viewport_tests.rs
|
||||
expression: "format!(\"text:\\n{text}\\nselection:\\n{mask}\")"
|
||||
---
|
||||
text:
|
||||
|
||||
three
|
||||
|
||||
selection:
|
||||
....................
|
||||
#####...............
|
||||
....................
|
||||
+14
@@ -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:
|
||||
......####
|
||||
#####.....
|
||||
..........
|
||||
..........
|
||||
Reference in New Issue
Block a user