fission-core 0.2.0

Core runtime, state, actions, effects, resources, input, and UI model for Fission
Documentation
use crate::event::{InputEvent, PointerEvent};
use crate::hit_test::hit_test_with_scroll;
use crate::input::{ControllerContext, InputController};
use crate::{ActionEnvelope, ActionId, ActionInput};
use fission_ir::op::{PaintOp, RichTextAnnotation};
use fission_ir::semantics::{ActionTrigger, MouseCursor};
use fission_ir::{NodeId, Op};
use fission_layout::{LayoutPoint, LayoutRect};

type ResolvedRichTextAnnotation = (NodeId, RichTextAnnotation);

pub struct HoverController;

impl HoverController {
    pub fn clear(ctx: &mut ControllerContext, point: Option<LayoutPoint>) -> bool {
        Self::apply_hover_path(ctx, Vec::new(), point)
    }

    fn hover_path_at_point(ctx: &ControllerContext, point: LayoutPoint) -> Vec<NodeId> {
        let Some(hit_node_id) = hit_test_with_scroll(ctx.ir, ctx.layout, ctx.scroll, point) else {
            return Vec::new();
        };

        let mut path = Vec::new();
        let mut current = Some(hit_node_id);
        while let Some(node_id) = current {
            path.push(node_id);
            current = ctx.ir.nodes.get(&node_id).and_then(|node| node.parent);
        }
        path
    }

    fn apply_hover_path(
        ctx: &mut ControllerContext,
        next_path: Vec<NodeId>,
        point: Option<LayoutPoint>,
    ) -> bool {
        let previous_path = ctx.interaction.hover_path.clone();
        let previous_annotation = ctx.interaction.hovered_rich_text_annotation().cloned();
        let next_annotation =
            point.and_then(|point| resolve_rich_text_annotation_at_point(ctx, &next_path, point));
        let common_tail_len = shared_tail_len(&previous_path, &next_path);
        let exited = &previous_path[..previous_path.len().saturating_sub(common_tail_len)];
        let entered = &next_path[..next_path.len().saturating_sub(common_tail_len)];

        for node_id in exited {
            ctx.interaction.set_hovered(*node_id, false);
        }
        for node_id in entered {
            ctx.interaction.set_hovered(*node_id, true);
        }

        for node_id in exited {
            dispatch_hover_actions(ctx, *node_id, ActionTrigger::HoverExit, point);
        }
        for node_id in entered.iter().rev() {
            dispatch_hover_actions(ctx, *node_id, ActionTrigger::HoverEnter, point);
        }

        if previous_annotation
            .as_ref()
            .map(|annotation| (&annotation.node_id, &annotation.annotation))
            != next_annotation
                .as_ref()
                .map(|(node_id, annotation)| (node_id, annotation))
        {
            if let Some(previous) = &previous_annotation {
                dispatch_annotation_actions(
                    ctx,
                    previous.node_id,
                    &previous.annotation,
                    ActionTrigger::HoverExit,
                    point,
                );
            }
            if let Some((node_id, annotation)) = &next_annotation {
                dispatch_annotation_actions(
                    ctx,
                    *node_id,
                    annotation,
                    ActionTrigger::HoverEnter,
                    point,
                );
            }
        }

        let next_cursor = resolve_cursor(ctx, &next_path, next_annotation.as_ref());
        let changed = previous_path != next_path
            || previous_annotation
                .as_ref()
                .map(|annotation| (&annotation.node_id, &annotation.annotation))
                != next_annotation
                    .as_ref()
                    .map(|(node_id, annotation)| (node_id, annotation))
            || ctx.interaction.cursor != next_cursor;
        ctx.interaction.set_hover_path(next_path);
        ctx.interaction
            .set_hovered_rich_text_annotation(next_annotation.map(|(node_id, annotation)| {
                crate::env::HoveredRichTextAnnotation {
                    node_id,
                    annotation,
                }
            }));
        ctx.interaction.set_cursor(next_cursor);
        changed
    }
}

impl InputController for HoverController {
    fn handle_event(&mut self, ctx: &mut ControllerContext, event: &InputEvent) -> bool {
        match event {
            InputEvent::Pointer(PointerEvent::Down { point, .. })
            | InputEvent::Pointer(PointerEvent::Up { point, .. })
            | InputEvent::Pointer(PointerEvent::Move { point, .. })
            | InputEvent::Pointer(PointerEvent::Scroll { point, .. }) => {
                let next_path = Self::hover_path_at_point(ctx, *point);
                let _ = Self::apply_hover_path(ctx, next_path, Some(*point));
            }
            _ => {}
        }
        false
    }
}

fn shared_tail_len(previous_path: &[NodeId], next_path: &[NodeId]) -> usize {
    previous_path
        .iter()
        .rev()
        .zip(next_path.iter().rev())
        .take_while(|(previous, next)| previous == next)
        .count()
}

