oriel-iced 0.1.0

iced integrator for the oriel headless data grid: satisfies the data and render seams for iced 0.14.
Documentation
//! The grid's own vertical scrollbar — because iced's cannot be told a
//! minimum thumb size.
//!
//! iced's scrollable hardcodes its minimum scroller length at **2 px**
//! (iced_widget-0.14.2/src/scrollable.rs:1997-1998, `.max(2.0)`), and the
//! `Scrollbar` config only exposes thickness. At data-grid scale (25 000
//! rows × 25 px against a ~650 px track) the thumb pins at that floor —
//! invisible and ungrabbable. So the grid hides the scrollable's vertical
//! bar (zero-width, the same trick the header/footer sync uses — wheel and
//! programmatic scrolling are unaffected) and renders this widget in the
//! gutter instead: a native-feeling bar with a real [`MIN_THUMB`], drawn in
//! iced's own default scrollbar colors.
//!
//! With a clamped minimum, thumb position must map through the REMAINING
//! track (`track − thumb`), not the raw ratio — otherwise the thumb never
//! reaches the bottom. The math lives in free functions, unit-tested below.

use iced::advanced::layout::{self, Layout};
use iced::advanced::widget::{self, Widget};
use iced::advanced::{mouse, renderer, Clipboard, Shell};
use iced::{border, Element, Event, Length, Rectangle, Shadow, Size};

/// The bar's thickness — matches the gutter the grid reserves.
pub const WIDTH: f32 = 10.0;
/// The minimum thumb length, px. iced's is 2.
pub const MIN_THUMB: f32 = 24.0;

/// Thumb length for a `viewport`-tall window over `content`-tall rows.
fn thumb_len(track: f32, viewport: f32, content: f32) -> f32 {
    if content <= 0.0 {
        return track;
    }
    (track * (viewport / content)).max(MIN_THUMB).min(track)
}

/// Thumb top (relative to the track) at `offset` of `max_offset`.
fn thumb_top(track: f32, thumb: f32, offset: f32, max_offset: f32) -> f32 {
    if max_offset <= 0.0 {
        0.0
    } else {
        (track - thumb) * (offset / max_offset).clamp(0.0, 1.0)
    }
}

/// Inverse of [`thumb_top`]: the scroll offset that puts the thumb at `top`.
fn offset_at(track: f32, thumb: f32, top: f32, max_offset: f32) -> f32 {
    if track <= thumb {
        0.0
    } else {
        (top / (track - thumb)).clamp(0.0, 1.0) * max_offset
    }
}

#[derive(Debug, Default)]
struct State {
    /// While dragging: the cursor's y-distance from the thumb's top edge.
    grab: Option<f32>,
}

/// The vertical scrollbar widget. Stateless inputs each frame (offset,
/// viewport, content — the grid's own scroll state); drag/click publish
/// the target offset through `on_scroll`.
#[allow(missing_debug_implementations)]
pub struct VScrollbar<'a, Message> {
    offset: f32,
    viewport: f32,
    content: f32,
    on_scroll: Box<dyn Fn(f32) -> Message + 'a>,
}

impl<'a, Message> VScrollbar<'a, Message> {
    /// A bar reflecting `offset` px into `content` px viewed through
    /// `viewport` px; `on_scroll(new_offset)` on drag or rail click.
    pub fn new(
        offset: f32,
        viewport: f32,
        content: f32,
        on_scroll: impl Fn(f32) -> Message + 'a,
    ) -> Self {
        Self {
            offset,
            viewport,
            content,
            on_scroll: Box::new(on_scroll),
        }
    }

    fn max_offset(&self) -> f32 {
        (self.content - self.viewport).max(0.0)
    }

    fn thumb(&self, bounds: Rectangle) -> Rectangle {
        let thumb = thumb_len(bounds.height, self.viewport, self.content);
        let top = thumb_top(bounds.height, thumb, self.offset, self.max_offset());
        Rectangle {
            x: bounds.x,
            y: bounds.y + top,
            width: bounds.width,
            height: thumb,
        }
    }
}

