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;