fission-core 0.14.1

Core runtime, state, actions, effects, resources, input, and UI model for Fission
Documentation
#![cfg(feature = "interactive-canvas")]

use fission_core::event::{InputEvent, PointerButton, PointerEvent, PointerId, PointerKind};
use fission_core::{
    Action, ActionId, ActionRegistry, GlobalState, ReducerContext, Runtime,
    ViewportInteractionPhase,
};
use fission_ir::semantics::{ActionEntry, ActionSet, ActionTrigger, Role};
use fission_ir::{
    CoreIR, LayoutOp, Op, Semantics, ViewportBoundary, ViewportClip, ViewportPanAxis,
    ViewportTransform, ViewportZoomPolicy, WidgetId,
};
use fission_layout::{LayoutNodeGeometry, LayoutPoint, LayoutRect, LayoutSize, LayoutSnapshot};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct RecordViewport(String);

impl Action for RecordViewport {
    fn static_id() -> ActionId {
        ActionId::from_name("viewport_input::RecordViewport")
    }
}

#[derive(Debug, Default)]
struct ViewportState {
    payloads: Vec<String>,
    phases: Vec<ViewportInteractionPhase>,
    transforms: Vec<ViewportTransform>,
}

impl GlobalState for ViewportState {}

fn record(
    state: &mut ViewportState,
    action: RecordViewport,
    context: &mut ReducerContext<ViewportState>,
) {
    let Some(interaction) = context.input.viewport_interaction() else {
        return;
    };
    state.payloads.push(action.0);
    state.phases.push(interaction.phase);
    state.transforms.push(interaction.transform);
}

fn action(trigger: ActionTrigger, label: &'static str) -> ActionEntry {
    let action = RecordViewport(label.into());
    ActionEntry {
        trigger,
        action_id: RecordViewport::static_id().as_u128(),
        payload_data: Some(action.encode()),
    }
}

fn viewport_tree(draggable_child: bool) -> (CoreIR, LayoutSnapshot, WidgetId) {
    let viewer = WidgetId::explicit("viewport");
    let child = WidgetId::explicit("viewport.child");
    let mut ir = CoreIR::default();
    ir.add_node(
        child,
        Op::Semantics(Semantics {
            role: Role::Generic,
            draggable: draggable_child,
            actions: ActionSet {
                entries: if draggable_child {
                    vec![action(ActionTrigger::DragUpdate, "child")]
                } else {
                    vec![action(ActionTrigger::Default, "child")]
                },
            },
            ..Default::default()
        }),
        Vec::new(),
    );
    ir.add_node(
        viewer,
        Op::Layout(LayoutOp::InteractiveViewport {
            initial_transform: ViewportTransform::IDENTITY,
            controlled_transform: None,
            pan_axis: ViewportPanAxis::Both,
            boundary: ViewportBoundary::Unbounded,
            clip: ViewportClip::HardEdge,
            zoom_policy: ViewportZoomPolicy::WheelWithModifier,
            min_scale: 0.25,
            max_scale: 4.0,
            friction: 0.0,
            on_interaction_start: Some(action(ActionTrigger::ViewportInteractionStart, "start")),
            on_interaction_update: Some(action(ActionTrigger::ViewportInteractionUpdate, "update")),
            on_interaction_end: Some(action(ActionTrigger::ViewportInteractionEnd, "end")),
        }),
        vec![child],
    );
    ir.set_root(viewer);

    let mut layout = LayoutSnapshot::new(LayoutSize::new(300.0, 200.0));
    for id in [viewer, child] {
        layout.nodes.insert(
            id,
            LayoutNodeGeometry {
                rect: LayoutRect::new(0.0, 0.0, 300.0, 200.0),
                content_size: LayoutSize::new(300.0, 200.0),
            },
        );
    }
    (ir, layout, viewer)
}

fn runtime(ir: &CoreIR, layout: &LayoutSnapshot) -> Runtime {
    let mut runtime = Runtime::default();
    runtime
        .add_app_state(Box::new(ViewportState::default()))
        .unwrap();
    let mut registry = ActionRegistry::<ViewportState>::new();
    registry.register(record as fn(&mut ViewportState, RecordViewport, &mut ReducerContext<_>));
    runtime.absorb_registry(registry);
    runtime.post_layout_hook(ir, layout);
    runtime
}

fn pointer(
    pointer_id: u128,
    kind: PointerKind,
    point: (f32, f32),
    phase: fn(PointerId, PointerKind, LayoutPoint) -> PointerEvent,
) -> InputEvent {
    InputEvent::Pointer(phase(
        PointerId(pointer_id),
        kind,
        LayoutPoint::new(point.0, point.1),
    ))
}

fn down(id: PointerId, kind: PointerKind, point: LayoutPoint) -> PointerEvent {
    PointerEvent::Down {
        pointer_id: id,
        kind,
        point,
        button: PointerButton::Primary,
        modifiers: 0,
    }
}

fn moved(id: PointerId, kind: PointerKind, point: LayoutPoint) -> PointerEvent {
    PointerEvent::Move {
        pointer_id: id,
        kind,
        point,
        modifiers: 0,
    }
}

fn up(id: PointerId, kind: PointerKind, point: LayoutPoint) -> PointerEvent {
    PointerEvent::Up {
        pointer_id: id,
        kind,
        point,
        button: PointerButton::Primary,
        modifiers: 0,
    }
}