fn resolve_cursor(
    ctx: &ControllerContext,
    hover_path: &[NodeId],
    rich_text_annotation: Option<&ResolvedRichTextAnnotation>,
) -> MouseCursor {
    if let Some((_, annotation)) = rich_text_annotation {
        if let Some(cursor) = annotation.mouse_cursor.map(map_rich_text_cursor) {
            return cursor;
        }
    }

    for node_id in hover_path {
        let Some(node) = ctx.ir.nodes.get(node_id) else {
            continue;
        };
        let Op::Semantics(semantics) = &node.op else {
            continue;
        };
        if let Some(cursor) = semantics
            .actions
            .entries
            .iter()
            .find_map(|entry| entry.as_hover_cursor())
        {
            return cursor;
        }
    }

    MouseCursor::Default
}

fn map_rich_text_cursor(cursor: fission_ir::op::MouseCursor) -> MouseCursor {
    match cursor {
        fission_ir::op::MouseCursor::Basic => MouseCursor::Default,
        fission_ir::op::MouseCursor::Pointer => MouseCursor::Pointer,
        fission_ir::op::MouseCursor::Text => MouseCursor::Text,
    }
}

pub(crate) fn resolve_rich_text_annotation_at_point(
    ctx: &ControllerContext,
    hover_path: &[NodeId],
    point: LayoutPoint,
) -> Option<ResolvedRichTextAnnotation> {
    let measurer = ctx.measurer?;

    for node_id in hover_path {
        let Some(any_annotations) = ctx.ir.custom_render_objects.get(node_id) else {
            continue;
        };
        let Some(annotations) = any_annotations.downcast_ref::<Vec<RichTextAnnotation>>() else {
            continue;
        };
        let Some(node) = ctx.ir.nodes.get(node_id) else {
            continue;
        };
        let Op::Paint(PaintOp::DrawRichText {
            runs,
            wrap,
            paragraph_style,
            ..
        }) = &node.op
        else {
            continue;
        };
        let Some(rect) = visual_rect_for_node(ctx, *node_id) else {
            continue;
        };
        let local_x = point.x - rect.origin.x;
        let local_y = point.y - rect.origin.y;
        let available_width = if *wrap && rect.width() > 0.0 {
            Some(rect.width())
        } else {
            None
        };

        if let Some(annotation) = measurer.resolve_rich_text_annotation_at_point(
            runs,
            available_width,
            local_x,
            local_y,
            paragraph_style.unwrap_or_default(),
            annotations,
        ) {
            return Some((*node_id, annotation));
        }
    }

    None
}

fn visual_rect_for_node(ctx: &ControllerContext, node_id: NodeId) -> Option<LayoutRect> {
    let mut rect = ctx.layout.get_node_rect(node_id)?;
    let mut current = ctx.ir.nodes.get(&node_id).and_then(|node| node.parent);
    while let Some(parent_id) = current {
        let Some(parent) = ctx.ir.nodes.get(&parent_id) else {
            break;
        };
        if let Op::Layout(fission_ir::LayoutOp::Scroll { direction, .. }) = &parent.op {
            let offset = ctx.scroll.get_offset(parent_id);
            match direction {
                fission_ir::FlexDirection::Row => rect.origin.x -= offset,
                fission_ir::FlexDirection::Column => rect.origin.y -= offset,
            }
        }
        current = parent.parent;
    }
    Some(rect)
}

fn dispatch_hover_actions(
    ctx: &mut ControllerContext,
    node_id: NodeId,
    trigger: ActionTrigger,
    point: Option<LayoutPoint>,
) {
    let Some(node) = ctx.ir.nodes.get(&node_id) else {
        return;
    };
    let Op::Semantics(semantics) = &node.op else {
        return;
    };

    for entry in semantics
        .actions
        .entries
        .iter()
        .filter(|entry| entry.trigger == trigger)
    {
        let Some(payload) = &entry.payload_data else {
            continue;
        };
        let input = crate::input::scoped_action_input(
            ctx.ir,
            node_id,
            point.map(pointer_input).unwrap_or(ActionInput::None),
        );
        ctx.dispatched_actions.push((
            node_id,
            ActionEnvelope {
                id: ActionId::from_u128(entry.action_id),
                payload: payload.clone(),
            },
            input,
        ));
    }
}

fn dispatch_annotation_actions(
    ctx: &mut ControllerContext,
    node_id: NodeId,
    annotation: &RichTextAnnotation,
    trigger: ActionTrigger,
    point: Option<LayoutPoint>,
) {
    for entry in annotation
        .actions
        .iter()
        .filter(|entry| entry.trigger == trigger)
    {
        let Some(payload) = &entry.payload_data else {
            continue;
        };
        let input = crate::input::scoped_action_input(
            ctx.ir,
            node_id,
            point.map(pointer_input).unwrap_or(ActionInput::None),
        );
        ctx.dispatched_actions.push((
            node_id,
            ActionEnvelope {
                id: ActionId::from_u128(entry.action_id),
                payload: payload.clone(),
            },
            input,
        ));
    }
}

fn pointer_input(point: LayoutPoint) -> ActionInput {
    ActionInput::Pointer {
        x: point.x,
        y: point.y,
        delta_x: 0.0,
        delta_y: 0.0,
    }
}