Skip to main content

fission_core/input/
text.rs

1use super::{ControllerContext, InputController};
2use crate::env::TextSelectionHandleKind;
3use crate::event::{
4    EditingCommand, InputEvent, KeyCode, KeyEvent, PointerEvent, MOD_ALT, MOD_CTRL, MOD_SHIFT,
5    MOD_SUPER,
6};
7use crate::ui::widgets::context_menu::TextContextMenuAction;
8use crate::ui::widgets::text_input::{
9    downcast_text_input_runtime_config, text_input_selection_handle_id,
10    text_input_toolbar_button_id, DragStartBehavior, TextScrollPolicy,
11};
12use crate::ActionEnvelope;
13use crate::ActionId;
14use fission_ir::FlexDirection;
15use fission_ir::{
16    op::{self, decode_text_paragraph_style, LayoutOp, Op, TextAlign, TextParagraphStyle},
17    semantics::InputFormatter,
18    Semantics, WidgetId,
19};
20use serde_json;
21use unicode_segmentation::UnicodeSegmentation;
22
23pub struct TextInputController;
24
25impl InputController for TextInputController {
26    fn handle_event(&mut self, ctx: &mut ControllerContext, event: &InputEvent) -> bool {
27        match event {
28            InputEvent::Keyboard(KeyEvent::Down {
29                key_code,
30                modifiers,
31            }) => self.handle_key(ctx, key_code.clone(), *modifiers),
32            InputEvent::Keyboard(KeyEvent::DownWithText {
33                key_code,
34                modifiers,
35                text,
36            }) => self.handle_key_with_produced_text(ctx, key_code.clone(), *modifiers, text),
37            InputEvent::Editing(command) => self.handle_editing_command(ctx, command),
38            InputEvent::TextEdit(command) => self.handle_text_edit_command(ctx, command.clone()),
39            InputEvent::Ime(ime) => self.handle_ime(ctx, ime),
40            InputEvent::Pointer(PointerEvent::Down {
41                point,
42                button,
43                modifiers,
44                ..
45            }) => {
46                let hit = crate::hit_test::hit_test_with_viewports(
47                    ctx.ir,
48                    ctx.layout,
49                    ctx.scroll,
50                    ctx.viewport,
51                    *point,
52                );
53
54                if let Some(focused_id) = ctx.interaction.focused {
55                    if let Some(node) = ctx.ir.nodes.get(&focused_id) {
56                        if let Op::Semantics(sem) = &node.op {
57                            if sem.role == fission_ir::semantics::Role::TextInput {
58                                if let Some(hit_node_id) = hit {
59                                    if let Some(action) =
60                                        Self::toolbar_action_hit(ctx.ir, focused_id, hit_node_id)
61                                    {
62                                        return self.execute_toolbar_action(ctx, action);
63                                    }
64                                    if let Some(handle_kind) =
65                                        Self::selection_handle_hit(ctx.ir, focused_id, hit_node_id)
66                                    {
67                                        let value = sem.value.as_deref().unwrap_or("").to_string();
68                                        if matches!(button, crate::event::PointerButton::Primary) {
69                                            ctx.interaction.pressed.clear();
70                                            ctx.interaction.set_pressed(focused_id, true);
71                                            ctx.interaction.last_down_point = Some(*point);
72                                        }
73                                        let state = ctx.text_edit.get_mut_or_default(focused_id);
74                                        state.affordances.active_handle = Some(handle_kind);
75                                        state.affordances.toolbar_visible = false;
76                                        Self::sync_text_input_affordances(
77                                            ctx, focused_id, sem, &value, false, None,
78                                        );
79                                        return true;
80                                    }
81                                }
82
83                                if matches!(button, crate::event::PointerButton::Secondary) {
84                                    let value = sem.value.as_deref().unwrap_or("").to_string();
85                                    let wrapper_anchor =
86                                        Self::input_wrapper_geometry(ctx, focused_id).map(|geom| {
87                                            fission_layout::LayoutPoint::new(
88                                                (point.x - geom.rect.origin.x).max(0.0),
89                                                (point.y - geom.rect.origin.y).max(0.0),
90                                            )
91                                        });
92                                    Self::sync_text_input_affordances(
93                                        ctx,
94                                        focused_id,
95                                        sem,
96                                        &value,
97                                        true,
98                                        wrapper_anchor,
99                                    );
100                                    return true;
101                                }
102                            }
103                        }
104                    }
105                }
106
107                // Only keep handling pointer-down inside the already-focused input
108                // if the hit test still resolves into that subtree. Otherwise we
109                // must fall through so Runtime can move focus to a different
110                // widget instead of swallowing the click.
111                let effective_focused = if let Some(focused_id) = ctx.interaction.focused {
112                    let mut walk = hit;
113                    let mut belongs_to_focused = false;
114                    while let Some(nid) = walk {
115                        if nid == focused_id {
116                            belongs_to_focused = true;
117                            break;
118                        }
119                        walk = ctx.ir.nodes.get(&nid).and_then(|n| n.parent);
120                    }
121                    if belongs_to_focused {
122                        Some(focused_id)
123                    } else {
124                        if let Some(node) = ctx.ir.nodes.get(&focused_id) {
125                            if let Op::Semantics(sem) = &node.op {
126                                if sem.role == fission_ir::semantics::Role::TextInput {
127                                    let current_value = sem.value.as_deref().unwrap_or("");
128                                    let _ = Self::dispatch_action_for_trigger(
129                                        ctx,
130                                        sem,
131                                        focused_id,
132                                        fission_ir::semantics::ActionTrigger::TapOutside,
133                                        Some(
134                                            serde_json::to_vec(&current_value.to_string()).unwrap(),
135                                        ),
136                                    );
137                                }
138                            }
139                        }
140                        Self::clear_text_input_affordances(ctx, focused_id);
141                        None
142                    }
143                } else {
144                    // If nothing is focused, try to find the TextInput under the
145                    // click point and focus + place the caret in one step.
146                    hit.and_then(|hit| {
147                        let mut walk = Some(hit);
148                        while let Some(nid) = walk {
149                            if let Some(node) = ctx.ir.nodes.get(&nid) {
150                                if let Op::Semantics(s) = &node.op {
151                                    if s.focusable
152                                        && s.role == fission_ir::semantics::Role::TextInput
153                                    {
154                                        let semantic_value =
155                                            s.value.as_deref().unwrap_or_default().to_string();
156                                        let select_all = Self::runtime_config(ctx, nid)
157                                            .is_some_and(|config| config.select_all_on_focus);
158                                        ctx.interaction.set_focused(Some(nid));
159                                        if select_all {
160                                            Self::sync_runtime_state(
161                                                ctx,
162                                                nid,
163                                                semantic_value.as_str(),
164                                            );
165                                            let state = ctx.text_edit.get_mut_or_default(nid);
166                                            state.anchor = 0;
167                                            state.caret = state.buffer.len_bytes();
168                                        }
169                                        let value = ctx
170                                            .text_edit
171                                            .get(nid)
172                                            .map(|state| state.editing_value())
173                                            .unwrap_or_else(|| {
174                                                crate::TextEditingValue::from_text(
175                                                    semantic_value.clone(),
176                                                )
177                                            });
178                                        if let Some((envelope, input)) =
179                                            crate::input::prepare_scoped_text_session_action(
180                                                ctx.ir,
181                                                s,
182                                                nid,
183                                                fission_ir::semantics::ActionTrigger::Focus,
184                                                value,
185                                                crate::TextEditSource::Pointer,
186                                                crate::TextEditPhase::Focused,
187                                            )
188                                        {
189                                            ctx.dispatched_actions.push((nid, envelope, input));
190                                        }
191                                        return Some(nid);
192                                    }
193                                }
194                                walk = node.parent;
195                            } else {
196                                break;
197                            }
198                        }
199                        None
200                    })
201                };
202                if let Some(focused_id) = effective_focused {
203                    if let Some(node) = ctx.ir.nodes.get(&focused_id) {
204                        if let Op::Semantics(sem) = &node.op {
205                            if sem.role == fission_ir::semantics::Role::TextInput {
206                                // Only handle pointer-down as a caret/selection update when the
207                                // pointer is inside the currently focused TextInput.
208                                //
209                                // Otherwise, allow the generic focus logic in `Runtime::handle_input`
210                                // to run so clicks can move focus to other widgets (including other
211                                // TextInputs, buttons, etc).
212                                //
213                                // The geometry rect is in layout coordinates (no scroll offset applied).
214                                // We need to adjust the rect by ancestor scroll offsets to compare
215                                // against the screen-coordinate click point.
216                                // The focused_id is a Semantics node which may not have
217                                // layout geometry.  Walk to its first child or parent
218                                // that has geometry for the containment check.
219                                let geom_id = std::iter::successors(Some(focused_id), |id| {
220                                    ctx.ir
221                                        .nodes
222                                        .get(id)
223                                        .and_then(|n| n.children.first().copied())
224                                })
225                                .find(|id| ctx.layout.get_node_geometry(*id).is_some())
226                                .or_else(|| {
227                                    let mut w =
228                                        ctx.ir.nodes.get(&focused_id).and_then(|n| n.parent);
229                                    while let Some(pid) = w {
230                                        if ctx.layout.get_node_geometry(pid).is_some() {
231                                            return Some(pid);
232                                        }
233                                        w = ctx.ir.nodes.get(&pid).and_then(|n| n.parent);
234                                    }
235                                    None
236                                });
237                                if let Some(geom) =
238                                    geom_id.and_then(|id| ctx.layout.get_node_geometry(id))
239                                {
240                                    let mut scroll_adj_y = 0.0f32;
241                                    let mut scroll_adj_x = 0.0f32;
242                                    let mut walk_id =
243                                        ctx.ir.nodes.get(&focused_id).and_then(|n| n.parent);
244                                    while let Some(pid) = walk_id {
245                                        if let Some(pnode) = ctx.ir.nodes.get(&pid) {
246                                            if let Op::Layout(LayoutOp::Scroll {
247                                                direction, ..
248                                            }) = &pnode.op
249                                            {
250                                                let poff = ctx.scroll.get_offset(pid);
251                                                match direction {
252                                                    FlexDirection::Row => scroll_adj_x += poff,
253                                                    FlexDirection::Column => scroll_adj_y += poff,
254                                                }
255                                            }
256                                            walk_id = pnode.parent;
257                                        } else {
258                                            break;
259                                        }
260                                    }
261                                    let visual_rect = fission_layout::LayoutRect::new(
262                                        geom.rect.origin.x - scroll_adj_x,
263                                        geom.rect.origin.y - scroll_adj_y,
264                                        geom.rect.size.width,
265                                        geom.rect.size.height,
266                                    );
267                                    // Skip containment check — the focus logic already verified
268                                    // the click is on this TextInput
269                                    let _ = visual_rect;
270                                }
271                                let scroll_result = Self::find_scroll_container_and_text_op(
272                                    ctx.ir,
273                                    focused_id,
274                                    sem.multiline,
275                                );
276                                if let Some((scroll_id, text_op_node_id, scroll_direction)) =
277                                    scroll_result
278                                {
279                                    if let Some(scroll_geom) =
280                                        ctx.layout.get_node_geometry(scroll_id)
281                                    {
282                                        if matches!(button, crate::event::PointerButton::Primary) {
283                                            ctx.interaction.pressed.clear();
284                                            ctx.interaction.set_pressed(focused_id, true);
285                                            ctx.interaction.last_down_point = Some(*point);
286                                        }
287                                        let value = sem.value.as_deref().unwrap_or("");
288                                        let display_value =
289                                            Self::display_value_for_metrics(ctx, focused_id, value);
290                                        let metric_text = if sem.masked {
291                                            Self::mask_text_for_metrics(&display_value)
292                                        } else {
293                                            display_value.clone()
294                                        };
295
296                                        let caret = if let Some(measurer) = ctx.measurer {
297                                            let local_point = Self::text_local_point_from_screen(
298                                                ctx,
299                                                scroll_id,
300                                                scroll_direction,
301                                                scroll_geom,
302                                                *point,
303                                            );
304
305                                            let masked_caret = Self::hit_test_text(
306                                                measurer,
307                                                ctx.layout,
308                                                ctx.ir,
309                                                focused_id,
310                                                text_op_node_id,
311                                                sem.masked,
312                                                &metric_text,
313                                                scroll_geom,
314                                                local_point.x,
315                                                local_point.y,
316                                            );
317                                            if sem.masked {
318                                                Self::source_byte_offset_from_masked(
319                                                    &display_value,
320                                                    &metric_text,
321                                                    masked_caret,
322                                                )
323                                            } else {
324                                                masked_caret
325                                            }
326                                        } else {
327                                            let font_size =
328                                                Self::extract_font_size(ctx.ir, focused_id)
329                                                    .unwrap_or(13.0);
330                                            Self::caret_from_point_in_text_fallback(
331                                                &display_value,
332                                                font_size,
333                                                scroll_geom.rect.origin.x,
334                                                scroll_geom.rect.size.width,
335                                                scroll_geom.content_size.width,
336                                                ctx.scroll.get_offset(scroll_id),
337                                                point.x,
338                                            )
339                                        };
340                                        let anchor = {
341                                            let st = ctx.text_edit.get_mut_or_default(focused_id);
342                                            st.caret = caret;
343                                            if !Self::has_shift(*modifiers) {
344                                                st.anchor = caret;
345                                            }
346                                            st.anchor
347                                        };
348                                        Self::dispatch_cursor_change(
349                                            ctx, sem, focused_id, caret, anchor,
350                                        );
351                                        Self::sync_text_input_affordances(
352                                            ctx, focused_id, sem, value, false, None,
353                                        );
354                                    }
355                                }
356                                return true;
357                            }
358                        }
359                    }
360                }
361
362                false
363            }
364            InputEvent::Pointer(PointerEvent::Move { point, .. }) => {
365                if let Some(focused_id) = ctx.interaction.focused {
366                    if let Some(node) = ctx.ir.nodes.get(&focused_id) {
367                        if let Op::Semantics(sem) = &node.op {
368                            if sem.role == fission_ir::semantics::Role::TextInput {
369                                let active_handle = ctx
370                                    .text_edit
371                                    .states
372                                    .get(&focused_id)
373                                    .and_then(|state| state.affordances.active_handle);
374                                if let Some(active_handle) = active_handle {
375                                    if let Some((scroll_id, text_op_node_id, scroll_direction)) =
376                                        Self::find_scroll_container_and_text_op(
377                                            ctx.ir,
378                                            focused_id,
379                                            sem.multiline,
380                                        )
381                                    {
382                                        if let Some(scroll_geom) =
383                                            ctx.layout.get_node_geometry(scroll_id)
384                                        {
385                                            let value = sem.value.as_deref().unwrap_or("");
386                                            let display_value = Self::display_value_for_metrics(
387                                                ctx, focused_id, value,
388                                            );
389                                            let metric_text = if sem.masked {
390                                                Self::mask_text_for_metrics(&display_value)
391                                            } else {
392                                                display_value.clone()
393                                            };
394                                            let new_caret = if let Some(measurer) = ctx.measurer {
395                                                let local_point =
396                                                    Self::text_local_point_from_screen(
397                                                        ctx,
398                                                        scroll_id,
399                                                        scroll_direction,
400                                                        scroll_geom,
401                                                        *point,
402                                                    );
403                                                let masked_caret = Self::hit_test_text(
404                                                    measurer,
405                                                    ctx.layout,
406                                                    ctx.ir,
407                                                    focused_id,
408                                                    text_op_node_id,
409                                                    sem.masked,
410                                                    &metric_text,
411                                                    scroll_geom,
412                                                    local_point.x,
413                                                    local_point.y,
414                                                );
415                                                if sem.masked {
416                                                    Self::source_byte_offset_from_masked(
417                                                        &display_value,
418                                                        &metric_text,
419                                                        masked_caret,
420                                                    )
421                                                } else {
422                                                    masked_caret
423                                                }
424                                            } else {
425                                                0
426                                            };
427                                            let (caret, anchor) = {
428                                                let st =
429                                                    ctx.text_edit.get_mut_or_default(focused_id);
430                                                match active_handle {
431                                                    TextSelectionHandleKind::Caret => {
432                                                        st.caret = new_caret;
433                                                        st.anchor = new_caret;
434                                                    }
435                                                    TextSelectionHandleKind::Start => {
436                                                        if st.caret <= st.anchor {
437                                                            st.caret = new_caret;
438                                                        } else {
439                                                            st.anchor = new_caret;
440                                                        }
441                                                    }
442                                                    TextSelectionHandleKind::End => {
443                                                        if st.caret >= st.anchor {
444                                                            st.caret = new_caret;
445                                                        } else {
446                                                            st.anchor = new_caret;
447                                                        }
448                                                    }
449                                                }
450                                                (st.caret, st.anchor)
451                                            };
452                                            Self::auto_scroll_textinput(ctx, focused_id);
453                                            Self::dispatch_cursor_change(
454                                                ctx, sem, focused_id, caret, anchor,
455                                            );
456                                            Self::sync_text_input_affordances(
457                                                ctx, focused_id, sem, value, false, None,
458                                            );
459                                        }
460                                    }
461                                    return true;
462                                }
463
464                                if ctx.interaction.is_pressed(focused_id) {
465                                    let moved_enough =
466                                        match Self::drag_start_behavior(ctx, focused_id) {
467                                            DragStartBehavior::Down => true,
468                                            DragStartBehavior::Start => {
469                                                let mut moved_enough = true;
470                                                if let Some(start) = ctx.interaction.last_down_point
471                                                {
472                                                    let dx = point.x - start.x;
473                                                    let dy = point.y - start.y;
474                                                    if dx * dx + dy * dy < 4.0 {
475                                                        moved_enough = false;
476                                                    }
477                                                }
478                                                moved_enough
479                                            }
480                                        };
481                                    if moved_enough {
482                                        if let Some((
483                                            scroll_id,
484                                            text_op_node_id,
485                                            scroll_direction,
486                                        )) = Self::find_scroll_container_and_text_op(
487                                            ctx.ir,
488                                            focused_id,
489                                            sem.multiline,
490                                        ) {
491                                            if let Some(scroll_geom) =
492                                                ctx.layout.get_node_geometry(scroll_id)
493                                            {
494                                                let value = sem.value.as_deref().unwrap_or("");
495                                                let display_value = Self::display_value_for_metrics(
496                                                    ctx, focused_id, value,
497                                                );
498                                                let metric_text = if sem.masked {
499                                                    Self::mask_text_for_metrics(&display_value)
500                                                } else {
501                                                    display_value.clone()
502                                                };
503                                                let new_caret = if let Some(measurer) = ctx.measurer
504                                                {
505                                                    let local_point =
506                                                        Self::text_local_point_from_screen(
507                                                            ctx,
508                                                            scroll_id,
509                                                            scroll_direction,
510                                                            scroll_geom,
511                                                            *point,
512                                                        );
513
514                                                    let masked_caret = Self::hit_test_text(
515                                                        measurer,
516                                                        ctx.layout,
517                                                        ctx.ir,
518                                                        focused_id,
519                                                        text_op_node_id,
520                                                        sem.masked,
521                                                        &metric_text,
522                                                        scroll_geom,
523                                                        local_point.x,
524                                                        local_point.y,
525                                                    );
526                                                    if sem.masked {
527                                                        Self::source_byte_offset_from_masked(
528                                                            &display_value,
529                                                            &metric_text,
530                                                            masked_caret,
531                                                        )
532                                                    } else {
533                                                        masked_caret
534                                                    }
535                                                } else {
536                                                    let font_size =
537                                                        Self::extract_font_size(ctx.ir, focused_id)
538                                                            .unwrap_or(13.0);
539                                                    Self::caret_from_point_in_text_fallback(
540                                                        &display_value,
541                                                        font_size,
542                                                        scroll_geom.rect.origin.x,
543                                                        scroll_geom.rect.size.width,
544                                                        scroll_geom.content_size.width,
545                                                        ctx.scroll.get_offset(scroll_id),
546                                                        point.x,
547                                                    )
548                                                };
549                                                let st =
550                                                    ctx.text_edit.get_mut_or_default(focused_id);
551                                                st.caret = new_caret;
552                                                let current_anchor = st.anchor;
553                                                Self::auto_scroll_textinput(ctx, focused_id);
554                                                Self::dispatch_cursor_change(
555                                                    ctx,
556                                                    sem,
557                                                    focused_id,
558                                                    new_caret,
559                                                    current_anchor,
560                                                );
561                                                Self::sync_text_input_affordances(
562                                                    ctx, focused_id, sem, value, false, None,
563                                                );
564                                            }
565                                        }
566                                    }
567                                }
568                                return true;
569                            }
570                        }
571                    }
572                }
573
574                false
575            }
576            InputEvent::Pointer(PointerEvent::Up { point, button, .. }) => {
577                if let Some(focused_id) = ctx.interaction.focused {
578                    if let Some(node) = ctx.ir.nodes.get(&focused_id) {
579                        if let Op::Semantics(sem) = &node.op {
580                            if sem.role == fission_ir::semantics::Role::TextInput {
581                                let value = sem.value.as_deref().unwrap_or("").to_string();
582                                let toolbar_anchor = Self::input_wrapper_geometry(ctx, focused_id)
583                                    .map(|geom| {
584                                        fission_layout::LayoutPoint::new(
585                                            (point.x - geom.rect.origin.x).max(0.0),
586                                            (point.y - geom.rect.origin.y).max(0.0),
587                                        )
588                                    });
589                                let show_toolbar =
590                                    matches!(button, crate::event::PointerButton::Secondary)
591                                        || ctx
592                                            .text_edit
593                                            .states
594                                            .get(&focused_id)
595                                            .map(|state| state.caret != state.anchor)
596                                            .unwrap_or(false);
597                                if let Some(state) = ctx.text_edit.states.get_mut(&focused_id) {
598                                    state.affordances.active_handle = None;
599                                    state.affordances.magnifier_visible = false;
600                                }
601                                Self::sync_text_input_affordances(
602                                    ctx,
603                                    focused_id,
604                                    sem,
605                                    &value,
606                                    show_toolbar,
607                                    if show_toolbar { toolbar_anchor } else { None },
608                                );
609                                return true;
610                            }
611                        }
612                    }
613                }
614
615                false
616            }
617            _ => false,
618        }
619    }
620}
621
622impl TextInputController {
623    fn handle_text_edit_command(
624        &mut self,
625        ctx: &mut ControllerContext,
626        command: crate::TextEditCommand,
627    ) -> bool {
628        let Some(focused_id) = ctx.interaction.focused else {
629            return false;
630        };
631        self.handle_text_edit_command_for(ctx, focused_id, command)
632    }
633
634    pub(crate) fn handle_text_edit_command_for(
635        &mut self,
636        ctx: &mut ControllerContext,
637        focused_id: WidgetId,
638        command: crate::TextEditCommand,
639    ) -> bool {
640        let Some(semantics) = Self::text_input_semantics(ctx, focused_id) else {
641            return false;
642        };
643        if semantics.disabled || semantics.read_only {
644            return true;
645        }
646        Self::sync_runtime_state(
647            ctx,
648            focused_id,
649            semantics.value.as_deref().unwrap_or_default(),
650        );
651        let runtime = Self::runtime_config(ctx, focused_id);
652        let old_value = ctx.text_edit.get_mut_or_default(focused_id).editing_value();
653        let mut formatters = semantics.input_formatters.clone();
654        if !semantics.multiline && !formatters.contains(&InputFormatter::SingleLine) {
655            formatters.push(InputFormatter::SingleLine);
656        }
657        let pipeline = crate::TextEditPipeline {
658            formatters,
659            custom_formatters: runtime
660                .map(|config| config.custom_input_formatters)
661                .unwrap_or_default(),
662            max_length: semantics.max_length,
663            max_length_enforcement: semantics.max_length_enforcement,
664        };
665        let Ok(result) = pipeline.apply(&old_value, command) else {
666            return true;
667        };
668        if matches!(result.phase, crate::TextEditPhase::Submitted) {
669            Self::dispatch_action_for_trigger(
670                ctx,
671                &semantics,
672                focused_id,
673                fission_ir::semantics::ActionTrigger::Submit,
674                None,
675            );
676            return true;
677        }
678        if matches!(result.phase, crate::TextEditPhase::EditingCompleted) {
679            Self::dispatch_action_for_trigger(
680                ctx,
681                &semantics,
682                focused_id,
683                fission_ir::semantics::ActionTrigger::EditingComplete,
684                None,
685            );
686            return true;
687        }
688        let source = result.source;
689        let mut caret = result.new_value.selection.extent.utf8_offset();
690        let mut anchor = result.new_value.selection.base.utf8_offset();
691        match result.phase {
692            crate::TextEditPhase::Selection => {
693                let state = ctx.text_edit.get_mut_or_default(focused_id);
694                state.caret = caret;
695                state.anchor = anchor;
696            }
697            crate::TextEditPhase::CompositionCancelled => {
698                let state = ctx.text_edit.get_mut_or_default(focused_id);
699                state.clear_preedit();
700                // The current IR may contain the projected composing value from
701                // the preceding frame. Keep the restored base authoritative
702                // until the next declarative rebuild supplies the model value.
703                state.pending_model_sync = true;
704                state.last_model_text = semantics.value.clone().unwrap_or_default();
705                caret = state.caret;
706                anchor = state.anchor;
707            }
708            crate::TextEditPhase::CompositionStarted | crate::TextEditPhase::CompositionUpdated => {
709                let state = ctx.text_edit.get_mut_or_default(focused_id);
710                state.sync_composing_value(result.new_value);
711            }
712            _ => {
713                ctx.text_edit
714                    .get_mut_or_default(focused_id)
715                    .apply_editing_value(result.new_value.clone());
716                self.dispatch_edit_result(ctx, &semantics, focused_id, result);
717            }
718        }
719        Self::dispatch_cursor_change_from(ctx, &semantics, focused_id, caret, anchor, source);
720        Self::auto_scroll_textinput(ctx, focused_id);
721        true
722    }
723
724    fn handle_editing_command(
725        &mut self,
726        ctx: &mut ControllerContext,
727        command: &EditingCommand,
728    ) -> bool {
729        let Some(focused_id) = ctx.interaction.focused else {
730            return false;
731        };
732        let Some(semantics) = Self::text_input_semantics(ctx, focused_id) else {
733            return false;
734        };
735        if semantics.disabled {
736            return false;
737        }
738
739        let (value, caret, anchor) =
740            Self::resolve_editing_value(ctx, focused_id, semantics.value.as_deref().unwrap_or(""));
741        let caret = Self::clamp_caret_to_value(&value, caret);
742        let anchor = Self::clamp_caret_to_value(&value, anchor);
743        let selection = (caret != anchor).then_some((caret.min(anchor), caret.max(anchor)));
744
745        match command {
746            EditingCommand::Copy => {
747                if let (Some((start, end)), Some(clipboard)) = (selection, ctx.clipboard) {
748                    clipboard.set_text(&value[start..end]);
749                }
750            }
751            EditingCommand::Cut => {
752                if let Some((start, end)) = selection {
753                    if let Some(clipboard) = ctx.clipboard {
754                        clipboard.set_text(&value[start..end]);
755                    }
756                    if !semantics.read_only {
757                        if let Some(result) = Self::apply_text_edit_transaction(
758                            ctx,
759                            &semantics,
760                            focused_id,
761                            start..end,
762                            String::new(),
763                            crate::TextEditSource::Clipboard,
764                        ) {
765                            let caret = result.new_value.selection.extent.utf8_offset();
766                            let anchor = result.new_value.selection.base.utf8_offset();
767                            self.dispatch_edit_result(ctx, &semantics, focused_id, result);
768                            Self::dispatch_cursor_change(
769                                ctx, &semantics, focused_id, caret, anchor,
770                            );
771                        }
772                    }
773                }
774            }
775            EditingCommand::Paste(text) => {
776                if !semantics.read_only && !text.is_empty() {
777                    let (start, end) = selection.unwrap_or((caret, caret));
778                    if let Some(inserted) =
779                        Self::prepare_inserted_text(&semantics, &value, start, end, text)
780                    {
781                        if let Some(result) = Self::apply_text_edit_transaction(
782                            ctx,
783                            &semantics,
784                            focused_id,
785                            start..end,
786                            inserted,
787                            crate::TextEditSource::Clipboard,
788                        ) {
789                            let caret = result.new_value.selection.extent.utf8_offset();
790                            let anchor = result.new_value.selection.base.utf8_offset();
791                            self.dispatch_edit_result(ctx, &semantics, focused_id, result);
792                            Self::dispatch_cursor_change(
793                                ctx, &semantics, focused_id, caret, anchor,
794                            );
795                        }
796                    }
797                }
798            }
799            EditingCommand::SelectAll => {
800                let state = ctx.text_edit.get_mut_or_default(focused_id);
801                state.caret = value.len();
802                state.anchor = 0;
803                state.clear_preedit();
804                Self::dispatch_cursor_change(ctx, &semantics, focused_id, value.len(), 0);
805            }
806            EditingCommand::Undo | EditingCommand::Redo => {
807                let edit = {
808                    let state = ctx.text_edit.get_mut_or_default(focused_id);
809                    match command {
810                        EditingCommand::Undo => state.undo(),
811                        EditingCommand::Redo => state.redo(),
812                        _ => unreachable!(),
813                    }
814                };
815                if let Some((next, next_caret, next_anchor)) = edit {
816                    self.dispatch_change(ctx, &semantics, focused_id, next);
817                    Self::dispatch_cursor_change(
818                        ctx,
819                        &semantics,
820                        focused_id,
821                        next_caret,
822                        next_anchor,
823                    );
824                }
825            }
826        }
827
828        let displayed_value = ctx
829            .text_edit
830            .get(focused_id)
831            .map(|state| state.committed_text().to_owned())
832            .unwrap_or(value);
833        Self::sync_text_input_affordances(
834            ctx,
835            focused_id,
836            &semantics,
837            displayed_value.as_str(),
838            false,
839            None,
840        );
841        true
842    }
843
844    fn text_input_semantics(ctx: &ControllerContext, focused_id: WidgetId) -> Option<Semantics> {
845        let mut current_id = Some(focused_id);
846        while let Some(node_id) = current_id {
847            let node = ctx.ir.nodes.get(&node_id)?;
848            if let Op::Semantics(semantics) = &node.op {
849                if semantics.role == fission_ir::semantics::Role::TextInput {
850                    return Some(semantics.clone());
851                }
852            }
853            current_id = node.parent;
854        }
855        None
856    }
857
858    fn handle_key_with_produced_text(
859        &mut self,
860        ctx: &mut ControllerContext,
861        key_code: KeyCode,
862        modifiers: u8,
863        text: &str,
864    ) -> bool {
865        let shortcut = ctx.editing_convention.has_primary_shortcut(modifiers)
866            && !ctx.editing_convention.is_alt_gr(modifiers);
867        let text_key = matches!(key_code, KeyCode::Char(_) | KeyCode::Space);
868        if shortcut || !text_key || text.is_empty() {
869            return self.handle_key(ctx, key_code, modifiers);
870        }
871        let Some(focused_id) = ctx.interaction.focused else {
872            return false;
873        };
874        let Some(semantics) = Self::text_input_semantics(ctx, focused_id) else {
875            return false;
876        };
877        if semantics.disabled || semantics.read_only {
878            return true;
879        }
880        Self::sync_runtime_state(
881            ctx,
882            focused_id,
883            semantics.value.as_deref().unwrap_or_default(),
884        );
885        let value = ctx.text_edit.get_mut_or_default(focused_id).editing_value();
886        self.handle_text_edit_command(
887            ctx,
888            crate::TextEditCommand::Replace {
889                range: value.selection_range(),
890                text: text.to_string(),
891                source: crate::TextEditSource::Keyboard,
892            },
893        )
894    }
895
896    fn handle_key(
897        &mut self,
898        ctx: &mut ControllerContext,
899        key_code: KeyCode,
900        modifiers: u8,
901    ) -> bool {
902        let focused_id = if let Some(id) = ctx.interaction.focused {
903            id
904        } else {
905            return false;
906        };
907
908        let mut semantics_node = None;
909        let mut current_id = Some(focused_id);
910        while let Some(node_id) = current_id {
911            if let Some(node) = ctx.ir.nodes.get(&node_id) {
912                if let Op::Semantics(s) = &node.op {
913                    if s.role == fission_ir::semantics::Role::TextInput {
914                        semantics_node = Some(s);
915                        break;
916                    }
917                }
918                current_id = node.parent;
919            } else {
920                break;
921            }
922        }
923
924        let semantics = if let Some(s) = semantics_node {
925            s
926        } else {
927            return false;
928        };
929
930        let (value, mut caret, mut anchor) =
931            Self::resolve_editing_value(ctx, focused_id, semantics.value.as_deref().unwrap_or(""));
932        if let Some(st) = ctx.text_edit.states.get_mut(&focused_id) {
933            st.clear_preedit();
934        }
935
936        caret = Self::clamp_caret_to_value(&value, caret);
937        anchor = Self::clamp_caret_to_value(&value, anchor);
938
939        let sel = if caret != anchor {
940            Some((caret.min(anchor), caret.max(anchor)))
941        } else {
942            None
943        };
944
945        // Logic for state changes
946        let mut next_caret = caret;
947        let mut next_anchor = anchor;
948        let mut next_edit: Option<(std::ops::Range<usize>, String)> = None;
949        let mut handled = false;
950
951        let read_only = semantics.read_only;
952        let disabled = semantics.disabled;
953        let convention = ctx.editing_convention;
954        let is_apple = convention.is_apple();
955        let shift = Self::has_shift(modifiers);
956        let primary_shortcut =
957            convention.has_primary_shortcut(modifiers) && !convention.is_alt_gr(modifiers);
958        let word_modifier = convention.has_word_modifier(modifiers);
959
960        if disabled {
961            return false;
962        }
963
964        match key_code {
965            KeyCode::Space => {
966                if read_only {
967                    handled = true;
968                } else {
969                    let (s, e) = sel.unwrap_or((caret, caret));
970                    if let Some(inserted) =
971                        Self::prepare_inserted_text(semantics, &value, s, e, " ")
972                    {
973                        next_caret = s + inserted.len();
974                        next_anchor = next_caret;
975                        next_edit = Some((s..e, inserted));
976                    }
977                    handled = true;
978                }
979            }
980            KeyCode::Char(ch) => {
981                let lower = ch.to_ascii_lowercase();
982                if primary_shortcut {
983                    let command = match lower {
984                        'a' => Some(EditingCommand::SelectAll),
985                        'c' => Some(EditingCommand::Copy),
986                        'x' => Some(EditingCommand::Cut),
987                        'v' => Some(EditingCommand::Paste(
988                            ctx.clipboard
989                                .and_then(|clipboard| clipboard.get_text())
990                                .unwrap_or_default(),
991                        )),
992                        'z' if shift => Some(EditingCommand::Redo),
993                        'z' => Some(EditingCommand::Undo),
994                        'y' if !is_apple => Some(EditingCommand::Redo),
995                        _ => None,
996                    };
997                    return command
998                        .map_or(true, |command| self.handle_editing_command(ctx, &command));
999                }
1000
1001                if !handled
1002                    && is_apple
1003                    && Self::has_ctrl(modifiers)
1004                    && !Self::has_alt(modifiers)
1005                    && !Self::has_super(modifiers)
1006                {
1007                    match lower {
1008                        'a' => {
1009                            let (line_start, _) = Self::current_line_bounds(
1010                                ctx, focused_id, semantics, &value, caret,
1011                            );
1012                            next_caret = line_start;
1013                            next_anchor = if shift { anchor } else { line_start };
1014                            handled = true;
1015                        }
1016                        'e' => {
1017                            let (_, line_end) = Self::current_line_bounds(
1018                                ctx, focused_id, semantics, &value, caret,
1019                            );
1020                            next_caret = line_end;
1021                            next_anchor = if shift { anchor } else { line_end };
1022                            handled = true;
1023                        }
1024                        'f' => {
1025                            let next = Self::next_grapheme_boundary(&value, caret);
1026                            next_caret = next;
1027                            next_anchor = if shift { anchor } else { next };
1028                            handled = true;
1029                        }
1030                        'b' => {
1031                            let prev = Self::prev_grapheme_boundary(&value, caret);
1032                            next_caret = prev;
1033                            next_anchor = if shift { anchor } else { prev };
1034                            handled = true;
1035                        }
1036                        'n' if semantics.multiline => {
1037                            self.handle_vertical_navigation(
1038                                ctx, focused_id, semantics, &value, caret, modifiers, false,
1039                            );
1040                            return true;
1041                        }
1042                        'p' if semantics.multiline => {
1043                            self.handle_vertical_navigation(
1044                                ctx, focused_id, semantics, &value, caret, modifiers, true,
1045                            );
1046                            return true;
1047                        }
1048                        'h' => {
1049                            handled = true;
1050                            if !read_only {
1051                                let (s, e) = sel.unwrap_or_else(|| {
1052                                    if caret == 0 {
1053                                        (0, 0)
1054                                    } else {
1055                                        (Self::prev_grapheme_boundary(&value, caret), caret)
1056                                    }
1057                                });
1058                                next_edit = Some((s..e, String::new()));
1059                                next_caret = s;
1060                                next_anchor = s;
1061                            }
1062                        }
1063                        'd' => {
1064                            handled = true;
1065                            if !read_only {
1066                                let (s, e) = sel.unwrap_or_else(|| {
1067                                    let next = Self::next_grapheme_boundary(&value, caret);
1068                                    (caret, next)
1069                                });
1070                                next_edit = Some((s..e, String::new()));
1071                                next_caret = s;
1072                                next_anchor = s;
1073                            }
1074                        }
1075                        _ => {}
1076                    }
1077                }
1078
1079                if !handled {
1080                    if read_only {
1081                        handled = true;
1082                    } else {
1083                        let (s, e) = sel.unwrap_or((caret, caret));
1084                        if let Some(inserted) =
1085                            Self::prepare_inserted_text(semantics, &value, s, e, &ch.to_string())
1086                        {
1087                            next_caret = s + inserted.len();
1088                            next_anchor = next_caret;
1089                            next_edit = Some((s..e, inserted));
1090                        }
1091                        handled = true;
1092                    }
1093                }
1094            }
1095            KeyCode::Backspace => {
1096                handled = true;
1097                if !read_only {
1098                    let (s, e) = if let Some((s, e)) = sel {
1099                        (s, e)
1100                    } else if is_apple && Self::has_super(modifiers) {
1101                        let (line_start, _) =
1102                            Self::current_line_bounds(ctx, focused_id, semantics, &value, caret);
1103                        (line_start, caret)
1104                    } else if word_modifier {
1105                        (Self::prev_word_boundary(&value, caret), caret)
1106                    } else if caret == 0 {
1107                        (0, 0)
1108                    } else {
1109                        (Self::prev_grapheme_boundary(&value, caret), caret)
1110                    };
1111                    next_edit = Some((s..e, String::new()));
1112                    next_caret = s;
1113                    next_anchor = s;
1114                }
1115            }
1116            KeyCode::Delete => {
1117                handled = true;
1118                if !read_only {
1119                    let (s, e) = if let Some((s, e)) = sel {
1120                        (s, e)
1121                    } else if is_apple && Self::has_super(modifiers) {
1122                        let (_, line_end) =
1123                            Self::current_line_bounds(ctx, focused_id, semantics, &value, caret);
1124                        (caret, line_end)
1125                    } else if word_modifier {
1126                        (caret, Self::next_word_boundary(&value, caret))
1127                    } else {
1128                        let next = Self::next_grapheme_boundary(&value, caret);
1129                        (caret, next)
1130                    };
1131                    next_edit = Some((s..e, String::new()));
1132                    next_caret = s;
1133                    next_anchor = s;
1134                }
1135            }
1136            KeyCode::Left => {
1137                let prev = if let Some((s, _)) = sel {
1138                    if !shift && !word_modifier && !(is_apple && Self::has_super(modifiers)) {
1139                        s
1140                    } else if is_apple && Self::has_super(modifiers) {
1141                        Self::current_line_bounds(ctx, focused_id, semantics, &value, caret).0
1142                    } else if word_modifier {
1143                        Self::prev_word_boundary(&value, caret)
1144                    } else {
1145                        Self::prev_grapheme_boundary(&value, caret)
1146                    }
1147                } else if is_apple && Self::has_super(modifiers) {
1148                    Self::current_line_bounds(ctx, focused_id, semantics, &value, caret).0
1149                } else if word_modifier {
1150                    Self::prev_word_boundary(&value, caret)
1151                } else {
1152                    Self::prev_grapheme_boundary(&value, caret)
1153                };
1154                next_caret = prev;
1155                next_anchor = if shift { anchor } else { prev };
1156                handled = true;
1157            }
1158            KeyCode::Right => {
1159                let next = if let Some((_, e)) = sel {
1160                    if !shift && !word_modifier && !(is_apple && Self::has_super(modifiers)) {
1161                        e
1162                    } else if is_apple && Self::has_super(modifiers) {
1163                        Self::current_line_bounds(ctx, focused_id, semantics, &value, caret).1
1164                    } else if word_modifier {
1165                        Self::next_word_boundary(&value, caret)
1166                    } else {
1167                        Self::next_grapheme_boundary(&value, caret)
1168                    }
1169                } else if is_apple && Self::has_super(modifiers) {
1170                    Self::current_line_bounds(ctx, focused_id, semantics, &value, caret).1
1171                } else if word_modifier {
1172                    Self::next_word_boundary(&value, caret)
1173                } else {
1174                    Self::next_grapheme_boundary(&value, caret)
1175                };
1176                next_caret = next;
1177                next_anchor = if shift { anchor } else { next };
1178                handled = true;
1179            }
1180            KeyCode::Home => {
1181                next_caret = if semantics.multiline && !(Self::has_ctrl(modifiers) && !is_apple) {
1182                    Self::current_line_bounds(ctx, focused_id, semantics, &value, caret).0
1183                } else {
1184                    0
1185                };
1186                next_anchor = if shift { anchor } else { next_caret };
1187                handled = true;
1188            }
1189            KeyCode::End => {
1190                next_caret = if semantics.multiline && !(Self::has_ctrl(modifiers) && !is_apple) {
1191                    Self::current_line_bounds(ctx, focused_id, semantics, &value, caret).1
1192                } else {
1193                    value.len()
1194                };
1195                next_anchor = if shift { anchor } else { next_caret };
1196                handled = true;
1197            }
1198            KeyCode::Enter => {
1199                if semantics.multiline {
1200                    handled = true;
1201                    if !read_only {
1202                        let insert_str = if semantics.auto_indent {
1203                            let line_start = value[..caret].rfind('\n').map(|p| p + 1).unwrap_or(0);
1204                            let leading: String = value[line_start..]
1205                                .chars()
1206                                .take_while(|c| *c == ' ' || *c == '\t')
1207                                .collect();
1208                            format!("\n{}", leading)
1209                        } else {
1210                            "\n".to_string()
1211                        };
1212                        let (s, e) = sel.unwrap_or((caret, caret));
1213                        if let Some(inserted) =
1214                            Self::prepare_inserted_text(semantics, &value, s, e, &insert_str)
1215                        {
1216                            next_caret = s + inserted.len();
1217                            next_anchor = next_caret;
1218                            next_edit = Some((s..e, inserted));
1219                        }
1220                    }
1221                } else if Self::dispatch_submit(ctx, semantics, focused_id, &value) {
1222                    return true;
1223                }
1224            }
1225            KeyCode::Up => {
1226                if is_apple && Self::has_super(modifiers) {
1227                    next_caret = 0;
1228                    next_anchor = if shift { anchor } else { 0 };
1229                    handled = true;
1230                } else if semantics.multiline {
1231                    self.handle_vertical_navigation(
1232                        ctx, focused_id, semantics, &value, caret, modifiers, true,
1233                    );
1234                    return true;
1235                }
1236            }
1237            KeyCode::Down => {
1238                if is_apple && Self::has_super(modifiers) {
1239                    next_caret = value.len();
1240                    next_anchor = if shift { anchor } else { value.len() };
1241                    handled = true;
1242                } else if semantics.multiline {
1243                    self.handle_vertical_navigation(
1244                        ctx, focused_id, semantics, &value, caret, modifiers, false,
1245                    );
1246                    return true;
1247                }
1248            }
1249            KeyCode::PageUp => {
1250                if semantics.multiline {
1251                    self.handle_page_navigation(
1252                        ctx, focused_id, semantics, &value, caret, modifiers, true,
1253                    );
1254                    return true;
1255                }
1256            }
1257            KeyCode::PageDown => {
1258                if semantics.multiline {
1259                    self.handle_page_navigation(
1260                        ctx, focused_id, semantics, &value, caret, modifiers, false,
1261                    );
1262                    return true;
1263                }
1264            }
1265            KeyCode::Tab => {
1266                if semantics.capture_tab {
1267                    handled = true;
1268                    if !read_only {
1269                        let tab_str = "    ";
1270                        let (s, e) = sel.unwrap_or((caret, caret));
1271                        if let Some(inserted) =
1272                            Self::prepare_inserted_text(semantics, &value, s, e, tab_str)
1273                        {
1274                            next_caret = s + inserted.len();
1275                            next_anchor = next_caret;
1276                            next_edit = Some((s..e, inserted));
1277                        }
1278                    }
1279                }
1280            }
1281            _ => {}
1282        }
1283
1284        if let Some((range, replacement)) = next_edit {
1285            if let Some(result) = Self::apply_text_edit_transaction(
1286                ctx,
1287                semantics,
1288                focused_id,
1289                range,
1290                replacement,
1291                crate::TextEditSource::Keyboard,
1292            ) {
1293                next_caret = result.new_value.selection.extent.utf8_offset();
1294                next_anchor = result.new_value.selection.base.utf8_offset();
1295                self.dispatch_edit_result(ctx, semantics, focused_id, result);
1296                Self::dispatch_cursor_change(ctx, semantics, focused_id, next_caret, next_anchor);
1297            }
1298            Self::sync_text_input_affordances(
1299                ctx,
1300                focused_id,
1301                semantics,
1302                value.as_str(),
1303                false,
1304                None,
1305            );
1306        } else if handled {
1307            // Cursor movement only
1308            let st = ctx.text_edit.get_mut_or_default(focused_id);
1309            st.caret = next_caret;
1310            st.anchor = next_anchor;
1311            st.clear_preedit();
1312            Self::auto_scroll_textinput(ctx, focused_id);
1313            Self::dispatch_cursor_change(ctx, semantics, focused_id, next_caret, next_anchor);
1314            Self::sync_text_input_affordances(
1315                ctx,
1316                focused_id,
1317                semantics,
1318                value.as_str(),
1319                false,
1320                None,
1321            );
1322        }
1323
1324        handled
1325    }
1326
1327    fn runtime_config(
1328        ctx: &ControllerContext,
1329        focused_id: WidgetId,
1330    ) -> Option<crate::ui::widgets::text_input::TextInputRuntimeConfig> {
1331        ctx.ir
1332            .custom_render_objects
1333            .get(&focused_id)
1334            .and_then(downcast_text_input_runtime_config)
1335            .cloned()
1336    }
1337
1338    fn drag_start_behavior(ctx: &ControllerContext, focused_id: WidgetId) -> DragStartBehavior {
1339        Self::runtime_config(ctx, focused_id)
1340            .map(|cfg| cfg.drag_start_behavior)
1341            .unwrap_or_default()
1342    }
1343
1344    fn sync_runtime_state(ctx: &mut ControllerContext, focused_id: WidgetId, semantic_value: &str) {
1345        let runtime = Self::runtime_config(ctx, focused_id);
1346        let masked =
1347            Self::text_input_semantics(ctx, focused_id).is_some_and(|semantics| semantics.masked);
1348        ctx.text_edit.sync_from_runtime(
1349            focused_id,
1350            semantic_value,
1351            runtime
1352                .as_ref()
1353                .and_then(|cfg| cfg.restoration_id.as_deref()),
1354            runtime
1355                .as_ref()
1356                .and_then(|cfg| cfg.undo_controller.as_ref().map(|undo| undo.capacity)),
1357            masked,
1358        );
1359    }
1360
1361    fn persist_runtime_state(ctx: &mut ControllerContext, focused_id: WidgetId) {
1362        let runtime = Self::runtime_config(ctx, focused_id);
1363        let masked =
1364            Self::text_input_semantics(ctx, focused_id).is_some_and(|semantics| semantics.masked);
1365        ctx.text_edit.persist_restoration(
1366            focused_id,
1367            runtime
1368                .as_ref()
1369                .and_then(|cfg| cfg.restoration_id.as_deref()),
1370            masked,
1371        );
1372    }
1373
1374    fn has_shift(modifiers: u8) -> bool {
1375        (modifiers & MOD_SHIFT) != 0
1376    }
1377
1378    fn has_alt(modifiers: u8) -> bool {
1379        (modifiers & MOD_ALT) != 0
1380    }
1381
1382    fn has_ctrl(modifiers: u8) -> bool {
1383        (modifiers & MOD_CTRL) != 0
1384    }
1385
1386    fn has_super(modifiers: u8) -> bool {
1387        (modifiers & MOD_SUPER) != 0
1388    }
1389
1390    fn node_or_ancestor_matches(
1391        ir: &fission_ir::CoreIR,
1392        node_id: WidgetId,
1393        expected: WidgetId,
1394    ) -> bool {
1395        let mut current = Some(node_id);
1396        while let Some(id) = current {
1397            if id == expected {
1398                return true;
1399            }
1400            current = ir.nodes.get(&id).and_then(|node| node.parent);
1401        }
1402        false
1403    }
1404
1405    fn toolbar_action_hit(
1406        ir: &fission_ir::CoreIR,
1407        focused_id: WidgetId,
1408        hit_node_id: WidgetId,
1409    ) -> Option<TextContextMenuAction> {
1410        for action in [
1411            TextContextMenuAction::Copy,
1412            TextContextMenuAction::Cut,
1413            TextContextMenuAction::Paste,
1414            TextContextMenuAction::SelectAll,
1415        ] {
1416            if Self::node_or_ancestor_matches(
1417                ir,
1418                hit_node_id,
1419                text_input_toolbar_button_id(focused_id, action),
1420            ) {
1421                return Some(action);
1422            }
1423        }
1424        None
1425    }
1426
1427    fn selection_handle_hit(
1428        ir: &fission_ir::CoreIR,
1429        focused_id: WidgetId,
1430        hit_node_id: WidgetId,
1431    ) -> Option<TextSelectionHandleKind> {
1432        for kind in [
1433            TextSelectionHandleKind::Caret,
1434            TextSelectionHandleKind::Start,
1435            TextSelectionHandleKind::End,
1436        ] {
1437            if Self::node_or_ancestor_matches(
1438                ir,
1439                hit_node_id,
1440                text_input_selection_handle_id(focused_id, kind),
1441            ) {
1442                return Some(kind);
1443            }
1444        }
1445        None
1446    }
1447
1448    fn execute_toolbar_action(
1449        &mut self,
1450        ctx: &mut ControllerContext,
1451        action: TextContextMenuAction,
1452    ) -> bool {
1453        let command = match action {
1454            TextContextMenuAction::Copy => EditingCommand::Copy,
1455            TextContextMenuAction::Cut => EditingCommand::Cut,
1456            TextContextMenuAction::Paste => EditingCommand::Paste(
1457                ctx.clipboard
1458                    .and_then(|clipboard| clipboard.get_text())
1459                    .unwrap_or_default(),
1460            ),
1461            TextContextMenuAction::SelectAll => EditingCommand::SelectAll,
1462        };
1463        self.handle_editing_command(ctx, &command)
1464    }
1465
1466    fn prepare_inserted_text(
1467        semantics: &Semantics,
1468        _current_value: &str,
1469        _replace_start: usize,
1470        _replace_end: usize,
1471        raw_text: &str,
1472    ) -> Option<String> {
1473        // Keyboard type and capitalization are platform intent, not validators.
1474        // Only explicit structural constraints may alter inserted text here.
1475        let mut inserted = raw_text.to_string();
1476        if !semantics.multiline {
1477            inserted = inserted.replace(['\r', '\n'], "");
1478        }
1479
1480        if let Some(mask) = &semantics.input_mask {
1481            inserted = inserted
1482                .chars()
1483                .filter(|ch| mask.is_valid_char(*ch))
1484                .collect();
1485        }
1486
1487        if inserted.is_empty() {
1488            None
1489        } else {
1490            Some(inserted)
1491        }
1492    }
1493
1494    fn apply_text_edit_transaction(
1495        ctx: &mut ControllerContext,
1496        semantics: &Semantics,
1497        node_id: WidgetId,
1498        range: std::ops::Range<usize>,
1499        replacement: String,
1500        source: crate::TextEditSource,
1501    ) -> Option<crate::TextEditResult> {
1502        let runtime = Self::runtime_config(ctx, node_id);
1503        let old_value = ctx.text_edit.get_mut_or_default(node_id).editing_value();
1504        let range = crate::TextRange::new(&old_value.text, range.start, range.end).ok()?;
1505        let mut formatters = semantics.input_formatters.clone();
1506        if !semantics.multiline && !formatters.contains(&InputFormatter::SingleLine) {
1507            formatters.push(InputFormatter::SingleLine);
1508        }
1509        let pipeline = crate::TextEditPipeline {
1510            formatters,
1511            custom_formatters: runtime
1512                .map(|config| config.custom_input_formatters)
1513                .unwrap_or_default(),
1514            max_length: semantics.max_length,
1515            max_length_enforcement: semantics.max_length_enforcement,
1516        };
1517        let mut result = pipeline
1518            .apply(
1519                &old_value,
1520                crate::TextEditCommand::Replace {
1521                    range,
1522                    text: replacement,
1523                    source,
1524                },
1525            )
1526            .ok()?;
1527        if source == crate::TextEditSource::Ime {
1528            result.phase = crate::TextEditPhase::CompositionCommitted;
1529        }
1530        if result.new_value == result.old_value {
1531            return None;
1532        }
1533        ctx.text_edit
1534            .get_mut_or_default(node_id)
1535            .apply_editing_value(result.new_value.clone());
1536        Some(result)
1537    }
1538
1539    fn dispatch_edit_result(
1540        &self,
1541        ctx: &mut ControllerContext,
1542        semantics: &Semantics,
1543        node_id: WidgetId,
1544        result: crate::TextEditResult,
1545    ) {
1546        Self::persist_runtime_state(ctx, node_id);
1547        if let Some((envelope, input)) =
1548            crate::input::prepare_scoped_text_input_edit(ctx.ir, semantics, node_id, result)
1549        {
1550            ctx.dispatched_actions.push((node_id, envelope, input));
1551            Self::auto_scroll_textinput(ctx, node_id);
1552        }
1553    }
1554
1555    fn handle_ime(&mut self, ctx: &mut ControllerContext, ime: &crate::event::ImeEvent) -> bool {
1556        match ime {
1557            crate::event::ImeEvent::Commit { text } => {
1558                if let Some(focused_id) = ctx.interaction.focused {
1559                    if let Some(node) = ctx.ir.nodes.get(&focused_id) {
1560                        if let Op::Semantics(semantics) = &node.op {
1561                            if semantics.role == fission_ir::semantics::Role::TextInput {
1562                                if semantics.disabled || semantics.read_only {
1563                                    return true;
1564                                }
1565                                Self::sync_runtime_state(
1566                                    ctx,
1567                                    focused_id,
1568                                    semantics.value.as_deref().unwrap_or(""),
1569                                );
1570                                let (value, start, end) = {
1571                                    let st = ctx.text_edit.get_mut_or_default(focused_id);
1572                                    if let Some(preedit) = &st.preedit {
1573                                        let (start, end) = preedit.range;
1574                                        let committed = st.committed_text();
1575                                        // Legacy IME preedit is a display projection over the
1576                                        // committed buffer. Remove that projection before the
1577                                        // canonical transaction replaces its original range;
1578                                        // otherwise the replacement is compared with the already
1579                                        // projected display value and is incorrectly treated as a
1580                                        // no-op.
1581                                        st.clear_preedit();
1582                                        (committed, start, end)
1583                                    } else {
1584                                        let value = st.editing_value();
1585                                        let range = value.selection_range();
1586                                        (
1587                                            value.text,
1588                                            range.start.utf8_offset(),
1589                                            range.end.utf8_offset(),
1590                                        )
1591                                    }
1592                                };
1593
1594                                if let Some(filtered_text) =
1595                                    Self::prepare_inserted_text(semantics, &value, start, end, text)
1596                                {
1597                                    if let Some(result) = Self::apply_text_edit_transaction(
1598                                        ctx,
1599                                        semantics,
1600                                        focused_id,
1601                                        start..end,
1602                                        filtered_text,
1603                                        crate::TextEditSource::Ime,
1604                                    ) {
1605                                        let caret = result.new_value.selection.extent.utf8_offset();
1606                                        let anchor = result.new_value.selection.base.utf8_offset();
1607                                        self.dispatch_edit_result(
1608                                            ctx, semantics, focused_id, result,
1609                                        );
1610                                        Self::dispatch_cursor_change(
1611                                            ctx, semantics, focused_id, caret, anchor,
1612                                        );
1613                                    }
1614                                } else {
1615                                    ctx.text_edit.get_mut_or_default(focused_id).clear_preedit();
1616                                }
1617
1618                                return true;
1619                            }
1620                        }
1621                    }
1622                }
1623            }
1624            crate::event::ImeEvent::Preedit { text, cursor } => {
1625                if let Some(focused_id) = ctx.interaction.focused {
1626                    if let Some(node) = ctx.ir.nodes.get(&focused_id) {
1627                        if let Op::Semantics(semantics) = &node.op {
1628                            if semantics.disabled || semantics.read_only {
1629                                return true;
1630                            }
1631                            Self::sync_runtime_state(
1632                                ctx,
1633                                focused_id,
1634                                semantics.value.as_deref().unwrap_or(""),
1635                            );
1636                        }
1637                    }
1638                    let st = ctx.text_edit.get_mut_or_default(focused_id);
1639                    st.set_preedit(text.clone(), *cursor);
1640                    Self::auto_scroll_textinput(ctx, focused_id);
1641                    return true;
1642                }
1643            }
1644            crate::event::ImeEvent::Cancel => {
1645                if let Some(focused_id) = ctx.interaction.focused {
1646                    if let Some(node) = ctx.ir.nodes.get(&focused_id) {
1647                        if let Op::Semantics(semantics) = &node.op {
1648                            if semantics.disabled || semantics.read_only {
1649                                return true;
1650                            }
1651                            Self::sync_runtime_state(
1652                                ctx,
1653                                focused_id,
1654                                semantics.value.as_deref().unwrap_or(""),
1655                            );
1656                        }
1657                    }
1658                    let st = ctx.text_edit.get_mut_or_default(focused_id);
1659                    st.clear_preedit();
1660                    Self::auto_scroll_textinput(ctx, focused_id);
1661                    return true;
1662                }
1663            }
1664        }
1665        false
1666    }
1667
1668    fn dispatch_change(
1669        &self,
1670        ctx: &mut ControllerContext,
1671        semantics: &fission_ir::Semantics,
1672        node_id: WidgetId,
1673        new_text: String,
1674    ) {
1675        Self::persist_runtime_state(ctx, node_id);
1676        let (new_caret, new_anchor) = ctx
1677            .text_edit
1678            .get(node_id)
1679            .map(|state| (state.caret, state.anchor))
1680            .unwrap_or((new_text.len(), new_text.len()));
1681        if let Some((envelope, input)) = crate::input::prepare_scoped_text_input_change(
1682            ctx.ir, semantics, node_id, new_text, new_caret, new_anchor,
1683        ) {
1684            ctx.dispatched_actions.push((node_id, envelope, input));
1685
1686            // State update moved to handle_key to avoid double borrow
1687
1688            Self::auto_scroll_textinput(ctx, node_id);
1689        }
1690    }
1691
1692    fn dispatch_cursor_change(
1693        ctx: &mut ControllerContext,
1694        semantics: &fission_ir::Semantics,
1695        node_id: WidgetId,
1696        new_caret: usize,
1697        new_anchor: usize,
1698    ) {
1699        Self::dispatch_cursor_change_from(
1700            ctx,
1701            semantics,
1702            node_id,
1703            new_caret,
1704            new_anchor,
1705            crate::TextEditSource::Programmatic,
1706        );
1707    }
1708
1709    fn dispatch_cursor_change_from(
1710        ctx: &mut ControllerContext,
1711        semantics: &fission_ir::Semantics,
1712        node_id: WidgetId,
1713        new_caret: usize,
1714        new_anchor: usize,
1715        source: crate::TextEditSource,
1716    ) {
1717        // Deduplicate: skip dispatch if cursor position hasn't actually changed
1718        // since our last dispatch. This prevents unnecessary model updates that
1719        // would trigger extra rebuild cycles.
1720        if let Some(st) = ctx.text_edit.states.get(&node_id) {
1721            if st.last_dispatched_cursor == Some((new_caret, new_anchor)) {
1722                return;
1723            }
1724        }
1725
1726        Self::persist_runtime_state(ctx, node_id);
1727
1728        if let Some(action_entry) = semantics
1729            .actions
1730            .entries
1731            .iter()
1732            .find(|e| e.trigger == fission_ir::semantics::ActionTrigger::CursorChange)
1733        {
1734            // Record the dispatched position before dispatching
1735            if let Some(st) = ctx.text_edit.states.get_mut(&node_id) {
1736                st.last_dispatched_cursor = Some((new_caret, new_anchor));
1737            }
1738
1739            let payload = action_entry.payload_data.clone().unwrap_or_default();
1740            let envelope = ActionEnvelope {
1741                id: ActionId::from_u128(action_entry.action_id),
1742                payload,
1743            };
1744            let mut value = ctx.text_edit.get_mut_or_default(node_id).editing_value();
1745            if let Ok(selection) = crate::TextSelection::new(
1746                &value.text,
1747                new_anchor,
1748                new_caret,
1749                crate::TextAffinity::Downstream,
1750            ) {
1751                value.selection = selection;
1752            }
1753            let input = crate::input::scoped_action_input(
1754                ctx.ir,
1755                node_id,
1756                crate::ActionInput::TextSelectionChanged(crate::action::UpdateTextSelection {
1757                    node_id,
1758                    value,
1759                    source,
1760                }),
1761            );
1762            ctx.dispatched_actions.push((node_id, envelope, input));
1763        }
1764    }
1765
1766    fn dispatch_submit(
1767        ctx: &mut ControllerContext,
1768        semantics: &fission_ir::Semantics,
1769        node_id: WidgetId,
1770        current_value: &str,
1771    ) -> bool {
1772        let mut dispatched = false;
1773        for trigger in [
1774            fission_ir::semantics::ActionTrigger::Validation,
1775            fission_ir::semantics::ActionTrigger::EditingComplete,
1776            fission_ir::semantics::ActionTrigger::Submit,
1777        ] {
1778            dispatched |= Self::dispatch_action_for_trigger(
1779                ctx,
1780                semantics,
1781                node_id,
1782                trigger,
1783                Some(serde_json::to_vec(&current_value.to_string()).unwrap()),
1784            );
1785        }
1786        dispatched
1787    }
1788
1789    fn dispatch_action_for_trigger(
1790        ctx: &mut ControllerContext,
1791        semantics: &fission_ir::Semantics,
1792        node_id: WidgetId,
1793        trigger: fission_ir::semantics::ActionTrigger,
1794        fallback_payload: Option<Vec<u8>>,
1795    ) -> bool {
1796        let Some(action_entry) = semantics
1797            .actions
1798            .entries
1799            .iter()
1800            .find(|e| e.trigger == trigger)
1801        else {
1802            return false;
1803        };
1804        let payload = action_entry
1805            .payload_data
1806            .clone()
1807            .or(fallback_payload)
1808            .unwrap_or_else(|| serde_json::to_vec(&()).unwrap());
1809        let envelope = ActionEnvelope {
1810            id: ActionId::from_u128(action_entry.action_id),
1811            payload,
1812        };
1813        let dynamic_input = match trigger {
1814            fission_ir::semantics::ActionTrigger::Submit
1815            | fission_ir::semantics::ActionTrigger::EditingComplete
1816            | fission_ir::semantics::ActionTrigger::Validation
1817            | fission_ir::semantics::ActionTrigger::TapOutside
1818            | fission_ir::semantics::ActionTrigger::Focus
1819            | fission_ir::semantics::ActionTrigger::Blur => {
1820                let value = ctx.text_edit.get_mut_or_default(node_id).editing_value();
1821                let (source, phase) = match trigger {
1822                    fission_ir::semantics::ActionTrigger::Submit => (
1823                        crate::TextEditSource::Keyboard,
1824                        crate::TextEditPhase::Submitted,
1825                    ),
1826                    fission_ir::semantics::ActionTrigger::EditingComplete => (
1827                        crate::TextEditSource::Keyboard,
1828                        crate::TextEditPhase::EditingCompleted,
1829                    ),
1830                    fission_ir::semantics::ActionTrigger::Validation => (
1831                        crate::TextEditSource::Programmatic,
1832                        crate::TextEditPhase::Validated,
1833                    ),
1834                    fission_ir::semantics::ActionTrigger::TapOutside => (
1835                        crate::TextEditSource::Pointer,
1836                        crate::TextEditPhase::TapOutside,
1837                    ),
1838                    fission_ir::semantics::ActionTrigger::Focus => (
1839                        crate::TextEditSource::Pointer,
1840                        crate::TextEditPhase::Focused,
1841                    ),
1842                    fission_ir::semantics::ActionTrigger::Blur => (
1843                        crate::TextEditSource::Pointer,
1844                        crate::TextEditPhase::Blurred,
1845                    ),
1846                    _ => unreachable!(),
1847                };
1848                let mut input = crate::UpdateTextInput::from_values(
1849                    node_id,
1850                    value.clone(),
1851                    value,
1852                    source,
1853                    phase,
1854                );
1855                if matches!(
1856                    trigger,
1857                    fission_ir::semantics::ActionTrigger::Submit
1858                        | fission_ir::semantics::ActionTrigger::EditingComplete
1859                ) {
1860                    input.editing_action = Some(semantics.text_input_action);
1861                }
1862                if trigger == fission_ir::semantics::ActionTrigger::Validation {
1863                    input.validation_state = Some(semantics.validation_state);
1864                    input.validation_message = semantics.validation_message.clone();
1865                }
1866                crate::ActionInput::TextChanged(input)
1867            }
1868            _ => crate::ActionInput::None,
1869        };
1870        let input = crate::input::scoped_action_input(ctx.ir, node_id, dynamic_input);
1871        ctx.dispatched_actions.push((node_id, envelope, input));
1872        true
1873    }
1874}
1875
1876mod geometry;
1877pub use geometry::caret_from_point_in_text;