cranpose-liquid 0.9.39

Glass controls, themes and motion for Cranpose apps
Documentation
use std::{cell::Cell, rc::Rc};

use cranpose_liquid::prelude::*;
use cranpose_testing::{
    RobotTestRule, TestRenderer, create_headless_robot_test, placed_semantics_from_shell,
};
use cranpose_ui::{KeyCode, KeyEvent, Modifier};

fn button() -> (RobotTestRule<TestRenderer>, Rc<Cell<usize>>) {
    let clicks = Rc::new(Cell::new(0));
    let recorded = Rc::clone(&clicks);
    let mut robot = create_headless_robot_test(400, 300, move || {
        let recorded = Rc::clone(&recorded);
        LiquidTheme(LiquidThemeSpec::default(), move || {
            GlassButton(
                Modifier::empty()
                    .offset(100.0, 100.0)
                    .size_points(100.0, 44.0),
                GlassButtonSpec::glass(),
                move || recorded.set(recorded.get() + 1),
                || GlassButtonLabel("Continue".into(), GlassButtonSpec::glass()),
            );
        });
    });
    robot.wait_for_idle();
    (robot, clicks)
}

#[test]
fn floating_release_tolerance_stays_relative_to_the_resting_target() {
    for hold_frames in [0, 36] {
        for (beyond_edge, expected) in [(69.0, 1), (70.0, 0)] {
            let (mut robot, clicks) = button();
            robot.mouse_move(150.0, 122.0);
            robot.mouse_down();
            for _ in 0..hold_frames {
                robot.advance_time(16_666_667);
            }
            robot.mouse_move(200.0 + beyond_edge, 122.0);
            robot.mouse_up();
            assert_eq!(
                clicks.get(),
                expected,
                "hold={hold_frames}, edge={beyond_edge}"
            );
        }
    }
}

#[test]
fn another_finger_cannot_release_the_active_floating_button() {
    let (mut robot, clicks) = button();
    robot.mouse_move(150.0, 122.0);
    robot.mouse_down();
    robot
        .shell_mut()
        .secondary_pointer_pressed(7, 150.0, 122.0, None);
    robot
        .shell_mut()
        .secondary_pointer_released(7, 150.0, 122.0, None);
    robot.wait_for_idle();
    assert_eq!(clicks.get(), 0);
    robot.mouse_up();
    assert_eq!(clicks.get(), 1);
}

#[test]
fn chip_selection_is_immediate_and_keeps_one_accessible_label_during_transition() {
    let selected = Rc::new(Cell::new(false));
    let observed = Rc::clone(&selected);
    let mut robot = create_headless_robot_test(400, 300, move || {
        let observed = Rc::clone(&observed);
        LiquidTheme(LiquidThemeSpec::default(), move || {
            let state = cranpose_core::rememberMutableStateOf(|| false);
            let observed = Rc::clone(&observed);
            LiquidChip(
                Modifier::empty().size_points(100.0, 28.0),
                state.get(),
                move || {
                    state.set(!state.get());
                    observed.set(state.get());
                },
                "Unread".into(),
            );
        });
    });
    robot.shell_mut().set_semantics_enabled(true);
    robot.wait_for_idle();
    for expected in [true, false, true] {
        robot.click_at(50.0, 14.0);
        assert_eq!(selected.get(), expected);
        robot.advance_time(50_000_000);
        let tree = placed_semantics_from_shell(robot.shell_mut()).expect("chip semantics");
        let actions: Vec<_> = tree
            .flatten()
            .into_iter()
            .filter(|node| node.clickable && !node.hidden)
            .collect();
        assert_eq!(actions.len(), 1);
        assert_eq!(actions[0].label.as_deref(), Some("Unread"));
    }
}

#[test]
fn floating_button_can_be_reached_and_activated_from_the_keyboard() {
    let (mut robot, clicks) = button();
    robot.shell_mut().set_semantics_enabled(true);
    robot.wait_for_idle();
    let tree = placed_semantics_from_shell(robot.shell_mut()).expect("button semantics");
    let buttons: Vec<_> = tree
        .flatten()
        .into_iter()
        .filter(|node| node.clickable && !node.hidden)
        .collect();
    assert_eq!(buttons.len(), 1, "one control, one accessible action");
    assert_eq!(buttons[0].label.as_deref(), Some("Continue"));
    assert_eq!(buttons[0].role, cranpose_ui::layout::SemanticsRole::Button);
    robot
        .shell_mut()
        .on_key_event(&KeyEvent::key_down(KeyCode::Tab, "\t"));
    robot.wait_for_idle();
    robot
        .shell_mut()
        .on_key_event(&KeyEvent::key_down(KeyCode::Enter, "\r"));
    robot.wait_for_idle();
    assert_eq!(clicks.get(), 1);
}

#[test]
fn pointer_activation_uses_the_callback_from_the_latest_composition() {
    let observed = Rc::new(Cell::new(usize::MAX));
    let recorded = Rc::clone(&observed);
    let mut robot = create_headless_robot_test(400, 300, move || {
        let recorded = Rc::clone(&recorded);
        LiquidTheme(LiquidThemeSpec::default(), move || {
            let generation = cranpose_core::rememberMutableStateOf(|| 0usize);
            let current = generation.get();
            GlassButton(
                Modifier::empty().size_points(100.0, 44.0),
                GlassButtonSpec::glass(),
                move || {
                    recorded.set(current);
                    generation.set(current + 1);
                },
                || GlassButtonLabel("Continue".into(), GlassButtonSpec::glass()),
            );
        });
    });
    robot.wait_for_idle();
    for expected in 0..3 {
        robot.click_at(50.0, 22.0);
        robot.wait_for_idle();
        assert_eq!(observed.get(), expected);
    }
}

#[test]
fn scrolling_from_a_floating_button_cancels_its_activation() {
    let clicks = Rc::new(Cell::new(0));
    let recorded = Rc::clone(&clicks);
    let mut robot = create_headless_robot_test(400, 300, move || {
        let recorded = Rc::clone(&recorded);
        LiquidTheme(LiquidThemeSpec::default(), move || {
            let scroll = cranpose_ui::rememberScrollState!(0.0);
            cranpose_ui::Column(
                Modifier::empty()
                    .fill_max_size()
                    .vertical_scroll(scroll, false),
                cranpose_ui::ColumnSpec::default(),
                move || {
                    cranpose_ui::Spacer(Modifier::empty().height(200.0));
                    let recorded = Rc::clone(&recorded);
                    GlassButton(
                        Modifier::empty().size_points(100.0, 44.0),
                        GlassButtonSpec::glass(),
                        move || recorded.set(recorded.get() + 1),
                        || GlassButtonLabel("Continue".into(), GlassButtonSpec::glass()),
                    );
                    cranpose_ui::Spacer(Modifier::empty().height(600.0));
                },
            );
        });
    });
    robot.wait_for_idle();
    let before = robot
        .find_by_text("Continue")
        .center()
        .expect("button position");
    robot.drag(before.x, before.y, before.x, before.y - 50.0);
    let after = robot
        .find_by_text("Continue")
        .center()
        .expect("scrolled button position");
    assert!(after.y < before.y - 20.0, "the parent must actually scroll");
    assert_eq!(clicks.get(), 0);
}