scroll_area.rs
raw
//! The ScrollArea primitive: a viewport over overflowing content.
//!
//! Scrolling is a *transform-only* change by design: the
//! offset shifts the children's `Child` transforms inside a clip in this
//! widget's fragment, marking paint dirt and never touching layout. The
//! same offset feeds hit testing and absolute origins through
//! [`Widget::content_offset`], so pointer targets always match pixels.
//!
//! Scrollbars are painted as overlays (no reserved gutter) in
//! [`Widget::paint_overlay`], over the clipped content; the thumb drags
//! through the ordinary pointer-capture mechanism. The scroll area places the
//! thumbs and decides when they exist; the theme supplies their appearance.
use guiduck_scene::Fragment;
use guiduck_scene::geom::{Point, Rect, Size, Vec2};
use guiduck_scene::paint::{Brush, Color};
use taffy::Overflow;
use super::Widget;
use crate::clipboard::Clipboard;
use crate::dirty::Dirt;
use crate::event::{EventData, EventKind, PointerButton, PointerEvent};
use crate::style::{ComputedStyle, StyleReader};
use crate::text::TextContext;
/// Width of the overlay scrollbar thumb, in logical px.
const THUMB_WIDTH: f64 = 6.0;
/// Gap between the thumb and the widget edge.
const THUMB_MARGIN: f64 = 2.0;
/// Minimum thumb length, so tiny viewports stay grabbable.
const MIN_THUMB: f64 = 24.0;
/// One arrow-key step.
const LINE: f64 = 40.0;
/// How much of the old view a Page keeps, for continuity across the jump.
const PAGE_OVERLAP: f64 = 40.0;
/// Which axes scroll. The other axis clips.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum ScrollAxes {
Vertical,
Horizontal,
Both,
}
impl ScrollAxes {
fn vertical(self) -> bool {
matches!(self, Self::Vertical | Self::Both)
}
fn horizontal(self) -> bool {
matches!(self, Self::Horizontal | Self::Both)
}
}
/// The appearance tokens a scroll area reads from the theme.
struct Tokens {
thumb: Brush,
thumb_corner_radius: f64,
}
impl Tokens {
/// The pre-fill state; [`Default`] fills every slot from the fallback
/// theme.
fn blank() -> Self {
Self {
thumb: Color::TRANSPARENT.into(),
thumb_corner_radius: 0.0,
}
}
/// Read every token out of a computed style, reporting whether any
/// changed.
fn read(&mut self, style: &ComputedStyle) -> bool {
let mut reader = StyleReader::new(style);
reader.brush(&mut self.thumb, "thumb-color");
reader.number(&mut self.thumb_corner_radius, "thumb-corner-radius");
reader.changed()
}
}
impl Default for Tokens {
fn default() -> Self {
let mut tokens = Self::blank();
tokens.read(&super::fallback_style("scroll-area"));
tokens
}
}
/// An in-progress scrollbar-thumb drag.
struct ThumbDrag {
vertical: bool,
/// Pointer position along the dragged axis at the press.
start_pointer: f64,
/// Scroll offset along the dragged axis at the press.
start_offset: f64,
}
pub struct ScrollArea {
axes: ScrollAxes,
/// Scroll position, ≥ 0 on each axis, ≤ content − size.
offset: Vec2,
size: Size,
content: Size,
drag: Option<ThumbDrag>,
tokens: Tokens,
dirt: Dirt,
emitted: Vec<EventData>,
}
impl Default for ScrollArea {
/// A vertically scrolling area (the common case); the horizontal axis
/// clips.
fn default() -> Self {
Self {
axes: ScrollAxes::Vertical,
offset: Vec2::ZERO,
size: Size::ZERO,
content: Size::ZERO,
drag: None,
tokens: Tokens::default(),
dirt: Dirt::CLEAN,
emitted: Vec::new(),
}
}
}
impl ScrollArea {
pub fn new() -> Self {
Self::default()
}
/// Which axes scroll.
///
/// This is a property, not a kind of scroll area: `:axis` in a `.gdc` is
/// this setter. It is read at insert — [`Widget::adjust_style`] turns it
/// into taffy's overflow — so it is set while the area is being built,
/// like every other static property.
pub fn set_axis(&mut self, axes: ScrollAxes) {
if self.axes != axes {
self.axes = axes;
self.dirt.mark_layout();
}
}
pub fn axis(mut self, axes: ScrollAxes) -> Self {
self.set_axis(axes);
self
}
/// The current scroll position (≥ 0; grows toward later content).
pub fn offset(&self) -> Vec2 {
self.offset
}
/// Scroll to a horizontal position — the controlled setter, so it is
/// silent: only the reader moving the view raises
/// [`EventData::Scrolled`], which is what lets an application hold the
/// position as state without its own binding echoing back at it.
pub fn set_offset_x(&mut self, x: f64) {
self.set_offset(Vec2::new(x, self.offset.y));
}
/// Scroll to a vertical position; controlled, like
/// [`set_offset_x`](Self::set_offset_x).
pub fn set_offset_y(&mut self, y: f64) {
self.set_offset(Vec2::new(self.offset.x, y));
}
/// Scroll to a position (clamped); paint-only.
pub fn set_offset(&mut self, offset: Vec2) {
let clamped = self.clamp(offset);
if clamped != self.offset {
self.offset = clamped;
self.dirt.mark_paint();
}
}
/// The maximum offset per axis: content minus viewport, never negative.
fn max_offset(&self) -> Vec2 {
Vec2::new(
if self.axes.horizontal() {
(self.content.width - self.size.width).max(0.0)
} else {
0.0
},
if self.axes.vertical() {
(self.content.height - self.size.height).max(0.0)
} else {
0.0
},
)
}
fn clamp(&self, offset: Vec2) -> Vec2 {
let max = self.max_offset();
Vec2::new(offset.x.clamp(0.0, max.x), offset.y.clamp(0.0, max.y))
}
/// Move to a position because the *reader* did — clamped, marked, and
/// reported. The one place a user-driven move happens, so "the reader
/// moved it" and "the application set it" cannot blur into each other.
fn move_to(&mut self, target: Vec2) -> bool {
let target = self.clamp(target);
if target == self.offset {
return false;
}
self.offset = target;
self.dirt.mark_paint();
self.emitted.push(EventData::Scrolled {
x: self.offset.x,
y: self.offset.y,
});
true
}
/// Move the offset by `delta` (clamped), reporting whether it moved.
///
/// The one definition of a scroll step: the wheel and the keyboard both
/// come through here, so "at the end of the range this does nothing"
/// cannot mean two things.
fn scroll_by(&mut self, delta: Vec2) -> bool {
self.move_to(self.offset + delta)
}
/// The vertical thumb's rectangle, when the content overflows.
fn vertical_thumb(&self) -> Option<Rect> {
let max = self.max_offset();
if max.y <= 0.0 {
return None;
}
let track = self.size.height;
let length = (track * self.size.height / self.content.height).max(MIN_THUMB);
let travel = track - length;
let top = travel * (self.offset.y / max.y);
let x = self.size.width - THUMB_MARGIN - THUMB_WIDTH;
Some(Rect::new(x, top, x + THUMB_WIDTH, top + length))
}
/// The horizontal thumb's rectangle, when the content overflows.
fn horizontal_thumb(&self) -> Option<Rect> {
let max = self.max_offset();
if max.x <= 0.0 {
return None;
}
let track = self.size.width;
let length = (track * self.size.width / self.content.width).max(MIN_THUMB);
let travel = track - length;
let left = travel * (self.offset.x / max.x);
let y = self.size.height - THUMB_MARGIN - THUMB_WIDTH;
Some(Rect::new(left, y, left + length, y + THUMB_WIDTH))
}
}
impl Widget for ScrollArea {
fn adjust_style(&self, style: &mut taffy::Style) {
// The widget owns its overflow requirements: scrolling axes use
// taffy's Scroll (content measured, min-content contribution
// relaxed), the rest clip.
let of = |scrolls: bool| {
if scrolls {
Overflow::Scroll
} else {
Overflow::Clip
}
};
style.overflow = taffy::Point {
x: of(self.axes.horizontal()),
y: of(self.axes.vertical()),
};
// And it owns not squashing its content, for the same reason: a scroll
// area exists so that content keeps its own size and overflows.
//
// `stretch` is the flex default, and across a *scrolling* axis it
// resizes the child to the viewport — so a window shrunk below the
// content's height leaves every part of it past the fold outside the
// child's own box. Painting is not bounded by that box and hit testing
// is, so the content below still draws and stops responding: links go
// dead, cursors stop changing, clicks land on nothing.
//
// Only `stretch` is replaced, because only `stretch` *resizes*;
// `center` and `end` place a child at its natural size and are left to
// mean what the author said.
let cross_axis_scrolls = match style.flex_direction {
taffy::FlexDirection::Row | taffy::FlexDirection::RowReverse => self.axes.vertical(),
taffy::FlexDirection::Column | taffy::FlexDirection::ColumnReverse => {
self.axes.horizontal()
}
};
let resizes = style.align_items.is_none_or(|align| {
matches!(
align.keyword,
taffy::AlignItemsKeyword::Stretch | taffy::AlignItemsKeyword::Baseline
)
});
if cross_axis_scrolls && resizes {
style.align_items = Some(taffy::AlignItems::FLEX_START);
}
}
fn take_emitted(&mut self) -> Vec<EventData> {
std::mem::take(&mut self.emitted)
}
fn finalize_layout(&mut self, _text: &mut TextContext, size: Size, content_size: Size) {
let moved = self.size != size || self.content != content_size;
self.size = size;
self.content = content_size;
// Content may have shrunk out from under the offset — navigating from
// the bottom of a long page to a short one — and the offset follows.
//
// Both of these change what this widget draws, so both mark it: the
// offset is the transform its children are carried under, and the
// thumb is drawn from the size and the content extent. Neither is
// implied by anything else the frame already knows to repaint — the
// widget's own box may not have moved by a pixel while everything
// inside it did.
let clamped = self.clamp(self.offset);
if moved || clamped != self.offset {
self.offset = clamped;
self.dirt.mark_paint();
}
}
fn content_offset(&self) -> Vec2 {
-self.offset
}
fn clips_content(&self) -> bool {
true
}
fn on_scroll(&mut self, delta: Vec2) -> bool {
// Platform orientation: positive delta scrolls up/away, which moves
// the offset back toward the start.
self.scroll_by(-delta)
}
fn scroll_reveal(&mut self, viewport_top_left: Point, target: Rect) -> Vec2 {
let old = self.offset;
let mut want = old;
// Move the offset only as far as needed to bring the target inside the
// viewport, on the axes this area scrolls. A target above/left of the
// viewport aligns to that edge (so the start of an over-large target
// stays visible); one below/right aligns to the far edge.
if self.axes.vertical() {
let top = viewport_top_left.y;
let bottom = top + self.size.height;
if target.y0 < top {
want.y -= top - target.y0;
} else if target.y1 > bottom {
want.y += target.y1 - bottom;
}
}
if self.axes.horizontal() {
let left = viewport_top_left.x;
let right = left + self.size.width;
if target.x0 < left {
want.x -= left - target.x0;
} else if target.x1 > right {
want.x += target.x1 - right;
}
}
// The tree re-encodes a container that reports a move, so setting the
// offset here does not mark dirt (that would cost a redundant frame).
self.offset = self.clamp(want);
self.offset - old
}
fn paint_overlay(&mut self, fragment: &mut Fragment, _size: Size) {
for thumb in [self.vertical_thumb(), self.horizontal_thumb()]
.into_iter()
.flatten()
{
fragment.fill(
thumb.to_rounded_rect(self.tokens.thumb_corner_radius),
self.tokens.thumb.clone(),
);
}
}
fn apply_style(&mut self, style: &ComputedStyle) {
if self.tokens.read(style) {
self.dirt.mark_paint();
}
}
/// Click-focusable, so the keyboard can scroll what you clicked into…
fn focusable(&self) -> bool {
true
}
/// …but not a Tab stop: a viewport has no business interrupting a walk
/// between the controls inside it.
fn tab_stop(&self) -> bool {
false
}
fn on_key(
&mut self,
key: &crate::event::KeyInput,
_text: &mut TextContext,
_clipboard: &mut dyn Clipboard,
) -> bool {
use crate::event::Key;
if !key.pressed {
return false;
}
// A page is the viewport less an overlap, so a reader keeps a couple
// of lines of context across a jump — the convention everywhere.
let page = |extent: f64| (extent - PAGE_OVERLAP).max(extent / 2.0);
let delta = match key.key {
Key::Up => Vec2::new(0.0, -LINE),
Key::Down => Vec2::new(0.0, LINE),
Key::Left => Vec2::new(-LINE, 0.0),
Key::Right => Vec2::new(LINE, 0.0),
Key::PageUp => Vec2::new(0.0, -page(self.size.height)),
Key::PageDown => Vec2::new(0.0, page(self.size.height)),
// Home and End go to the ends of the scrolling axis, which is a
// clamp away rather than a special case.
Key::Home => Vec2::new(0.0, -f64::MAX),
Key::End => Vec2::new(0.0, f64::MAX),
_ => return false,
};
// A key that would not move anything is not ours: Down at the bottom
// must not swallow the keystroke that could scroll an ancestor.
self.scroll_by(delta)
}
fn on_pointer(
&mut self,
kind: EventKind,
event: &PointerEvent,
_text: &mut TextContext,
_clipboard: &mut dyn Clipboard,
) -> bool {
match kind {
EventKind::PointerDown if event.button == Some(PointerButton::Left) => {
if self
.vertical_thumb()
.is_some_and(|thumb| thumb.contains(event.local))
{
self.drag = Some(ThumbDrag {
vertical: true,
start_pointer: event.window.y,
start_offset: self.offset.y,
});
return true;
}
if self
.horizontal_thumb()
.is_some_and(|thumb| thumb.contains(event.local))
{
self.drag = Some(ThumbDrag {
vertical: false,
start_pointer: event.window.x,
start_offset: self.offset.x,
});
return true;
}
false
}
EventKind::PointerMove => {
let Some(drag) = &self.drag else {
return false;
};
let (vertical, start_pointer, start_offset) =
(drag.vertical, drag.start_pointer, drag.start_offset);
// Thumb travel maps linearly onto the scroll range. Window
// coordinates avoid feedback through the widget's own
// moving content.
let max = self.max_offset();
let (pointer, extent, content_extent, range) = if vertical {
(event.window.y, self.size.height, self.content.height, max.y)
} else {
(event.window.x, self.size.width, self.content.width, max.x)
};
let length = (extent * extent / content_extent).max(MIN_THUMB);
let track = extent - length;
if track <= 0.0 {
return true;
}
let moved = (pointer - start_pointer) / track * range;
let target = if vertical {
Vec2::new(self.offset.x, start_offset + moved)
} else {
Vec2::new(start_offset + moved, self.offset.y)
};
self.move_to(target);
true
}
EventKind::PointerUp => {
if self.drag.take().is_some() {
return true;
}
false
}
_ => false,
}
}
fn take_dirt(&mut self) -> Dirt {
std::mem::take(&mut self.dirt)
}
fn type_name(&self) -> &'static str {
"scroll-area"
}
fn role(&self) -> accesskit::Role {
accesskit::Role::ScrollView
}
}
#[cfg(test)]
mod tests;