Skip to main content

qframe/widgets/
scrollbar.rs

1//! The one-column scrollbar drawn by scrolling widgets.
2
3use crate::geometry::Rect;
4use crate::style::CellStyle;
5use crate::widget::PaintCx;
6
7/// Position of a scrolled view: how many rows exist, how many are visible and the first
8/// visible row.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10pub struct ScrollMetrics {
11    /// Rows of content.
12    pub total: usize,
13    /// Rows that fit.
14    pub visible: usize,
15    /// First visible row.
16    pub offset: usize,
17}
18
19impl ScrollMetrics {
20    /// Whether the content is taller than the view.
21    #[must_use]
22    pub fn overflows(self) -> bool {
23        self.total > self.visible
24    }
25
26    /// The largest valid offset.
27    #[must_use]
28    pub fn max_offset(self) -> usize {
29        self.total.saturating_sub(self.visible)
30    }
31
32    /// Thumb start row and length inside a track of `track` rows.
33    #[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    /// The offset that puts the thumb under track row `row` (for clicks and drags).
46    #[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/// How a scrollbar is drawn. The theme picks one with `[style.scrollbar] style = "…"`;
56/// scrolling widgets can pin one with their `scrollbar` option.
57///
58/// Every style is a single column that appears only when content overflows; none of them draws
59/// a frame. In ASCII mode glyph styles fall back to coloured cells.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
61pub enum ScrollbarStyle {
62    /// A solid thumb on a solid track, drawn only with background colours: no glyph ever enters a
63    /// text selection, and it looks the same in every glyph mode. The default.
64    #[default]
65    Block,
66    /// A thin track `▕` with a half-block thumb `▐`.
67    Half,
68    /// No track; only a thin thumb `▕`.
69    Thin,
70    /// A dotted track `·` with a filled thumb `•`.
71    Dots,
72}
73
74impl ScrollbarStyle {
75    /// Every style, in the order the theme documentation lists them: the default first.
76    pub const ALL: [Self; 4] = [Self::Block, Self::Half, Self::Thin, Self::Dots];
77
78    /// The theme word for this style.
79    #[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    /// The style named `name` in a theme.
90    #[must_use]
91    pub fn from_name(name: &str) -> Option<Self> {
92        Self::ALL.into_iter().find(|style| style.name() == name)
93    }
94
95    /// Icon keys of the track (if drawn as a glyph) and the thumb; `None` means a coloured cell.
96    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    /// Whether the track is painted at all.
106    fn has_track(self) -> bool {
107        self != Self::Thin
108    }
109}
110
111/// Paints a scrollbar into the one-column `rect` using the `scrollbar` style: a faint track
112/// and a thumb that brightens while `active` (hovered or dragged). `pinned` overrides the
113/// theme's `style` word. Colours come from `scrollbar.<style>` so a theme can tune each style.
114pub(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    // Whatever style draws it, the column is decoration: a clean copy leaves it out.
125    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        // A blank glyph (the block style, or ASCII mode) shows the colour as the cell background.
146        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/// Column `x` of a test screen read as a default block scrollbar, top to bottom: `#` for a cell
155/// in a thumb colour (resting or hovered), `-` for a cell in the track colour and a space for any
156/// other cell. The block style draws no glyphs, so tests of scrolling widgets look at colours.
157#[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    /// A list taller than its view, for drawing the scrollbar in each style.
216    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    /// A log line with a glyph scrollbar beside it, in a selectable region.
235    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}