fix(tui): make transcript scrollbar inert

## Summary
- remove app-owned transcript scrollbar click/drag state so the right gutter cannot capture mouse drags
- keep wheel scrolling, jump-to-latest, and normal transcript text selection intact
- align the behavior with opencode's session surface: scroll via wheel/key paths, no app-level scrollbar drag affordance

## Test plan
- cargo test -p deepseek-tui transcript_scrollbar_gutter_is_not_draggable --locked
- cargo test -p deepseek-tui left_down_inside_transcript_starts_selection --locked
- cargo fmt --all -- --check
- git diff --check
- GitHub CI: lint, version drift, ubuntu/macos/windows tests, npm wrapper smoke, GitGuardian
This commit is contained in:
Hunter Bown
2026-05-08 10:05:07 -05:00
committed by GitHub
parent cd8f247fa1
commit 37178ed6b4
3 changed files with 20 additions and 83 deletions
-3
View File
@@ -574,7 +574,6 @@ pub struct ViewportState {
pub mouse_scroll: MouseScrollState,
pub transcript_cache: TranscriptViewCache,
pub transcript_selection: TranscriptSelection,
pub transcript_scrollbar_dragging: bool,
pub last_transcript_area: Option<Rect>,
pub last_transcript_top: usize,
pub last_transcript_visible: usize,
@@ -591,7 +590,6 @@ impl Default for ViewportState {
mouse_scroll: MouseScrollState::new(),
transcript_cache: TranscriptViewCache::new(),
transcript_selection: TranscriptSelection::default(),
transcript_scrollbar_dragging: false,
last_transcript_area: None,
last_transcript_top: 0,
last_transcript_visible: 0,
@@ -2457,7 +2455,6 @@ impl App {
self.viewport.pending_scroll_delta = 0;
self.viewport.transcript_selection.clear();
self.viewport.transcript_scrollbar_dragging = false;
self.viewport.last_transcript_area = None;
self.viewport.last_transcript_top = 0;
-69
View File
@@ -7483,20 +7483,11 @@ fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec<ViewEvent> {
}
}
MouseEventKind::Down(MouseButton::Left) => {
app.viewport.transcript_scrollbar_dragging = false;
if mouse_hits_rect(mouse, app.viewport.jump_to_latest_button_area) {
app.scroll_to_bottom();
return Vec::new();
}
if mouse_hits_transcript_scrollbar(app, mouse) {
app.viewport.transcript_scrollbar_dragging = true;
app.viewport.transcript_selection.clear();
scroll_transcript_to_mouse_row(app, mouse.row);
return Vec::new();
}
if let Some(point) = selection_point_from_mouse(app, mouse) {
app.viewport.transcript_selection.anchor = Some(point);
app.viewport.transcript_selection.head = Some(point);
@@ -7516,21 +7507,12 @@ fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec<ViewEvent> {
}
}
MouseEventKind::Drag(MouseButton::Left) => {
if app.viewport.transcript_scrollbar_dragging {
scroll_transcript_to_mouse_row(app, mouse.row);
return Vec::new();
}
if app.viewport.transcript_selection.dragging
&& let Some(point) = selection_point_from_mouse(app, mouse)
{
app.viewport.transcript_selection.head = Some(point);
}
}
MouseEventKind::Up(MouseButton::Left) if app.viewport.transcript_scrollbar_dragging => {
app.viewport.transcript_scrollbar_dragging = false;
app.needs_redraw = true;
}
MouseEventKind::Up(MouseButton::Left) if app.viewport.transcript_selection.dragging => {
app.viewport.transcript_selection.dragging = false;
if selection_has_content(app) {
@@ -7546,57 +7528,6 @@ fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec<ViewEvent> {
Vec::new()
}
fn mouse_hits_transcript_scrollbar(app: &App, mouse: MouseEvent) -> bool {
let Some(area) = app.viewport.last_transcript_area else {
return false;
};
if area.width <= 1 || app.viewport.last_transcript_total <= app.viewport.last_transcript_visible
{
return false;
}
let scrollbar_col = area.x.saturating_add(area.width.saturating_sub(1));
mouse.column == scrollbar_col
&& mouse.row >= area.y
&& mouse.row < area.y.saturating_add(area.height)
}
fn scroll_transcript_to_mouse_row(app: &mut App, row: u16) -> bool {
let Some(area) = app.viewport.last_transcript_area else {
return false;
};
let total = app.viewport.last_transcript_total;
let visible = app.viewport.last_transcript_visible;
if area.height == 0 || total <= visible {
return false;
}
let max_start = total.saturating_sub(visible);
if max_start == 0 {
app.scroll_to_bottom();
return true;
}
let max_row = usize::from(area.height.saturating_sub(1));
let relative_row = usize::from(row.saturating_sub(area.y)).min(max_row);
let numerator = relative_row
.saturating_mul(max_start)
.saturating_add(max_row / 2);
// Round to the nearest transcript offset so short thumbs still feel
// responsive on compact terminals.
let top = numerator.checked_div(max_row).unwrap_or(0);
app.viewport.transcript_scroll = if top >= max_start {
TranscriptScroll::to_bottom()
} else {
TranscriptScroll::at_line(top)
};
app.viewport.pending_scroll_delta = 0;
app.user_scrolled_during_stream = !app.viewport.transcript_scroll.is_at_tail();
app.needs_redraw = true;
true
}
fn mouse_hits_rect(mouse: MouseEvent, area: Option<Rect>) -> bool {
let Some(area) = area else {
return false;
+20 -11
View File
@@ -338,8 +338,19 @@ fn jump_to_latest_button_click_scrolls_to_tail() {
}
#[test]
fn transcript_scrollbar_drag_maps_mouse_row_to_scroll_position() {
fn transcript_scrollbar_gutter_is_not_draggable() {
let mut app = create_test_app();
app.history = vec![HistoryCell::Assistant {
content: "alpha beta".to_string(),
streaming: false,
}];
app.resync_history_revisions();
app.viewport.transcript_cache.ensure(
&app.history,
&app.history_revisions,
80,
app.transcript_render_options(),
);
app.viewport.last_transcript_area = Some(Rect {
x: 2,
y: 5,
@@ -349,6 +360,7 @@ fn transcript_scrollbar_drag_maps_mouse_row_to_scroll_position() {
app.viewport.last_transcript_visible = 10;
app.viewport.last_transcript_total = 110;
app.viewport.transcript_scroll = TranscriptScroll::to_bottom();
app.user_scrolled_during_stream = false;
let events = handle_mouse_event(
&mut app,
@@ -361,12 +373,9 @@ fn transcript_scrollbar_drag_maps_mouse_row_to_scroll_position() {
);
assert!(events.is_empty());
assert!(app.viewport.transcript_scrollbar_dragging);
assert!(!app.viewport.transcript_selection.dragging);
assert!(!app.viewport.transcript_scroll.is_at_tail());
let (_, top) = app.viewport.transcript_scroll.resolve_top(&[], 100);
assert_eq!(top, 0);
assert!(app.user_scrolled_during_stream);
assert!(app.viewport.transcript_selection.dragging);
assert!(app.viewport.transcript_scroll.is_at_tail());
assert!(!app.user_scrolled_during_stream);
handle_mouse_event(
&mut app,
@@ -380,6 +389,7 @@ fn transcript_scrollbar_drag_maps_mouse_row_to_scroll_position() {
assert!(app.viewport.transcript_scroll.is_at_tail());
assert!(!app.user_scrolled_during_stream);
assert!(app.viewport.transcript_selection.dragging);
handle_mouse_event(
&mut app,
@@ -391,11 +401,11 @@ fn transcript_scrollbar_drag_maps_mouse_row_to_scroll_position() {
},
);
assert!(!app.viewport.transcript_scrollbar_dragging);
assert!(!app.viewport.transcript_selection.dragging);
}
#[test]
fn new_left_down_clears_stale_transcript_scrollbar_drag() {
fn left_down_inside_transcript_starts_selection() {
let mut app = create_test_app();
app.history = vec![HistoryCell::Assistant {
content: "alpha beta".to_string(),
@@ -410,7 +420,6 @@ fn new_left_down_clears_stale_transcript_scrollbar_drag() {
});
app.viewport.last_transcript_visible = 10;
app.viewport.last_transcript_total = 110;
app.viewport.transcript_scrollbar_dragging = true;
let events = handle_mouse_event(
&mut app,
@@ -423,7 +432,7 @@ fn new_left_down_clears_stale_transcript_scrollbar_drag() {
);
assert!(events.is_empty());
assert!(!app.viewport.transcript_scrollbar_dragging);
assert!(app.viewport.transcript_selection.dragging);
}
#[test]