//! 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, tokens: Tokens, dirt: Dirt, emitted: Vec, } 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 { 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 { 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 { 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;