1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
//! Shared scrollbar drawing for list-style panes. Each pane renders
//! its body, reserves the rightmost column, then calls
//! [`paint_simple_scrollbar`] + pushes a [`crate::app::ScrollbarHit`]
//! so the existing dispatcher in `tui.rs` handles click + drag.
//!
//! "Simple" because there are no change-density markers here — the
//! editor + diff scrollbars are a richer variant that lives next to
//! their renderers.
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::widgets::Paragraph;
use crate::ui::theme::Theme;
/// Paint a 1-cell scrollbar over `area`. Track is a dim `│` glyph on
/// `bg2`; thumb is a solid `█` block glyph in `comment` fg. `total`
/// is the underlying row count, `viewport` is the visible row count,
/// `scroll` is the top-row offset. No-op when `area` is empty.
///
/// vscode-mouse-2026-06-10 SEV-3 #7: the previous version painted
/// solid-color background blocks only — `comment` thumb on the
/// editor's `bg2` background was nearly indistinguishable on
/// onedark / catppuccin / kanagawa themes (the colors are
/// intentionally close). Switching to a glyph-on-bg model makes
/// the thumb visible without changing the palette.
pub fn paint_simple_scrollbar(
frame: &mut Frame,
area: Rect,
t: &Theme,
total: usize,
viewport: usize,
scroll: usize,
) {
if area.height == 0 || area.width == 0 {
return;
}
let cells = area.height as usize;
// 2026-07-08 user report: track glyph was `│` (thin box-drawings
// vertical) while thumb was `█` (full block). Mixed silhouettes
// read as a thin line running through the thumb instead of a
// clean scrollbar gutter. Now both use `█`: the track is a `█`
// in the dim `bg2` foreground on the same bg (so the column
// reads as a subtle recessed strip), the thumb is a brighter
// `█` in `comment` on top. Same width the whole way, no glyph
// switch.
let bar_glyph = "█".repeat(area.width as usize);
for cy in 0..cells {
frame.render_widget(
Paragraph::new(bar_glyph.clone()).style(Style::default().fg(t.bg2).bg(t.bg2)),
Rect::new(area.x, area.y + cy as u16, area.width, 1),
);
}
if total > viewport && viewport > 0 {
let thumb_h = ((cells * viewport) / total).max(1);
let max_scroll = total - viewport;
let max_thumb_top = cells.saturating_sub(thumb_h);
let thumb_top = (scroll * max_thumb_top)
.checked_div(max_scroll)
.unwrap_or(0);
for cy in thumb_top..(thumb_top + thumb_h).min(cells) {
frame.render_widget(
Paragraph::new(bar_glyph.clone()).style(Style::default().fg(t.comment).bg(t.bg2)),
Rect::new(area.x, area.y + cy as u16, area.width, 1),
);
}
}
}
/// Paint a 1-row HORIZONTAL scrollbar over `area` (a single row). `total`
/// is the widest content column, `viewport` the visible column count,
/// `scroll` the left-column offset (`Buffer.h_scroll`). The thumb spans
/// the visible fraction; track in `bg2`, thumb in `comment`.
pub fn paint_horizontal_scrollbar(
frame: &mut Frame,
area: Rect,
t: &Theme,
total: usize,
viewport: usize,
scroll: usize,
) {
if area.height == 0 || area.width == 0 {
return;
}
let cells = area.width as usize;
// Track.
frame.render_widget(
Paragraph::new("─".repeat(cells)).style(Style::default().fg(t.bg2).bg(t.bg_dark)),
area,
);
if total > viewport && viewport > 0 {
let thumb_w = ((cells * viewport) / total).max(1);
let max_scroll = total - viewport;
let max_thumb_left = cells.saturating_sub(thumb_w);
let thumb_left = (scroll.min(max_scroll) * max_thumb_left)
.checked_div(max_scroll)
.unwrap_or(0);
frame.render_widget(
Paragraph::new("━".repeat(thumb_w)).style(Style::default().fg(t.comment).bg(t.bg_dark)),
Rect::new(
area.x + thumb_left as u16,
area.y,
thumb_w.min(cells) as u16,
1,
),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui::theme;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
/// The horizontal scrollbar paints a `━` thumb over a `─` track —
/// the thumb width + offset are deterministic geometry.
#[test]
fn horizontal_scrollbar_places_the_thumb_by_scroll() {
let t = theme::onedark();
let row = |total: usize, viewport: usize, scroll: usize| -> String {
let mut term = Terminal::new(TestBackend::new(20, 1)).unwrap();
term.draw(|f| paint_horizontal_scrollbar(f, f.area(), &t, total, viewport, scroll))
.unwrap();
let buf = term.backend().buffer();
(0..20).map(|x| buf[(x, 0)].symbol().to_string()).collect()
};
// 20/100 visible ⇒ a 4-cell thumb. At scroll 0 it's flush left.
let r = row(100, 20, 0);
assert_eq!(r, format!("{}{}", "━".repeat(4), "─".repeat(16)));
// Scrolled to the end ⇒ thumb flush right.
let r = row(100, 20, 80);
assert_eq!(r, format!("{}{}", "─".repeat(16), "━".repeat(4)));
// Content fits the viewport ⇒ no thumb, all track.
assert_eq!(row(10, 20, 0), "─".repeat(20));
}
/// Both track and thumb paint `█` (2026-07-08 — was `│` track
/// + `█` thumb, but the shape switch showed as a broken line
/// through the thumb). Distinguish by fg color: track fg =
/// `bg2` (invisible on bg2 bg), thumb fg = `comment`.
#[test]
fn simple_scrollbar_sizes_and_places_the_thumb() {
let t = theme::onedark();
let thumb_rows = |total: usize, viewport: usize, scroll: usize| -> Vec<usize> {
let mut term = Terminal::new(TestBackend::new(1, 10)).unwrap();
term.draw(|f| paint_simple_scrollbar(f, f.area(), &t, total, viewport, scroll))
.unwrap();
let buf = term.backend().buffer();
(0..10u16)
.filter(|&y| buf[(0, y)].fg == t.comment)
.map(|y| y as usize)
.collect()
};
// 10/100 visible over a 10-cell bar ⇒ a 1-cell thumb at the top.
assert_eq!(thumb_rows(100, 10, 0), vec![0]);
// Scrolled to the bottom ⇒ the thumb sits on the last row.
assert_eq!(thumb_rows(100, 10, 90), vec![9]);
// Content fits ⇒ no thumb at all.
assert!(thumb_rows(10, 20, 0).is_empty());
}
}