hydrolysis 0.1.0

A modern UI framework for Rust
use waterui::navigation::{
    AnyNavigationTransition, NavigationTransitionDirection,
    NavigationTransitionFrame as ResolvedNavigationTransitionFrame, NavigationTransitionLayer,
    RetainedNavigationTransition,
};
use waterui_backend_core::widget::NavigationMotion;

use super::{NavigationCapturedScene, NavigationMatchedElement};

pub(crate) struct NavigationTransitionFrame<'a> {
    pub(crate) scene: &'a mut vello::Scene,
    pub(crate) transform: vello::kurbo::Affine,
    pub(crate) bounds: vello::kurbo::Rect,
    pub(crate) style: AnyNavigationTransition,
    pub(crate) motion: NavigationMotion,
    pub(crate) direction: NavigationTransitionDirection,
    pub(crate) progress: f64,
    pub(crate) from_scene: &'a NavigationCapturedScene,
    pub(crate) to_scene: &'a NavigationCapturedScene,
}

pub(crate) fn draw_navigation_transition(frame: NavigationTransitionFrame<'_>) {
    // Dispatch on the retained capability, never on the native projection: a
    // custom transition may report a platform-native projection for Apple and
    // Android while still resolving its own frames here.
    let retained = frame.style.retained();
    #[allow(clippy::cast_possible_truncation)]
    let progress = frame.progress as f32;
    let resolved = match retained {
        RetainedNavigationTransition::MatchedGeometry(id) => {
            draw_matched_navigation_transition(frame, id);
            return;
        }
        RetainedNavigationTransition::PlatformDefault => material_shared_axis_x_frame(
            progress,
            frame.direction,
            frame.motion.shared_axis_slide_distance,
            frame.bounds.width(),
            frame.motion.fade_through_threshold,
        ),
        RetainedNavigationTransition::None => ResolvedNavigationTransitionFrame::IDENTITY,
        RetainedNavigationTransition::Frames => frame.style.frame(progress, frame.direction),
    };
    let from_scene = frame.from_scene.composed();
    let to_scene = frame.to_scene.composed();
    let outgoing = (&from_scene, resolved.outgoing);
    let incoming = (&to_scene, resolved.incoming);
    let layers = match frame.direction {
        NavigationTransitionDirection::Push => [outgoing, incoming],
        NavigationTransitionDirection::Pop => [incoming, outgoing],
    };
    for (scene, layer) in layers {
        append_scene_layer(frame.scene, frame.transform, frame.bounds, scene, layer);
    }
}

fn material_shared_axis_x_frame(
    progress: f32,
    direction: NavigationTransitionDirection,
    slide_distance: f64,
    viewport_width: f64,
    fade_through_threshold: f32,
) -> ResolvedNavigationTransitionFrame {
    assert!(
        viewport_width > 0.0,
        "navigation shared-axis viewport width must be positive"
    );
    assert!(
        slide_distance >= 0.0 && slide_distance.is_finite(),
        "navigation shared-axis slide distance must be finite and non-negative"
    );
    assert!(
        (0.0..1.0).contains(&fade_through_threshold),
        "navigation fade-through threshold must be in 0.0..1.0"
    );
    let slide_fraction = (slide_distance / viewport_width) as f32;
    let direction = match direction {
        NavigationTransitionDirection::Push => -1.0,
        NavigationTransitionDirection::Pop => 1.0,
    };
    let outgoing_opacity = 1.0 - (progress / fade_through_threshold).clamp(0.0, 1.0);
    let incoming_opacity =
        ((progress - fade_through_threshold) / (1.0 - fade_through_threshold)).clamp(0.0, 1.0);

    ResolvedNavigationTransitionFrame {
        outgoing: NavigationTransitionLayer {
            offset_x: direction * slide_fraction * progress,
            opacity: outgoing_opacity,
            ..NavigationTransitionLayer::IDENTITY
        },
        incoming: NavigationTransitionLayer {
            offset_x: -direction * slide_fraction * (1.0 - progress),
            opacity: incoming_opacity,
            ..NavigationTransitionLayer::IDENTITY
        },
    }
}

fn draw_matched_navigation_transition(
    frame: NavigationTransitionFrame<'_>,
    id: waterui_core::id::Id,
) {
    let (from_element, to_element, from_is_source, to_is_source) = match frame.direction {
        NavigationTransitionDirection::Push => (
            frame.from_scene.sources.get(&id),
            frame.to_scene.destinations.get(&id),
            true,
            false,
        ),
        NavigationTransitionDirection::Pop => (
            frame.from_scene.destinations.get(&id),
            frame.to_scene.sources.get(&id),
            false,
            true,
        ),
    };
    let from_element = from_element.unwrap_or_else(|| {
        panic!("navigation zoom source {id:?} is not present in the outgoing page")
    });
    let to_element = to_element.unwrap_or_else(|| {
        panic!("navigation zoom destination {id:?} is not present in the incoming page")
    });
    assert!(
        from_element.bounds.width() > 0.0
            && from_element.bounds.height() > 0.0
            && to_element.bounds.width() > 0.0
            && to_element.bounds.height() > 0.0,
        "navigation zoom geometry must have a positive size"
    );

    let from_page = frame.from_scene.composed_without(from_is_source, id);
    let to_page = frame.to_scene.composed_without(to_is_source, id);
    append_scene_with_opacity(
        frame.scene,
        frame.transform,
        frame.bounds,
        &from_page,
        1.0 - frame.progress as f32,
    );
    append_scene_with_opacity(
        frame.scene,
        frame.transform,
        frame.bounds,
        &to_page,
        frame.progress as f32,
    );

    let bounds = interpolate_rect(from_element.bounds, to_element.bounds, frame.progress);
    append_matched_element(
        frame.scene,
        frame.transform,
        from_element,
        bounds,
        1.0 - frame.progress as f32,
    );
    append_matched_element(
        frame.scene,
        frame.transform,
        to_element,
        bounds,
        frame.progress as f32,
    );
}

