Allow transcript selection and copying while bottom modals are open (#50564)

## Why

The plan confirmation prompt allowed transcript scrolling but blocked selecting and copying the visible plan text.

## What changed

Allow mouse selection and copying in the visible transcript while a bottom modal is open, preserving drags across redraws. Keep other keyboard input with the modal and suppress transcript link activation, disclosure toggles, and follow-control clicks during modal mouse handling.

## Testing

Extend the plan prompt test to cover selection, `Ctrl+Insert` copying, selection highlighting, and popup bounds. Add coverage confirming that ordinary and Control-clicks do not open links in selection-only mouse handling.

GitOrigin-RevId: 9e1fdfcb4bc18215e62a714f173bc8cb372b090a
This commit is contained in:
Eric Traut
2026-10-03 07:45:47 +00:00
committed by copyberry
parent 58ca099b03
commit b741e480e2
5 changed files with 189 additions and 33 deletions
+55 -16
View File
@@ -26,6 +26,12 @@ pub(crate) enum ViewAction {
OpenLink(String),
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum MouseMode {
Interactive,
Selection,
}
/// Transcript jumps shared with input routing so legacy terminals preserve their Alt modifier.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum JumpTarget {
@@ -200,10 +206,31 @@ impl TranscriptView {
event: MouseEvent,
cells: &[Arc<dyn HistoryCell>],
) -> Option<ViewAction> {
if let Some(action) = self.handle_follow_control_mouse(event) {
self.handle_mouse_with_mode(event, cells, MouseMode::Interactive)
}
/// Handles selection and scrolling without activating transcript content.
pub(crate) fn handle_selection_mouse(
&mut self,
event: MouseEvent,
cells: &[Arc<dyn HistoryCell>],
) -> Option<ViewAction> {
self.handle_mouse_with_mode(event, cells, MouseMode::Selection)
}
fn handle_mouse_with_mode(
&mut self,
event: MouseEvent,
cells: &[Arc<dyn HistoryCell>],
mode: MouseMode,
) -> Option<ViewAction> {
if mode == MouseMode::Interactive
&& let Some(action) = self.handle_follow_control_mouse(event)
{
return Some(action);
}
if event.kind == MouseEventKind::Down(MouseButton::Left)
if mode == MouseMode::Interactive
&& event.kind == MouseEventKind::Down(MouseButton::Left)
&& event
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::SUPER)
@@ -264,21 +291,28 @@ impl TranscriptView {
.filter(|text| !text.is_empty())
.map(ViewAction::Copy);
}
MouseEventKind::Down(MouseButton::Left) => return self.pointer_down(event, cells),
MouseEventKind::Down(MouseButton::Left) => {
return self.pointer_down(event, cells, mode);
}
MouseEventKind::Drag(MouseButton::Left) if dragging => {
self.extend_selection(event.column, event.row);
}
MouseEventKind::Up(MouseButton::Left) if dragging => {
// Open only a stationary click. Dragging back to the origin is still selection,
// and wheel scrolling clears the pointer even when it cannot move the viewport.
let link = self.selection.as_mut().and_then(|selection| {
(inside
&& !selection.moved
&& selection.pointer == Some(ScreenPosition::new(event.column, event.row))
&& event.modifiers.is_empty())
.then(|| selection.pressed_link.take())
.flatten()
});
let link = if mode == MouseMode::Interactive {
self.selection.as_mut().and_then(|selection| {
(inside
&& !selection.moved
&& selection.pointer
== Some(ScreenPosition::new(event.column, event.row))
&& event.modifiers.is_empty())
.then(|| selection.pressed_link.take())
.flatten()
})
} else {
None
};
let link = link.filter(|destination| {
self.link_at(event.column, event.row).as_ref() == Some(destination)
});
@@ -385,17 +419,22 @@ impl TranscriptView {
&mut self,
event: MouseEvent,
cells: &[Arc<dyn HistoryCell>],
mode: MouseMode,
) -> Option<ViewAction> {
if event.modifiers.is_empty() && self.toggle_disclosure_at(cells, event.column, event.row) {
if mode == MouseMode::Interactive
&& event.modifiers.is_empty()
&& self.toggle_disclosure_at(cells, event.column, event.row)
{
return Some(ViewAction::Changed);
}
self.disclosure.focused = None;
let visible = self
.visible
.get(usize::from(event.row.checked_sub(self.area.y)?))?;
if event
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::SUPER)
if mode == MouseMode::Interactive
&& event
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::SUPER)
{
return self
.link_at(event.column, event.row)
@@ -420,7 +459,7 @@ impl TranscriptView {
{
return None;
}
let link = (clicks == 1 && event.modifiers.is_empty())
let link = (mode == MouseMode::Interactive && clicks == 1 && event.modifiers.is_empty())
.then(|| self.link_at(event.column, event.row))
.flatten();
self.begin_selection(cells, event.column, event.row, clicks);
@@ -306,6 +306,38 @@ fn ordinary_click_opens_bare_and_markdown_links_on_release() {
}
}
#[test]
fn selection_mouse_does_not_open_links() {
for modifiers in [KeyModifiers::NONE, KeyModifiers::CONTROL] {
let (mut view, cells) = transcript("[docs](https://example.com/docs)", /*width*/ 60);
let down = MouseEvent {
modifiers,
..mouse(
MouseEventKind::Down(MouseButton::Left),
/*column*/ 4,
/*row*/ 1,
)
};
assert!(matches!(
view.handle_selection_mouse(down, &cells),
Some(ViewAction::Changed)
));
let area = view.area;
view.render(area, &mut Buffer::empty(area), &cells);
assert!(matches!(
view.handle_selection_mouse(
MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
..down
},
&cells,
),
Some(ViewAction::Changed)
));
assert_eq!(view.selected_text(&cells), None);
}
}
#[test]
fn dragging_a_link_selects_text_and_never_opens_it() {
for return_to_origin in [false, true] {