feat(tui): support transcript scrollbar dragging

This commit is contained in:
Zhiping
2026-05-07 22:34:47 +08:00
committed by Hunter Bown
parent 96e58d23fd
commit 0044361bc5
3 changed files with 130 additions and 0 deletions
+2
View File
@@ -575,6 +575,7 @@ 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,6 +592,7 @@ 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,
+71
View File
@@ -7324,6 +7324,13 @@ fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec<ViewEvent> {
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);
@@ -7343,12 +7350,21 @@ 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) {
@@ -7364,6 +7380,61 @@ 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 top = if max_row == 0 {
0
} else {
// Round to the nearest transcript offset so short thumbs still feel
// responsive on compact terminals.
(relative_row
.saturating_mul(max_start)
.saturating_add(max_row / 2))
/ max_row
};
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;
+57
View File
@@ -302,6 +302,63 @@ fn jump_to_latest_button_click_scrolls_to_tail() {
assert!(!app.viewport.transcript_selection.dragging);
}
#[test]
fn transcript_scrollbar_drag_maps_mouse_row_to_scroll_position() {
let mut app = create_test_app();
app.viewport.last_transcript_area = Some(Rect {
x: 2,
y: 5,
width: 20,
height: 10,
});
app.viewport.last_transcript_visible = 10;
app.viewport.last_transcript_total = 110;
app.viewport.transcript_scroll = TranscriptScroll::to_bottom();
let events = handle_mouse_event(
&mut app,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 21,
row: 5,
modifiers: KeyModifiers::NONE,
},
);
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);
handle_mouse_event(
&mut app,
MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: 21,
row: 14,
modifiers: KeyModifiers::NONE,
},
);
assert!(app.viewport.transcript_scroll.is_at_tail());
assert!(!app.user_scrolled_during_stream);
handle_mouse_event(
&mut app,
MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: 21,
row: 14,
modifiers: KeyModifiers::NONE,
},
);
assert!(!app.viewport.transcript_scrollbar_dragging);
}
#[test]
fn right_click_opens_context_menu() {
let mut app = create_test_app();