fluidattacks-designs 0.11.0

Fluid Attacks design system, GPUI variant: desktop widgets over the shared tokens
//! `Scrollbar` — the web's always-visible bar, painted over a `track_scroll`
//! container since gpui draws none natively. Overlay it inside the scroll
//! area's `relative()` parent:
//!
//! ```ignore
//! div()
//!     .relative()
//!     .child(div().id("list").overflow_y_scroll().track_scroll(&handle))
//!     .child(Scrollbar::vertical("list-scrollbar", &handle))
//! ```
//!
//! Both axes exist, thin by default like the web's tables and slide-out
//! menus; `wide()` matches the untouched native width of containers and
//! modals. Thumb drag and track jumps mirror the native bars the web
//! restyles. Mechanism after gpui-component's scrollbar (Apache-2.0).
//!
//! gpui bubbles wheel deltas to every scrollable ancestor, unlike the
//! browser's gesture latching: wrap nested scroll areas with
//! [`stop_dominant_axis`] so a sideways gesture over an inner strip
//! doesn't also drag the page.

use fluidattacks_designs_domain::scrollbar::{
    offset_for, thumb, RADIUS, THUMB, TRACK, WIDE_WIDTH, WIDTH,
};
use gpui::{
    canvas, div, fill, point, px, rgb, size, App, Bounds, ElementId, Entity, EntityId, IntoElement,
    ListState, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Point,
    RenderOnce, ScrollHandle, Styled as _, Window,
};

use crate::theme::ActiveTheme as _;

/// Swallow the wheel before outer scrollers see it when the gesture's
/// dominant axis matches `axis` — the browser latching nested scroll
/// areas get for free.
pub fn stop_dominant_axis(
    axis: ScrollAxis,
) -> impl Fn(&gpui::ScrollWheelEvent, &mut Window, &mut App) + 'static {
    move |event, window, cx| {
        let delta = event.delta.pixel_delta(window.line_height());
        let horizontal = f32::from(delta.x).abs() > f32::from(delta.y).abs();
        let dominant = match axis {
            ScrollAxis::Horizontal => horizontal,
            ScrollAxis::Vertical => !horizontal,
        };
        if dominant {
            cx.stop_propagation();
        }
    }
}

/// The axis argument for [`stop_dominant_axis`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ScrollAxis {
    Vertical,
    Horizontal,
}

#[derive(Clone, Copy, PartialEq, Eq)]
enum Axis {
    Vertical,
    Horizontal,
}

/// What a bar reads and moves: a plain scroll container, or a list whose rows
/// differ in height and so keeps its own offset.
#[derive(Clone)]
enum Source {
    Plain(ScrollHandle),
    Rows(ListState),
}

impl Source {
    fn offset(&self) -> Point<Pixels> {
        match self {
            Self::Plain(handle) => handle.offset(),
            Self::Rows(rows) => rows.scroll_px_offset_for_scrollbar(),
        }
    }

    fn max_offset(&self) -> Point<Pixels> {
        match self {
            Self::Plain(handle) => handle.max_offset(),
            Self::Rows(rows) => rows.max_offset_for_scrollbar(),
        }
    }

    fn set_offset(&self, offset: Point<Pixels>) {
        match self {
            Self::Plain(handle) => handle.set_offset(offset),
            Self::Rows(rows) => rows.set_offset_from_scrollbar(offset),
        }
    }

    /// A list measures rows as they come into sight; holding its height still
    /// while the thumb is held keeps the thumb under the pointer.
    fn grab(&self) {
        if let Self::Rows(rows) = self {
            rows.scrollbar_drag_started();
        }
    }

    fn release(&self) {
        if let Self::Rows(rows) = self {
            rows.scrollbar_drag_ended();
        }
    }
}

#[derive(IntoElement)]
pub struct Scrollbar {
    id: ElementId,
    source: Source,
    track: bool,
    axis: Axis,
    breadth: f32,
    corner: bool,
}

