Skip to content

Commit 78be8ce

Browse files
echobtfactorydroid
andauthored
feat(tui): add scrollbar click-drag scrolling and hover interaction (#242)
- Add scrollbar dragging: clicking and holding on the scrollbar area while moving the mouse now scrolls the content (instead of text selection) - Add scrollbar hover: scrollbar becomes visible when mouse approaches the right edge of the chat area (4 chars from edge) - Add scrollbar zone detection (3 chars from right edge for drag interaction) - Keep scrollbar visible while dragging or hovering - Full opacity while interacting, then fades out normally This improves UX by making the scrollbar behave like standard GUI scrollbars. Co-authored-by: Droid Agent <droid@factory.ai>
1 parent 011b696 commit 78be8ce

3 files changed

Lines changed: 188 additions & 16 deletions

File tree

cortex-tui/src/app.rs

Lines changed: 95 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,11 @@ pub struct AppState {
506506
pub chat_scroll: usize,
507507
pub sidebar_scroll: usize,
508508
pub scrollbar_visible_until: Option<Instant>,
509+
pub scrollbar_hovered: bool,
510+
pub scrollbar_dragging: bool,
511+
/// Cached chat content metrics for scroll calculations
512+
pub chat_content_lines: usize,
513+
pub chat_visible_lines: usize,
509514
pub chat_scroll_pinned_bottom: bool,
510515
pub input: CortexInput<'static>,
511516
pub autocomplete: AutocompleteState,
@@ -633,6 +638,10 @@ impl AppState {
633638
chat_scroll: 0,
634639
sidebar_scroll: 0,
635640
scrollbar_visible_until: None,
641+
scrollbar_hovered: false,
642+
scrollbar_dragging: false,
643+
chat_content_lines: 0,
644+
chat_visible_lines: 0,
636645
chat_scroll_pinned_bottom: true,
637646
input: CortexInput::new(),
638647
autocomplete: AutocompleteState::new(),
@@ -858,13 +867,21 @@ impl AppState {
858867

859868
/// Check if the scrollbar should be visible
860869
pub fn is_scrollbar_visible(&self) -> bool {
861-
self.scrollbar_visible_until
862-
.map(|until| Instant::now() < until)
863-
.unwrap_or(false)
870+
// Scrollbar is visible when: hovered, dragging, or within visibility timeout
871+
self.scrollbar_hovered
872+
|| self.scrollbar_dragging
873+
|| self.scrollbar_visible_until
874+
.map(|until| Instant::now() < until)
875+
.unwrap_or(false)
864876
}
865877

866878
/// Get the scrollbar opacity (for fade effect)
867879
pub fn scrollbar_opacity(&self) -> f32 {
880+
// Full opacity when hovered or dragging
881+
if self.scrollbar_hovered || self.scrollbar_dragging {
882+
return 1.0;
883+
}
884+
868885
self.scrollbar_visible_until
869886
.map(|until| {
870887
let remaining = until.saturating_duration_since(Instant::now());
@@ -880,13 +897,88 @@ impl AppState {
880897

881898
/// Tick the scrollbar visibility timer
882899
pub fn tick_scrollbar(&mut self) {
900+
// Don't expire visibility while hovered or dragging
901+
if self.scrollbar_hovered || self.scrollbar_dragging {
902+
return;
903+
}
883904
if let Some(until) = self.scrollbar_visible_until {
884905
if Instant::now() >= until {
885906
self.scrollbar_visible_until = None;
886907
}
887908
}
888909
}
889910

911+
/// Set scrollbar hover state
912+
pub fn set_scrollbar_hovered(&mut self, hovered: bool) {
913+
self.scrollbar_hovered = hovered;
914+
if hovered {
915+
self.show_scrollbar();
916+
}
917+
}
918+
919+
/// Start scrollbar drag operation
920+
pub fn start_scrollbar_drag(&mut self) {
921+
self.scrollbar_dragging = true;
922+
self.show_scrollbar();
923+
}
924+
925+
/// End scrollbar drag operation
926+
pub fn end_scrollbar_drag(&mut self) {
927+
self.scrollbar_dragging = false;
928+
}
929+
930+
/// Update cached chat content metrics (called after rendering)
931+
pub fn update_chat_metrics(&mut self, total_lines: usize, visible_lines: usize) {
932+
self.chat_content_lines = total_lines;
933+
self.chat_visible_lines = visible_lines;
934+
}
935+
936+
/// Estimate total chat content lines based on messages
937+
/// This is used for scroll position calculations when actual metrics aren't available
938+
pub fn estimate_chat_lines(&self, area_width: u16) -> usize {
939+
let mut total = 0;
940+
for msg in &self.messages {
941+
// Estimate lines: content length / width + 2 for spacing
942+
let content_len = msg.content.len();
943+
let width = area_width.saturating_sub(4) as usize; // Account for margins
944+
if width > 0 {
945+
total += (content_len / width) + 1; // At least 1 line per message
946+
}
947+
total += 2; // Blank line + some overhead
948+
}
949+
// Add tool calls
950+
total += self.tool_calls.len() * 3; // Estimate 3 lines per tool call
951+
// Add subagents
952+
total += self.active_subagents.len() * 4; // Estimate 4 lines per subagent
953+
total
954+
}
955+
956+
/// Calculate scroll position from Y coordinate relative to chat area
957+
/// Returns scroll offset (0 = at bottom, max_scroll = at top)
958+
pub fn scroll_from_y_position(&self, y_in_area: u16, area_height: u16) -> usize {
959+
// Use cached metrics if available, otherwise estimate
960+
let total_lines = if self.chat_content_lines > 0 {
961+
self.chat_content_lines
962+
} else {
963+
// Estimate based on terminal width (80 is a reasonable default)
964+
self.estimate_chat_lines(80)
965+
};
966+
let visible_lines = if self.chat_visible_lines > 0 {
967+
self.chat_visible_lines
968+
} else {
969+
area_height as usize
970+
};
971+
972+
if total_lines <= visible_lines {
973+
return 0;
974+
}
975+
let max_scroll = total_lines.saturating_sub(visible_lines);
976+
// y_in_area = 0 means top of scrollbar (max_scroll)
977+
// y_in_area = area_height-1 means bottom of scrollbar (0)
978+
let ratio = 1.0 - (y_in_area as f32 / area_height.saturating_sub(1).max(1) as f32);
979+
(ratio * max_scroll as f32).round() as usize
980+
}
981+
890982
/// Scroll the sidebar
891983
pub fn scroll_sidebar(&mut self, delta: i32) {
892984
if delta < 0 {

cortex-tui/src/input/zones.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,9 @@ pub enum ClickZoneId {
119119
/// Tool output area
120120
ToolOutput,
121121

122+
/// Scrollbar track area (for click and drag scrolling)
123+
Scrollbar,
124+
122125
/// Custom zone with numeric ID for extensibility
123126
Custom(u32),
124127
}

cortex-tui/src/runner/event_loop.rs

Lines changed: 90 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7918,20 +7918,54 @@ impl EventLoop {
79187918
current,
79197919
button: MouseButton::Left,
79207920
} => {
7921-
// Handle text selection anywhere on screen using absolute screen coordinates
7922-
let (width, height) = self.app_state.terminal_size;
7923-
let screen_area = Rect::new(0, 0, width, height);
7924-
7925-
self.app_state.text_selection.set_screen_area(screen_area);
7926-
if !self.app_state.text_selection.is_selecting() {
7921+
// Check if dragging on scrollbar
7922+
if self.app_state.scrollbar_dragging {
7923+
// Handle scrollbar dragging
7924+
self.handle_scrollbar_drag(current.0, current.1)?;
7925+
self.render(terminal)?;
7926+
} else if let Some(chat_area) = self.get_chat_area() {
7927+
// Check if starting in the scrollbar zone (right edge of chat area)
7928+
let scrollbar_zone_width = 3; // 3 chars from right edge
7929+
let scrollbar_x = chat_area.right().saturating_sub(scrollbar_zone_width);
7930+
if start.0 >= scrollbar_x
7931+
&& start.0 < chat_area.right()
7932+
&& start.1 >= chat_area.y
7933+
&& start.1 < chat_area.bottom()
7934+
{
7935+
// Start scrollbar dragging
7936+
self.app_state.start_scrollbar_drag();
7937+
self.handle_scrollbar_drag(current.0, current.1)?;
7938+
self.render(terminal)?;
7939+
} else {
7940+
// Handle text selection anywhere on screen using absolute screen coordinates
7941+
let (width, height) = self.app_state.terminal_size;
7942+
let screen_area = Rect::new(0, 0, width, height);
7943+
self.app_state.text_selection.set_screen_area(screen_area);
7944+
if !self.app_state.text_selection.is_selecting() {
7945+
self.app_state
7946+
.text_selection
7947+
.start_selection(start.0, start.1);
7948+
}
7949+
self.app_state
7950+
.text_selection
7951+
.update_selection(current.0, current.1);
7952+
self.render(terminal)?;
7953+
}
7954+
} else {
7955+
// No chat area - fall back to text selection
7956+
let (width, height) = self.app_state.terminal_size;
7957+
let screen_area = Rect::new(0, 0, width, height);
7958+
self.app_state.text_selection.set_screen_area(screen_area);
7959+
if !self.app_state.text_selection.is_selecting() {
7960+
self.app_state
7961+
.text_selection
7962+
.start_selection(start.0, start.1);
7963+
}
79277964
self.app_state
79287965
.text_selection
7929-
.start_selection(start.0, start.1);
7966+
.update_selection(current.0, current.1);
7967+
self.render(terminal)?;
79307968
}
7931-
self.app_state
7932-
.text_selection
7933-
.update_selection(current.0, current.1);
7934-
self.render(terminal)?;
79357969
}
79367970

79377971
MouseAction::Drag { .. } => {
@@ -7943,8 +7977,12 @@ impl EventLoop {
79437977
y: _,
79447978
button: MouseButton::Left,
79457979
} => {
7946-
// Finish selection (but don't auto-copy - wait for right-click or Ctrl+C)
7947-
if self.app_state.text_selection.is_selecting() {
7980+
// End scrollbar dragging if active
7981+
if self.app_state.scrollbar_dragging {
7982+
self.app_state.end_scrollbar_drag();
7983+
self.render(terminal)?;
7984+
} else if self.app_state.text_selection.is_selecting() {
7985+
// Finish selection (but don't auto-copy - wait for right-click or Ctrl+C)
79487986
self.app_state.text_selection.finish_selection();
79497987
self.render(terminal)?;
79507988
}
@@ -7955,17 +7993,56 @@ impl EventLoop {
79557993
}
79567994

79577995
MouseAction::Move { x, y } => {
7996+
// Handle scrollbar hover (show scrollbar when mouse is near right edge of chat area)
7997+
let mut scrollbar_hover = false;
7998+
if let Some(chat_area) = self.get_chat_area() {
7999+
let scrollbar_hover_zone = 4; // 4 chars from right edge to trigger hover
8000+
let hover_x = chat_area.right().saturating_sub(scrollbar_hover_zone);
8001+
if x >= hover_x
8002+
&& x < chat_area.right()
8003+
&& y >= chat_area.y
8004+
&& y < chat_area.bottom()
8005+
{
8006+
scrollbar_hover = true;
8007+
}
8008+
}
8009+
let was_hovered = self.app_state.scrollbar_hovered;
8010+
self.app_state.set_scrollbar_hovered(scrollbar_hover);
8011+
79588012
// Handle hover effects for Questions view
79598013
if self.app_state.view == AppView::Questions {
79608014
self.handle_question_hover(x, y);
79618015
self.render(terminal)?;
8016+
} else if scrollbar_hover != was_hovered {
8017+
// Re-render if scrollbar hover state changed
8018+
self.render(terminal)?;
79628019
}
79638020
}
79648021
}
79658022

79668023
Ok(())
79678024
}
79688025

8026+
/// Handles scrollbar drag - scrolls based on Y position within chat area
8027+
fn handle_scrollbar_drag(&mut self, _x: u16, y: u16) -> Result<()> {
8028+
if let Some(chat_area) = self.get_chat_area() {
8029+
// Calculate Y position relative to chat area
8030+
let y_in_area = y.saturating_sub(chat_area.y);
8031+
let area_height = chat_area.height;
8032+
8033+
// Calculate new scroll position
8034+
let new_scroll =
8035+
self.app_state
8036+
.scroll_from_y_position(y_in_area, area_height);
8037+
8038+
// Update scroll position directly (bypass scroll_chat to avoid showing "scrolled" behavior)
8039+
self.app_state.chat_scroll = new_scroll;
8040+
self.app_state.chat_scroll_pinned_bottom = new_scroll == 0;
8041+
self.app_state.show_scrollbar();
8042+
}
8043+
Ok(())
8044+
}
8045+
79698046
/// Handles mouse hover for question prompts
79708047
fn handle_question_hover(&mut self, x: u16, y: u16) {
79718048
let (width, height) = self.app_state.terminal_size;

0 commit comments

Comments
 (0)