cranpose-liquid 0.9.42

Glass controls, themes and motion for Cranpose apps
Documentation
use super::*;

#[test]
fn a_scope_keeps_actions_with_their_items() {
    let selected = Rc::new(Cell::new(false));
    let selected_by_action = Rc::clone(&selected);
    let entries = collect_entries(|scope| {
        scope.header("Section");
        scope.separator();
        scope.item(LiquidMenuItem::new("Open"), move || {
            selected_by_action.set(true);
        });
    });

    assert_eq!(entries.len(), 2);
    assert!(entries[0].item.header);
    assert!(entries[1].item.section_start);
    entries[1].action.as_ref().unwrap()();
    assert!(selected.get());
}

#[test]
fn dropdown_spec_builders_preserve_menu_configuration() {
    let menu = LiquidMenuSpec { width: 180.0 };
    let absorbed = LiquidMenuAbsorbedSource::new(
        Rect::from_origin_size(Point::ZERO, Size::new(44.0, 44.0)),
        crate::widgets::GlassButtonSpec::default(),
        44.0,
        crate::icons::SEARCH,
    );
    let spec = LiquidDropdownMenuSpec::default()
        .menu(menu)
        .absorbed(vec![absorbed]);
    assert_eq!(spec.menu, menu);
    assert_eq!(spec.absorbed.len(), 1);
}

#[test]
fn menu_rows_use_the_reference_leading_grid_and_vertical_rhythm() {
    assert_eq!(ROW_PADDING_X, 28.0);
    assert_eq!(CHECK_COLUMN, 24.0);
    assert_eq!(ICON_SIZE, 24.0);
    assert_eq!(ICON_GAP, 12.0);

    let check_center = ROW_PADDING_X + 8.0;
    let icon_center = ROW_PADDING_X + CHECK_COLUMN + ICON_SIZE * 0.5;
    let label_start = ROW_PADDING_X + CHECK_COLUMN + ICON_SIZE + ICON_GAP;
    assert_eq!((check_center, icon_center, label_start), (36.0, 64.0, 88.0));

    let row_height = ICON_SIZE + ROW_PADDING_Y * 2.0;
    assert!((42.0..=43.0).contains(&row_height));
    assert_eq!(MENU_CONTENT_INSET_Y, 10.0);
    let two_row_panel_height = row_height * 2.0 + MENU_CONTENT_INSET_Y * 2.0;
    assert!((103.5..=104.5).contains(&two_row_panel_height));
}

#[test]
fn menu_geometry_keeps_the_source_cluster_horizontal_before_card_growth() {
    let anchor = MenuShape::capsule(228.0, 22.0, 44.0, 44.0);
    let absorbed = [MenuShape::capsule(176.0, 22.0, 44.0, 44.0)];
    let target = MenuShape {
        center_x: 125.0,
        center_y: 52.0,
        width: 250.0,
        height: 104.0,
        radius: 32.0,
    };
    let pose = |appear| menu_morph_geometry(true, appear, anchor, &absorbed, target).primary;

    let initial = pose(0.0);
    assert_eq!((initial.width, initial.height), (44.0, 44.0));

    let merged = pose(0.028_576);
    assert_eq!(merged, initial);
    let source = menu_source_shape(anchor, &absorbed, target);
    assert_eq!(source.width, 96.0);
    assert!((82.5..=82.6).contains(&source.height));
    assert_eq!(source.center_y, anchor.center_y);
    assert_eq!(menu_absorbed_shape_presence(0.0), 1.0);
    assert_eq!(menu_absorbed_shape_presence(0.30), 0.0);

    let early = pose(0.070_208);
    let middle = pose(0.199_019);
    let broad = pose(0.539_174);
    assert_eq!(early, initial);
    assert_eq!(middle.width, source.width);
    assert!(middle.height >= source.height);
    assert!(broad.width > middle.width && broad.height <= target.height * 1.1);
    assert!(middle.width > middle.height);
    assert!(broad.width > broad.height * 2.0);

    let swell = pose(0.701_903);
    assert!(
        (252.0..=259.0).contains(&swell.width) && (102.0..=106.0).contains(&swell.height),
        "the broad body must overshoot horizontally without inflating vertically: {swell:?}"
    );
    assert!(menu_ellipse_blend(0.25) > 0.3);
    assert_eq!(menu_ellipse_blend(0.0), 0.0);
    assert_eq!(menu_ellipse_blend(1.0), 0.0);

    let overshoot = pose(1.08);
    assert!((250.0..=254.0).contains(&overshoot.width));
    assert!((104.0..=106.0).contains(&overshoot.height));
    assert_eq!(MENU_RADIUS, 32.0);
    assert!((0.045..=0.055).contains(&MENU_GROW_DELAY));
    assert!((55.0..=70.0).contains(&MENU_GROW_STIFFNESS));
}

