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 row animations (ag-grid's `animateRows` pair):
//!
//! - the **flight** — a draw-time vertical translation easing to zero, so
//!   rows visible before AND after a reorder glide to their new position;
//! - the **enter fade** — new rows materialize from the background. iced has
//!   no alpha-group primitive, so the fade is a background-colored cover
//!   quad whose alpha eases from 1 to 0 over the row (renderer-agnostic and
//!   correct over arbitrary cell content).
//!
//! Layout and hit-testing stay at the FINAL position — only the painting is
//! offset/covered — and the widget self-drives redraws
//! (`Shell::request_redraw`) until settled. With no effect it is an inert
//! pass-through; every grid row wears one so the keyed children keep a
//! uniform top-level type (a keyed-diff requirement).

use std::rc::Rc;
use std::time::{Duration, Instant};

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

/// A flight easing curve: maps normalized time `t ∈ [0, 1]` to progress
/// `∈ [0, 1]`. Must satisfy `f(1) = 1` — every flight from one upheaval
/// lands exactly at the shared deadline, whatever the curve does before it.
pub type Easing = Rc<dyn Fn(f32) -> f32>;

/// The default easing: a first-order **low-pass** response — fast initial
/// approach, asymptotic-feeling settle — normalized so it reaches exactly
/// 1.0 at the deadline (a raw exponential never arrives; the normalization
/// reconciles the LPF feel with the all-rows-land-at-`t[N]` contract).
pub fn low_pass_easing(t: f32) -> f32 {
    const K: f32 = 5.0;
    let t = t.clamp(0.0, 1.0);
    (1.0 - (-K * t).exp()) / (1.0 - (-K).exp())
}

/// An alternative preset: cubic ease-out (`1 − (1 − t)³`).
pub fn ease_out_cubic(t: f32) -> f32 {
    let t = t.clamp(0.0, 1.0);
    1.0 - (1.0 - t).powi(3)
}

/// The color an enter-fade covers with (fading out to reveal the row).
pub type CoverFn<Theme> = Rc<dyn Fn(&Theme) -> Color>;

/// The default enter-fade cover: the theme's base background — rows emerge
/// from the surface they sit on.
pub fn theme_background(theme: &iced::Theme) -> Color {
    theme.extended_palette().background.base.color
}

/// One row's flight: starts `from` pixels away (screen space) and eases to
/// zero, landing exactly when `duration` elapses.
#[derive(Debug, Clone, Copy)]
pub struct Flight {
    /// Initial vertical offset in px (old screen position minus new).
    pub from: f32,
    /// When the flight began.
    pub started: Instant,
    /// Total flight time — the shared deadline.
    pub duration: Duration,
}

impl Flight {
    /// Whether the flight is still in progress at `now`.
    pub fn is_live(&self, now: Instant) -> bool {
        now.saturating_duration_since(self.started) < self.duration
    }

    /// Eased progress `∈ [0, 1)` while live, `None` once the deadline passed.
    fn progress_at(&self, now: Instant, easing: &Easing) -> Option<f32> {
        if !self.is_live(now) {
            return None;
        }
        let elapsed = now.saturating_duration_since(self.started);
        let t = elapsed.as_secs_f32() / self.duration.as_secs_f32();
        Some((easing)(t).clamp(0.0, 1.0))
    }

    /// The current offset under `easing`, or `None` once settled (deadline
    /// reached, or within half a pixel of home).
    fn offset_at(&self, now: Instant, easing: &Easing) -> Option<f32> {
        let progress = self.progress_at(now, easing)?;
        let offset = self.from * (1.0 - progress);
        (offset.abs() >= 0.5).then_some(offset)
    }
}

/// The wrapper widget carrying a row's optional animations.
#[allow(missing_debug_implementations)]
pub struct Slide<'a, Message, Theme, Renderer> {
    content: Element<'a, Message, Theme, Renderer>,
    flight: Option<Flight>,
    fade: Option<Flight>,
    easing: Easing,
    cover: CoverFn<Theme>,
}

impl<'a, Message, Theme, Renderer> Slide<'a, Message, Theme, Renderer> {
    /// Wraps `content`. `flight` glides it home; `fade` materializes it from
    /// the [`cover`](CoverFn) color (only the timing of the `fade` [`Flight`]
    /// is used); both `None` = inert pass-through.
    pub fn new(
        content: impl Into<Element<'a, Message, Theme, Renderer>>,
        flight: Option<Flight>,
        fade: Option<Flight>,
        easing: Easing,
        cover: CoverFn<Theme>,
    ) -> Self {
        Self {
            content: content.into(),
            flight,
            fade,
            easing,
            cover,
        }
    }
}

impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
    for Slide<'_, Message, Theme, Renderer>
