Skip to content

Commit 0c35b2b

Browse files
echobtfactorydroid
andauthored
feat(cortex-tui): add scrollbar support to dropdown menus (#216)
Replace simple scroll indicators (^/v) with proper visual scrollbars in all dropdown menu components: - AutocompletePopup: Command and mention autocomplete menus now display a scrollbar when items exceed the visible area - SelectionList: Generic selection lists now use scrollbar instead of arrow indicators - MentionPopup: File mention popup now has proper scrollbar support - ScrollableDropdown: New centralized reusable dropdown component with scrollbar that can be used by other dropdown menus The scrollbar improves UX by showing the current scroll position and total content size, making it clearer when there are more items above or below the visible area. Co-authored-by: Droid Agent <droid@factory.ai>
1 parent 5e9a35a commit 0c35b2b

5 files changed

Lines changed: 771 additions & 59 deletions

File tree

cortex-tui/src/widgets/autocomplete.rs

Lines changed: 39 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ use cortex_core::style::{
2323
};
2424
use ratatui::prelude::*;
2525
use ratatui::symbols::border::Set as BorderSet;
26-
use ratatui::widgets::{Block, Borders, Clear, Widget};
26+
use ratatui::widgets::{
27+
Block, Borders, Clear, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, Widget,
28+
};
2729

2830
/// Custom rounded border set.
2931
const ROUNDED_BORDER: BorderSet = BorderSet {
@@ -245,6 +247,9 @@ impl Widget for AutocompletePopup<'_> {
245247
let inner = block.inner(popup_area);
246248
block.render(popup_area, buf);
247249

250+
// Check if we need scrollbar
251+
let needs_scrollbar = self.state.items.len() > self.state.max_visible;
252+
248253
// Render items
249254
let visible_items = self.state.visible_items();
250255
for (i, item) in visible_items.iter().enumerate() {
@@ -253,40 +258,50 @@ impl Widget for AutocompletePopup<'_> {
253258
break;
254259
}
255260

261+
// Reserve space for scrollbar on the right if needed
262+
let item_width = if needs_scrollbar {
263+
inner.width.saturating_sub(1)
264+
} else {
265+
inner.width
266+
};
267+
256268
let item_area = Rect {
257269
x: inner.x,
258270
y,
259-
width: inner.width,
271+
width: item_width,
260272
height: 1,
261273
};
262274

263275
let is_selected = self.state.scroll_offset + i == self.state.selected;
264276
self.render_item(item, is_selected, item_area, buf);
265277
}
266278

267-
// Scroll indicators if needed
268-
if self.state.scroll_offset > 0 {
269-
// Show "more above" indicator
270-
let style = Style::default().fg(TEXT_MUTED).bg(SURFACE_1);
271-
if let Some(cell) = buf.cell_mut((popup_area.x + popup_area.width - 2, popup_area.y)) {
272-
cell.set_char('^').set_style(style);
273-
}
274-
}
279+
// Render scrollbar if needed
280+
if needs_scrollbar {
281+
// Create scrollbar state
282+
// content_length = total items
283+
// position = selected item for proper thumb position
284+
let total_items = self.state.items.len();
285+
let mut scrollbar_state =
286+
ScrollbarState::new(total_items).position(self.state.selected);
287+
288+
// Define scrollbar area (right side of the inner content area)
289+
let scrollbar_area = Rect {
290+
x: inner.right().saturating_sub(1),
291+
y: inner.y,
292+
width: 1,
293+
height: inner.height,
294+
};
275295

276-
let max_offset = self
277-
.state
278-
.items
279-
.len()
280-
.saturating_sub(self.state.max_visible);
281-
if self.state.scroll_offset < max_offset {
282-
// Show "more below" indicator
283-
let style = Style::default().fg(TEXT_MUTED).bg(SURFACE_1);
284-
if let Some(cell) = buf.cell_mut((
285-
popup_area.x + popup_area.width - 2,
286-
popup_area.y + popup_area.height - 1,
287-
)) {
288-
cell.set_char('v').set_style(style);
289-
}
296+
// Render scrollbar
297+
Scrollbar::new(ScrollbarOrientation::VerticalRight)
298+
.begin_symbol(None)
299+
.end_symbol(None)
300+
.track_symbol(Some("│"))
301+
.track_style(Style::default().fg(SURFACE_1))
302+
.thumb_symbol("█")
303+
.thumb_style(Style::default().fg(TEXT_MUTED))
304+
.render(scrollbar_area, buf, &mut scrollbar_state);
290305
}
291306
}
292307
}

