mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
fix(tui2): start transcript selection on drag (#8466)
Avoid distracting 1-cell highlights on simple click by tracking an anchor on mouse down and only creating a visible selection once the mouse is dragged (selection head set). When dragging while following the bottom during streaming, request a scroll lock so the viewport stops moving under the active selection. Move selection state transitions into transcript_selection helpers (returning change/lock outcomes for the caller) and add unit tests for the state machine.
This commit is contained in:
committed by
GitHub
Unverified
parent
cbc5fb9acf
commit
2828549323
+29
-24
@@ -846,7 +846,7 @@ impl App {
|
||||
/// - Mouse wheel movement scrolls the conversation history using stream-based
|
||||
/// normalization (events-per-line factor, discrete vs. continuous streams),
|
||||
/// independent of the terminal's own scrollback.
|
||||
/// - Mouse clicks and drags adjust a text selection defined in terms of
|
||||
/// - Mouse drags adjust a text selection defined in terms of
|
||||
/// flattened transcript lines and columns, so the selection is anchored
|
||||
/// to the underlying content rather than absolute screen rows.
|
||||
/// - When the user drags to extend a selection while the view is following the bottom
|
||||
@@ -959,42 +959,47 @@ impl App {
|
||||
MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight => {}
|
||||
MouseEventKind::Down(MouseButton::Left) => {
|
||||
self.transcript_copy_ui.set_dragging(true);
|
||||
if let Some(point) = self.transcript_point_from_coordinates(
|
||||
let point = self.transcript_point_from_coordinates(
|
||||
transcript_area,
|
||||
base_x,
|
||||
clamped_x,
|
||||
clamped_y,
|
||||
) {
|
||||
self.transcript_selection = TranscriptSelection::new(point, point);
|
||||
);
|
||||
if crate::transcript_selection::on_mouse_down(&mut self.transcript_selection, point)
|
||||
{
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
}
|
||||
MouseEventKind::Drag(MouseButton::Left) => {
|
||||
if let Some(anchor) = self.transcript_selection.anchor
|
||||
&& let Some(point) = self.transcript_point_from_coordinates(
|
||||
transcript_area,
|
||||
base_x,
|
||||
clamped_x,
|
||||
clamped_y,
|
||||
)
|
||||
{
|
||||
if streaming
|
||||
&& matches!(self.transcript_scroll, TranscriptScroll::ToBottom)
|
||||
&& point != anchor
|
||||
{
|
||||
self.lock_transcript_scroll_to_current_view(
|
||||
transcript_area.height as usize,
|
||||
transcript_area.width,
|
||||
);
|
||||
}
|
||||
self.transcript_selection.head = Some(point);
|
||||
let point = self.transcript_point_from_coordinates(
|
||||
transcript_area,
|
||||
base_x,
|
||||
clamped_x,
|
||||
clamped_y,
|
||||
);
|
||||
let outcome = crate::transcript_selection::on_mouse_drag(
|
||||
&mut self.transcript_selection,
|
||||
&self.transcript_scroll,
|
||||
point,
|
||||
streaming,
|
||||
);
|
||||
if outcome.lock_scroll {
|
||||
self.lock_transcript_scroll_to_current_view(
|
||||
transcript_area.height as usize,
|
||||
transcript_area.width,
|
||||
);
|
||||
}
|
||||
if outcome.changed {
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
}
|
||||
MouseEventKind::Up(MouseButton::Left) => {
|
||||
self.transcript_copy_ui.set_dragging(false);
|
||||
if self.transcript_selection.anchor == self.transcript_selection.head {
|
||||
self.transcript_selection = TranscriptSelection::default();
|
||||
let selection_changed =
|
||||
crate::transcript_selection::on_mouse_up(&mut self.transcript_selection);
|
||||
let has_active_selection = self.transcript_selection.anchor.is_some()
|
||||
&& self.transcript_selection.head.is_some();
|
||||
if selection_changed || has_active_selection {
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,49 @@
|
||||
//! Transcript selection helpers.
|
||||
//!
|
||||
//! This module defines a content-relative selection model for the inline chat
|
||||
//! transcript and utilities for extracting the selected region as plain text.
|
||||
//! Selection endpoints are expressed in terms of flattened, wrapped transcript
|
||||
//! line indices and columns so they remain stable across scrolling and
|
||||
//! reflowing when the terminal is resized.
|
||||
//! This module owns the inline transcript's selection model and helper
|
||||
//! utilities:
|
||||
//!
|
||||
//! Copy uses offscreen rendering into a 1-row `ratatui::Buffer` per visual line
|
||||
//! to match on-screen glyph layout (including indentation/prefixes) while
|
||||
//! skipping the transcript gutter.
|
||||
//! - A **content-relative** selection representation ([`TranscriptSelection`])
|
||||
//! expressed in terms of flattened, wrapped transcript line indices and
|
||||
//! columns.
|
||||
//! - A small mouse-driven **selection state machine** (`on_mouse_*` helpers)
|
||||
//! that implements "start selection on drag" semantics.
|
||||
//! - Copy extraction ([`selection_text`]) that matches on-screen glyph layout by
|
||||
//! rendering selected lines into an offscreen [`ratatui::Buffer`].
|
||||
//!
|
||||
//! ## Coordinate model
|
||||
//!
|
||||
//! Selection endpoints are expressed in *wrapped* coordinates so they remain
|
||||
//! stable across scrolling and reflowing when the terminal is resized:
|
||||
//!
|
||||
//! - `line_index` is an index into flattened wrapped transcript lines
|
||||
//! (i.e. "visual lines").
|
||||
//! - `column` is a 0-based column offset within that visual line, measured from
|
||||
//! the first content column to the right of the transcript gutter.
|
||||
//!
|
||||
//! The transcript gutter is reserved for UI affordances (bullets, prefixes,
|
||||
//! etc.). The gutter itself is not copyable; both selection highlighting and
|
||||
//! copy extraction treat selection columns as starting at `base_x =
|
||||
//! TRANSCRIPT_GUTTER_COLS`.
|
||||
//!
|
||||
//! ## Mouse selection semantics
|
||||
//!
|
||||
//! The transcript supports click-and-drag selection for copying text. To avoid
|
||||
//! distracting "1-cell selections" on a simple click, the selection highlight
|
||||
//! only becomes active once the user drags:
|
||||
//!
|
||||
//! - `on_mouse_down`: stores an **anchor** point and clears any existing head.
|
||||
//! - `on_mouse_drag`: sets the **head** point, creating an active selection
|
||||
//! (`anchor` + `head`).
|
||||
//! - `on_mouse_up`: clears the selection if it never became active (no head) or
|
||||
//! if the drag ended at the anchor.
|
||||
//!
|
||||
//! The helper APIs return whether the selection state changed so callers can
|
||||
//! schedule a redraw. `on_mouse_drag` also returns whether the caller should
|
||||
//! lock the transcript scroll position when dragging while following the bottom
|
||||
//! during streaming output.
|
||||
|
||||
use crate::tui::scrolling::TranscriptScroll;
|
||||
use itertools::Itertools as _;
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
@@ -24,7 +58,7 @@ pub(crate) const TRANSCRIPT_GUTTER_COLS: u16 = 2;
|
||||
/// Selection endpoints are expressed in terms of flattened, wrapped transcript
|
||||
/// line indices and columns, so the highlight tracks logical conversation
|
||||
/// content even when the viewport scrolls or the terminal is resized.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub(crate) struct TranscriptSelection {
|
||||
/// The selection anchor (fixed start) in transcript coordinates.
|
||||
pub(crate) anchor: Option<TranscriptSelectionPoint>,
|
||||
@@ -34,6 +68,7 @@ pub(crate) struct TranscriptSelection {
|
||||
|
||||
impl TranscriptSelection {
|
||||
/// Create an active selection with both endpoints set.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn new(
|
||||
anchor: impl Into<TranscriptSelectionPoint>,
|
||||
head: impl Into<TranscriptSelectionPoint>,
|
||||
@@ -73,6 +108,133 @@ impl From<(usize, u16)> for TranscriptSelectionPoint {
|
||||
}
|
||||
}
|
||||
|
||||
/// Begin a potential transcript selection (left button down).
|
||||
///
|
||||
/// This records an anchor point and clears any existing head. The selection is
|
||||
/// not considered "active" until a drag sets a head, which avoids highlighting
|
||||
/// a 1-cell region on simple click.
|
||||
///
|
||||
/// Returns whether the selection changed (useful to decide whether to request a
|
||||
/// redraw).
|
||||
pub(crate) fn on_mouse_down(
|
||||
selection: &mut TranscriptSelection,
|
||||
point: Option<TranscriptSelectionPoint>,
|
||||
) -> bool {
|
||||
let before = *selection;
|
||||
let Some(point) = point else {
|
||||
return false;
|
||||
};
|
||||
begin(selection, point);
|
||||
*selection != before
|
||||
}
|
||||
|
||||
/// The outcome of a mouse drag update.
|
||||
///
|
||||
/// This is returned by [`on_mouse_drag`]. It separates selection state updates
|
||||
/// from `App`-level actions, so callers can decide when to schedule redraws or
|
||||
/// lock the transcript scroll position.
|
||||
///
|
||||
/// `lock_scroll` indicates the caller should lock the transcript viewport (if
|
||||
/// currently following the bottom) so ongoing streaming output does not move
|
||||
/// the selection under the cursor.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct MouseDragOutcome {
|
||||
/// Whether the selection changed (useful to decide whether to request a
|
||||
/// redraw).
|
||||
pub(crate) changed: bool,
|
||||
/// Whether the caller should lock the transcript scroll position.
|
||||
pub(crate) lock_scroll: bool,
|
||||
}
|
||||
|
||||
/// Update the selection state for a left-button drag.
|
||||
///
|
||||
/// This sets the selection head (creating an active selection) and returns:
|
||||
///
|
||||
/// - `changed`: whether the selection state changed (useful to decide whether to
|
||||
/// request a redraw).
|
||||
/// - `lock_scroll`: whether the caller should lock transcript scrolling to
|
||||
/// freeze the viewport under the selection while streaming output arrives.
|
||||
///
|
||||
/// `point` is expected to already be clamped to the transcript's content area
|
||||
/// (e.g. not in the gutter). If `point` is `None`, this is a no-op.
|
||||
pub(crate) fn on_mouse_drag(
|
||||
selection: &mut TranscriptSelection,
|
||||
scroll: &TranscriptScroll,
|
||||
point: Option<TranscriptSelectionPoint>,
|
||||
streaming: bool,
|
||||
) -> MouseDragOutcome {
|
||||
let before = *selection;
|
||||
let Some(point) = point else {
|
||||
return MouseDragOutcome {
|
||||
changed: false,
|
||||
lock_scroll: false,
|
||||
};
|
||||
};
|
||||
let lock_scroll = drag(selection, scroll, point, streaming);
|
||||
MouseDragOutcome {
|
||||
changed: *selection != before,
|
||||
lock_scroll,
|
||||
}
|
||||
}
|
||||
|
||||
/// Finalize the selection state when the left button is released.
|
||||
///
|
||||
/// If the selection never became active (no head) or the head ended up equal to
|
||||
/// the anchor, the selection is cleared so a click does not leave a persistent
|
||||
/// highlight.
|
||||
///
|
||||
/// Returns whether the selection changed (useful to decide whether to request a
|
||||
/// redraw).
|
||||
pub(crate) fn on_mouse_up(selection: &mut TranscriptSelection) -> bool {
|
||||
let before = *selection;
|
||||
end(selection);
|
||||
*selection != before
|
||||
}
|
||||
|
||||
/// Begin a potential selection by recording an anchor and clearing any head.
|
||||
///
|
||||
/// This ensures a plain click does not create an active selection/highlight.
|
||||
/// The selection becomes active on the first drag that sets `head`.
|
||||
fn begin(selection: &mut TranscriptSelection, point: TranscriptSelectionPoint) {
|
||||
*selection = TranscriptSelection {
|
||||
anchor: Some(point),
|
||||
head: None,
|
||||
};
|
||||
}
|
||||
|
||||
/// Update selection state during a drag by setting `head` when anchored.
|
||||
///
|
||||
/// Returns whether the caller should lock the transcript scroll position while
|
||||
/// streaming and following the bottom, so new output doesn't move the selection
|
||||
/// under the cursor.
|
||||
fn drag(
|
||||
selection: &mut TranscriptSelection,
|
||||
scroll: &TranscriptScroll,
|
||||
point: TranscriptSelectionPoint,
|
||||
streaming: bool,
|
||||
) -> bool {
|
||||
let Some(anchor) = selection.anchor else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let should_lock_scroll =
|
||||
streaming && matches!(*scroll, TranscriptScroll::ToBottom) && point != anchor;
|
||||
|
||||
selection.head = Some(point);
|
||||
|
||||
should_lock_scroll
|
||||
}
|
||||
|
||||
/// Finalize selection on mouse up.
|
||||
///
|
||||
/// Clears the selection if it never became active (no head) or if the head
|
||||
/// ended up equal to the anchor, so a click doesn't leave a 1-cell highlight.
|
||||
fn end(selection: &mut TranscriptSelection) {
|
||||
if selection.head.is_none() || selection.anchor == selection.head {
|
||||
*selection = TranscriptSelection::default();
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the full transcript selection as plain text.
|
||||
///
|
||||
/// This intentionally does *not* use viewport state. Instead it:
|
||||
@@ -460,4 +622,152 @@ mod tests {
|
||||
|
||||
assert_eq!(selection_text(&lines, selection, 40), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_only_highlights_on_drag() {
|
||||
let anchor = TranscriptSelectionPoint::new(0, 1);
|
||||
let head = TranscriptSelectionPoint::new(0, 3);
|
||||
|
||||
let mut selection = TranscriptSelection::default();
|
||||
assert!(on_mouse_down(&mut selection, Some(anchor)));
|
||||
assert_eq!(
|
||||
selection,
|
||||
TranscriptSelection {
|
||||
anchor: Some(anchor),
|
||||
head: None,
|
||||
}
|
||||
);
|
||||
|
||||
assert!(on_mouse_up(&mut selection));
|
||||
assert_eq!(selection, TranscriptSelection::default());
|
||||
|
||||
assert!(on_mouse_down(&mut selection, Some(anchor)));
|
||||
let outcome = on_mouse_drag(
|
||||
&mut selection,
|
||||
&TranscriptScroll::ToBottom,
|
||||
Some(head),
|
||||
false,
|
||||
);
|
||||
assert!(outcome.changed);
|
||||
assert!(!outcome.lock_scroll);
|
||||
assert_eq!(
|
||||
selection,
|
||||
TranscriptSelection {
|
||||
anchor: Some(anchor),
|
||||
head: Some(head),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_clears_when_drag_ends_at_anchor() {
|
||||
let point = TranscriptSelectionPoint::new(0, 1);
|
||||
|
||||
let mut selection = TranscriptSelection::default();
|
||||
assert!(on_mouse_down(&mut selection, Some(point)));
|
||||
let outcome = on_mouse_drag(
|
||||
&mut selection,
|
||||
&TranscriptScroll::ToBottom,
|
||||
Some(point),
|
||||
false,
|
||||
);
|
||||
assert!(outcome.changed);
|
||||
assert!(!outcome.lock_scroll);
|
||||
assert!(on_mouse_up(&mut selection));
|
||||
|
||||
assert_eq!(selection, TranscriptSelection::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_requests_scroll_lock_when_streaming_at_bottom_and_point_moves() {
|
||||
let anchor = TranscriptSelectionPoint::new(0, 1);
|
||||
let head = TranscriptSelectionPoint::new(0, 2);
|
||||
|
||||
let mut selection = TranscriptSelection::default();
|
||||
assert!(on_mouse_down(&mut selection, Some(anchor)));
|
||||
let outcome = on_mouse_drag(
|
||||
&mut selection,
|
||||
&TranscriptScroll::ToBottom,
|
||||
Some(head),
|
||||
true,
|
||||
);
|
||||
assert!(outcome.changed);
|
||||
assert!(outcome.lock_scroll);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_helpers_noop_without_points_or_anchor() {
|
||||
let mut selection = TranscriptSelection::default();
|
||||
assert!(!on_mouse_down(&mut selection, None));
|
||||
assert_eq!(selection, TranscriptSelection::default());
|
||||
|
||||
let outcome = on_mouse_drag(&mut selection, &TranscriptScroll::ToBottom, None, false);
|
||||
assert_eq!(
|
||||
outcome,
|
||||
MouseDragOutcome {
|
||||
changed: false,
|
||||
lock_scroll: false,
|
||||
}
|
||||
);
|
||||
assert_eq!(selection, TranscriptSelection::default());
|
||||
|
||||
let outcome = on_mouse_drag(
|
||||
&mut selection,
|
||||
&TranscriptScroll::ToBottom,
|
||||
Some(TranscriptSelectionPoint::new(0, 1)),
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
outcome,
|
||||
MouseDragOutcome {
|
||||
changed: false,
|
||||
lock_scroll: false,
|
||||
}
|
||||
);
|
||||
assert_eq!(selection, TranscriptSelection::default());
|
||||
|
||||
assert!(!on_mouse_up(&mut selection));
|
||||
assert_eq!(selection, TranscriptSelection::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_down_resets_head() {
|
||||
let anchor = TranscriptSelectionPoint::new(0, 1);
|
||||
let head = TranscriptSelectionPoint::new(0, 2);
|
||||
let next_anchor = TranscriptSelectionPoint::new(1, 0);
|
||||
|
||||
let mut selection = TranscriptSelection {
|
||||
anchor: Some(anchor),
|
||||
head: Some(head),
|
||||
};
|
||||
|
||||
assert!(on_mouse_down(&mut selection, Some(next_anchor)));
|
||||
assert_eq!(
|
||||
selection,
|
||||
TranscriptSelection {
|
||||
anchor: Some(next_anchor),
|
||||
head: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_does_not_request_scroll_lock_when_not_at_bottom() {
|
||||
let anchor = TranscriptSelectionPoint::new(0, 1);
|
||||
let head = TranscriptSelectionPoint::new(0, 2);
|
||||
|
||||
let mut selection = TranscriptSelection::default();
|
||||
assert!(on_mouse_down(&mut selection, Some(anchor)));
|
||||
let outcome = on_mouse_drag(
|
||||
&mut selection,
|
||||
&TranscriptScroll::Scrolled {
|
||||
cell_index: 0,
|
||||
line_in_cell: 0,
|
||||
},
|
||||
Some(head),
|
||||
true,
|
||||
);
|
||||
assert!(outcome.changed);
|
||||
assert!(!outcome.lock_scroll);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user