use crate::geometry::Rect;
use crate::style::CellStyle;
use crate::widget::PaintCx;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ScrollMetrics {
pub total: usize,
pub visible: usize,
pub offset: usize,
}
impl ScrollMetrics {
#[must_use]
pub fn overflows(self) -> bool {
self.total > self.visible
}
#[must_use]
pub fn max_offset(self) -> usize {
self.total.saturating_sub(self.visible)
}
#[must_use]
pub fn thumb(self, track: u16) -> (u16, u16) {
let track_len = usize::from(track);
if track_len == 0 || self.total == 0 {
return (0, 0);
}
let length = (track_len * self.visible / self.total).clamp(1, track_len);
let travel = track_len - length;
let start = (self.offset * travel).checked_div(self.max_offset()).unwrap_or(0);
(u16::try_from(start).unwrap_or(0), u16::try_from(length).unwrap_or(1))
}
#[must_use]
pub fn offset_at(self, row: u16, track: u16) -> usize {
let (_, length) = self.thumb(track);
let travel = usize::from(track.saturating_sub(length));
let row = usize::from(row.saturating_sub(length / 2)).min(travel);
(row * self.max_offset()).checked_div(travel).unwrap_or(0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ScrollbarStyle {
#[default]
Block,
Half,
Thin,
Dots,
}
impl ScrollbarStyle {
pub const ALL: [Self; 4] = [Self::Block, Self::Half, Self::Thin, Self::Dots];
#[must_use]
pub fn name(self) -> &'static str {
match self {
Self::Block => "block",
Self::Half => "half",
Self::Thin => "thin",
Self::Dots => "dots",
}
}
#[must_use]
pub fn from_name(name: &str) -> Option<Self> {
Self::ALL.into_iter().find(|style| style.name() == name)
}
fn glyphs(self) -> (Option<&'static str>, Option<&'static str>) {
match self {
Self::Block => (None, None),
Self::Half => (Some("scroll-track"), Some("scroll-thumb")),
Self::Thin => (None, Some("scroll-thin")),
Self::Dots => (Some("scroll-dot"), Some("scroll-dot-thumb")),
}
}
fn has_track(self) -> bool {
self != Self::Thin
}
}
pub(crate) fn paint(
cx: &mut PaintCx<'_>,
rect: Rect,
metrics: ScrollMetrics,
active: bool,
pinned: Option<ScrollbarStyle>,
) {
if !metrics.overflows() || rect.is_empty() {
return;
}
cx.decoration(rect);
let states = if active { vec![crate::theme::State::Hover] } else { Vec::new() };
let kind = pinned.unwrap_or_else(|| {
let word = cx.env().theme().style("scrollbar", None, &[]).word("style");
word.and_then(ScrollbarStyle::from_name).unwrap_or_default()
});
let style = cx.style("scrollbar", Some(kind.name()), &states);
let track = style.color("track").unwrap_or_else(|| cx.color("raised"));
let thumb = style.color("thumb").unwrap_or_else(|| cx.color("muted"));
let (start, length) = metrics.thumb(rect.height);
let (track_key, thumb_key) = kind.glyphs();
let glyph = |key: Option<&str>| key.map(|key| cx.env().icons().glyph(key).into_owned()).unwrap_or_default();
let (track_glyph, thumb_glyph) = (glyph(track_key), glyph(thumb_key));
for row in 0..rect.height {
let on_thumb = row >= start && row < start + length;
if !on_thumb && !kind.has_track() {
continue;
}
let (glyph, color) = if on_thumb { (&thumb_glyph, thumb) } else { (&track_glyph, track) };
let y = rect.y + i32::from(row);
if glyph.trim().is_empty() {
cx.clear(Rect::new(rect.x, y, 1, 1), color);
} else {
cx.text(rect.x, y, glyph, CellStyle::fg(color), 1);
}
}
}
#[cfg(test)]
pub(crate) fn column<A: crate::runtime::App>(h: &crate::runtime::Harness<A>, x: u16) -> String {
use crate::theme::State;
let theme = h.env().theme();
let color =
|states: &[State], key: &str| theme.style("scrollbar", Some("block"), states).paint(key).map(|p| p.at(0.0));
let (track, thumb, lit) = (color(&[], "track"), color(&[], "thumb"), color(&[State::Hover], "thumb"));
let rows = u16::try_from(h.screen().lines().count()).unwrap_or(0);
(0..rows)
.map(|y| match h.bg(x, y) {
bg if bg.is_some() && (bg == thumb || bg == lit) => '#',
bg if bg.is_some() && bg == track => '-',
_ => ' ',
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn thumb_tracks_offset() {
let metrics = ScrollMetrics { total: 100, visible: 10, offset: 0 };
assert_eq!(metrics.thumb(10), (0, 1));
assert_eq!(ScrollMetrics { offset: 90, ..metrics }.thumb(10), (9, 1));
assert_eq!(ScrollMetrics { offset: 45, ..metrics }.thumb(10), (4, 1));
assert_eq!(ScrollMetrics { total: 20, visible: 10, offset: 5 }.thumb(10), (2, 5));
assert!(!ScrollMetrics { total: 5, visible: 10, offset: 0 }.overflows());
}
#[test]
fn style_names_round_trip_and_match_the_theme_words() {
let words = crate::theme::WORD_PROPS.iter().find(|(widget, key, _)| *widget == "scrollbar" && *key == "style");
let words = words.map(|(_, _, words)| *words).unwrap_or_default();
let names: Vec<&str> = ScrollbarStyle::ALL.iter().map(|style| style.name()).collect();
assert_eq!(names, words);
for style in ScrollbarStyle::ALL {
assert_eq!(ScrollbarStyle::from_name(style.name()), Some(style));
}
assert_eq!(ScrollbarStyle::from_name("wavy"), None);
assert_eq!(names, ["block", "half", "thin", "dots"], "the default comes first");
assert_eq!(ScrollbarStyle::default(), ScrollbarStyle::Block);
}
#[test]
fn the_retired_cell_word_is_a_diagnostic_and_the_default_block_is_used() {
let mut registry = crate::theme::ThemeRegistry::builtin();
let text = "[meta]\nname = \"Old\"\nextends = \"monochrome\"\n\n[style.scrollbar]\nstyle = \"cell\"\n";
assert!(registry.add_source("old", "old.toml", text), "the rest of the file still loads");
let problem = registry.diagnostics().iter().find(|d| d.message.contains("`style` must be one of"));
let problem = problem.expect("the word `cell` is reported");
assert!(problem.message.ends_with("block, half, thin, dots"), "{}", problem.message);
assert_eq!(problem.location.as_ref().map(|l| l.line), Some(6));
let theme = registry.resolve("old").theme.expect("resolves");
assert_eq!(theme.style("scrollbar", None, &[]).word("style"), Some("block"), "inherits the default");
}
struct Deploys(Option<ScrollbarStyle>);
impl crate::runtime::App for Deploys {
type Msg = ();
fn update(&mut self, _: ()) -> crate::runtime::Command<()> {
crate::runtime::Command::none()
}
fn view(&self, ui: &mut crate::widget::View<'_, ()>) {
let items = (0..12).map(|i| crate::widgets::ListItem::new(format!("deploy {i}")));
let list = crate::widgets::List::new(items);
ui.add(match self.0 {
Some(style) => list.scrollbar(style),
None => list,
})
.fill();
}
}
struct Logged;
impl crate::widget::Widget<()> for Logged {
fn measure(
&self,
_cx: &mut crate::widget::MeasureCx<'_>,
available: crate::geometry::Size,
) -> crate::geometry::Size {
available
}
fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
cx.text(area.x, area.y, "pulled image", CellStyle::default(), 12);
let metrics = ScrollMetrics { total: 8, visible: usize::from(area.height), offset: 0 };
paint(cx, Rect::new(area.right() - 1, area.y, 1, area.height), metrics, false, Some(ScrollbarStyle::Half));
}
}
struct LoggedApp;
impl crate::runtime::App for LoggedApp {
type Msg = ();
fn update(&mut self, _: ()) -> crate::runtime::Command<()> {
crate::runtime::Command::none()
}
fn view(&self, ui: &mut crate::widget::View<'_, ()>) {
ui.add(Logged).selectable(true).fill();
}
}
#[test]
fn a_clean_copy_leaves_every_scrollbar_style_out() {
let mut h = crate::runtime::Harness::new(LoggedApp, 16, 2);
h.set_glyph_mode(crate::icons::GlyphMode::Unicode);
assert!(h.screen().starts_with("pulled image ▐"), "{}", h.screen());
h.drag((0, 0), (15, 0)).press("ctrl+c");
assert_eq!(h.clipboard(), Some("pulled image"), "no scrollbar glyph in a clean copy");
}
#[test]
fn block_is_the_default_and_draws_only_colours_in_every_glyph_mode() {
use crate::icons::GlyphMode;
for mode in [GlyphMode::Nerd, GlyphMode::Unicode, GlyphMode::Ascii] {
let mut theme_default = crate::runtime::Harness::new(Deploys(None), 16, 4);
let mut pinned = crate::runtime::Harness::new(Deploys(Some(ScrollbarStyle::Block)), 16, 4);
theme_default.set_glyph_mode(mode);
pinned.set_glyph_mode(mode);
assert_eq!(column(&theme_default, 15), "#---", "{mode:?}");
assert_eq!(theme_default.screen(), pinned.screen());
assert!(theme_default.screen().lines().all(|line| !line.ends_with(['█', '▐', '▕', '•', '·'])));
let theme = theme_default.env().theme();
assert_eq!(theme_default.bg(15, 0), theme.color("muted"));
assert_eq!(theme_default.bg(15, 3), theme.color("raised"));
}
let mut h = crate::runtime::Harness::new(Deploys(None), 16, 4);
h.hover(15, 2);
assert_eq!(h.bg(15, 0), h.env().theme().color("dim"), "the thumb brightens under the pointer");
}
#[test]
fn offset_from_track_row() {
let metrics = ScrollMetrics { total: 100, visible: 10, offset: 0 };
assert_eq!(metrics.offset_at(0, 10), 0);
assert_eq!(metrics.offset_at(9, 10), 90);
assert_eq!(metrics.offset_at(20, 10), 90);
}
}