cortex-tui/src/widgets/mention_popup.rs

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ use cortex_core::style::{
2020
};
2121
use ratatui::prelude::*;
2222
use ratatui::symbols::border::Set as BorderSet;
23-
use ratatui::widgets::{Block, Borders, Clear, Widget};
23+
use ratatui::widgets::{
24+
Block, Borders, Clear, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, Widget,
25+
};
2426
use std::path::Path;
2527

2628
/// Custom rounded border set.
@@ -240,6 +242,10 @@ impl Widget for MentionPopup<'_> {
240242
let inner = block.inner(popup_area);
241243
block.render(popup_area, buf);
242244

245+
// Check if we need scrollbar
246+
let total_results = self.state.results().len();
247+
let needs_scrollbar = self.state.has_more_above() || self.state.has_more_below();
248+
243249
// Render items
244250
let visible = self.state.visible_results();
245251
for (i, path) in visible.iter().enumerate() {
@@ -248,28 +254,42 @@ impl Widget for MentionPopup<'_> {
248254
break;
249255
}
250256

251-
let item_area = Rect::new(inner.x, y, inner.width, 1);
257+
// Reserve space for scrollbar on the right if needed
258+
let item_width = if needs_scrollbar {
259+
inner.width.saturating_sub(1)
260+
} else {
261+
inner.width
262+
};
263+
264+
let item_area = Rect::new(inner.x, y, item_width, 1);
252265
let is_selected = i == self.state.selected_visible();
253266

254267
self.render_item(path, i, is_selected, item_area, buf);
255268
}
256269

257-
// Scroll indicators
258-
if self.state.has_more_above() {
259-
let style = Style::default().fg(TEXT_MUTED);
260-
if let Some(cell) = buf.cell_mut((popup_area.x + popup_area.width - 2, popup_area.y)) {
261-
cell.set_char('▲').set_style(style);
262-
}
263-
}
264-
265-
if self.state.has_more_below() {
266-
let style = Style::default().fg(TEXT_MUTED);
267-
if let Some(cell) = buf.cell_mut((
268-
popup_area.x + popup_area.width - 2,
269-
popup_area.y + popup_area.height - 1,
270-
)) {
271-
cell.set_char('▼').set_style(style);
272-
}
270+
// Render scrollbar if needed
271+
if needs_scrollbar {
272+
// Create scrollbar state
273+
let mut scrollbar_state =
274+
ScrollbarState::new(total_results).position(self.state.selected());
275+
276+
// Define scrollbar area (right side of inner area)
277+
let scrollbar_area = Rect {
278+
x: inner.right().saturating_sub(1),
279+
y: inner.y,
280+
width: 1,
281+
height: inner.height,
282+
};
283+
284+
// Render scrollbar
285+
Scrollbar::new(ScrollbarOrientation::VerticalRight)
286+
.begin_symbol(None)
287+
.end_symbol(None)
288+
.track_symbol(Some("│"))
289+
.track_style(Style::default().fg(SURFACE_1))
290+
.thumb_symbol("█")
291+
.thumb_style(Style::default().fg(TEXT_MUTED))
292+
.render(scrollbar_area, buf, &mut scrollbar_state);
273293
}
274294
}
275295
}

cortex-tui/src/widgets/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ pub mod key_hints;
2828
pub mod mention_popup;
2929
pub mod model_picker;
3030
// pub mod provider_picker; // REMOVED (single Cortex provider)
31+
pub mod scrollable_dropdown;
3132
pub mod selection_list;
3233
pub mod status_indicator;
3334
pub mod toast;
@@ -48,6 +49,10 @@ pub use key_hints::{HintContext, KeyHints};
4849
pub use mention_popup::MentionPopup;
4950
pub use model_picker::{ModelItem, ModelPicker, ModelPickerState};
5051
// pub use provider_picker::{PickerFocus, ProviderItem, ProviderPicker, ProviderPickerState}; // REMOVED (single Cortex provider)
52+
pub use scrollable_dropdown::{
53+
DropdownItem, DropdownPosition, ScrollableDropdown, ScrollbarStyle, calculate_scroll_offset,
54+
select_next, select_prev,
55+
};
5156
pub use selection_list::{SelectionItem, SelectionList, SelectionResult};
5257
pub use status_indicator::{StatusIndicator, fmt_elapsed_compact};
5358
pub use toast::{Toast, ToastLevel, ToastManager, ToastPosition, ToastWidget};

0 commit comments

Comments
 (0)