fission-core 0.9.1

Core runtime, state, actions, effects, resources, input, and UI model for Fission
Documentation
use fission_core::internal::BuildCtx;
use fission_core::{
    build, BoxStyle, Column, Env, Length, MotionDeclarationKind, MotionPhase, MotionPropertyId,
    MotionTransition, Pressable, PressableRole, PressableStyle, RuntimeState, Text, Widget,
    WidgetId,
};
use fission_ir::op::{Color, Fill};
use fission_ir::{LayoutOp, Op, Role};

fn build_pressable(pressable: Pressable) -> (Widget, BuildCtx<()>) {
    let runtime = RuntimeState::default();
    let env = Env::default();
    let view = fission_core::View::new(&(), &runtime, &env, None);
    let mut context = BuildCtx::<()>::new();
    let widget = build::enter(&mut context, &view, || pressable.into());
    (widget, context)
}

#[test]
fn pressable_is_visually_neutral_and_semantically_complete() {
    let (widget, _) = build_pressable(
        Pressable::new(Text::new("Read more"))
            .role(PressableRole::Link)
            .label("Read more")
            .semantics_identifier("read-more"),
    );
    let ir = fission_core::internal::lower_widget_to_ir(&widget);

    assert!(!ir
        .nodes
        .values()
        .any(|node| matches!(node.op, Op::Paint(fission_ir::PaintOp::DrawRect { .. }))));
    let semantics = ir
        .nodes
        .values()
        .find_map(|node| match &node.op {
            Op::Semantics(semantics) if semantics.identifier.as_deref() == Some("read-more") => {
                Some(semantics)
            }
            _ => None,
        })
        .expect("pressable semantics");
    assert_eq!(semantics.role, Role::Link);
    assert_eq!(semantics.label.as_deref(), Some("Read more"));
    assert!(semantics.focusable);
}

#[test]
fn pressable_registers_layout_paint_and_composite_state_transitions() {
    let id = WidgetId::explicit("animated-pressable");
    let (widget, context) = build_pressable(Pressable {
        id: Some(id),
        transition: Some(MotionTransition::ease_out(160)),
        style: PressableStyle {
            background: Some(Fill::Solid(Color::WHITE)),
            padding: Some(std::array::from_fn(|_| Length::Points(8.0))),
            opacity: Some(1.0),
            scale: Some(1.0),
            ..Default::default()
        },
        hover_style: Some(PressableStyle {
            background: Some(Fill::Solid(Color::BLUE)),
            padding: Some(std::array::from_fn(|_| Length::Points(12.0))),
            opacity: Some(0.9),
            scale: Some(1.02),
            ..Default::default()
        }),
        child: Text::new("Hover me").into(),
        ..Default::default()
    });

    let declaration = context
        .motion_declarations
        .iter()
        .find(|declaration| matches!(declaration.kind, MotionDeclarationKind::Tracks { .. }))
        .expect("motion declaration");
    let MotionDeclarationKind::Tracks { tracks } = &declaration.kind else {
        unreachable!();
    };
    assert!(tracks.iter().any(|track| {
        track.phase == MotionPhase::Composite && track.property == MotionPropertyId::Opacity
    }));
    assert!(tracks.iter().any(|track| {
        track.phase == MotionPhase::Paint && track.property == MotionPropertyId::BackgroundColor
    }));
    assert!(tracks.iter().any(|track| {
        track.phase == MotionPhase::Layout && track.property == MotionPropertyId::PaddingLeft
    }));

    let ir = fission_core::internal::lower_widget_to_ir(&widget);
    assert!(ir.nodes.values().any(|node| matches!(
        &node.op,
        Op::Paint(fission_ir::PaintOp::DrawRect {
            fill: Some(Fill::Solid(Color::WHITE)),
            ..
        })
    )));
}

#[test]
fn pressable_state_styles_remain_declarative_without_a_transition() {
    let (_, context) = build_pressable(Pressable::new(Text::new("Immediate hover")).hover(
        PressableStyle {
            opacity: Some(0.8),
            ..Default::default()
        },
    ));

    let declaration = context
        .motion_declarations
        .iter()
        .find(|declaration| matches!(declaration.kind, MotionDeclarationKind::Tracks { .. }))
        .expect("state declaration");
    let MotionDeclarationKind::Tracks { tracks } = &declaration.kind else {
        unreachable!();
    };
    assert_eq!(tracks.len(), 1);
    assert_eq!(tracks[0].transition, MotionTransition::Instant);
}