fn interpolate_rect(
    from: vello::kurbo::Rect,
    to: vello::kurbo::Rect,
    progress: f64,
) -> vello::kurbo::Rect {
    let interpolate = |from: f64, to: f64| from + (to - from) * progress;
    vello::kurbo::Rect::new(
        interpolate(from.x0, to.x0),
        interpolate(from.y0, to.y0),
        interpolate(from.x1, to.x1),
        interpolate(from.y1, to.y1),
    )
}

fn append_matched_element(
    scene: &mut vello::Scene,
    transform: vello::kurbo::Affine,
    element: &NavigationMatchedElement,
    target: vello::kurbo::Rect,
    opacity: f32,
) {
    if opacity <= 0.0 {
        return;
    }
    let local = vello::kurbo::Affine::translate((target.x0, target.y0))
        * vello::kurbo::Affine::scale_non_uniform(
            target.width() / element.bounds.width(),
            target.height() / element.bounds.height(),
        )
        * vello::kurbo::Affine::translate((-element.bounds.x0, -element.bounds.y0));
    scene.push_layer(
        vello::peniko::Fill::NonZero,
        vello::peniko::BlendMode::default(),
        opacity,
        transform,
        &target,
    );
    scene.append(&element.scene, Some(transform * local));
    scene.pop_layer();
}

fn append_scene_with_opacity(
    scene: &mut vello::Scene,
    transform: vello::kurbo::Affine,
    clip_bounds: vello::kurbo::Rect,
    content: &vello::Scene,
    opacity: f32,
) {
    append_scene_layer(
        scene,
        transform,
        clip_bounds,
        content,
        NavigationTransitionLayer {
            opacity,
            ..NavigationTransitionLayer::IDENTITY
        },
    );
}

fn append_scene_layer(
    scene: &mut vello::Scene,
    transform: vello::kurbo::Affine,
    clip_bounds: vello::kurbo::Rect,
    content: &vello::Scene,
    layer: NavigationTransitionLayer,
) {
    if layer.opacity <= 0.0 {
        return;
    }
    let center = clip_bounds.center();
    let local = vello::kurbo::Affine::translate((
        f64::from(layer.offset_x) * clip_bounds.width(),
        f64::from(layer.offset_y) * clip_bounds.height(),
    )) * vello::kurbo::Affine::translate((center.x, center.y))
        * vello::kurbo::Affine::scale(f64::from(layer.scale))
        * vello::kurbo::Affine::translate((-center.x, -center.y));
    let transformed_bounds = local.transform_rect_bbox(clip_bounds);
    scene.push_layer(
        vello::peniko::Fill::NonZero,
        vello::peniko::BlendMode::default(),
        layer.opacity,
        transform,
        &transformed_bounds,
    );
    scene.append(content, Some(transform * local));
    scene.pop_layer();
}

#[cfg(test)]
mod tests {
    use super::material_shared_axis_x_frame;
    use waterui::navigation::NavigationTransitionDirection;

    fn assert_near(actual: f32, expected: f32) {
        assert!(
            (actual - expected).abs() <= f32::EPSILON,
            "expected {expected}, got {actual}"
        );
    }

    #[test]
    fn material_shared_axis_push_uses_thirty_point_travel_and_fade_through() {
        let start = material_shared_axis_x_frame(
            0.0,
            NavigationTransitionDirection::Push,
            30.0,
            300.0,
            0.35,
        );
        assert_near(start.outgoing.offset_x, 0.0);
        assert_near(start.outgoing.opacity, 1.0);
        assert_near(start.incoming.offset_x, 0.1);
        assert_near(start.incoming.opacity, 0.0);

        let threshold = material_shared_axis_x_frame(
            0.35,
            NavigationTransitionDirection::Push,
            30.0,
            300.0,
            0.35,
        );
        assert_near(threshold.outgoing.opacity, 0.0);
        assert_near(threshold.incoming.opacity, 0.0);

        let end = material_shared_axis_x_frame(
            1.0,
            NavigationTransitionDirection::Push,
            30.0,
            300.0,
            0.35,
        );
        assert_near(end.outgoing.offset_x, -0.1);
        assert_near(end.outgoing.opacity, 0.0);
        assert_near(end.incoming.offset_x, 0.0);
        assert_near(end.incoming.opacity, 1.0);
    }

    #[test]
    fn material_shared_axis_pop_reverses_both_layers() {
        let middle = material_shared_axis_x_frame(
            0.5,
            NavigationTransitionDirection::Pop,
            30.0,
            300.0,
            0.35,
        );

        assert_near(middle.outgoing.offset_x, 0.05);
        assert_near(middle.incoming.offset_x, -0.05);
    }
}