#[test]
fn mouse_pan_preserves_payload_and_delivers_typed_camera_changes() {
    let (ir, layout, viewer) = viewport_tree(false);
    let mut runtime = runtime(&ir, &layout);
    runtime
        .handle_input(
            pointer(0, PointerKind::Mouse, (10.0, 10.0), down),
            &ir,
            &layout,
        )
        .unwrap();
    runtime
        .handle_input(
            pointer(0, PointerKind::Mouse, (30.0, 25.0), moved),
            &ir,
            &layout,
        )
        .unwrap();
    runtime
        .handle_input(
            pointer(0, PointerKind::Mouse, (30.0, 25.0), up),
            &ir,
            &layout,
        )
        .unwrap();

    let state = runtime.get_app_state::<ViewportState>().unwrap();
    assert_eq!(state.payloads, ["start", "update", "end"]);
    assert_eq!(
        state.phases,
        [
            ViewportInteractionPhase::Start,
            ViewportInteractionPhase::Update,
            ViewportInteractionPhase::End,
        ]
    );
    assert_eq!(
        runtime.runtime_state.viewport.transform(viewer),
        Some(ViewportTransform::new(20.0, 15.0, 1.0))
    );
}

#[test]
fn two_touch_pinch_is_order_independent_and_two_to_one_does_not_jump() {
    let (ir, layout, viewer) = viewport_tree(false);
    let mut runtime = runtime(&ir, &layout);
    for event in [
        pointer(9, PointerKind::Touch, (50.0, 50.0), down),
        pointer(3, PointerKind::Touch, (150.0, 50.0), down),
        pointer(3, PointerKind::Touch, (200.0, 50.0), moved),
        pointer(3, PointerKind::Touch, (200.0, 50.0), up),
    ] {
        runtime.handle_input(event, &ir, &layout).unwrap();
    }
    let before = runtime.runtime_state.viewport.transform(viewer).unwrap();
    runtime
        .handle_input(
            pointer(9, PointerKind::Touch, (55.0, 50.0), moved),
            &ir,
            &layout,
        )
        .unwrap();
    let after = runtime.runtime_state.viewport.transform(viewer).unwrap();
    assert_eq!(after.translation[0] - before.translation[0], 5.0);
    assert_eq!(after.scale, before.scale);
}

#[test]
fn draggable_child_wins_single_pointer_arbitration() {
    let (ir, layout, viewer) = viewport_tree(true);
    let mut runtime = runtime(&ir, &layout);
    runtime
        .handle_input(
            pointer(0, PointerKind::Mouse, (10.0, 10.0), down),
            &ir,
            &layout,
        )
        .unwrap();
    runtime
        .handle_input(
            pointer(0, PointerKind::Mouse, (40.0, 30.0), moved),
            &ir,
            &layout,
        )
        .unwrap();
    assert_eq!(
        runtime.runtime_state.viewport.transform(viewer),
        Some(ViewportTransform::IDENTITY)
    );
}

#[test]
fn controlled_transform_remains_the_authority_on_every_rebuild() {
    let (mut ir, layout, viewer) = viewport_tree(false);
    let declared = ViewportTransform::new(12.0, 18.0, 2.0);
    let Op::Layout(LayoutOp::InteractiveViewport {
        controlled_transform,
        ..
    }) = &mut ir.nodes.get_mut(&viewer).expect("viewer node").op
    else {
        panic!("expected interactive viewport");
    };
    *controlled_transform = Some(declared);

    let mut runtime = runtime(&ir, &layout);
    runtime
        .runtime_state
        .viewport
        .set_transform(viewer, ViewportTransform::new(90.0, 70.0, 3.0));
    runtime.post_layout_hook(&ir, &layout);

    assert_eq!(
        runtime.runtime_state.viewport.transform(viewer),
        Some(declared)
    );
}

#[test]
fn inertia_uses_the_runtime_clock() {
    let (mut ir, layout, viewer) = viewport_tree(false);
    let Op::Layout(LayoutOp::InteractiveViewport { friction, .. }) =
        &mut ir.nodes.get_mut(&viewer).expect("viewer node").op
    else {
        panic!("expected interactive viewport");
    };
    *friction = 0.0000135;

    let mut runtime = runtime(&ir, &layout);
    runtime
        .handle_input(
            pointer(0, PointerKind::Mouse, (10.0, 10.0), down),
            &ir,
            &layout,
        )
        .unwrap();
    runtime.tick(16).unwrap();
    runtime
        .handle_input(
            pointer(0, PointerKind::Mouse, (30.0, 10.0), moved),
            &ir,
            &layout,
        )
        .unwrap();
    runtime
        .handle_input(
            pointer(0, PointerKind::Mouse, (30.0, 10.0), up),
            &ir,
            &layout,
        )
        .unwrap();
    let before = runtime.runtime_state.viewport.transform(viewer).unwrap();

    runtime.tick(16).unwrap();
    assert!(runtime.post_layout_hook(&ir, &layout));
    let after = runtime.runtime_state.viewport.transform(viewer).unwrap();

    assert!(after.translation[0] > before.translation[0]);
    assert_eq!(after.translation[1], before.translation[1]);
}