#[test]
fn menu_open_spring_departs_early_then_settles_without_a_dead_interval() {
    let (source_phase, _) =
        cranpose_animation::advance_spring(0.0, 0.0, 1.0, 0.78, MENU_GROW_STIFFNESS, 0.054);
    assert!(
        source_phase > MENU_GROW_DELAY,
        "the departing oval must be visible by the target's early frame: {source_phase}"
    );
    let (broad_phase, _) =
        cranpose_animation::advance_spring(0.0, 0.0, 1.0, 0.78, MENU_GROW_STIFFNESS, 0.180);
    assert!(
        (0.35..=0.60).contains(&broad_phase),
        "the broad menu body must be established by 180ms: {broad_phase}"
    );
    let (settled_phase, _) =
        cranpose_animation::advance_spring(0.0, 0.0, 1.0, 0.78, MENU_GROW_STIFFNESS, 0.600);
    assert!(settled_phase > 0.95);
}

#[test]
fn menu_body_uses_the_shared_vertical_rebound_path() {
    let anchor = MenuShape::capsule(228.0, 22.0, 44.0, 44.0);
    let absorbed = [MenuShape::capsule(176.0, 22.0, 44.0, 44.0)];
    let target = MenuShape {
        center_x: 125.0,
        center_y: 52.0,
        width: 250.0,
        height: 104.0,
        radius: 32.0,
    };

    let source = menu_source_shape(anchor, &absorbed, target);
    for appear in [0.199_019, 0.296_780, 0.412_956, 0.539_174] {
        let geometry = menu_morph_geometry(true, appear, anchor, &absorbed, target);
        let phase = menu_geometry_phase(true, appear);
        let descent = smoothstep(0.10, 0.90, phase.path);
        let interpolated_y = source.center_y + (target.center_y - source.center_y) * descent;
        let expected_y = interpolated_y + menu_vertical_rebound(phase.path);
        assert!(
            (geometry.primary.center_y - expected_y).abs() < 0.001,
            "body and content must resolve the same rebound path: {geometry:?}"
        );
    }
    assert_eq!(menu_vertical_rebound(0.0), 0.0);
    assert!(menu_vertical_rebound(0.25) > 0.0);
    assert_eq!(menu_vertical_rebound(MENU_VERTICAL_REBOUND_END), 0.0);
}

#[test]
fn menu_close_reverses_through_a_smooth_oval() {
    let phase = menu_geometry_phase(false, 0.6);
    assert!(
        phase.width > 0.80,
        "the close must retain its broad body at mid-flight: {phase:?}"
    );
    assert!(
        44.0 + (250.0 - 44.0) * phase.width > 1.8 * (44.0 + (104.0 - 44.0) * phase.height),
        "the close must pass back through the wide oval in physical dimensions: {phase:?}"
    );
    assert!(
        menu_content_progress(false, 0.6, 1.0) > 0.75,
        "content must remain coherent through the initial deflation"
    );
    assert_eq!(menu_content_progress(false, 0.2, 1.0), 0.0);
    let rounded_volume = menu_geometry_phase(false, 0.21);
    assert!(
        rounded_volume.width > 0.35,
        "the terminal body must contract continuously into the anchor: {rounded_volume:?}"
    );
    assert!(
        44.0 + (250.0 - 44.0) * rounded_volume.width
            > 44.0 + (104.0 - 44.0) * rounded_volume.height,
        "the terminal body must stay smooth rather than forming a vertical leaf in physical dimensions: {rounded_volume:?}"
    );
}

