fission-core 0.9.0

Core runtime, state, actions, effects, resources, input, and UI model for Fission
Documentation
use super::{ControllerContext, InputController};
use crate::event::{InputEvent, KeyCode, KeyEvent, PointerEvent, MOD_CTRL, MOD_SUPER};
use crate::ui::widgets::context_menu::{text_context_menu_button_id, TextContextMenuAction};
use fission_ir::{op::LayoutOp, Op, Semantics, WidgetId};
use fission_layout::{LayoutNodeGeometry, LayoutPoint};

pub struct SelectableTextController;

impl InputController for SelectableTextController {
    fn handle_event(&mut self, ctx: &mut ControllerContext, event: &InputEvent) -> bool {
        match event {
            InputEvent::Keyboard(KeyEvent::Down {
                key_code,
                modifiers,
            }) => self.handle_key(ctx, key_code.clone(), *modifiers),
            InputEvent::Pointer(PointerEvent::Down {
                point,
                button,
                modifiers,
                ..
            }) => {
                let hit =
                    crate::hit_test::hit_test_with_scroll(ctx.ir, ctx.layout, ctx.scroll, *point);
                if let (Some(owner), Some(hit_node_id)) = (ctx.context_menu.owner, hit) {
                    if let Some(action) = Self::toolbar_action_hit(ctx.ir, owner, hit_node_id) {
                        return self.execute_action(ctx, owner, action);
                    }
                }

                if !matches!(button, crate::event::PointerButton::Primary) {
                    return false;
                }

                let Some(hit_node_id) = hit else {
                    return false;
                };
                let Some((text_id, semantics)) = Self::selectable_text_at_hit(ctx, hit_node_id)
                else {
                    return false;
                };
                let caret = Self::caret_for_text(ctx, text_id, &semantics, *point);
                let state = ctx.selectable_text.get_mut_or_default(text_id);
                if !Self::has_shift(*modifiers) {
                    state.anchor = caret;
                }
                state.caret = caret;
                state.selecting = true;
                ctx.interaction.set_focused(Some(text_id));
                ctx.context_menu.close();
                true
            }
            InputEvent::Pointer(PointerEvent::Move { point, .. }) => {
                let Some(text_id) = Self::active_selection_owner(ctx) else {
                    return false;
                };
                let Some(semantics) = Self::selectable_text_semantics(ctx, text_id) else {
                    return false;
                };
                let caret = Self::caret_for_text(ctx, text_id, &semantics, *point);
                ctx.selectable_text.get_mut_or_default(text_id).caret = caret;
                true
            }
            InputEvent::Pointer(PointerEvent::Up { point, button, .. }) => {
                let Some(text_id) = Self::active_selection_owner(ctx) else {
                    return false;
                };
                let show_menu = ctx
                    .selectable_text
                    .get(text_id)
                    .and_then(|state| state.selection_range())
                    .is_some()
                    && matches!(button, crate::event::PointerButton::Secondary);
                ctx.selectable_text.get_mut_or_default(text_id).selecting = false;
                if show_menu {
                    ctx.context_menu.open(text_id, *point);
                }
                true
            }
            _ => false,
        }
    }
}

impl SelectableTextController {
    fn handle_key(
        &mut self,
        ctx: &mut ControllerContext,
        key_code: KeyCode,
        modifiers: u8,
    ) -> bool {
        let Some(owner) = ctx.interaction.focused else {
            return false;
        };
        let Some(semantics) = Self::selectable_text_semantics(ctx, owner) else {
            return false;
        };
        if !Self::has_primary_shortcut(modifiers) {
            return false;
        }
        match key_code {
            KeyCode::Char('c') | KeyCode::Char('C') => {
                if let Some(selected) = Self::selected_text(ctx, owner, &semantics) {
                    if let Some(clipboard) = ctx.clipboard {
                        clipboard.set_text(&selected);
                    }
                }
                true
            }
            KeyCode::Char('a') | KeyCode::Char('A') => {
                let len = semantics.value.as_deref().unwrap_or("").len();
                let state = ctx.selectable_text.get_mut_or_default(owner);
                state.anchor = 0;
                state.caret = len;
                true
            }
            _ => false,
        }
    }

