use std::time::{Duration, Instant};
use crate::event::{Event, EventCtx, MouseButton};
use crate::geometry::{Color, Point, Rect};
use crate::include_svg;
use crate::painter::Painter;
use crate::svg::SvgImage;
use crate::theme::Theme;
use crate::widget::Widget;
pub const SCROLLBAR_THICKNESS: i32 = 16;
const ARROW_BTN: i32 = SCROLLBAR_THICKNESS;
const MIN_THUMB: i32 = 16;
const REPEAT_DELAY: Duration = Duration::from_millis(300);
const REPEAT_INTERVAL: Duration = Duration::from_millis(50);
#[derive(Clone, Copy)]
struct HeldButton {
dir: ArrowDir,
armed: bool,
pressed_at: Instant,
last_repeat: Instant,
}
impl HeldButton {
fn new(dir: ArrowDir) -> Self {
let now = Instant::now();
Self {
dir,
armed: true,
pressed_at: now,
last_repeat: now,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Orientation {
Vertical,
Horizontal,
}
pub struct ScrollBar {
rect: Rect,
orientation: Orientation,
value: i32,
max: i32,
viewport: i32,
line_step: i32,
drag_offset: Option<i32>,
held: Option<HeldButton>,
wheel_accum: f32,
}
impl ScrollBar {
pub fn new(rect: Rect, orientation: Orientation) -> Self {
Self {
rect,
orientation,
value: 0,
max: 0,
viewport: 0,
line_step: 1,
drag_offset: None,
held: None,
wheel_accum: 0.0,
}
}
pub fn vertical(rect: Rect) -> Self {
Self::new(rect, Orientation::Vertical)
}
pub fn horizontal(rect: Rect) -> Self {
Self::new(rect, Orientation::Horizontal)
}
pub fn rect(&self) -> Rect {
self.rect
}
pub fn set_rect(&mut self, rect: Rect) {
self.rect = rect;
}
pub fn value(&self) -> i32 {
self.value
}
pub fn set_value(&mut self, value: i32) {
self.value = value.clamp(0, self.max);
}
pub fn max(&self) -> i32 {
self.max
}
pub fn viewport(&self) -> i32 {
self.viewport
}
pub fn set_range(&mut self, viewport: i32, max: i32) {
self.viewport = viewport.max(0);
self.max = max.max(0);
if self.value > self.max {
self.value = self.max;
}
}
pub fn set_line_step(&mut self, step: i32) {
self.line_step = step.max(1);
}
pub fn end_drag(&mut self) {
self.drag_offset = None;
self.held = None;
}
fn track_rect(&self) -> Rect {
match self.orientation {
Orientation::Vertical => Rect::new(
self.rect.x,
self.rect.y + ARROW_BTN,
self.rect.w,
(self.rect.h - 2 * ARROW_BTN).max(0),
),
Orientation::Horizontal => Rect::new(
self.rect.x + ARROW_BTN,
self.rect.y,
(self.rect.w - 2 * ARROW_BTN).max(0),
self.rect.h,
),
}
}
fn track_extent(&self) -> i32 {
let t = self.track_rect();
match self.orientation {
Orientation::Vertical => t.h,
Orientation::Horizontal => t.w,
}
}
fn thumb_size(&self) -> i32 {
let track = self.track_extent();
if self.max <= 0 || self.viewport <= 0 {
return track;
}
let total = self.viewport + self.max;
((track * self.viewport) / total.max(1))
.max(MIN_THUMB)
.min(track)
}
fn thumb_offset(&self) -> i32 {
if self.max <= 0 {
return 0;
}
let movable = (self.track_extent() - self.thumb_size()).max(0);
(movable as i64 * self.value as i64 / self.max.max(1) as i64) as i32
}
fn thumb_rect(&self) -> Rect {
let track = self.track_rect();
let off = self.thumb_offset();
let size = self.thumb_size();
match self.orientation {
Orientation::Vertical => Rect::new(track.x, track.y + off, track.w, size),
Orientation::Horizontal => Rect::new(track.x + off, track.y, size, track.h),
}
}
fn neg_arrow_rect(&self) -> Rect {
match self.orientation {
Orientation::Vertical => Rect::new(self.rect.x, self.rect.y, self.rect.w, ARROW_BTN),
Orientation::Horizontal => Rect::new(self.rect.x, self.rect.y, ARROW_BTN, self.rect.h),
}
}
fn pos_arrow_rect(&self) -> Rect {
match self.orientation {
Orientation::Vertical => Rect::new(
self.rect.x,
self.rect.bottom() - ARROW_BTN,
self.rect.w,
ARROW_BTN,
),
Orientation::Horizontal => Rect::new(
self.rect.right() - ARROW_BTN,
self.rect.y,
ARROW_BTN,
self.rect.h,
),
}
}
fn scroll_by(&mut self, delta: i32) {
self.set_value(self.value.saturating_add(delta));
}
fn scroll_lines(&mut self, lines: f32) -> bool {
self.wheel_accum += lines;
let whole = self.wheel_accum.trunc();
self.wheel_accum -= whole;
let step = whole as i32;
if step == 0 {
return false;
}
let before = self.value;
self.scroll_by(step);
if self.value == before {
self.wheel_accum = 0.0;
false
} else {
true
}
}
fn page_step(&self) -> i32 {
self.viewport.max(1)
}
fn held_button_rect(&self) -> Option<Rect> {
self.held.map(|h| match h.dir {
ArrowDir::Negative => self.neg_arrow_rect(),
ArrowDir::Positive => self.pos_arrow_rect(),
})
}
fn tick_repeat(&mut self) -> bool {
let Some(held) = self.held else {
return false;
};
if !held.armed {
return false;
}
let now = Instant::now();
if now.duration_since(held.pressed_at) < REPEAT_DELAY
|| now.duration_since(held.last_repeat) < REPEAT_INTERVAL
{
return false;
}
if let Some(h) = self.held.as_mut() {
h.last_repeat = now;
}
let step = match held.dir {
ArrowDir::Negative => -self.line_step,
ArrowDir::Positive => self.line_step,
};
let before = self.value;
self.scroll_by(step);
self.value != before
}
fn handle_press(&mut self, pos: Point) {
if self.neg_arrow_rect().contains(pos) {
self.scroll_by(-self.line_step);
self.held = Some(HeldButton::new(ArrowDir::Negative));
} else if self.pos_arrow_rect().contains(pos) {
self.scroll_by(self.line_step);
self.held = Some(HeldButton::new(ArrowDir::Positive));
} else if self.thumb_rect().contains(pos) {
let thumb = self.thumb_rect();
let offset = match self.orientation {
Orientation::Vertical => pos.y - thumb.y,
Orientation::Horizontal => pos.x - thumb.x,
};
self.drag_offset = Some(offset);
} else if self.track_rect().contains(pos) {
let thumb = self.thumb_rect();
let page = self.page_step();
match self.orientation {
Orientation::Vertical => {
if pos.y < thumb.y {
self.scroll_by(-page);
} else if pos.y >= thumb.bottom() {
self.scroll_by(page);
}
}
Orientation::Horizontal => {
if pos.x < thumb.x {
self.scroll_by(-page);
} else if pos.x >= thumb.right() {
self.scroll_by(page);
}
}
}
}
}
fn handle_drag(&mut self, pos: Point) {
let Some(offset) = self.drag_offset else {
return;
};
let track = self.track_rect();
let thumb_size = self.thumb_size();
let movable = (self.track_extent() - thumb_size).max(1);
let pos_in_track = match self.orientation {
Orientation::Vertical => pos.y - offset - track.y,
Orientation::Horizontal => pos.x - offset - track.x,
};
let clamped = pos_in_track.clamp(0, movable);
self.value = ((self.max as i64 * clamped as i64) / movable as i64) as i32;
}
}
impl Widget for ScrollBar {
fn bounds(&self) -> Rect {
self.rect
}
fn paint(&mut self, painter: &mut Painter, theme: &Theme) {
let up = self.neg_arrow_rect();
let down = self.pos_arrow_rect();
let thumb_opt = (self.max > 0).then(|| self.thumb_rect());
let (neg_pressed, pos_pressed) = match self.held {
Some(HeldButton {
dir: ArrowDir::Negative,
armed: true,
..
}) => (true, false),
Some(HeldButton {
dir: ArrowDir::Positive,
armed: true,
..
}) => (false, true),
_ => (false, false),
};
painter.fill_checker(self.track_rect(), theme.face, theme.border);
painter.light_button(up, theme, neg_pressed);
painter.light_button(down, theme, pos_pressed);
if let Some(thumb) = thumb_opt {
let track = self.track_rect();
let mut t = thumb;
match self.orientation {
Orientation::Vertical => {
if thumb.y <= track.y {
t.y -= 1;
t.h += 1;
}
if thumb.bottom() >= track.bottom() {
t.h += 1;
}
}
Orientation::Horizontal => {
if thumb.x <= track.x {
t.x -= 1;
t.w += 1;
}
if thumb.right() >= track.right() {
t.w += 1;
}
}
}
painter.light_button(t, theme, false);
}
painter.stroke_rect(self.rect, theme.border);
let nudge = |r: Rect, on: bool| {
if on {
Rect::new(r.x + 1, r.y + 1, r.w, r.h)
} else {
r
}
};
draw_arrow(
painter,
nudge(up, neg_pressed),
self.orientation,
ArrowDir::Negative,
theme.text,
);
draw_arrow(
painter,
nudge(down, pos_pressed),
self.orientation,
ArrowDir::Positive,
theme.text,
);
}
fn event(&mut self, event: &Event, ctx: &mut EventCtx) {
match event {
Event::PointerDown {
pos,
button: MouseButton::Left,
..
} => {
self.handle_press(*pos);
ctx.request_paint();
}
Event::PointerMove { pos } if self.drag_offset.is_some() => {
self.handle_drag(*pos);
ctx.request_paint();
}
Event::PointerMove { pos } if self.held.is_some() => {
let over = self.held_button_rect().is_some_and(|r| r.contains(*pos));
if let Some(h) = self.held.as_mut()
&& h.armed != over
{
h.armed = over;
ctx.request_paint();
}
}
Event::PointerUp {
button: MouseButton::Left,
..
} if self.drag_offset.is_some() || self.held.is_some() => {
self.drag_offset = None;
self.held = None;
ctx.request_paint();
}
Event::PointerLeave if self.drag_offset.is_some() || self.held.is_some() => {
self.drag_offset = None;
self.held = None;
ctx.request_paint();
}
Event::Tick if self.held.is_some() => {
if self.tick_repeat() {
ctx.request_paint();
}
}
Event::Scroll {
delta_x, delta_y, ..
} => {
let lines = match self.orientation {
Orientation::Vertical => *delta_y,
Orientation::Horizontal => *delta_x,
};
if self.scroll_lines(lines) {
ctx.request_paint();
}
}
_ => {}
}
if self.held.is_some() {
ctx.request_tick();
}
}
fn captures_pointer(&self) -> bool {
self.drag_offset.is_some() || self.held.is_some()
}
fn layout(&mut self, bounds: Rect) {
self.rect = bounds;
}
}
#[derive(Clone, Copy)]
enum ArrowDir {
Negative,
Positive,
}
const ARROW_UP: SvgImage = include_svg!("assets/scrollbar/up.svg");
const ARROW_DOWN: SvgImage = include_svg!("assets/scrollbar/down.svg");
const ARROW_LEFT: SvgImage = include_svg!("assets/scrollbar/left.svg");
const ARROW_RIGHT: SvgImage = include_svg!("assets/scrollbar/right.svg");
fn draw_arrow(painter: &mut Painter, btn: Rect, orient: Orientation, dir: ArrowDir, color: Color) {
let arrow = match (orient, dir) {
(Orientation::Vertical, ArrowDir::Negative) => &ARROW_UP,
(Orientation::Vertical, ArrowDir::Positive) => &ARROW_DOWN,
(Orientation::Horizontal, ArrowDir::Negative) => &ARROW_LEFT,
(Orientation::Horizontal, ArrowDir::Positive) => &ARROW_RIGHT,
};
arrow.draw_tinted(painter, btn, color);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::Modifiers;
fn vbar() -> ScrollBar {
let mut sb = ScrollBar::vertical(Rect::new(0, 0, 16, 200));
sb.set_range(10, 90);
sb
}
fn center(r: Rect) -> Point {
Point::new(r.x + r.w / 2, r.y + r.h / 2)
}
fn send(sb: &mut ScrollBar, event: Event) -> EventCtx {
let mut ctx = EventCtx::new();
sb.event(&event, &mut ctx);
ctx
}
fn press(sb: &mut ScrollBar, pos: Point) -> EventCtx {
send(
sb,
Event::PointerDown {
pos,
button: MouseButton::Left,
modifiers: Modifiers::default(),
},
)
}
fn release(sb: &mut ScrollBar, pos: Point) -> EventCtx {
send(
sb,
Event::PointerUp {
pos,
button: MouseButton::Left,
modifiers: Modifiers::default(),
},
)
}
fn tick(sb: &mut ScrollBar) -> EventCtx {
send(sb, Event::Tick)
}
fn ripen_repeat(sb: &mut ScrollBar) {
let past = Instant::now()
.checked_sub(REPEAT_DELAY + REPEAT_INTERVAL)
.expect("clock is far enough past the epoch");
let h = sb.held.as_mut().expect("a button is held");
h.pressed_at = past;
h.last_repeat = past;
}
#[test]
fn pressing_an_arrow_scrolls_one_step_and_grabs_the_pointer() {
let mut sb = vbar();
let p = center(sb.pos_arrow_rect());
let ctx = press(&mut sb, p);
assert_eq!(sb.value(), 1, "the press itself scrolls one line-step");
assert!(sb.captures_pointer(), "a held button captures the pointer");
assert!(
ctx.tick_requested,
"a held button pushes a tick request to drive the repeat"
);
}
#[test]
fn releasing_the_button_ends_the_repeat() {
let mut sb = vbar();
let p = center(sb.pos_arrow_rect());
press(&mut sb, p);
let ctx = release(&mut sb, p);
assert!(!sb.captures_pointer(), "the pointer is freed on release");
assert!(
!ctx.tick_requested,
"and the bar stops re-requesting ticks, so they wind down"
);
}
#[test]
fn no_repeat_before_the_initial_delay_elapses() {
let mut sb = vbar();
let p = center(sb.pos_arrow_rect());
press(&mut sb, p);
let ctx = tick(&mut sb);
assert_eq!(sb.value(), 1, "the button doesn't repeat during the delay");
assert!(!ctx.paint_requested, "nothing moved, so no repaint");
assert!(
ctx.tick_requested,
"but the held button keeps the clock alive through the delay"
);
}
#[test]
fn the_repeat_fires_once_the_delay_has_elapsed() {
let mut sb = vbar();
let p = center(sb.pos_arrow_rect());
press(&mut sb, p);
ripen_repeat(&mut sb);
let ctx = tick(&mut sb);
assert_eq!(sb.value(), 2, "a ripe held button repeats its line-step");
assert!(
ctx.paint_requested,
"a repeat that moves asks for a repaint"
);
assert!(ctx.tick_requested, "and keeps requesting the next tick");
}
#[test]
fn sliding_off_the_held_button_pauses_the_repeat() {
let mut sb = vbar();
let p = center(sb.pos_arrow_rect());
press(&mut sb, p);
let off = center(sb.track_rect());
send(&mut sb, Event::PointerMove { pos: off });
ripen_repeat(&mut sb);
let ctx = tick(&mut sb);
assert_eq!(
sb.value(),
1,
"an off-button (disarmed) hold doesn't repeat"
);
assert!(sb.captures_pointer(), "but the hold persists until release");
assert!(
ctx.tick_requested,
"and the clock keeps running so a slide back can resume it"
);
}
#[test]
fn the_up_arrow_at_the_top_holds_without_underflowing() {
let mut sb = vbar();
let p = center(sb.neg_arrow_rect());
press(&mut sb, p);
assert_eq!(sb.value(), 0, "already at the top, value can't go negative");
assert!(
sb.captures_pointer(),
"the button still grabs so the release lands here"
);
}
#[test]
fn pointer_leave_releases_a_held_button() {
let mut sb = vbar();
let p = center(sb.pos_arrow_rect());
press(&mut sb, p);
let ctx = send(&mut sb, Event::PointerLeave);
assert!(!sb.captures_pointer(), "leaving the window ends the hold");
assert!(!ctx.tick_requested, "and stops the tick re-requests");
}
struct BareWrapper(ScrollBar);
impl Widget for BareWrapper {
fn bounds(&self) -> Rect {
self.0.bounds()
}
fn paint(&mut self, _painter: &mut Painter, _theme: &Theme) {}
fn event(&mut self, event: &Event, ctx: &mut EventCtx) {
self.0.event(event, ctx);
}
fn layout(&mut self, bounds: Rect) {
self.0.layout(bounds);
}
}
#[test]
fn a_held_button_requests_ticks_through_a_non_forwarding_wrapper() {
let mut host = BareWrapper(vbar());
assert!(
!host.wants_ticks(),
"the wrapper doesn't forward wants_ticks"
);
let p = center(host.0.pos_arrow_rect());
let mut ctx = EventCtx::new();
host.event(
&Event::PointerDown {
pos: p,
button: MouseButton::Left,
modifiers: Modifiers::default(),
},
&mut ctx,
);
assert!(
ctx.tick_requested,
"the held scrollbar's tick request reaches the top with no forwarding"
);
assert!(
!host.wants_ticks(),
"and it never had to rely on wants_ticks to do so"
);
}
}