#[test]
fn menu_content_materializes_early_and_is_sharp_by_settle() {
    let birth = menu_content_progress(true, 0.35, 0.25);
    assert!(
        birth > 0.02 && birth < 0.08,
        "rows must begin as a faint smudge after the blank birth phase: {birth}"
    );
    let mid = menu_content_progress(true, 0.55, 0.55);
    assert!(
        (0.55..0.70).contains(&mid),
        "rows must remain visibly soft at mid-flight: {mid}"
    );
    let settle = menu_content_progress(true, 1.0, 0.92);
    assert!(
        settle > 0.99,
        "rows must be effectively sharp when the shape settles: {settle}"
    );
    assert!(menu_content_blur(birth) > 13.0);
    assert!((7.0..8.0).contains(&menu_content_blur(mid)));
    assert!(menu_content_blur(settle) < 0.5);
    assert!((20.0..=32.0).contains(&MENU_REVEAL_STIFFNESS));
    assert!((0.34..=0.37).contains(&menu_content_alpha(0.10)));
    assert!((0.79..=0.82).contains(&menu_content_alpha(0.62)));
    assert_eq!(menu_content_alpha(1.0), 1.0);
    assert!((0.85..=0.87).contains(&menu_content_scale(0.30)));
    assert!((0.92..=0.93).contains(&menu_content_scale(0.62)));
    assert_eq!(menu_content_scale(1.0), 1.0);
}

#[test]
fn menu_surface_motion_is_smooth_and_capture_cadence_independent() {
    let merged = menu_surface_phase(true, 0.14, 0.0);
    assert_eq!(merged.anchor_presence, 0.0);
    assert_eq!(merged.glue, 0.0);
    let recoil = menu_surface_phase(true, 0.275, 0.0);
    assert_eq!(recoil.glue, 0.0);

    let early = menu_surface_phase(true, 0.40, 0.25);
    assert!(
        early.anchor_presence == 0.0,
        "the primary alone owns the anchor recoil: {early:?}"
    );
    assert_eq!(early.glue, 0.0);
    assert!(early.wobble <= 0.10);
    assert!(early.bulge <= 0.40);
    assert_eq!(early, menu_surface_phase(true, 0.40, 0.25));

    let closing = menu_surface_phase(false, 0.6, 0.68);
    assert_eq!(closing.anchor_presence, 0.0);
    assert_eq!(closing.glue, 0.0);
    assert!(closing.wobble <= 0.05);
    assert!(
        closing.bulge <= 0.30,
        "close must remain smooth: {closing:?}"
    );
}

#[test]
fn menu_trigger_backdrop_unmounts_during_the_first_absorption_frame() {
    assert!((30..=40).contains(&MENU_TRIGGER_ABSORPTION_MS));
    assert_eq!(MENU_TRIGGER_RESTORE_DELAY_MS, 205);
}

#[test]
fn absorbed_source_foreground_stays_readable_then_stretches_into_the_surface() {
    let source = LiquidMenuAbsorbedSource::new(
        Rect {
            x: 10.0,
            y: 20.0,
            width: 44.0,
            height: 44.0,
        },
        crate::widgets::GlassButtonSpec::glass()
            .with_icon_backplate(Color::from_rgb_u8(0, 122, 255))
            .with_content_color(Color::WHITE),
        44.0,
        "M0 0",
    );
    assert_eq!(source.rect.width, 44.0);
    assert_eq!(source.diameter, 44.0);
    assert_eq!(source.icon_path, "M0 0");

    let source = menu_absorbed_visual_phase(0.0, 0.0);
    assert_eq!(source.foreground_alpha, 1.0);
    assert_eq!(source.backdrop_alpha, 0.0);

    let crisp = menu_absorbed_visual_phase(0.20, 0.20);
    assert_eq!(crisp.foreground_alpha, 1.0);
    assert_eq!(crisp.backdrop_alpha, 0.0);
    assert_eq!(crisp.foreground_blur, 0.0);
    assert!((0.76..=0.78).contains(&crisp.scale_x));
    assert!((0.76..=0.78).contains(&crisp.scale_y));
    let dimmed = menu_absorbed_visual_phase(0.60, 0.30);
    assert!((0.38..=0.42).contains(&dimmed.foreground_alpha));

    let melt = menu_absorbed_visual_phase(0.95, 0.90);
    assert_eq!(melt.foreground_alpha, 0.0);
    assert_eq!(melt.backdrop_alpha, 0.62);
    assert!((0.92..=0.97).contains(&melt.scale_y));
    assert!((0.87..=0.905).contains(&melt.scale_x));

    let smear = menu_absorbed_visual_phase(0.72, 0.69);
    assert!((0.94..=0.98).contains(&smear.scale_y));
    assert!((0.89..=0.91).contains(&smear.scale_x));
    assert!((0.10..=0.18).contains(&smear.foreground_alpha));
    assert!((0.36..=0.44).contains(&smear.backdrop_alpha));
    let transition = menu_absorbed_visual_phase(0.42, 0.382);
    assert!((0.60..=0.75).contains(&transition.foreground_alpha));
    assert_eq!(transition.backdrop_alpha, 0.0);
    let settled = menu_absorbed_visual_phase(1.0, 1.0);
    assert_eq!(settled.foreground_alpha, 0.0);
    assert_eq!(settled.backdrop_alpha, 0.62);
    assert_eq!(MENU_SOURCE_FOREGROUND_HIDE_MS, 200);
    assert_eq!(MENU_SOURCE_FOREGROUND_RESTORE_DELAY_MS, 205);
}