    fn selectable_text_at_hit(
        ctx: &ControllerContext,
        hit_node_id: WidgetId,
    ) -> Option<(WidgetId, Semantics)> {
        let mut current = Some(hit_node_id);
        while let Some(node_id) = current {
            let node = ctx.ir.nodes.get(&node_id)?;
            if let Op::Semantics(semantics) = &node.op {
                if semantics.selectable_text && !semantics.disabled {
                    return Some((node_id, semantics.clone()));
                }
            }
            current = node.parent;
        }
        None
    }

    fn selectable_text_semantics(ctx: &ControllerContext, owner: WidgetId) -> Option<Semantics> {
        let node = ctx.ir.nodes.get(&owner)?;
        match &node.op {
            Op::Semantics(semantics) if semantics.selectable_text => Some(semantics.clone()),
            _ => None,
        }
    }

    fn toolbar_action_hit(
        ir: &fission_ir::CoreIR,
        owner: WidgetId,
        hit_node_id: WidgetId,
    ) -> Option<TextContextMenuAction> {
        for action in [
            TextContextMenuAction::Copy,
            TextContextMenuAction::Cut,
            TextContextMenuAction::Paste,
            TextContextMenuAction::SelectAll,
        ] {
            if Self::node_or_ancestor_matches(
                ir,
                hit_node_id,
                text_context_menu_button_id(owner, action),
            ) {
                return Some(action);
            }
        }
        None
    }

    fn execute_action(
        &mut self,
        ctx: &mut ControllerContext,
        owner: WidgetId,
        action: TextContextMenuAction,
    ) -> bool {
        let Some(semantics) = Self::selectable_text_semantics(ctx, owner) else {
            return false;
        };
        match action {
            TextContextMenuAction::Copy => {
                if let Some(selected) = Self::selected_text(ctx, owner, &semantics) {
                    if let Some(clipboard) = ctx.clipboard {
                        clipboard.set_text(&selected);
                    }
                }
                ctx.context_menu.close();
                true
            }
            TextContextMenuAction::SelectAll => {
                let len = semantics.value.as_deref().unwrap_or("").len();
                let state = ctx.selectable_text.get_mut_or_default(owner);
                state.anchor = 0;
                state.caret = len;
                state.selecting = false;
                ctx.context_menu.close();
                true
            }
            TextContextMenuAction::Cut | TextContextMenuAction::Paste => true,
        }
    }

    fn selected_text(
        ctx: &ControllerContext,
        owner: WidgetId,
        semantics: &Semantics,
    ) -> Option<String> {
        let value = semantics.value.as_deref().unwrap_or("");
        let state = ctx.selectable_text.get(owner)?;
        let (anchor, caret) = state.selection_range()?;
        let start = Self::clamp_to_char_boundary(value, anchor.min(caret));
        let end = Self::clamp_to_char_boundary(value, anchor.max(caret));
        value.get(start..end).map(ToString::to_string)
    }

    fn caret_for_text(
        ctx: &ControllerContext,
        owner: WidgetId,
        semantics: &Semantics,
        point: LayoutPoint,
    ) -> usize {
        let value = semantics.value.as_deref().unwrap_or("");
        let Some((layout_id, geom)) = Self::layout_geometry(ctx, owner) else {
            return 0;
        };
        let local = Self::local_point(ctx, layout_id, geom, point);
        let Some(measurer) = ctx.measurer else {
            return 0;
        };
        let width = (geom.rect.size.width > 0.0).then_some(geom.rect.size.width);
        let caret = if let Some(runs) = Self::rich_runs(ctx.ir, owner) {
            measurer.hit_test_rich(&runs, width, local.x, local.y)
        } else {
            let font_size = Self::font_size(ctx.ir, owner).unwrap_or(14.0);
            measurer.hit_test(value, font_size, width, local.x, local.y)
        };
        Self::clamp_to_char_boundary(value, caret.min(value.len()))
    }