impl Scrollbar {
    /// A vertical bar for `handle`'s container. Distinct instances need
    /// distinct ids: element state keeps the drag grip across frames.
    pub fn vertical(id: impl Into<ElementId>, handle: &ScrollHandle) -> Self {
        Self::vertical_over(id, Source::Plain(handle.clone()))
    }

    /// A vertical bar for a [`gpui::list`], the table's rows.
    pub(crate) fn rows(id: impl Into<ElementId>, rows: &ListState) -> Self {
        Self::vertical_over(id, Source::Rows(rows.clone()))
    }

    fn vertical_over(id: impl Into<ElementId>, source: Source) -> Self {
        Self {
            id: id.into(),
            source,
            track: true,
            axis: Axis::Vertical,
            breadth: WIDTH,
            corner: false,
        }
    }

    /// A horizontal bar, the web's table overflow case.
    #[must_use]
    pub fn horizontal(id: impl Into<ElementId>, handle: &ScrollHandle) -> Self {
        Self {
            id: id.into(),
            source: Source::Plain(handle.clone()),
            track: true,
            axis: Axis::Horizontal,
            breadth: WIDTH,
            corner: false,
        }
    }

    /// Drop the gray track — the web's tables paint the bar directly over
    /// their own surface.
    #[must_use]
    pub const fn track_transparent(mut self) -> Self {
        self.track = false;
        self
    }

    /// The untouched native width the web's containers and modals keep.
    #[must_use]
    pub const fn wide(mut self) -> Self {
        self.breadth = WIDE_WIDTH;
        self
    }

    /// Stop short of the far end, leaving the square where a crossing bar
    /// would run. Two bars on one box otherwise reach into the same corner and
    /// the one drawn last takes it, hit test included; set this on both. The
    /// room left is this bar's own breadth.
    #[must_use]
    pub const fn reserve_corner(mut self) -> Self {
        self.corner = true;
        self
    }
}

/// One bar's live wiring, shared by the per-frame mouse handlers.
#[derive(Clone)]
struct Bar {
    source: Source,
    /// The grab point inside the thumb while dragging.
    grip: Entity<Option<f32>>,
    view: EntityId,
    track: bool,
    axis: Axis,
    breadth: f32,
    /// Track and thumb, resolved for the active mode at render time — the
    /// paint closure runs without a theme to ask.
    tones: (u32, u32),
}

#[derive(Clone, Copy)]
struct Geometry {
    bounds: Bounds<Pixels>,
    thumb: Bounds<Pixels>,
    container: f32,
    content: f32,
}

impl Geometry {
    /// The pointer's position along the track, in track coordinates.
    fn along(&self, axis: Axis, position: Point<Pixels>) -> f32 {
        match axis {
            Axis::Vertical => f32::from(position.y) - f32::from(self.bounds.origin.y),
            Axis::Horizontal => f32::from(position.x) - f32::from(self.bounds.origin.x),
        }
    }

    /// The grab point inside the thumb.
    #[must_use]
    fn grab(&self, axis: Axis, position: Point<Pixels>) -> f32 {
        match axis {
            Axis::Vertical => f32::from(position.y) - f32::from(self.thumb.origin.y),
            Axis::Horizontal => f32::from(position.x) - f32::from(self.thumb.origin.x),
        }
    }

    #[must_use]
    fn thumb_length(&self, axis: Axis) -> f32 {
        match axis {
            Axis::Vertical => f32::from(self.thumb.size.height),
            Axis::Horizontal => f32::from(self.thumb.size.width),
        }
    }
}

fn scroll_to(geometry: &Geometry, bar: &Bar, top: f32, cx: &mut App) {
    let offset = px(offset_for(geometry.container, geometry.content, top));
    let current = bar.source.offset();
    let target = match bar.axis {
        Axis::Vertical => point(current.x, offset),
        Axis::Horizontal => point(offset, current.y),
    };
    bar.source.set_offset(target);
    cx.notify(bar.view);
}