impl<Message, Renderer> Widget<Message, iced::Theme, Renderer> for VScrollbar<'_, Message>
where
    Renderer: renderer::Renderer,
{
    fn tag(&self) -> widget::tree::Tag {
        widget::tree::Tag::of::<State>()
    }

    fn state(&self) -> widget::tree::State {
        widget::tree::State::new(State::default())
    }

    fn size(&self) -> Size<Length> {
        Size::new(Length::Fixed(WIDTH), Length::Fill)
    }

    fn layout(
        &mut self,
        _tree: &mut widget::Tree,
        _renderer: &Renderer,
        limits: &layout::Limits,
    ) -> layout::Node {
        layout::atomic(limits, WIDTH, Length::Fill)
    }

    fn update(
        &mut self,
        tree: &mut widget::Tree,
        event: &Event,
        layout: Layout<'_>,
        cursor: mouse::Cursor,
        _renderer: &Renderer,
        _clipboard: &mut dyn Clipboard,
        shell: &mut Shell<'_, Message>,
        _viewport: &Rectangle,
    ) {
        let state = tree.state.downcast_mut::<State>();
        let bounds = layout.bounds();
        let thumb = self.thumb(bounds);

        match event {
            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
                let Some(position) = cursor.position_over(bounds) else {
                    return;
                };
                if thumb.contains(position) {
                    state.grab = Some(position.y - thumb.y);
                } else {
                    // Rail click: center the thumb on the cursor and start
                    // dragging from there (the native gesture).
                    state.grab = Some(thumb.height / 2.0);
                    let top = position.y - bounds.y - thumb.height / 2.0;
                    shell.publish((self.on_scroll)(offset_at(
                        bounds.height,
                        thumb.height,
                        top,
                        self.max_offset(),
                    )));
                }
                shell.capture_event();
            }
            Event::Mouse(mouse::Event::CursorMoved { .. }) => {
                let (Some(grab), Some(position)) = (state.grab, cursor.position()) else {
                    return;
                };
                let top = position.y - bounds.y - grab;
                shell.publish((self.on_scroll)(offset_at(
                    bounds.height,
                    thumb.height,
                    top,
                    self.max_offset(),
                )));
                shell.capture_event();
            }
            Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
                state.grab = None;
            }
            _ => {}
        }
    }

    fn draw(
        &self,
        tree: &widget::Tree,
        renderer: &mut Renderer,
        theme: &iced::Theme,
        _style: &renderer::Style,
        layout: Layout<'_>,
        _cursor: mouse::Cursor,
        _viewport: &Rectangle,
    ) {
        let state = tree.state.downcast_ref::<State>();
        let bounds = layout.bounds();
        let palette = theme.extended_palette();

        // iced's own default scrollbar look (scrollable.rs:2345-2355):
        // background.weak rail, background.strongest thumb, radius 2 —
        // plus a primary tint while dragging.
        renderer.fill_quad(
            renderer::Quad {
                bounds,
                border: border::rounded(2),
                shadow: Shadow::default(),
                snap: false,
            },
            palette.background.weak.color,
        );
        renderer.fill_quad(
            renderer::Quad {
                bounds: self.thumb(bounds),
                border: border::rounded(2),
                shadow: Shadow::default(),
                snap: false,
            },
            if state.grab.is_some() {
                palette.primary.base.color
            } else {
                palette.background.strongest.color
            },
        );
    }

    fn mouse_interaction(
        &self,
        _tree: &widget::Tree,
        _layout: Layout<'_>,
        _cursor: mouse::Cursor,
        _viewport: &Rectangle,
        _renderer: &Renderer,
    ) -> mouse::Interaction {
        mouse::Interaction::Idle
    }
}

impl<'a, Message, Renderer> From<VScrollbar<'a, Message>>
    for Element<'a, Message, iced::Theme, Renderer>
where
    Message: 'a,
    Renderer: renderer::Renderer + 'a,
{
    fn from(bar: VScrollbar<'a, Message>) -> Self {
        Element::new(bar)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn thumb_clamps_to_minimum_but_still_reaches_both_ends() {
        // 25k rows x 25px against a 650px track: raw ratio would be sub-px.
        let (track, viewport, content) = (650.0, 650.0, 625_000.0);
        let thumb = thumb_len(track, viewport, content);
        assert_eq!(thumb, MIN_THUMB);

        let max = content - viewport;
        assert_eq!(thumb_top(track, thumb, 0.0, max), 0.0);
        let bottom = thumb_top(track, thumb, max, max);
        assert!((bottom - (track - thumb)).abs() < 0.001, "thumb must reach the track bottom");
    }

    #[test]
    fn offset_round_trips_through_the_thumb_position() {
        let (track, viewport, content) = (600.0, 600.0, 100_000.0);
        let thumb = thumb_len(track, viewport, content);
        let max = content - viewport;
        for offset in [0.0, 12_345.0, max / 2.0, max] {
            let top = thumb_top(track, thumb, offset, max);
            let back = offset_at(track, thumb, top, max);
            assert!((back - offset).abs() < 0.5, "{offset} -> {top} -> {back}");
        }
    }

    #[test]
    fn small_content_fills_the_track_and_pins_offset_zero() {
        let thumb = thumb_len(400.0, 400.0, 300.0);
        assert_eq!(thumb, 400.0);
        assert_eq!(offset_at(400.0, thumb, 50.0, 0.0), 0.0);
    }
}