where
    Renderer: renderer::Renderer,
{
    fn children(&self) -> Vec<widget::Tree> {
        vec![widget::Tree::new(&self.content)]
    }

    fn diff(&self, tree: &mut widget::Tree) {
        tree.diff_children(std::slice::from_ref(&self.content));
    }

    fn size(&self) -> Size<Length> {
        self.content.as_widget().size()
    }

    fn layout(
        &mut self,
        tree: &mut widget::Tree,
        renderer: &Renderer,
        limits: &layout::Limits,
    ) -> layout::Node {
        self.content
            .as_widget_mut()
            .layout(&mut tree.children[0], renderer, limits)
    }

    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,
    ) {
        self.content.as_widget_mut().update(
            &mut tree.children[0],
            event,
            layout,
            cursor,
            renderer,
            clipboard,
            shell,
            viewport,
        );
        // Keep frames coming while any effect is live.
        let now = Instant::now();
        if self.flight.is_some_and(|f| f.is_live(now))
            || self.fade.is_some_and(|f| f.is_live(now))
        {
            shell.request_redraw();
        }
    }

    fn mouse_interaction(
        &self,
        tree: &widget::Tree,
        layout: Layout<'_>,
        cursor: mouse::Cursor,
        viewport: &Rectangle,
        renderer: &Renderer,
    ) -> mouse::Interaction {
        self.content.as_widget().mouse_interaction(
            &tree.children[0],
            layout,
            cursor,
            viewport,
            renderer,
        )
    }

    fn draw(
        &self,
        tree: &widget::Tree,
        renderer: &mut Renderer,
        theme: &Theme,
        style: &renderer::Style,
        layout: Layout<'_>,
        cursor: mouse::Cursor,
        viewport: &Rectangle,
    ) {
        let now = Instant::now();
        let offset = self.flight.and_then(|f| f.offset_at(now, &self.easing));
        match offset {
            // A row in flight renders as a SOLID card in its OWN LAYER.
            // Both halves matter: row backgrounds are translucent (zebra
            // alpha / none), so an opaque underlay stops crossed rows from
            // showing through — and within a single iced layer all quads
            // render before all text, so without a dedicated layer the
            // covered row's TEXT would still paint on top of the card. The
            // layer makes the card truly opaque: an inserted row is visible
            // only in the gap the parting movers open.
            //
            // The layer must be clipped to `viewport` BY US: `push_clip`
            // never intersects a new layer with the enclosing clip
            // (iced_graphics layer.rs:85-98), so a card at the viewport
            // edge would escape the scrollable's clip and paint over the
            // header/footer.
            Some(dy) => {
                let destination = layout.bounds() + Vector::new(0.0, dy);
                let Some(clip) = destination.intersection(viewport) else {
                    return;
                };
                renderer.with_layer(clip, |renderer| {
                    renderer.with_translation(Vector::new(0.0, dy), |renderer| {
                        let base = (self.cover)(theme);
                        renderer.fill_quad(
                            renderer::Quad {
                                bounds: layout.bounds(),
                                border: Border::default(),
                                shadow: Shadow::default(),
                                snap: false,
                            },
                            Color { a: 1.0, ..base },
                        );
                        self.content.as_widget().draw(
                            &tree.children[0],
                            renderer,
                            theme,
                            style,
                            layout,
                            cursor,
                            viewport,
                        );
                    });
                });
            }
            None => self.content.as_widget().draw(
                &tree.children[0],
                renderer,
                theme,
                style,
                layout,
                cursor,
                viewport,
            ),
        }
        // Enter fade: a cover quad easing out reveals the freshly-drawn row —
        // in its own layer, or the row's text would render on top of it (see
        // above). Clipped to `viewport` for the same reason as the flight
        // card.
        if let Some(alpha) = self
            .fade
            .and_then(|f| f.progress_at(now, &self.easing))
            .map(|progress| 1.0 - progress)
            .filter(|alpha| *alpha > 0.01)
        {
            let Some(clip) = layout.bounds().intersection(viewport) else {
                return;
            };
            let cover = (self.cover)(theme);
            renderer.with_layer(clip, |renderer| {
                renderer.fill_quad(
                    renderer::Quad {
                        bounds: layout.bounds(),
                        border: Border::default(),
                        shadow: Shadow::default(),
                        snap: false,
                    },
                    Color {
                        a: cover.a * alpha,
                        ..cover
                    },
                );
            });
        }
    }

    fn operate(
        &mut self,
        tree: &mut widget::Tree,
        layout: Layout<'_>,
        renderer: &Renderer,
        operation: &mut dyn widget::Operation,
    ) {
        self.content.as_widget_mut().operate(
            &mut tree.children[0],
            layout,
            renderer,
            operation,
        );
    }

    fn overlay<'b>(
        &'b mut self,
        tree: &'b mut widget::Tree,
        layout: Layout<'b>,
        renderer: &Renderer,
        viewport: &Rectangle,
        translation: Vector,
    ) -> Option<iced::advanced::overlay::Element<'b, Message, Theme, Renderer>> {
        self.content.as_widget_mut().overlay(
            &mut tree.children[0],
            layout,
            renderer,
            viewport,
            translation,
        )
    }
}

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