Skip to main content

fission_core/input/
mod.rs

1use crate::env::{
2    Clipboard, ContextMenuState, InteractionStateMap, ScrollStateMap, SelectableTextStateMap,
3    TextEditStateMap,
4};
5use crate::event::InputEvent;
6use crate::{ActionEnvelope, ActionId, ActionInput, UpdateTextInput};
7use fission_ir::semantics::ActionTrigger;
8use fission_ir::{CoreIR, Op, Semantics, WidgetId};
9use fission_layout::{LayoutSnapshot, TextMeasurer};
10use std::sync::Arc;
11
12pub mod gesture;
13pub mod hover;
14pub mod selectable_text;
15pub mod slider;
16pub mod text;
17
18pub struct ControllerContext<'a> {
19    pub ir: &'a CoreIR,
20    pub layout: &'a LayoutSnapshot,
21    pub text_edit: &'a mut TextEditStateMap,
22    pub selectable_text: &'a mut SelectableTextStateMap,
23    pub context_menu: &'a mut ContextMenuState,
24    pub interaction: &'a mut InteractionStateMap,
25    pub scroll: &'a mut ScrollStateMap,
26    pub gesture: &'a mut crate::env::GestureState,
27    pub clipboard: Option<&'a Arc<dyn Clipboard>>,
28    pub measurer: Option<&'a Arc<dyn TextMeasurer>>,
29    // We queue actions here instead of dispatching immediately to keep Controller pure logic
30    pub dispatched_actions: Vec<(WidgetId, ActionEnvelope, ActionInput)>,
31}
32
33pub trait InputController {
34    fn handle_event(&mut self, ctx: &mut ControllerContext, event: &InputEvent) -> bool;
35}
36
37/// Builds the action envelope and event input for one committed text edit.
38///
39/// This is the shared integration boundary for the native text controller,
40/// accessibility adapters, browser islands, and other interactive shells.
41/// The bound action payload is preserved and the complete live edit is carried
42/// separately in [`ActionInput::TextChanged`].
43#[doc(hidden)]
44pub fn prepare_text_input_change(
45    semantics: &Semantics,
46    node_id: WidgetId,
47    new_text: String,
48    new_caret: usize,
49    new_anchor: usize,
50) -> Option<(ActionEnvelope, ActionInput)> {
51    let entry = semantics
52        .actions
53        .entries
54        .iter()
55        .find(|entry| entry.trigger == ActionTrigger::TextChanged)?;
56
57    // Lowered text inputs always retain a payload. Preserve an empty payload
58    // for malformed external IR so reducer dispatch reports a structured
59    // deserialization failure instead of silently dropping the edit here.
60    let payload = entry.payload_data.clone().unwrap_or_default();
61    let input = ActionInput::TextChanged(UpdateTextInput {
62        node_id,
63        new_text,
64        new_caret,
65        new_anchor,
66    });
67
68    Some((
69        ActionEnvelope {
70            id: ActionId::from_u128(entry.action_id),
71            payload,
72        },
73        input,
74    ))
75}
76
77/// Builds and action-scopes one committed text edit.
78///
79/// Interactive shells should use this boundary so native, accessibility, and
80/// browser-originated edits all preserve the same action payload and resolve
81/// the nearest ancestor action scope consistently.
82#[doc(hidden)]
83pub fn prepare_scoped_text_input_change(
84    ir: &CoreIR,
85    semantics: &Semantics,
86    node_id: WidgetId,
87    new_text: String,
88    new_caret: usize,
89    new_anchor: usize,
90) -> Option<(ActionEnvelope, ActionInput)> {
91    let (envelope, input) =
92        prepare_text_input_change(semantics, node_id, new_text, new_caret, new_anchor)?;
93    Some((envelope, scoped_action_input(ir, node_id, input)))
94}
95
96pub(crate) fn action_scope_for_node(ir: &CoreIR, node_id: WidgetId) -> Option<u128> {
97    let mut current_id = Some(node_id);
98    while let Some(id) = current_id {
99        let Some(node) = ir.nodes.get(&id) else {
100            break;
101        };
102        if let Op::Semantics(semantics) = &node.op {
103            if let Some(scope_id) = semantics.action_scope_id {
104                return Some(scope_id);
105            }
106        }
107        current_id = node.parent;
108    }
109    None
110}
111
112pub(crate) fn scoped_action_input(
113    ir: &CoreIR,
114    target: WidgetId,
115    input: ActionInput,
116) -> ActionInput {
117    if let Some(scope_id) = action_scope_for_node(ir, target) {
118        ActionInput::scoped_raw(scope_id, target, input)
119    } else {
120        input
121    }
122}