    fn layout_geometry<'a>(
        ctx: &'a ControllerContext,
        owner: WidgetId,
    ) -> Option<(WidgetId, &'a LayoutNodeGeometry)> {
        fn walk<'a>(
            ctx: &'a ControllerContext,
            node_id: WidgetId,
        ) -> Option<(WidgetId, &'a LayoutNodeGeometry)> {
            if let Some(geom) = ctx.layout.get_node_geometry(node_id) {
                return Some((node_id, geom));
            }
            for child in &ctx.ir.nodes.get(&node_id)?.children {
                if let Some(found) = walk(ctx, *child) {
                    return Some(found);
                }
            }
            None
        }
        walk(ctx, owner)
    }

    fn local_point(
        ctx: &ControllerContext,
        node_id: WidgetId,
        geom: &LayoutNodeGeometry,
        point: LayoutPoint,
    ) -> LayoutPoint {
        let mut scroll_x = 0.0;
        let mut scroll_y = 0.0;
        let mut walk = ctx.ir.nodes.get(&node_id).and_then(|node| node.parent);
        while let Some(parent_id) = walk {
            let Some(parent) = ctx.ir.nodes.get(&parent_id) else {
                break;
            };
            if let Op::Layout(LayoutOp::Scroll { direction, .. }) = &parent.op {
                let offset = ctx.scroll.get_offset(parent_id);
                match direction {
                    fission_ir::FlexDirection::Row => scroll_x += offset,
                    fission_ir::FlexDirection::Column => scroll_y += offset,
                }
            }
            walk = parent.parent;
        }
        LayoutPoint::new(
            point.x - geom.rect.origin.x + scroll_x,
            point.y - geom.rect.origin.y + scroll_y,
        )
    }

    fn rich_runs(ir: &fission_ir::CoreIR, owner: WidgetId) -> Option<Vec<fission_ir::op::TextRun>> {
        fn walk(
            ir: &fission_ir::CoreIR,
            node_id: WidgetId,
        ) -> Option<Vec<fission_ir::op::TextRun>> {
            let node = ir.nodes.get(&node_id)?;
            match &node.op {
                Op::Paint(fission_ir::PaintOp::DrawRichText { runs, .. }) if !runs.is_empty() => {
                    Some(runs.clone())
                }
                _ => node.children.iter().find_map(|child| walk(ir, *child)),
            }
        }
        walk(ir, owner)
    }

    fn font_size(ir: &fission_ir::CoreIR, owner: WidgetId) -> Option<f32> {
        fn walk(ir: &fission_ir::CoreIR, node_id: WidgetId) -> Option<f32> {
            let node = ir.nodes.get(&node_id)?;
            match &node.op {
                Op::Paint(fission_ir::PaintOp::DrawText { size, .. }) => Some(*size),
                Op::Paint(fission_ir::PaintOp::DrawRichText { runs, .. }) => {
                    runs.first().map(|run| run.style.font_size)
                }
                _ => node.children.iter().find_map(|child| walk(ir, *child)),
            }
        }
        walk(ir, owner)
    }

    fn active_selection_owner(ctx: &ControllerContext) -> Option<WidgetId> {
        ctx.selectable_text
            .states
            .iter()
            .find_map(|(id, state)| state.selecting.then_some(*id))
    }

    fn node_or_ancestor_matches(
        ir: &fission_ir::CoreIR,
        node_id: WidgetId,
        expected: WidgetId,
    ) -> bool {
        let mut current = Some(node_id);
        while let Some(id) = current {
            if id == expected {
                return true;
            }
            current = ir.nodes.get(&id).and_then(|node| node.parent);
        }
        false
    }

    fn clamp_to_char_boundary(value: &str, mut index: usize) -> usize {
        index = index.min(value.len());
        while index > 0 && !value.is_char_boundary(index) {
            index -= 1;
        }
        index
    }

    fn has_shift(modifiers: u8) -> bool {
        (modifiers & crate::event::MOD_SHIFT) != 0
    }

    fn has_primary_shortcut(modifiers: u8) -> bool {
        if cfg!(target_os = "macos") || cfg!(target_os = "ios") {
            (modifiers & MOD_SUPER) != 0
        } else {
            (modifiers & MOD_CTRL) != 0
        }
    }
}