use std::cell::RefCell;
use std::rc::Rc;
use smudge_core::element::{Element, IntoElement, Value};
use smudge_core::geometry::Point;
use smudge_core::paint::PaintOp;
use smudge_core::reactive::{RwSignal, prelude::*};
use smudge_core::semantics::{Role, Semantics};
use smudge_core::style::{Edges, Length, px};
use smudge_core::theme::ColorToken;
use smudge_core::tree::Dirty;
use smudge_core::widget::{PaintCx, Panel, Widget, panel};
const THUMB_WIDTH: f32 = 4.0;
const THUMB_RIGHT_INSET: f32 = 2.0;
const MIN_THUMB_LENGTH: f32 = 24.0;
#[derive(Clone, Copy, Debug, PartialEq)]
struct Viewport {
visible: f32,
track: f32,
}
struct ScrollChrome {
offset: RefCell<RwSignal<f32>>,
content_height: RwSignal<Option<f32>>,
viewport: RwSignal<Option<Viewport>>,
visible_height: RwSignal<Option<f32>>,
}
impl ScrollChrome {
fn signal(&self) -> RwSignal<f32> {
self.offset.borrow().clone()
}
fn max_offset(&self) -> Option<f32> {
let content = self.content_height.get()?;
let viewport = self.viewport.get()?;
Some((content - viewport.visible).max(0.0))
}
}
pub struct ScrollView {
chrome: Rc<ScrollChrome>,
}
impl Widget for ScrollView {
fn semantics(&self) -> Option<Semantics> {
Some(Semantics::new(Role::ScrollView))
}
fn paint(&self, cx: &PaintCx<'_>, out: &mut Vec<PaintOp>) {
let border = cx.style.layout.border_width;
let visible = finite(cx.content.height());
report(
&self.chrome.viewport,
Viewport {
visible,
track: finite(cx.rect.height() - border.top - border.bottom).max(0.0),
},
);
report(&self.chrome.visible_height, visible);
Panel.paint(cx, out);
}
}
struct ScrollContent {
chrome: Rc<ScrollChrome>,
}
impl Widget for ScrollContent {
fn paint(&self, cx: &PaintCx<'_>, _out: &mut Vec<PaintOp>) {
report(&self.chrome.content_height, finite(cx.rect.height()));
}
}
fn finite(value: f32) -> f32 {
if value.is_finite() { value } else { 0.0 }
}
fn report<T: PartialEq + 'static>(signal: &RwSignal<Option<T>>, value: T) {
let value = Some(value);
if signal.read(|current| current != &value) {
signal.set(value);
}
}
fn clamp_offset(desired: f32, max: f32) -> f32 {
finite(desired).clamp(0.0, max)
}
pub fn scroll(content: impl IntoElement) -> Element<ScrollView> {
scroll_with_visible_height(content, RwSignal::new(None))
}
pub(crate) fn scroll_with_visible_height(
content: impl IntoElement,
visible_height: RwSignal<Option<f32>>,
) -> Element<ScrollView> {
let chrome = Rc::new(ScrollChrome {
offset: RefCell::new(RwSignal::new(0.0)),
content_height: RwSignal::new(None),
viewport: RwSignal::new(None),
visible_height,
});
let wheel_chrome = Rc::clone(&chrome);
let offset_chrome = Rc::clone(&chrome);
let content_chrome = Rc::clone(&chrome);
let track_chrome = Rc::clone(&chrome);
Element::new(ScrollView {
chrome: Rc::clone(&chrome),
})
.column()
.clip(true)
.on_wheel(move |delta| {
let Some(max) = wheel_chrome.max_offset() else {
return;
};
let signal = wheel_chrome.signal();
let current = clamp_offset(signal.read(|offset| *offset), max);
let next = clamp_offset(current + finite(delta.y), max);
if next != current {
signal.set(next);
}
})
.scroll_offset(move || {
let signal = offset_chrome.signal();
let desired = signal.get();
let applied = match offset_chrome.max_offset() {
Some(max) => {
let clamped = clamp_offset(desired, max);
if clamped != desired {
signal.set(clamped);
}
clamped
}
None => finite(desired).max(0.0),
};
Point { x: 0.0, y: applied }
})
.child(
Element::new(ScrollContent {
chrome: content_chrome,
})
.column()
.shrink(0.0)
.child(content),
)
.child(thumb(track_chrome))
}
pub trait ScrollExt: Sized {
fn offset(self, signal: RwSignal<f32>) -> Self;
}
impl ScrollExt for Element<ScrollView> {
fn offset(self, signal: RwSignal<f32>) -> Self {
self.bind(Value::Static(signal), use_offset_signal)
}
}
fn use_offset_signal(view: &mut ScrollView, signal: RwSignal<f32>) -> Dirty {
*view.chrome.offset.borrow_mut() = signal;
Dirty::Clean
}
fn thumb(chrome: Rc<ScrollChrome>) -> Element<Panel> {
let hidden_chrome = Rc::clone(&chrome);
let length_chrome = Rc::clone(&chrome);
let top_chrome = chrome;
panel()
.absolute()
.inset(Edges {
top: Length::px(0.0),
right: Length::px(THUMB_RIGHT_INSET),
bottom: Length::Auto,
left: Length::Auto,
})
.width(px(THUMB_WIDTH))
.height(px(0.0))
.column()
.hidden(move || thumb_track(&hidden_chrome).is_none())
.scroll_offset(move || Point {
x: 0.0,
y: -thumb_top(&top_chrome),
})
.child(
panel()
.width(px(THUMB_WIDTH))
.height(move || Length::px(thumb_length(&length_chrome)))
.shrink(0.0)
.corner_radius(THUMB_WIDTH / 2.0)
.background(ColorToken::BORDER),
)
}
struct Track {
length: f32,
track: f32,
max_offset: f32,
}
fn thumb_track(chrome: &ScrollChrome) -> Option<Track> {
let max_offset = chrome.max_offset()?;
let content = chrome.content_height.get()?;
let viewport = chrome.viewport.get()?;
if max_offset <= 0.0 || viewport.track <= 0.0 {
return None;
}
let length = (viewport.track * viewport.visible / content)
.clamp(MIN_THUMB_LENGTH.min(viewport.track), viewport.track)
.round();
Some(Track {
length,
track: viewport.track,
max_offset,
})
}
fn thumb_length(chrome: &ScrollChrome) -> f32 {
thumb_track(chrome).map_or(0.0, |track| track.length)
}
fn thumb_top(chrome: &ScrollChrome) -> f32 {
let Some(track) = thumb_track(chrome) else {
return 0.0;
};
let offset = clamp_offset(chrome.signal().get(), track.max_offset);
(offset / track.max_offset * (track.track - track.length)).round()
}