#[test]
fn liquid_menu_item_builders_preserve_the_row_contract() {
    let item = LiquidMenuItem::new("Delete")
        .icon("M0 0")
        .checked(true)
        .destructive()
        .section_start();
    assert_eq!(item.label, "Delete");
    assert_eq!(item.icon, Some("M0 0"));
    assert!(item.checked);
    assert!(item.destructive);
    assert!(item.section_start);
    assert!(!item.header);

    let header = LiquidMenuItem::header("Show");
    assert_eq!(header.label, "Show");
    assert!(header.header);
}

#[test]
fn claimed_menu_gesture_streams_one_release_to_an_interactive_row() {
    let _runtime =
        cranpose_core::Runtime::new(std::sync::Arc::new(cranpose_core::DefaultScheduler));
    let gesture = LiquidMenuGesture::new();
    let items = collect_entries(|scope| {
        scope.header("Show");
        scope.item(LiquidMenuItem::new("Grid"), || {});
    });
    gesture.item_rect(0).set(Rect {
        x: 10.0,
        y: 20.0,
        width: 100.0,
        height: 30.0,
    });
    gesture.item_rect(1).set(Rect {
        x: 10.0,
        y: 50.0,
        width: 100.0,
        height: 40.0,
    });

    gesture.begin(Point::new(80.0, 10.0));
    gesture.claim();
    gesture.move_to(Point::new(40.0, 65.0));
    let held = gesture.snapshot();
    assert!(held.active && held.claimed);
    assert_eq!(gesture.item_at(held.position, &items), Some(1));
    assert_eq!(gesture.item_at(Point::new(40.0, 35.0), &items), None);

    gesture.release(Point::new(40.0, 65.0));
    let released = gesture.snapshot();
    assert!(!released.active);
    assert_eq!(released.release, Some((1, Point::new(40.0, 65.0))));
    gesture.release(Point::new(40.0, 65.0));
    assert_eq!(gesture.snapshot().release, released.release);
}

#[test]
fn committing_a_row_dismisses_unless_the_row_keeps_the_menu_open() {
    let taps = Rc::new(Cell::new(0usize));
    let dismissals = Rc::new(Cell::new(0usize));
    let on_item: Rc<dyn Fn(usize)> = {
        let taps = Rc::clone(&taps);
        Rc::new(move |_| taps.set(taps.get() + 1))
    };
    let on_dismiss: Rc<dyn Fn()> = {
        let dismissals = Rc::clone(&dismissals);
        Rc::new(move || dismissals.set(dismissals.get() + 1))
    };

    commit_menu_row(0, false, &on_item, &on_dismiss);
    assert_eq!(
        (taps.get(), dismissals.get()),
        (1, 1),
        "an ordinary row dismisses"
    );

    commit_menu_row(1, true, &on_item, &on_dismiss);
    assert_eq!(
        (taps.get(), dismissals.get()),
        (2, 1),
        "a row that keeps the menu open runs its action and dismisses nothing"
    );
}

#[test]
fn a_row_is_committed_exactly_once_per_tap() {
    let dismissals = Rc::new(Cell::new(0usize));
    let on_item: Rc<dyn Fn(usize)> = Rc::new(|_| {});
    let on_dismiss: Rc<dyn Fn()> = {
        let dismissals = Rc::clone(&dismissals);
        Rc::new(move || dismissals.set(dismissals.get() + 1))
    };
    commit_menu_row(0, false, &on_item, &on_dismiss);
    assert_eq!(dismissals.get(), 1);
}