/// Registers the drag/jump handlers for the frame. Window mouse handlers
/// live for one frame only, so paint re-registers them, mirroring how
/// gpui-component's scrollbar element does it.
fn handle_mouse(geometry: Geometry, bar: &Bar, window: &mut Window) {
    window.on_mouse_event({
        let bar = bar.clone();
        move |event: &MouseDownEvent, phase, _, cx| {
            if !(phase.bubble() && geometry.bounds.contains(&event.position)) {
                return;
            }
            cx.stop_propagation();
            if geometry.thumb.contains(&event.position) {
                let within = geometry.grab(bar.axis, event.position);
                bar.source.grab();
                bar.grip.update(cx, |grip, _| *grip = Some(within));
            } else {
                // jump the thumb's center to the click, like native bars
                let half = geometry.thumb_length(bar.axis) / 2.0;
                let top = geometry.along(bar.axis, event.position) - half;
                scroll_to(&geometry, &bar, top, cx);
            }
        }
    });
    window.on_mouse_event({
        let bar = bar.clone();
        move |event: &MouseMoveEvent, _, _, cx| {
            let Some(within) = *bar.grip.read(cx) else {
                return;
            };
            if !event.dragging() {
                return;
            }
            cx.stop_propagation();
            let top = geometry.along(bar.axis, event.position) - within;
            scroll_to(&geometry, &bar, top, cx);
        }
    });
    window.on_mouse_event({
        let bar = bar.clone();
        move |_: &MouseUpEvent, phase, _, cx| {
            if phase.bubble() && bar.grip.read(cx).is_some() {
                bar.source.release();
                bar.grip.update(cx, |grip, _| *grip = None);
            }
        }
    });
}

fn paint(bounds: Bounds<Pixels>, bar: &Bar, window: &mut Window) {
    let (container, max_offset, offset) = match bar.axis {
        Axis::Vertical => (
            bounds.size.height,
            bar.source.max_offset().y,
            bar.source.offset().y,
        ),
        Axis::Horizontal => (
            bounds.size.width,
            bar.source.max_offset().x,
            bar.source.offset().x,
        ),
    };
    let container = f32::from(container);
    let content = f32::from(max_offset) + container;
    let Some((top, length)) = thumb(container, content, f32::from(offset)) else {
        return;
    };
    let (track, thumb_color) = bar.tones;
    if bar.track {
        window.paint_quad(fill(bounds, rgb(track)));
    }
    let thumb_bounds = match bar.axis {
        Axis::Vertical => Bounds {
            origin: point(bounds.origin.x, px(f32::from(bounds.origin.y) + top)),
            size: size(px(bar.breadth), px(length)),
        },
        Axis::Horizontal => Bounds {
            origin: point(px(f32::from(bounds.origin.x) + top), bounds.origin.y),
            size: size(px(length), px(bar.breadth)),
        },
    };
    window.paint_quad(fill(thumb_bounds, rgb(thumb_color)).corner_radii(px(RADIUS)));
    let geometry = Geometry {
        bounds,
        thumb: thumb_bounds,
        container,
        content,
    };
    handle_mouse(geometry, bar, window);
}

impl RenderOnce for Scrollbar {
    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
        let bar = Bar {
            source: self.source,
            grip: window.use_keyed_state(self.id, cx, |_, _| None),
            view: window.current_view(),
            track: self.track,
            axis: self.axis,
            breadth: self.breadth,
            tones: (cx.theme().shade(TRACK), cx.theme().shade(THUMB)),
        };
        let strip = div().absolute();
        // the reserved square is measured off the end the two bars share
        let end = px(if self.corner { self.breadth } else { 0.0 });
        let strip = match self.axis {
            Axis::Vertical => strip.top_0().right_0().bottom(end).w(px(self.breadth)),
            Axis::Horizontal => strip.bottom_0().left_0().right(end).h(px(self.breadth)),
        };
        strip.child(
            canvas(
                |_, _, _| (),
                move |bounds, (), paint_window, _paint_cx| {
                    paint(bounds, &bar, paint_window);
                },
            )
            .size_full(),
        )
    }
}