#[test]
fn pressable_applies_the_common_box_margin() {
    let (widget, _) = build_pressable(Pressable {
        id: Some(WidgetId::explicit("margin-pressable")),
        layout: BoxStyle {
            margin: Some(std::array::from_fn(|_| Length::Points(6.0))),
            ..Default::default()
        },
        child: Text::new("Margin").into(),
        ..Default::default()
    });
    let ir = fission_core::internal::lower_widget_to_ir(&widget);

    let margin = ir
        .nodes
        .values()
        .find(|node| {
            matches!(
                &node.op,
                Op::Layout(LayoutOp::StyledBox {
                    style: BoxStyle {
                        padding: Some(padding),
                        ..
                    },
                    ..
                }) if padding.iter().all(|length| length == &Length::Points(6.0))
            )
        })
        .expect("margin box");
    let semantics = ir
        .nodes
        .values()
        .find(|node| matches!(node.op, Op::Semantics(_)))
        .expect("pressable semantics");
    assert_eq!(
        semantics.parent,
        Some(margin.id),
        "margin must remain outside the interactive semantic bounds"
    );
}

#[test]
fn implicit_pressable_motion_ids_are_stable_and_distinct_within_a_build() {
    let runtime = RuntimeState::default();
    let env = Env::default();
    let view = fission_core::View::new(&(), &runtime, &env, None);
    let mut context = BuildCtx::<()>::new();
    let _: Widget = build::enter(&mut context, &view, || {
        Column::default()
            .children(vec![
                Pressable::new(Text::new("First"))
                    .hover(PressableStyle {
                        opacity: Some(0.8),
                        ..Default::default()
                    })
                    .transition(MotionTransition::ease_out(100))
                    .into(),
                Pressable::new(Text::new("Second"))
                    .hover(PressableStyle {
                        opacity: Some(0.8),
                        ..Default::default()
                    })
                    .transition(MotionTransition::ease_out(100))
                    .into(),
            ])
            .into()
    });

    assert_eq!(context.motion_declarations.len(), 2);
    assert_ne!(
        context.motion_declarations[0].id,
        context.motion_declarations[1].id
    );
}

#[test]
fn pressable_paint_style_interpolates_through_runtime_motion() {
    let pressable_id = WidgetId::explicit("interpolated-pressable");
    let make_pressable = || {
        Pressable::new(Text::new("Hover"))
            .id(pressable_id)
            .style(PressableStyle {
                background: Some(Fill::Solid(Color::BLACK)),
                ..Default::default()
            })
            .hover(PressableStyle {
                background: Some(Fill::Solid(Color::WHITE)),
                ..Default::default()
            })
            .transition(MotionTransition::tween(
                100,
                fission_core::MotionEasing::Linear,
            ))
    };
    let mut runtime = fission_core::Runtime::default();
    let env = Env::default();

    let build_once = |runtime_state: &RuntimeState| {
        let view = fission_core::View::new(&(), runtime_state, &env, None);
        let mut context = BuildCtx::<()>::new();
        let widget = build::enter(&mut context, &view, || make_pressable().into());
        (widget, context)
    };

    let (_, initial) = build_once(&runtime.runtime_state);
    runtime.sync_motion_declarations(&initial.motion_declarations, None);
    runtime
        .runtime_state
        .interaction
        .set_hovered(pressable_id, true);
    let (_, hovered) = build_once(&runtime.runtime_state);
    runtime.sync_motion_declarations(&hovered.motion_declarations, None);
    runtime.tick(50).expect("advance pressable transition");
    let (widget, _) = build_once(&runtime.runtime_state);
    let mut lowering =
        fission_core::internal::InternalLoweringCx::new(&env, &runtime.runtime_state, None, None);
    let root = fission_core::internal::lower_widget(&widget, &mut lowering);
    lowering.ir.root = Some(root);
    let ir = lowering.ir;
    let color = ir.nodes.values().find_map(|node| match &node.op {
        Op::Paint(fission_ir::PaintOp::DrawRect {
            fill: Some(Fill::Solid(color)),
            ..
        }) => Some(*color),
        _ => None,
    });

    let color = color.expect("animated background");
    assert!(color.r > 0 && color.r < 255);
    assert_eq!(color.r, color.g);
    assert_eq!(color.g, color.b);
}