1use crate::geometry::Rect;
4use crate::style::CellStyle;
5use crate::widget::PaintCx;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10pub struct ScrollMetrics {
11 pub total: usize,
13 pub visible: usize,
15 pub offset: usize,
17}
18
19impl ScrollMetrics {
20 #[must_use]
22 pub fn overflows(self) -> bool {
23 self.total > self.visible
24 }
25
26 #[must_use]
28 pub fn max_offset(self) -> usize {
29 self.total.saturating_sub(self.visible)
30 }
31
32 #[must_use]
34 pub fn thumb(self, track: u16) -> (u16, u16) {
35 let track_len = usize::from(track);
36 if track_len == 0 || self.total == 0 {
37 return (0, 0);
38 }
39 let length = (track_len * self.visible / self.total).clamp(1, track_len);
40 let travel = track_len - length;
41 let start = (self.offset * travel).checked_div(self.max_offset()).unwrap_or(0);
42 (u16::try_from(start).unwrap_or(0), u16::try_from(length).unwrap_or(1))
43 }
44
45 #[must_use]
47 pub fn offset_at(self, row: u16, track: u16) -> usize {
48 let (_, length) = self.thumb(track);
49 let travel = usize::from(track.saturating_sub(length));
50 let row = usize::from(row.saturating_sub(length / 2)).min(travel);
51 (row * self.max_offset()).checked_div(travel).unwrap_or(0)
52 }
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
61pub enum ScrollbarStyle {
62 #[default]
65 Block,
66 Half,
68 Thin,
70 Dots,
72}
73
74impl ScrollbarStyle {
75 pub const ALL: [Self; 4] = [Self::Block, Self::Half, Self::Thin, Self::Dots];
77
78 #[must_use]
80 pub fn name(self) -> &'static str {
81 match self {
82 Self::Block => "block",
83 Self::Half => "half",
84 Self::Thin => "thin",
85 Self::Dots => "dots",
86 }
87 }
88
89 #[must_use]
91 pub fn from_name(name: &str) -> Option<Self> {
92 Self::ALL.into_iter().find(|style| style.name() == name)
93 }
94
95 fn glyphs(self) -> (Option<&'static str>, Option<&'static str>) {
97 match self {
98 Self::Block => (None, None),
99 Self::Half => (Some("scroll-track"), Some("scroll-thumb")),
100 Self::Thin => (None, Some("scroll-thin")),
101 Self::Dots => (Some("scroll-dot"), Some("scroll-dot-thumb")),
102 }
103 }
104
105 fn has_track(self) -> bool {
107 self != Self::Thin
108 }
109}
110
111pub(crate) fn paint(
115 cx: &mut PaintCx<'_>,
116 rect: Rect,
117 metrics: ScrollMetrics,
118 active: bool,
119 pinned: Option<ScrollbarStyle>,
120) {
121 if !metrics.overflows() || rect.is_empty() {
122 return;
123 }
124 cx.decoration(rect);
126 let states = if active { vec![crate::theme::State::Hover] } else { Vec::new() };
127 let kind = pinned.unwrap_or_else(|| {
128 let word = cx.env().theme().style("scrollbar", None, &[]).word("style");
129 word.and_then(ScrollbarStyle::from_name).unwrap_or_default()
130 });
131 let style = cx.style("scrollbar", Some(kind.name()), &states);
132 let track = style.color("track").unwrap_or_else(|| cx.color("raised"));
133 let thumb = style.color("thumb").unwrap_or_else(|| cx.color("muted"));
134 let (start, length) = metrics.thumb(rect.height);
135 let (track_key, thumb_key) = kind.glyphs();
136 let glyph = |key: Option<&str>| key.map(|key| cx.env().icons().glyph(key).into_owned()).unwrap_or_default();
137 let (track_glyph, thumb_glyph) = (glyph(track_key), glyph(thumb_key));
138 for row in 0..rect.height {
139 let on_thumb = row >= start && row < start + length;
140 if !on_thumb && !kind.has_track() {
141 continue;
142 }
143 let (glyph, color) = if on_thumb { (&thumb_glyph, thumb) } else { (&track_glyph, track) };
144 let y = rect.y + i32::from(row);
145 if glyph.trim().is_empty() {
147 cx.clear(Rect::new(rect.x, y, 1, 1), color);
148 } else {
149 cx.text(rect.x, y, glyph, CellStyle::fg(color), 1);
150 }
151 }
152}
153
154#[cfg(test)]
158pub(crate) fn column<A: crate::runtime::App>(h: &crate::runtime::Harness<A>, x: u16) -> String {
159 use crate::theme::State;
160 let theme = h.env().theme();
161 let color =
162 |states: &[State], key: &str| theme.style("scrollbar", Some("block"), states).paint(key).map(|p| p.at(0.0));
163 let (track, thumb, lit) = (color(&[], "track"), color(&[], "thumb"), color(&[State::Hover], "thumb"));
164 let rows = u16::try_from(h.screen().lines().count()).unwrap_or(0);
165 (0..rows)
166 .map(|y| match h.bg(x, y) {
167 bg if bg.is_some() && (bg == thumb || bg == lit) => '#',
168 bg if bg.is_some() && bg == track => '-',
169 _ => ' ',
170 })
171 .collect()
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn thumb_tracks_offset() {
180 let metrics = ScrollMetrics { total: 100, visible: 10, offset: 0 };
181 assert_eq!(metrics.thumb(10), (0, 1));
182 assert_eq!(ScrollMetrics { offset: 90, ..metrics }.thumb(10), (9, 1));
183 assert_eq!(ScrollMetrics { offset: 45, ..metrics }.thumb(10), (4, 1));
184 assert_eq!(ScrollMetrics { total: 20, visible: 10, offset: 5 }.thumb(10), (2, 5));
185 assert!(!ScrollMetrics { total: 5, visible: 10, offset: 0 }.overflows());
186 }
187
188 #[test]
189 fn style_names_round_trip_and_match_the_theme_words() {
190 let words = crate::theme::WORD_PROPS.iter().find(|(widget, key, _)| *widget == "scrollbar" && *key == "style");
191 let words = words.map(|(_, _, words)| *words).unwrap_or_default();
192 let names: Vec<&str> = ScrollbarStyle::ALL.iter().map(|style| style.name()).collect();
193 assert_eq!(names, words);
194 for style in ScrollbarStyle::ALL {
195 assert_eq!(ScrollbarStyle::from_name(style.name()), Some(style));
196 }
197 assert_eq!(ScrollbarStyle::from_name("wavy"), None);
198 assert_eq!(names, ["block", "half", "thin", "dots"], "the default comes first");
199 assert_eq!(ScrollbarStyle::default(), ScrollbarStyle::Block);
200 }
201
202 #[test]
203 fn the_retired_cell_word_is_a_diagnostic_and_the_default_block_is_used() {
204 let mut registry = crate::theme::ThemeRegistry::builtin();
205 let text = "[meta]\nname = \"Old\"\nextends = \"monochrome\"\n\n[style.scrollbar]\nstyle = \"cell\"\n";
206 assert!(registry.add_source("old", "old.toml", text), "the rest of the file still loads");
207 let problem = registry.diagnostics().iter().find(|d| d.message.contains("`style` must be one of"));
208 let problem = problem.expect("the word `cell` is reported");
209 assert!(problem.message.ends_with("block, half, thin, dots"), "{}", problem.message);
210 assert_eq!(problem.location.as_ref().map(|l| l.line), Some(6));
211 let theme = registry.resolve("old").theme.expect("resolves");
212 assert_eq!(theme.style("scrollbar", None, &[]).word("style"), Some("block"), "inherits the default");
213 }
214
215 struct Deploys(Option<ScrollbarStyle>);
217
218 impl crate::runtime::App for Deploys {
219 type Msg = ();
220 fn update(&mut self, _: ()) -> crate::runtime::Command<()> {
221 crate::runtime::Command::none()
222 }
223 fn view(&self, ui: &mut crate::widget::View<'_, ()>) {
224 let items = (0..12).map(|i| crate::widgets::ListItem::new(format!("deploy {i}")));
225 let list = crate::widgets::List::new(items);
226 ui.add(match self.0 {
227 Some(style) => list.scrollbar(style),
228 None => list,
229 })
230 .fill();
231 }
232 }
233
234 struct Logged;
236
237 impl crate::widget::Widget<()> for Logged {
238 fn measure(
239 &self,
240 _cx: &mut crate::widget::MeasureCx<'_>,
241 available: crate::geometry::Size,
242 ) -> crate::geometry::Size {
243 available
244 }
245 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
246 cx.text(area.x, area.y, "pulled image", CellStyle::default(), 12);
247 let metrics = ScrollMetrics { total: 8, visible: usize::from(area.height), offset: 0 };
248 paint(cx, Rect::new(area.right() - 1, area.y, 1, area.height), metrics, false, Some(ScrollbarStyle::Half));
249 }
250 }
251
252 struct LoggedApp;
253
254 impl crate::runtime::App for LoggedApp {
255 type Msg = ();
256 fn update(&mut self, _: ()) -> crate::runtime::Command<()> {
257 crate::runtime::Command::none()
258 }
259 fn view(&self, ui: &mut crate::widget::View<'_, ()>) {
260 ui.add(Logged).selectable(true).fill();
261 }
262 }
263
264 #[test]
265 fn a_clean_copy_leaves_every_scrollbar_style_out() {
266 let mut h = crate::runtime::Harness::new(LoggedApp, 16, 2);
267 h.set_glyph_mode(crate::icons::GlyphMode::Unicode);
268 assert!(h.screen().starts_with("pulled image ▐"), "{}", h.screen());
269 h.drag((0, 0), (15, 0)).press("ctrl+c");
270 assert_eq!(h.clipboard(), Some("pulled image"), "no scrollbar glyph in a clean copy");
271 }
272
273 #[test]
274 fn block_is_the_default_and_draws_only_colours_in_every_glyph_mode() {
275 use crate::icons::GlyphMode;
276 for mode in [GlyphMode::Nerd, GlyphMode::Unicode, GlyphMode::Ascii] {
277 let mut theme_default = crate::runtime::Harness::new(Deploys(None), 16, 4);
278 let mut pinned = crate::runtime::Harness::new(Deploys(Some(ScrollbarStyle::Block)), 16, 4);
279 theme_default.set_glyph_mode(mode);
280 pinned.set_glyph_mode(mode);
281 assert_eq!(column(&theme_default, 15), "#---", "{mode:?}");
282 assert_eq!(theme_default.screen(), pinned.screen());
283 assert!(theme_default.screen().lines().all(|line| !line.ends_with(['█', '▐', '▕', '•', '·'])));
284 let theme = theme_default.env().theme();
285 assert_eq!(theme_default.bg(15, 0), theme.color("muted"));
286 assert_eq!(theme_default.bg(15, 3), theme.color("raised"));
287 }
288 let mut h = crate::runtime::Harness::new(Deploys(None), 16, 4);
289 h.hover(15, 2);
290 assert_eq!(h.bg(15, 0), h.env().theme().color("dim"), "the thumb brightens under the pointer");
291 }
292
293 #[test]
294 fn offset_from_track_row() {
295 let metrics = ScrollMetrics { total: 100, visible: 10, offset: 0 };
296 assert_eq!(metrics.offset_at(0, 10), 0);
297 assert_eq!(metrics.offset_at(9, 10), 90);
298 assert_eq!(metrics.offset_at(20, 10), 90);
299 }
300}