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 canvas;
13pub mod gesture;
14pub mod hover;
15pub mod selectable_text;
16pub mod slider;
17pub mod text;
18pub mod viewport;
19
20mod editing_convention;
21pub use editing_convention::TextEditingConvention;
22
23pub struct ControllerContext<'a> {
24    pub ir: &'a CoreIR,
25    pub layout: &'a LayoutSnapshot,
26    pub text_edit: &'a mut TextEditStateMap,
27    pub selectable_text: &'a mut SelectableTextStateMap,
28    pub context_menu: &'a mut ContextMenuState,
29    pub interaction: &'a mut InteractionStateMap,
30    pub scroll: &'a mut ScrollStateMap,
31    pub viewport: &'a viewport::ViewportStateMap,
32    pub gesture: &'a mut crate::env::GestureState,
33    pub editing_convention: TextEditingConvention,
34    /// Monotonic runtime time used for deterministic multi-click and hold gestures.
35    pub current_time: crate::time::CurrentTime,
36    pub clipboard: Option<&'a Arc<dyn Clipboard>>,
37    pub measurer: Option<&'a Arc<dyn TextMeasurer>>,
38    // We queue actions here instead of dispatching immediately to keep Controller pure logic
39    pub dispatched_actions: Vec<(WidgetId, ActionEnvelope, ActionInput)>,
40}
41
42pub trait InputController {
43    fn handle_event(&mut self, ctx: &mut ControllerContext, event: &InputEvent) -> bool;
44}
45
46/// Builds the action envelope and event input for one committed text edit.
47///
48/// This is the shared integration boundary for the native text controller,
49/// accessibility adapters, browser islands, and other interactive shells.
50/// The bound action payload is preserved and the complete live edit is carried
51/// separately in [`ActionInput::TextChanged`].
52#[doc(hidden)]
53pub fn prepare_text_input_change(
54    semantics: &Semantics,
55    node_id: WidgetId,
56    new_text: String,
57    new_caret: usize,
58    new_anchor: usize,
59) -> Option<(ActionEnvelope, ActionInput)> {
60    let entry = semantics
61        .actions
62        .entries
63        .iter()
64        .find(|entry| entry.trigger == ActionTrigger::TextChanged)?;
65
66    // Lowered text inputs always retain a payload. Preserve an empty payload
67    // for malformed external IR so reducer dispatch reports a structured
68    // deserialization failure instead of silently dropping the edit here.
69    let payload = entry.payload_data.clone().unwrap_or_default();
70    let selection = crate::TextSelection::new(
71        &new_text,
72        new_anchor,
73        new_caret,
74        crate::TextAffinity::Downstream,
75    )
76    .unwrap_or_else(|_| crate::TextSelection::collapsed(crate::TextPosition::at_end(&new_text)));
77    let new_value = crate::TextEditingValue::new(new_text.clone(), selection, None)
78        .expect("selection was validated against the same text");
79    let old_value =
80        crate::TextEditingValue::from_text(semantics.value.as_deref().unwrap_or_default());
81    let input = ActionInput::TextChanged(UpdateTextInput {
82        node_id,
83        new_text: new_text.clone(),
84        new_caret,
85        new_anchor,
86        old_value,
87        new_value,
88        source: crate::TextEditSource::Programmatic,
89        phase: crate::TextEditPhase::Committed,
90        editing_action: None,
91        validation_state: None,
92        validation_message: None,
93    });
94
95    Some((
96        ActionEnvelope {
97            id: ActionId::from_u128(entry.action_id),
98            payload,
99        },
100        input,
101    ))
102}
103
104/// Builds a payload-preserving action from one complete text transaction.
105#[doc(hidden)]
106pub fn prepare_text_input_edit(
107    semantics: &Semantics,
108    node_id: WidgetId,
109    result: crate::TextEditResult,
110) -> Option<(ActionEnvelope, ActionInput)> {
111    let entry = semantics
112        .actions
113        .entries
114        .iter()
115        .find(|entry| entry.trigger == ActionTrigger::TextChanged)?;
116    let payload = entry.payload_data.clone().unwrap_or_default();
117    Some((
118        ActionEnvelope {
119            id: ActionId::from_u128(entry.action_id),
120            payload,
121        },
122        ActionInput::TextChanged(UpdateTextInput::from_values(
123            node_id,
124            result.old_value,
125            result.new_value,
126            result.source,
127            result.phase,
128        )),
129    ))
130}
131
132#[doc(hidden)]
133pub fn prepare_scoped_text_input_edit(
134    ir: &CoreIR,
135    semantics: &Semantics,
136    node_id: WidgetId,
137    result: crate::TextEditResult,
138) -> Option<(ActionEnvelope, ActionInput)> {
139    let (envelope, input) = prepare_text_input_edit(semantics, node_id, result)?;
140    Some((envelope, scoped_action_input(ir, node_id, input)))
141}
142
143/// Builds a payload-preserving action for a text-session lifecycle event.
144///
145/// Focus, blur, validation, submit, and completion do not replace the bound
146/// action payload with the live field value. The current complete editing
147/// value travels through the same typed input used by text mutations.
148#[doc(hidden)]
149pub fn prepare_scoped_text_session_action(
150    ir: &CoreIR,
151    semantics: &Semantics,
152    node_id: WidgetId,
153    trigger: ActionTrigger,
154    value: crate::TextEditingValue,
155    source: crate::TextEditSource,
156    phase: crate::TextEditPhase,
157) -> Option<(ActionEnvelope, ActionInput)> {
158    let entry = semantics
159        .actions
160        .entries
161        .iter()
162        .find(|entry| entry.trigger == trigger)?;
163    let envelope = ActionEnvelope {
164        id: ActionId::from_u128(entry.action_id),
165        payload: entry.payload_data.clone().unwrap_or_default(),
166    };
167    let input = ActionInput::TextChanged(UpdateTextInput::from_values(
168        node_id,
169        value.clone(),
170        value,
171        source,
172        phase,
173    ));
174    Some((envelope, scoped_action_input(ir, node_id, input)))
175}
176
177/// Builds and action-scopes one committed text edit.
178///
179/// Interactive shells should use this boundary so native, accessibility, and
180/// browser-originated edits all preserve the same action payload and resolve
181/// the nearest ancestor action scope consistently.
182#[doc(hidden)]
183pub fn prepare_scoped_text_input_change(
184    ir: &CoreIR,
185    semantics: &Semantics,
186    node_id: WidgetId,
187    new_text: String,
188    new_caret: usize,
189    new_anchor: usize,
190) -> Option<(ActionEnvelope, ActionInput)> {
191    let (envelope, input) =
192        prepare_text_input_change(semantics, node_id, new_text, new_caret, new_anchor)?;
193    Some((envelope, scoped_action_input(ir, node_id, input)))
194}
195
196pub(crate) fn action_scope_for_node(ir: &CoreIR, node_id: WidgetId) -> Option<u128> {
197    let mut current_id = Some(node_id);
198    while let Some(id) = current_id {
199        let Some(node) = ir.nodes.get(&id) else {
200            break;
201        };
202        if let Op::Semantics(semantics) = &node.op {
203            if let Some(scope_id) = semantics.action_scope_id {
204                return Some(scope_id);
205            }
206        }
207        current_id = node.parent;
208    }
209    None
210}
211
212pub(crate) fn scoped_action_input(
213    ir: &CoreIR,
214    target: WidgetId,
215    input: ActionInput,
216) -> ActionInput {
217    if let Some(scope_id) = action_scope_for_node(ir, target) {
218        ActionInput::scoped_raw(scope_id, target, input)
219    } else {
220        input
221    }
222}