use crate::core::{Bounds, Pixels, Point, Rgba, Size};
use crate::view::context::ElementContext;
use crate::view::interactivity::{HandlerEntry, ScrollState, SharedScroll};
use std::rc::Rc;
pub const TRACK_WIDTH: f32 = 12.0;
const THUMB_WIDTH: f32 = 6.0;
const MIN_THUMB: f32 = 24.0;
const THUMB_ALPHA: f32 = 0.35;
const THUMB_ALPHA_ACTIVE: f32 = 0.6;
const TRACK_ALPHA: f32 = 0.07;
const MARKER_HEIGHT: f32 = 2.0;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Marker {
pub fraction: f32,
pub color: Rgba,
}
#[derive(Clone, Copy, Debug)]
pub struct Geometry {
pub track: Bounds,
pub thumb: Bounds,
max_offset: f32,
}
impl Geometry {
pub fn new(bounds: Bounds, state: ScrollState) -> Option<Self> {
let height = bounds.size.height.0;
if state.max_y <= 0.0 || height <= 0.0 {
return None;
}
let total = state.max_y + state.viewport_y;
let thumb_height = (height * state.viewport_y / total).clamp(MIN_THUMB.min(height), height);
let top = bounds.origin.y.0 + (height - thumb_height) * (state.offset_y / state.max_y).clamp(0.0, 1.0);
let right = bounds.right().0;
let rect = |x: f32, y: f32, width: f32, height: f32| {
Bounds::new(Point::new(Pixels(x), Pixels(y)), Size::new(Pixels(width), Pixels(height)))
};
Some(Self {
track: rect(right - TRACK_WIDTH, bounds.origin.y.0, TRACK_WIDTH, height),
thumb: rect(right - (TRACK_WIDTH + THUMB_WIDTH) / 2.0, top, THUMB_WIDTH, thumb_height),
max_offset: state.max_y,
})
}
pub fn contains(&self, point: Point) -> bool {
self.track.contains(point)
}
pub fn press(&self, y: f32) -> f32 {
let (top, height) = (self.thumb.origin.y.0, self.thumb.size.height.0);
if (top..=top + height).contains(&y) { y - top } else { height / 2.0 }
}
pub fn offset_for(&self, y: f32, grab: f32) -> f32 {
let travel = (self.track.size.height.0 - self.thumb.size.height.0).max(1.0);
((y - grab - self.track.origin.y.0) / travel).clamp(0.0, 1.0) * self.max_offset
}
}
impl ElementContext<'_> {
pub fn scrollbar(&mut self, bounds: Bounds, scroll: &SharedScroll, markers: &[Marker]) {
let state = scroll.get();
let Some(geometry) = Geometry::new(bounds, state) else { return };
self.paint_scrollbar_geometry(&geometry, state.grab.is_some(), markers);
let hitbox = self.insert_hitbox(geometry.track, false);
let (on_press, on_drag) = (scroll.clone(), scroll.clone());
self.register_handlers(HandlerEntry {
on_mouse_down: Some(Rc::new(move |event, _| {
let mut state = on_press.get();
if let Some(geometry) = Geometry::new(bounds, state) {
let grab = geometry.press(event.position.y.0);
state.offset_y = geometry.offset_for(event.position.y.0, grab);
state.grab = Some(grab);
on_press.set(state);
}
})),
on_drag: Some(Rc::new(move |event, _| {
let mut state = on_drag.get();
if let (Some(grab), Some(geometry)) = (state.grab, Geometry::new(bounds, state)) {
state.offset_y = geometry.offset_for(event.position.y.0, grab);
on_drag.set(state);
}
})),
..HandlerEntry::new(hitbox)
});
}
pub fn paint_scrollbar_geometry(&mut self, geometry: &Geometry, dragging: bool, markers: &[Marker]) {
let hovered = dragging || geometry.contains(self.mouse_position());
let muted = self.theme.color(crate::view::theme::Token::TextMuted);
if hovered {
self.fill_rect(geometry.track, muted.with_alpha(TRACK_ALPHA));
}
let track = geometry.track;
for marker in markers {
let y = track.origin.y.0 + marker.fraction.clamp(0.0, 1.0) * (track.size.height.0 - MARKER_HEIGHT);
let rect = Bounds::new(
Point::new(track.origin.x, Pixels(y)),
Size::new(Pixels(TRACK_WIDTH), Pixels(MARKER_HEIGHT)),
);
self.fill_rect(rect, marker.color);
}
let alpha = if hovered { THUMB_ALPHA_ACTIVE } else { THUMB_ALPHA };
self.paint_rounded_rect(geometry.thumb, muted.with_alpha(alpha), Pixels(THUMB_WIDTH / 2.0));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn bounds() -> Bounds {
Bounds::new(Point::new(Pixels(100.0), Pixels(10.0)), Size::new(Pixels(300.0), Pixels(200.0)))
}
fn state(offset: f32) -> ScrollState {
ScrollState { offset_y: offset, max_y: 800.0, viewport_y: 200.0, grab: None }
}
#[test]
fn thumb_follows_the_offset_and_maps_back() {
let top = Geometry::new(bounds(), state(0.0)).expect("hay barra");
assert_eq!(top.thumb.origin.y.0, 10.0);
assert_eq!(top.thumb.size.height.0, 40.0, "un quinto del contenido es visible");
let middle = Geometry::new(bounds(), state(400.0)).expect("hay barra");
assert_eq!(middle.thumb.origin.y.0, 90.0);
assert_eq!(top.offset_for(110.0, 20.0), 400.0);
assert!(Geometry::new(bounds(), ScrollState { max_y: 0.0, ..state(0.0) }).is_none());
}
#[test]
fn pressing_the_track_centers_the_thumb_and_pressing_the_thumb_grabs_it() {
let geometry = Geometry::new(bounds(), state(0.0)).expect("hay barra");
assert_eq!(geometry.press(20.0), 10.0, "sobre el pulgar: se agarra donde se pulsó");
let grab = geometry.press(150.0);
assert_eq!(grab, 20.0, "en la pista: el pulgar se centra bajo el puntero");
let offset = geometry.offset_for(150.0, grab);
assert!((offset - 600.0).abs() < 0.01, "(150 - 20 - 10) / 160 = 75 % de 800 = {offset}");
}
}