use iced::advanced::layout::{self, Layout};
use iced::advanced::widget::{self, Widget};
use iced::advanced::{mouse, renderer, Clipboard, Shell};
use iced::{border, Element, Event, Length, Rectangle, Shadow, Size};
pub const WIDTH: f32 = 10.0;
pub const MIN_THUMB: f32 = 24.0;
fn thumb_len(track: f32, viewport: f32, content: f32) -> f32 {
if content <= 0.0 {
return track;
}
(track * (viewport / content)).max(MIN_THUMB).min(track)
}
fn thumb_top(track: f32, thumb: f32, offset: f32, max_offset: f32) -> f32 {
if max_offset <= 0.0 {
0.0
} else {
(track - thumb) * (offset / max_offset).clamp(0.0, 1.0)
}
}
fn offset_at(track: f32, thumb: f32, top: f32, max_offset: f32) -> f32 {
if track <= thumb {
0.0
} else {
(top / (track - thumb)).clamp(0.0, 1.0) * max_offset
}
}
#[derive(Debug, Default)]
struct State {
grab: Option<f32>,
}
#[allow(missing_debug_implementations)]
pub struct VScrollbar<'a, Message> {
offset: f32,
viewport: f32,
content: f32,
on_scroll: Box<dyn Fn(f32) -> Message + 'a>,
}
impl<'a, Message> VScrollbar<'a, Message> {
pub fn new(
offset: f32,
viewport: f32,
content: f32,
on_scroll: impl Fn(f32) -> Message + 'a,
) -> Self {
Self {
offset,
viewport,
content,
on_scroll: Box::new(on_scroll),
}
}
fn max_offset(&self) -> f32 {
(self.content - self.viewport).max(0.0)
}
fn thumb(&self, bounds: Rectangle) -> Rectangle {
let thumb = thumb_len(bounds.height, self.viewport, self.content);
let top = thumb_top(bounds.height, thumb, self.offset, self.max_offset());
Rectangle {
x: bounds.x,
y: bounds.y + top,
width: bounds.width,
height: thumb,
}
}
}
impl<Message, Renderer> Widget<Message, iced::Theme, Renderer> for VScrollbar<'_, Message>
where
Renderer: renderer::Renderer,
{
fn tag(&self) -> widget::tree::Tag {
widget::tree::Tag::of::<State>()
}
fn state(&self) -> widget::tree::State {
widget::tree::State::new(State::default())
}
fn size(&self) -> Size<Length> {
Size::new(Length::Fixed(WIDTH), Length::Fill)
}
fn layout(
&mut self,
_tree: &mut widget::Tree,
_renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
layout::atomic(limits, WIDTH, Length::Fill)
}
fn update(
&mut self,
tree: &mut widget::Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
_renderer: &Renderer,
_clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
_viewport: &Rectangle,
) {
let state = tree.state.downcast_mut::<State>();
let bounds = layout.bounds();
let thumb = self.thumb(bounds);
match event {
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
let Some(position) = cursor.position_over(bounds) else {
return;
};
if thumb.contains(position) {
state.grab = Some(position.y - thumb.y);
} else {
state.grab = Some(thumb.height / 2.0);
let top = position.y - bounds.y - thumb.height / 2.0;
shell.publish((self.on_scroll)(offset_at(
bounds.height,
thumb.height,
top,
self.max_offset(),
)));
}
shell.capture_event();
}
Event::Mouse(mouse::Event::CursorMoved { .. }) => {
let (Some(grab), Some(position)) = (state.grab, cursor.position()) else {
return;
};
let top = position.y - bounds.y - grab;
shell.publish((self.on_scroll)(offset_at(
bounds.height,
thumb.height,
top,
self.max_offset(),
)));
shell.capture_event();
}
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
state.grab = None;
}
_ => {}
}
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut Renderer,
theme: &iced::Theme,
_style: &renderer::Style,
layout: Layout<'_>,
_cursor: mouse::Cursor,
_viewport: &Rectangle,
) {
let state = tree.state.downcast_ref::<State>();
let bounds = layout.bounds();
let palette = theme.extended_palette();
renderer.fill_quad(
renderer::Quad {
bounds,
border: border::rounded(2),
shadow: Shadow::default(),
snap: false,
},
palette.background.weak.color,
);
renderer.fill_quad(
renderer::Quad {
bounds: self.thumb(bounds),
border: border::rounded(2),
shadow: Shadow::default(),
snap: false,
},
if state.grab.is_some() {
palette.primary.base.color
} else {
palette.background.strongest.color
},
);
}
fn mouse_interaction(
&self,
_tree: &widget::Tree,
_layout: Layout<'_>,
_cursor: mouse::Cursor,
_viewport: &Rectangle,
_renderer: &Renderer,
) -> mouse::Interaction {
mouse::Interaction::Idle
}
}
impl<'a, Message, Renderer> From<VScrollbar<'a, Message>>
for Element<'a, Message, iced::Theme, Renderer>
where
Message: 'a,
Renderer: renderer::Renderer + 'a,
{
fn from(bar: VScrollbar<'a, Message>) -> Self {
Element::new(bar)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn thumb_clamps_to_minimum_but_still_reaches_both_ends() {
let (track, viewport, content) = (650.0, 650.0, 625_000.0);
let thumb = thumb_len(track, viewport, content);
assert_eq!(thumb, MIN_THUMB);
let max = content - viewport;
assert_eq!(thumb_top(track, thumb, 0.0, max), 0.0);
let bottom = thumb_top(track, thumb, max, max);
assert!((bottom - (track - thumb)).abs() < 0.001, "thumb must reach the track bottom");
}
#[test]
fn offset_round_trips_through_the_thumb_position() {
let (track, viewport, content) = (600.0, 600.0, 100_000.0);
let thumb = thumb_len(track, viewport, content);
let max = content - viewport;
for offset in [0.0, 12_345.0, max / 2.0, max] {
let top = thumb_top(track, thumb, offset, max);
let back = offset_at(track, thumb, top, max);
assert!((back - offset).abs() < 0.5, "{offset} -> {top} -> {back}");
}
}
#[test]
fn small_content_fills_the_track_and_pins_offset_zero() {
let thumb = thumb_len(400.0, 400.0, 300.0);
assert_eq!(thumb, 400.0);
assert_eq!(offset_at(400.0, thumb, 50.0, 0.0), 0.0);
}
}