Skip to main content

fission_core/input/
text.rs

1use super::{ControllerContext, InputController};
2use crate::env::TextSelectionHandleKind;
3use crate::event::{
4    InputEvent, KeyCode, KeyEvent, PointerEvent, MOD_ALT, MOD_CTRL, MOD_SHIFT, MOD_SUPER,
5};
6use crate::ui::widgets::context_menu::TextContextMenuAction;
7use crate::ui::widgets::text_input::{
8    downcast_text_input_runtime_config, text_input_selection_handle_id,
9    text_input_toolbar_button_id, DragStartBehavior,
10};
11use crate::ActionEnvelope;
12use crate::ActionId;
13use fission_ir::FlexDirection;
14use fission_ir::{
15    op::{self, decode_text_paragraph_style, LayoutOp, Op, TextAlign, TextParagraphStyle},
16    semantics::{InputFormatter, MaxLengthEnforcement, TextCapitalization, TextInputType},
17    Semantics, WidgetId,
18};
19use serde_json;
20use unicode_segmentation::UnicodeSegmentation;
21
22pub struct TextInputController;
23
24impl InputController for TextInputController {
25    fn handle_event(&mut self, ctx: &mut ControllerContext, event: &InputEvent) -> bool {
26        match event {
27            InputEvent::Keyboard(KeyEvent::Down {
28                key_code,
29                modifiers,
30            }) => self.handle_key(ctx, key_code.clone(), *modifiers),
31            InputEvent::Ime(ime) => self.handle_ime(ctx, ime),
32            InputEvent::Pointer(PointerEvent::Down {
33                point,
34                button,
35                modifiers,
36                ..
37            }) => {
38                let hit =
39                    crate::hit_test::hit_test_with_scroll(ctx.ir, ctx.layout, ctx.scroll, *point);
40
41                if let Some(focused_id) = ctx.interaction.focused {
42                    if let Some(node) = ctx.ir.nodes.get(&focused_id) {
43                        if let Op::Semantics(sem) = &node.op {
44                            if sem.role == fission_ir::semantics::Role::TextInput {
45                                if let Some(hit_node_id) = hit {
46                                    if let Some(action) =
47                                        Self::toolbar_action_hit(ctx.ir, focused_id, hit_node_id)
48                                    {
49                                        return self.execute_toolbar_action(ctx, action);
50                                    }
51                                    if let Some(handle_kind) =
52                                        Self::selection_handle_hit(ctx.ir, focused_id, hit_node_id)
53                                    {
54                                        let value = sem.value.as_deref().unwrap_or("").to_string();
55                                        if matches!(button, crate::event::PointerButton::Primary) {
56                                            ctx.interaction.pressed.clear();
57                                            ctx.interaction.set_pressed(focused_id, true);
58                                            ctx.interaction.last_down_point = Some(*point);
59                                        }
60                                        let state = ctx.text_edit.get_mut_or_default(focused_id);
61                                        state.affordances.active_handle = Some(handle_kind);
62                                        state.affordances.toolbar_visible = false;
63                                        Self::sync_text_input_affordances(
64                                            ctx, focused_id, sem, &value, false, None,
65                                        );
66                                        return true;
67                                    }
68                                }
69
70                                if matches!(button, crate::event::PointerButton::Secondary) {
71                                    let value = sem.value.as_deref().unwrap_or("").to_string();
72                                    let wrapper_anchor =
73                                        Self::input_wrapper_geometry(ctx, focused_id).map(|geom| {
74                                            fission_layout::LayoutPoint::new(
75                                                (point.x - geom.rect.origin.x).max(0.0),
76                                                (point.y - geom.rect.origin.y).max(0.0),
77                                            )
78                                        });
79                                    Self::sync_text_input_affordances(
80                                        ctx,
81                                        focused_id,
82                                        sem,
83                                        &value,
84                                        true,
85                                        wrapper_anchor,
86                                    );
87                                    return true;
88                                }
89                            }
90                        }
91                    }
92                }
93
94                // Only keep handling pointer-down inside the already-focused input
95                // if the hit test still resolves into that subtree. Otherwise we
96                // must fall through so Runtime can move focus to a different
97                // widget instead of swallowing the click.
98                let effective_focused = if let Some(focused_id) = ctx.interaction.focused {
99                    let mut walk = hit;
100                    let mut belongs_to_focused = false;
101                    while let Some(nid) = walk {
102                        if nid == focused_id {
103                            belongs_to_focused = true;
104                            break;
105                        }
106                        walk = ctx.ir.nodes.get(&nid).and_then(|n| n.parent);
107                    }
108                    if belongs_to_focused {
109                        Some(focused_id)
110                    } else {
111                        if let Some(node) = ctx.ir.nodes.get(&focused_id) {
112                            if let Op::Semantics(sem) = &node.op {
113                                if sem.role == fission_ir::semantics::Role::TextInput {
114                                    let current_value = sem.value.as_deref().unwrap_or("");
115                                    let _ = Self::dispatch_action_for_trigger(
116                                        ctx,
117                                        sem,
118                                        focused_id,
119                                        fission_ir::semantics::ActionTrigger::TapOutside,
120                                        Some(
121                                            serde_json::to_vec(&current_value.to_string()).unwrap(),
122                                        ),
123                                    );
124                                }
125                            }
126                        }
127                        Self::clear_text_input_affordances(ctx, focused_id);
128                        None
129                    }
130                } else {
131                    // If nothing is focused, try to find the TextInput under the
132                    // click point and focus + place the caret in one step.
133                    hit.and_then(|hit| {
134                        let mut walk = Some(hit);
135                        while let Some(nid) = walk {
136                            if let Some(node) = ctx.ir.nodes.get(&nid) {
137                                if let Op::Semantics(s) = &node.op {
138                                    if s.focusable
139                                        && s.role == fission_ir::semantics::Role::TextInput
140                                    {
141                                        ctx.interaction.set_focused(Some(nid));
142                                        return Some(nid);
143                                    }
144                                }
145                                walk = node.parent;
146                            } else {
147                                break;
148                            }
149                        }
150                        None
151                    })
152                };
153                if let Some(focused_id) = effective_focused {
154                    if let Some(node) = ctx.ir.nodes.get(&focused_id) {
155                        if let Op::Semantics(sem) = &node.op {
156                            if sem.role == fission_ir::semantics::Role::TextInput {
157                                // Only handle pointer-down as a caret/selection update when the
158                                // pointer is inside the currently focused TextInput.
159                                //
160                                // Otherwise, allow the generic focus logic in `Runtime::handle_input`
161                                // to run so clicks can move focus to other widgets (including other
162                                // TextInputs, buttons, etc).
163                                //
164                                // The geometry rect is in layout coordinates (no scroll offset applied).
165                                // We need to adjust the rect by ancestor scroll offsets to compare
166                                // against the screen-coordinate click point.
167                                // The focused_id is a Semantics node which may not have
168                                // layout geometry.  Walk to its first child or parent
169                                // that has geometry for the containment check.
170                                let geom_id = std::iter::successors(Some(focused_id), |id| {
171                                    ctx.ir
172                                        .nodes
173                                        .get(id)
174                                        .and_then(|n| n.children.first().copied())
175                                })
176                                .find(|id| ctx.layout.get_node_geometry(*id).is_some())
177                                .or_else(|| {
178                                    let mut w =
179                                        ctx.ir.nodes.get(&focused_id).and_then(|n| n.parent);
180                                    while let Some(pid) = w {
181                                        if ctx.layout.get_node_geometry(pid).is_some() {
182                                            return Some(pid);
183                                        }
184                                        w = ctx.ir.nodes.get(&pid).and_then(|n| n.parent);
185                                    }
186                                    None
187                                });
188                                if let Some(geom) =
189                                    geom_id.and_then(|id| ctx.layout.get_node_geometry(id))
190                                {
191                                    let mut scroll_adj_y = 0.0f32;
192                                    let mut scroll_adj_x = 0.0f32;
193                                    let mut walk_id =
194                                        ctx.ir.nodes.get(&focused_id).and_then(|n| n.parent);
195                                    while let Some(pid) = walk_id {
196                                        if let Some(pnode) = ctx.ir.nodes.get(&pid) {
197                                            if let Op::Layout(LayoutOp::Scroll {
198                                                direction, ..
199                                            }) = &pnode.op
200                                            {
201                                                let poff = ctx.scroll.get_offset(pid);
202                                                match direction {
203                                                    FlexDirection::Row => scroll_adj_x += poff,
204                                                    FlexDirection::Column => scroll_adj_y += poff,
205                                                }
206                                            }
207                                            walk_id = pnode.parent;
208                                        } else {
209                                            break;
210                                        }
211                                    }
212                                    let visual_rect = fission_layout::LayoutRect::new(
213                                        geom.rect.origin.x - scroll_adj_x,
214                                        geom.rect.origin.y - scroll_adj_y,
215                                        geom.rect.size.width,
216                                        geom.rect.size.height,
217                                    );
218                                    // Skip containment check — the focus logic already verified
219                                    // the click is on this TextInput
220                                    let _ = visual_rect;
221                                }
222                                let scroll_result = Self::find_scroll_container_and_text_op(
223                                    ctx.ir,
224                                    focused_id,
225                                    sem.multiline,
226                                );
227                                if let Some((scroll_id, _text_op_node_id, scroll_direction)) =
228                                    scroll_result
229                                {
230                                    if let Some(scroll_geom) =
231                                        ctx.layout.get_node_geometry(scroll_id)
232                                    {
233                                        if matches!(button, crate::event::PointerButton::Primary) {
234                                            ctx.interaction.pressed.clear();
235                                            ctx.interaction.set_pressed(focused_id, true);
236                                            ctx.interaction.last_down_point = Some(*point);
237                                        }
238                                        let value = sem.value.as_deref().unwrap_or("");
239                                        let display_value =
240                                            Self::display_value_for_metrics(ctx, focused_id, value);
241                                        let metric_text = if sem.masked {
242                                            Self::mask_text_for_metrics(&display_value)
243                                        } else {
244                                            display_value.clone()
245                                        };
246
247                                        let caret = if let Some(measurer) = ctx.measurer {
248                                            let local_point = Self::text_local_point_from_screen(
249                                                ctx,
250                                                scroll_id,
251                                                scroll_direction,
252                                                scroll_geom,
253                                                *point,
254                                            );
255
256                                            let masked_caret = Self::hit_test_text(
257                                                measurer,
258                                                ctx.ir,
259                                                focused_id,
260                                                sem.masked,
261                                                &metric_text,
262                                                scroll_geom,
263                                                local_point.x,
264                                                local_point.y,
265                                            );
266                                            if sem.masked {
267                                                Self::source_byte_offset_from_masked(
268                                                    &display_value,
269                                                    &metric_text,
270                                                    masked_caret,
271                                                )
272                                            } else {
273                                                masked_caret
274                                            }
275                                        } else {
276                                            let font_size =
277                                                Self::extract_font_size(ctx.ir, focused_id)
278                                                    .unwrap_or(13.0);
279                                            Self::caret_from_point_in_text_fallback(
280                                                &display_value,
281                                                font_size,
282                                                scroll_geom.rect.origin.x,
283                                                scroll_geom.rect.size.width,
284                                                scroll_geom.content_size.width,
285                                                ctx.scroll.get_offset(scroll_id),
286                                                point.x,
287                                            )
288                                        };
289                                        let anchor = {
290                                            let st = ctx.text_edit.get_mut_or_default(focused_id);
291                                            st.caret = caret;
292                                            if !Self::has_shift(*modifiers) {
293                                                st.anchor = caret;
294                                            }
295                                            st.anchor
296                                        };
297                                        Self::dispatch_cursor_change(
298                                            ctx, sem, focused_id, caret, anchor,
299                                        );
300                                        Self::sync_text_input_affordances(
301                                            ctx, focused_id, sem, value, false, None,
302                                        );
303                                    }
304                                }
305                                return true;
306                            }
307                        }
308                    }
309                }
310
311                false
312            }
313            InputEvent::Pointer(PointerEvent::Move { point, .. }) => {
314                if let Some(focused_id) = ctx.interaction.focused {
315                    if let Some(node) = ctx.ir.nodes.get(&focused_id) {
316                        if let Op::Semantics(sem) = &node.op {
317                            if sem.role == fission_ir::semantics::Role::TextInput {
318                                let active_handle = ctx
319                                    .text_edit
320                                    .states
321                                    .get(&focused_id)
322                                    .and_then(|state| state.affordances.active_handle);
323                                if let Some(active_handle) = active_handle {
324                                    if let Some((scroll_id, _text_op_node_id, scroll_direction)) =
325                                        Self::find_scroll_container_and_text_op(
326                                            ctx.ir,
327                                            focused_id,
328                                            sem.multiline,
329                                        )
330                                    {
331                                        if let Some(scroll_geom) =
332                                            ctx.layout.get_node_geometry(scroll_id)
333                                        {
334                                            let value = sem.value.as_deref().unwrap_or("");
335                                            let display_value = Self::display_value_for_metrics(
336                                                ctx, focused_id, value,
337                                            );
338                                            let metric_text = if sem.masked {
339                                                Self::mask_text_for_metrics(&display_value)
340                                            } else {
341                                                display_value.clone()
342                                            };
343                                            let new_caret = if let Some(measurer) = ctx.measurer {
344                                                let local_point =
345                                                    Self::text_local_point_from_screen(
346                                                        ctx,
347                                                        scroll_id,
348                                                        scroll_direction,
349                                                        scroll_geom,
350                                                        *point,
351                                                    );
352                                                let masked_caret = Self::hit_test_text(
353                                                    measurer,
354                                                    ctx.ir,
355                                                    focused_id,
356                                                    sem.masked,
357                                                    &metric_text,
358                                                    scroll_geom,
359                                                    local_point.x,
360                                                    local_point.y,
361                                                );
362                                                if sem.masked {
363                                                    Self::source_byte_offset_from_masked(
364                                                        &display_value,
365                                                        &metric_text,
366                                                        masked_caret,
367                                                    )
368                                                } else {
369                                                    masked_caret
370                                                }
371                                            } else {
372                                                0
373                                            };
374                                            let (caret, anchor) = {
375                                                let st =
376                                                    ctx.text_edit.get_mut_or_default(focused_id);
377                                                match active_handle {
378                                                    TextSelectionHandleKind::Caret => {
379                                                        st.caret = new_caret;
380                                                        st.anchor = new_caret;
381                                                    }
382                                                    TextSelectionHandleKind::Start => {
383                                                        if st.caret <= st.anchor {
384                                                            st.caret = new_caret;
385                                                        } else {
386                                                            st.anchor = new_caret;
387                                                        }
388                                                    }
389                                                    TextSelectionHandleKind::End => {
390                                                        if st.caret >= st.anchor {
391                                                            st.caret = new_caret;
392                                                        } else {
393                                                            st.anchor = new_caret;
394                                                        }
395                                                    }
396                                                }
397                                                (st.caret, st.anchor)
398                                            };
399                                            Self::auto_scroll_textinput(ctx, focused_id);
400                                            Self::dispatch_cursor_change(
401                                                ctx, sem, focused_id, caret, anchor,
402                                            );
403                                            Self::sync_text_input_affordances(
404                                                ctx, focused_id, sem, value, false, None,
405                                            );
406                                        }
407                                    }
408                                    return true;
409                                }
410
411                                if ctx.interaction.is_pressed(focused_id) {
412                                    let moved_enough =
413                                        match Self::drag_start_behavior(ctx, focused_id) {
414                                            DragStartBehavior::Down => true,
415                                            DragStartBehavior::Start => {
416                                                let mut moved_enough = true;
417                                                if let Some(start) = ctx.interaction.last_down_point
418                                                {
419                                                    let dx = point.x - start.x;
420                                                    let dy = point.y - start.y;
421                                                    if dx * dx + dy * dy < 4.0 {
422                                                        moved_enough = false;
423                                                    }
424                                                }
425                                                moved_enough
426                                            }
427                                        };
428                                    if moved_enough {
429                                        if let Some((
430                                            scroll_id,
431                                            _text_op_node_id,
432                                            scroll_direction,
433                                        )) = Self::find_scroll_container_and_text_op(
434                                            ctx.ir,
435                                            focused_id,
436                                            sem.multiline,
437                                        ) {
438                                            if let Some(scroll_geom) =
439                                                ctx.layout.get_node_geometry(scroll_id)
440                                            {
441                                                let value = sem.value.as_deref().unwrap_or("");
442                                                let display_value = Self::display_value_for_metrics(
443                                                    ctx, focused_id, value,
444                                                );
445                                                let metric_text = if sem.masked {
446                                                    Self::mask_text_for_metrics(&display_value)
447                                                } else {
448                                                    display_value.clone()
449                                                };
450                                                let new_caret = if let Some(measurer) = ctx.measurer
451                                                {
452                                                    let local_point =
453                                                        Self::text_local_point_from_screen(
454                                                            ctx,
455                                                            scroll_id,
456                                                            scroll_direction,
457                                                            scroll_geom,
458                                                            *point,
459                                                        );
460
461                                                    let masked_caret = Self::hit_test_text(
462                                                        measurer,
463                                                        ctx.ir,
464                                                        focused_id,
465                                                        sem.masked,
466                                                        &metric_text,
467                                                        scroll_geom,
468                                                        local_point.x,
469                                                        local_point.y,
470                                                    );
471                                                    if sem.masked {
472                                                        Self::source_byte_offset_from_masked(
473                                                            &display_value,
474                                                            &metric_text,
475                                                            masked_caret,
476                                                        )
477                                                    } else {
478                                                        masked_caret
479                                                    }
480                                                } else {
481                                                    let font_size =
482                                                        Self::extract_font_size(ctx.ir, focused_id)
483                                                            .unwrap_or(13.0);
484                                                    Self::caret_from_point_in_text_fallback(
485                                                        &display_value,
486                                                        font_size,
487                                                        scroll_geom.rect.origin.x,
488                                                        scroll_geom.rect.size.width,
489                                                        scroll_geom.content_size.width,
490                                                        ctx.scroll.get_offset(scroll_id),
491                                                        point.x,
492                                                    )
493                                                };
494                                                let st =
495                                                    ctx.text_edit.get_mut_or_default(focused_id);
496                                                st.caret = new_caret;
497                                                let current_anchor = st.anchor;
498                                                Self::auto_scroll_textinput(ctx, focused_id);
499                                                Self::dispatch_cursor_change(
500                                                    ctx,
501                                                    sem,
502                                                    focused_id,
503                                                    new_caret,
504                                                    current_anchor,
505                                                );
506                                                Self::sync_text_input_affordances(
507                                                    ctx, focused_id, sem, value, false, None,
508                                                );
509                                            }
510                                        }
511                                    }
512                                }
513                                return true;
514                            }
515                        }
516                    }
517                }
518
519                false
520            }
521            InputEvent::Pointer(PointerEvent::Up { point, button, .. }) => {
522                if let Some(focused_id) = ctx.interaction.focused {
523                    if let Some(node) = ctx.ir.nodes.get(&focused_id) {
524                        if let Op::Semantics(sem) = &node.op {
525                            if sem.role == fission_ir::semantics::Role::TextInput {
526                                let value = sem.value.as_deref().unwrap_or("").to_string();
527                                let toolbar_anchor = Self::input_wrapper_geometry(ctx, focused_id)
528                                    .map(|geom| {
529                                        fission_layout::LayoutPoint::new(
530                                            (point.x - geom.rect.origin.x).max(0.0),
531                                            (point.y - geom.rect.origin.y).max(0.0),
532                                        )
533                                    });
534                                let show_toolbar =
535                                    matches!(button, crate::event::PointerButton::Secondary)
536                                        || ctx
537                                            .text_edit
538                                            .states
539                                            .get(&focused_id)
540                                            .map(|state| state.caret != state.anchor)
541                                            .unwrap_or(false);
542                                if let Some(state) = ctx.text_edit.states.get_mut(&focused_id) {
543                                    state.affordances.active_handle = None;
544                                    state.affordances.magnifier_visible = false;
545                                }
546                                Self::sync_text_input_affordances(
547                                    ctx,
548                                    focused_id,
549                                    sem,
550                                    &value,
551                                    show_toolbar,
552                                    if show_toolbar { toolbar_anchor } else { None },
553                                );
554                                return true;
555                            }
556                        }
557                    }
558                }
559
560                false
561            }
562            _ => false,
563        }
564    }
565}
566
567impl TextInputController {
568    fn handle_key(
569        &mut self,
570        ctx: &mut ControllerContext,
571        key_code: KeyCode,
572        modifiers: u8,
573    ) -> bool {
574        let focused_id = if let Some(id) = ctx.interaction.focused {
575            id
576        } else {
577            return false;
578        };
579
580        let mut semantics_node = None;
581        let mut current_id = Some(focused_id);
582        while let Some(node_id) = current_id {
583            if let Some(node) = ctx.ir.nodes.get(&node_id) {
584                if let Op::Semantics(s) = &node.op {
585                    if s.role == fission_ir::semantics::Role::TextInput {
586                        semantics_node = Some(s);
587                        break;
588                    }
589                }
590                current_id = node.parent;
591            } else {
592                break;
593            }
594        }
595
596        let semantics = if let Some(s) = semantics_node {
597            s
598        } else {
599            return false;
600        };
601
602        let (value, mut caret, mut anchor) =
603            Self::resolve_editing_value(ctx, focused_id, semantics.value.as_deref().unwrap_or(""));
604        if let Some(st) = ctx.text_edit.states.get_mut(&focused_id) {
605            st.clear_preedit();
606        }
607
608        caret = Self::clamp_caret_to_value(&value, caret);
609        anchor = Self::clamp_caret_to_value(&value, anchor);
610
611        let sel = if caret != anchor {
612            Some((caret.min(anchor), caret.max(anchor)))
613        } else {
614            None
615        };
616
617        // Logic for state changes
618        let mut next_caret = caret;
619        let mut next_anchor = anchor;
620        let mut next_edit: Option<(std::ops::Range<usize>, String)> = None;
621        let mut handled = false;
622
623        // Undo/Redo logic result
624        let mut undo_redo_result: Option<(String, usize, usize)> = None;
625        let read_only = semantics.read_only;
626        let disabled = semantics.disabled;
627        let is_apple = Self::is_apple_platform();
628        let shift = Self::has_shift(modifiers);
629        let primary_shortcut = Self::has_primary_shortcut(modifiers);
630        let word_modifier = Self::has_word_modifier(modifiers);
631
632        if disabled {
633            return false;
634        }
635
636        match key_code {
637            KeyCode::Space => {
638                if read_only {
639                    handled = true;
640                } else {
641                    let (s, e) = sel.unwrap_or((caret, caret));
642                    if let Some(inserted) =
643                        Self::prepare_inserted_text(semantics, &value, s, e, " ")
644                    {
645                        next_caret = s + inserted.len();
646                        next_anchor = next_caret;
647                        next_edit = Some((s..e, inserted));
648                    }
649                    handled = true;
650                }
651            }
652            KeyCode::Char(ch) => {
653                let lower = ch.to_ascii_lowercase();
654                if primary_shortcut {
655                    let (s, e) = sel.unwrap_or((caret, caret));
656                    match lower {
657                        'a' => {
658                            next_caret = value.len();
659                            next_anchor = 0;
660                            handled = true;
661                        }
662                        'c' => {
663                            if s != e {
664                                let txt = value[s..e].to_string();
665                                if let Some(cb) = ctx.clipboard {
666                                    cb.set_text(&txt);
667                                }
668                            }
669                            handled = true;
670                        }
671                        'x' => {
672                            if s != e {
673                                let txt = value[s..e].to_string();
674                                if let Some(cb) = ctx.clipboard {
675                                    cb.set_text(&txt);
676                                }
677                                if !read_only {
678                                    next_edit = Some((s..e, String::new()));
679                                    next_caret = s;
680                                    next_anchor = s;
681                                }
682                            }
683                            handled = true;
684                        }
685                        'v' => {
686                            handled = true;
687                            if !read_only {
688                                let text_to_paste = if let Some(cb) = ctx.clipboard {
689                                    cb.get_text().unwrap_or_default()
690                                } else {
691                                    String::new()
692                                };
693                                if !text_to_paste.is_empty() {
694                                    if let Some(inserted) = Self::prepare_inserted_text(
695                                        semantics,
696                                        &value,
697                                        s,
698                                        e,
699                                        &text_to_paste,
700                                    ) {
701                                        next_caret = s + inserted.len();
702                                        next_anchor = next_caret;
703                                        next_edit = Some((s..e, inserted));
704                                    }
705                                }
706                            }
707                        }
708                        'z' => {
709                            let st = ctx.text_edit.get_mut_or_default(focused_id);
710                            if shift {
711                                if let Some((v, c, a)) = st.redo() {
712                                    undo_redo_result = Some((v, c, a));
713                                }
714                            } else if let Some((v, c, a)) = st.undo() {
715                                undo_redo_result = Some((v, c, a));
716                            }
717                            handled = true;
718                        }
719                        'y' if !is_apple => {
720                            let st = ctx.text_edit.get_mut_or_default(focused_id);
721                            if let Some((v, c, a)) = st.redo() {
722                                undo_redo_result = Some((v, c, a));
723                            }
724                            handled = true;
725                        }
726                        _ => {}
727                    }
728                    if handled {
729                        // Skip plain text insertion when a primary shortcut matched.
730                    }
731                }
732
733                if !handled
734                    && is_apple
735                    && Self::has_ctrl(modifiers)
736                    && !Self::has_alt(modifiers)
737                    && !Self::has_super(modifiers)
738                {
739                    match lower {
740                        'a' => {
741                            let (line_start, _) = Self::current_line_bounds(
742                                ctx, focused_id, semantics, &value, caret,
743                            );
744                            next_caret = line_start;
745                            next_anchor = if shift { anchor } else { line_start };
746                            handled = true;
747                        }
748                        'e' => {
749                            let (_, line_end) = Self::current_line_bounds(
750                                ctx, focused_id, semantics, &value, caret,
751                            );
752                            next_caret = line_end;
753                            next_anchor = if shift { anchor } else { line_end };
754                            handled = true;
755                        }
756                        'f' => {
757                            let next = Self::next_grapheme_boundary(&value, caret);
758                            next_caret = next;
759                            next_anchor = if shift { anchor } else { next };
760                            handled = true;
761                        }
762                        'b' => {
763                            let prev = Self::prev_grapheme_boundary(&value, caret);
764                            next_caret = prev;
765                            next_anchor = if shift { anchor } else { prev };
766                            handled = true;
767                        }
768                        'n' if semantics.multiline => {
769                            self.handle_vertical_navigation(
770                                ctx, focused_id, semantics, &value, caret, modifiers, false,
771                            );
772                            return true;
773                        }
774                        'p' if semantics.multiline => {
775                            self.handle_vertical_navigation(
776                                ctx, focused_id, semantics, &value, caret, modifiers, true,
777                            );
778                            return true;
779                        }
780                        'h' => {
781                            handled = true;
782                            if !read_only {
783                                let (s, e) = sel.unwrap_or_else(|| {
784                                    if caret == 0 {
785                                        (0, 0)
786                                    } else {
787                                        (Self::prev_grapheme_boundary(&value, caret), caret)
788                                    }
789                                });
790                                next_edit = Some((s..e, String::new()));
791                                next_caret = s;
792                                next_anchor = s;
793                            }
794                        }
795                        'd' => {
796                            handled = true;
797                            if !read_only {
798                                let (s, e) = sel.unwrap_or_else(|| {
799                                    let next = Self::next_grapheme_boundary(&value, caret);
800                                    (caret, next)
801                                });
802                                next_edit = Some((s..e, String::new()));
803                                next_caret = s;
804                                next_anchor = s;
805                            }
806                        }
807                        _ => {}
808                    }
809                }
810
811                if !handled {
812                    if read_only {
813                        handled = true;
814                    } else {
815                        let (s, e) = sel.unwrap_or((caret, caret));
816                        if let Some(inserted) =
817                            Self::prepare_inserted_text(semantics, &value, s, e, &ch.to_string())
818                        {
819                            next_caret = s + inserted.len();
820                            next_anchor = next_caret;
821                            next_edit = Some((s..e, inserted));
822                        }
823                        handled = true;
824                    }
825                }
826            }
827            KeyCode::Backspace => {
828                handled = true;
829                if !read_only {
830                    let (s, e) = if let Some((s, e)) = sel {
831                        (s, e)
832                    } else if is_apple && Self::has_super(modifiers) {
833                        let (line_start, _) =
834                            Self::current_line_bounds(ctx, focused_id, semantics, &value, caret);
835                        (line_start, caret)
836                    } else if word_modifier {
837                        (Self::prev_word_boundary(&value, caret), caret)
838                    } else if caret == 0 {
839                        (0, 0)
840                    } else {
841                        (Self::prev_grapheme_boundary(&value, caret), caret)
842                    };
843                    next_edit = Some((s..e, String::new()));
844                    next_caret = s;
845                    next_anchor = s;
846                }
847            }
848            KeyCode::Delete => {
849                handled = true;
850                if !read_only {
851                    let (s, e) = if let Some((s, e)) = sel {
852                        (s, e)
853                    } else if is_apple && Self::has_super(modifiers) {
854                        let (_, line_end) =
855                            Self::current_line_bounds(ctx, focused_id, semantics, &value, caret);
856                        (caret, line_end)
857                    } else if word_modifier {
858                        (caret, Self::next_word_boundary(&value, caret))
859                    } else {
860                        let next = Self::next_grapheme_boundary(&value, caret);
861                        (caret, next)
862                    };
863                    next_edit = Some((s..e, String::new()));
864                    next_caret = s;
865                    next_anchor = s;
866                }
867            }
868            KeyCode::Left => {
869                let prev = if let Some((s, _)) = sel {
870                    if !shift && !word_modifier && !(is_apple && Self::has_super(modifiers)) {
871                        s
872                    } else if is_apple && Self::has_super(modifiers) {
873                        Self::current_line_bounds(ctx, focused_id, semantics, &value, caret).0
874                    } else if word_modifier {
875                        Self::prev_word_boundary(&value, caret)
876                    } else {
877                        Self::prev_grapheme_boundary(&value, caret)
878                    }
879                } else if is_apple && Self::has_super(modifiers) {
880                    Self::current_line_bounds(ctx, focused_id, semantics, &value, caret).0
881                } else if word_modifier {
882                    Self::prev_word_boundary(&value, caret)
883                } else {
884                    Self::prev_grapheme_boundary(&value, caret)
885                };
886                next_caret = prev;
887                next_anchor = if shift { anchor } else { prev };
888                handled = true;
889            }
890            KeyCode::Right => {
891                let next = if let Some((_, e)) = sel {
892                    if !shift && !word_modifier && !(is_apple && Self::has_super(modifiers)) {
893                        e
894                    } else if is_apple && Self::has_super(modifiers) {
895                        Self::current_line_bounds(ctx, focused_id, semantics, &value, caret).1
896                    } else if word_modifier {
897                        Self::next_word_boundary(&value, caret)
898                    } else {
899                        Self::next_grapheme_boundary(&value, caret)
900                    }
901                } else if is_apple && Self::has_super(modifiers) {
902                    Self::current_line_bounds(ctx, focused_id, semantics, &value, caret).1
903                } else if word_modifier {
904                    Self::next_word_boundary(&value, caret)
905                } else {
906                    Self::next_grapheme_boundary(&value, caret)
907                };
908                next_caret = next;
909                next_anchor = if shift { anchor } else { next };
910                handled = true;
911            }
912            KeyCode::Home => {
913                next_caret = if semantics.multiline && !(Self::has_ctrl(modifiers) && !is_apple) {
914                    Self::current_line_bounds(ctx, focused_id, semantics, &value, caret).0
915                } else {
916                    0
917                };
918                next_anchor = if shift { anchor } else { next_caret };
919                handled = true;
920            }
921            KeyCode::End => {
922                next_caret = if semantics.multiline && !(Self::has_ctrl(modifiers) && !is_apple) {
923                    Self::current_line_bounds(ctx, focused_id, semantics, &value, caret).1
924                } else {
925                    value.len()
926                };
927                next_anchor = if shift { anchor } else { next_caret };
928                handled = true;
929            }
930            KeyCode::Enter => {
931                if semantics.multiline {
932                    handled = true;
933                    if !read_only {
934                        let insert_str = if semantics.auto_indent {
935                            let line_start = value[..caret].rfind('\n').map(|p| p + 1).unwrap_or(0);
936                            let leading: String = value[line_start..]
937                                .chars()
938                                .take_while(|c| *c == ' ' || *c == '\t')
939                                .collect();
940                            format!("\n{}", leading)
941                        } else {
942                            "\n".to_string()
943                        };
944                        let (s, e) = sel.unwrap_or((caret, caret));
945                        if let Some(inserted) =
946                            Self::prepare_inserted_text(semantics, &value, s, e, &insert_str)
947                        {
948                            next_caret = s + inserted.len();
949                            next_anchor = next_caret;
950                            next_edit = Some((s..e, inserted));
951                        }
952                    }
953                } else if Self::dispatch_submit(ctx, semantics, focused_id, &value) {
954                    return true;
955                }
956            }
957            KeyCode::Up => {
958                if is_apple && Self::has_super(modifiers) {
959                    next_caret = 0;
960                    next_anchor = if shift { anchor } else { 0 };
961                    handled = true;
962                } else if semantics.multiline {
963                    self.handle_vertical_navigation(
964                        ctx, focused_id, semantics, &value, caret, modifiers, true,
965                    );
966                    return true;
967                }
968            }
969            KeyCode::Down => {
970                if is_apple && Self::has_super(modifiers) {
971                    next_caret = value.len();
972                    next_anchor = if shift { anchor } else { value.len() };
973                    handled = true;
974                } else if semantics.multiline {
975                    self.handle_vertical_navigation(
976                        ctx, focused_id, semantics, &value, caret, modifiers, false,
977                    );
978                    return true;
979                }
980            }
981            KeyCode::PageUp => {
982                if semantics.multiline {
983                    self.handle_page_navigation(
984                        ctx, focused_id, semantics, &value, caret, modifiers, true,
985                    );
986                    return true;
987                }
988            }
989            KeyCode::PageDown => {
990                if semantics.multiline {
991                    self.handle_page_navigation(
992                        ctx, focused_id, semantics, &value, caret, modifiers, false,
993                    );
994                    return true;
995                }
996            }
997            KeyCode::Tab => {
998                if semantics.capture_tab {
999                    handled = true;
1000                    if !read_only {
1001                        let tab_str = "    ";
1002                        let (s, e) = sel.unwrap_or((caret, caret));
1003                        if let Some(inserted) =
1004                            Self::prepare_inserted_text(semantics, &value, s, e, tab_str)
1005                        {
1006                            next_caret = s + inserted.len();
1007                            next_anchor = next_caret;
1008                            next_edit = Some((s..e, inserted));
1009                        }
1010                    }
1011                }
1012            }
1013            _ => {}
1014        }
1015
1016        if let Some((v, c, a)) = undo_redo_result {
1017            // Apply undo/redo result
1018            self.dispatch_change(ctx, semantics, focused_id, v);
1019            Self::dispatch_cursor_change(ctx, semantics, focused_id, c, a);
1020            Self::sync_text_input_affordances(
1021                ctx,
1022                focused_id,
1023                semantics,
1024                value.as_str(),
1025                false,
1026                None,
1027            );
1028            return true;
1029        }
1030
1031        if let Some((range, replacement)) = next_edit {
1032            // Apply text change
1033            let st = ctx.text_edit.get_mut_or_default(focused_id);
1034            let txt = st.apply_edit(range, &replacement, next_caret, next_anchor);
1035            self.dispatch_change(ctx, semantics, focused_id, txt);
1036            Self::dispatch_cursor_change(ctx, semantics, focused_id, next_caret, next_anchor);
1037            Self::sync_text_input_affordances(
1038                ctx,
1039                focused_id,
1040                semantics,
1041                value.as_str(),
1042                false,
1043                None,
1044            );
1045        } else if handled {
1046            // Cursor movement only
1047            let st = ctx.text_edit.get_mut_or_default(focused_id);
1048            st.caret = next_caret;
1049            st.anchor = next_anchor;
1050            st.clear_preedit();
1051            Self::auto_scroll_textinput(ctx, focused_id);
1052            Self::dispatch_cursor_change(ctx, semantics, focused_id, next_caret, next_anchor);
1053            Self::sync_text_input_affordances(
1054                ctx,
1055                focused_id,
1056                semantics,
1057                value.as_str(),
1058                false,
1059                None,
1060            );
1061        }
1062
1063        handled
1064    }
1065
1066    fn is_apple_platform() -> bool {
1067        cfg!(target_os = "macos") || cfg!(target_os = "ios")
1068    }
1069
1070    fn runtime_config(
1071        ctx: &ControllerContext,
1072        focused_id: WidgetId,
1073    ) -> Option<crate::ui::widgets::text_input::TextInputRuntimeConfig> {
1074        ctx.ir
1075            .custom_render_objects
1076            .get(&focused_id)
1077            .and_then(downcast_text_input_runtime_config)
1078            .cloned()
1079    }
1080
1081    fn drag_start_behavior(ctx: &ControllerContext, focused_id: WidgetId) -> DragStartBehavior {
1082        Self::runtime_config(ctx, focused_id)
1083            .map(|cfg| cfg.drag_start_behavior)
1084            .unwrap_or_default()
1085    }
1086
1087    fn sync_runtime_state(ctx: &mut ControllerContext, focused_id: WidgetId, semantic_value: &str) {
1088        let runtime = Self::runtime_config(ctx, focused_id);
1089        ctx.text_edit.sync_from_runtime(
1090            focused_id,
1091            semantic_value,
1092            runtime
1093                .as_ref()
1094                .and_then(|cfg| cfg.restoration_id.as_deref()),
1095            runtime
1096                .as_ref()
1097                .and_then(|cfg| cfg.undo_controller.as_ref().map(|undo| undo.capacity)),
1098        );
1099    }
1100
1101    fn persist_runtime_state(ctx: &mut ControllerContext, focused_id: WidgetId) {
1102        let runtime = Self::runtime_config(ctx, focused_id);
1103        ctx.text_edit.persist_restoration(
1104            focused_id,
1105            runtime
1106                .as_ref()
1107                .and_then(|cfg| cfg.restoration_id.as_deref()),
1108        );
1109    }
1110
1111    fn has_shift(modifiers: u8) -> bool {
1112        (modifiers & MOD_SHIFT) != 0
1113    }
1114
1115    fn has_alt(modifiers: u8) -> bool {
1116        (modifiers & MOD_ALT) != 0
1117    }
1118
1119    fn has_ctrl(modifiers: u8) -> bool {
1120        (modifiers & MOD_CTRL) != 0
1121    }
1122
1123    fn has_super(modifiers: u8) -> bool {
1124        (modifiers & MOD_SUPER) != 0
1125    }
1126
1127    fn has_primary_shortcut(modifiers: u8) -> bool {
1128        if Self::is_apple_platform() {
1129            Self::has_super(modifiers)
1130        } else {
1131            Self::has_ctrl(modifiers)
1132        }
1133    }
1134
1135    fn has_word_modifier(modifiers: u8) -> bool {
1136        if Self::is_apple_platform() {
1137            Self::has_alt(modifiers)
1138        } else {
1139            Self::has_ctrl(modifiers)
1140        }
1141    }
1142
1143    fn primary_shortcut_modifier() -> u8 {
1144        if Self::is_apple_platform() {
1145            MOD_SUPER
1146        } else {
1147            MOD_CTRL
1148        }
1149    }
1150
1151    fn node_or_ancestor_matches(
1152        ir: &fission_ir::CoreIR,
1153        node_id: WidgetId,
1154        expected: WidgetId,
1155    ) -> bool {
1156        let mut current = Some(node_id);
1157        while let Some(id) = current {
1158            if id == expected {
1159                return true;
1160            }
1161            current = ir.nodes.get(&id).and_then(|node| node.parent);
1162        }
1163        false
1164    }
1165
1166    fn toolbar_action_hit(
1167        ir: &fission_ir::CoreIR,
1168        focused_id: WidgetId,
1169        hit_node_id: WidgetId,
1170    ) -> Option<TextContextMenuAction> {
1171        for action in [
1172            TextContextMenuAction::Copy,
1173            TextContextMenuAction::Cut,
1174            TextContextMenuAction::Paste,
1175            TextContextMenuAction::SelectAll,
1176        ] {
1177            if Self::node_or_ancestor_matches(
1178                ir,
1179                hit_node_id,
1180                text_input_toolbar_button_id(focused_id, action),
1181            ) {
1182                return Some(action);
1183            }
1184        }
1185        None
1186    }
1187
1188    fn selection_handle_hit(
1189        ir: &fission_ir::CoreIR,
1190        focused_id: WidgetId,
1191        hit_node_id: WidgetId,
1192    ) -> Option<TextSelectionHandleKind> {
1193        for kind in [
1194            TextSelectionHandleKind::Caret,
1195            TextSelectionHandleKind::Start,
1196            TextSelectionHandleKind::End,
1197        ] {
1198            if Self::node_or_ancestor_matches(
1199                ir,
1200                hit_node_id,
1201                text_input_selection_handle_id(focused_id, kind),
1202            ) {
1203                return Some(kind);
1204            }
1205        }
1206        None
1207    }
1208
1209    fn execute_toolbar_action(
1210        &mut self,
1211        ctx: &mut ControllerContext,
1212        action: TextContextMenuAction,
1213    ) -> bool {
1214        match action {
1215            TextContextMenuAction::Copy => {
1216                self.handle_key(ctx, KeyCode::Char('c'), Self::primary_shortcut_modifier())
1217            }
1218            TextContextMenuAction::Cut => {
1219                self.handle_key(ctx, KeyCode::Char('x'), Self::primary_shortcut_modifier())
1220            }
1221            TextContextMenuAction::Paste => {
1222                self.handle_key(ctx, KeyCode::Char('v'), Self::primary_shortcut_modifier())
1223            }
1224            TextContextMenuAction::SelectAll => {
1225                self.handle_key(ctx, KeyCode::Char('a'), Self::primary_shortcut_modifier())
1226            }
1227        }
1228    }
1229
1230    fn input_wrapper_geometry<'a>(
1231        ctx: &'a ControllerContext<'_>,
1232        focused_id: WidgetId,
1233    ) -> Option<&'a fission_layout::LayoutNodeGeometry> {
1234        let wrapper_id = ctx.ir.nodes.get(&focused_id)?.children.first().copied()?;
1235        ctx.layout.get_node_geometry(wrapper_id)
1236    }
1237
1238    fn text_local_point_from_screen(
1239        ctx: &ControllerContext<'_>,
1240        scroll_id: WidgetId,
1241        scroll_direction: FlexDirection,
1242        scroll_geom: &fission_layout::LayoutNodeGeometry,
1243        point: fission_layout::LayoutPoint,
1244    ) -> fission_layout::LayoutPoint {
1245        let mut ancestor_scroll_x = 0.0f32;
1246        let mut ancestor_scroll_y = 0.0f32;
1247        let mut walk = ctx.ir.nodes.get(&scroll_id).and_then(|node| node.parent);
1248        while let Some(parent_id) = walk {
1249            if let Some(parent_node) = ctx.ir.nodes.get(&parent_id) {
1250                if let Op::Layout(LayoutOp::Scroll { direction, .. }) = &parent_node.op {
1251                    let offset = ctx.scroll.get_offset(parent_id);
1252                    match direction {
1253                        FlexDirection::Row => ancestor_scroll_x += offset,
1254                        FlexDirection::Column => ancestor_scroll_y += offset,
1255                    }
1256                }
1257                walk = parent_node.parent;
1258            } else {
1259                break;
1260            }
1261        }
1262
1263        let own_scroll_offset = ctx.scroll.get_offset(scroll_id);
1264        let mut local_x = point.x - scroll_geom.rect.origin.x + ancestor_scroll_x;
1265        let mut local_y = point.y - scroll_geom.rect.origin.y + ancestor_scroll_y;
1266        match scroll_direction {
1267            FlexDirection::Row => local_x += own_scroll_offset,
1268            FlexDirection::Column => local_y += own_scroll_offset,
1269        }
1270
1271        fission_layout::LayoutPoint::new(local_x, local_y)
1272    }
1273
1274    fn line_metric_for_index<'a>(
1275        line_metrics: &'a [fission_layout::LineMetric],
1276        caret_index: usize,
1277    ) -> Option<(usize, &'a fission_layout::LineMetric)> {
1278        line_metrics
1279            .iter()
1280            .enumerate()
1281            .find(|(_, line)| caret_index >= line.start_index && caret_index <= line.end_index)
1282            .or_else(|| line_metrics.iter().enumerate().last())
1283    }
1284
1285    fn local_text_point_for_index(
1286        measurer: &std::sync::Arc<dyn fission_layout::TextMeasurer>,
1287        ir: &fission_ir::CoreIR,
1288        focused_id: WidgetId,
1289        wrapper_geom: &fission_layout::LayoutNodeGeometry,
1290        scroll_geom: &fission_layout::LayoutNodeGeometry,
1291        scroll_direction: FlexDirection,
1292        scroll_offset: f32,
1293        metric_text: &str,
1294        metric_index: usize,
1295    ) -> Option<fission_layout::LayoutPoint> {
1296        let font_size = Self::extract_font_size(ir, focused_id).unwrap_or(16.0);
1297        let paragraph = Self::extract_paragraph_style(ir, focused_id).unwrap_or_default();
1298        let render_width = if scroll_direction == FlexDirection::Column {
1299            Some(scroll_geom.rect.size.width)
1300        } else {
1301            None
1302        };
1303        let (mut caret_x, caret_y) =
1304            measurer.get_caret_position(metric_text, font_size, render_width, metric_index);
1305        let line_metrics = measurer.get_line_metrics(metric_text, font_size, render_width);
1306        let (line_index, line_metric) = Self::line_metric_for_index(&line_metrics, metric_index)?;
1307        let is_last_line = line_index + 1 == line_metrics.len();
1308        if let Some(width) = render_width {
1309            caret_x +=
1310                Self::paragraph_line_x_offset(paragraph, width, line_metric.width, is_last_line);
1311        }
1312
1313        let visible_x = if scroll_direction == FlexDirection::Row {
1314            caret_x - scroll_offset
1315        } else {
1316            caret_x
1317        };
1318        let visible_y = if scroll_direction == FlexDirection::Column {
1319            caret_y - scroll_offset
1320        } else {
1321            caret_y
1322        };
1323
1324        let local_x = (scroll_geom.rect.origin.x - wrapper_geom.rect.origin.x) + visible_x;
1325        let local_y = (scroll_geom.rect.origin.y - wrapper_geom.rect.origin.y)
1326            + visible_y
1327            + line_metric.height.max(1.0);
1328
1329        Some(fission_layout::LayoutPoint::new(local_x, local_y))
1330    }
1331
1332    fn clear_text_input_affordances(ctx: &mut ControllerContext, focused_id: WidgetId) {
1333        if let Some(state) = ctx.text_edit.states.get_mut(&focused_id) {
1334            state.affordances = Default::default();
1335        }
1336    }
1337
1338    fn sync_text_input_affordances(
1339        ctx: &mut ControllerContext,
1340        focused_id: WidgetId,
1341        semantics: &Semantics,
1342        value: &str,
1343        toolbar_visible: bool,
1344        toolbar_anchor_override: Option<fission_layout::LayoutPoint>,
1345    ) {
1346        let Some(measurer) = ctx.measurer else {
1347            Self::clear_text_input_affordances(ctx, focused_id);
1348            return;
1349        };
1350        let Some(wrapper_geom) = Self::input_wrapper_geometry(ctx, focused_id).cloned() else {
1351            Self::clear_text_input_affordances(ctx, focused_id);
1352            return;
1353        };
1354        let Some((scroll_id, _text_node_id, scroll_direction)) =
1355            Self::find_scroll_container_and_text_op(ctx.ir, focused_id, semantics.multiline)
1356        else {
1357            Self::clear_text_input_affordances(ctx, focused_id);
1358            return;
1359        };
1360        let Some(scroll_geom) = ctx.layout.get_node_geometry(scroll_id).cloned() else {
1361            Self::clear_text_input_affordances(ctx, focused_id);
1362            return;
1363        };
1364
1365        let display_value = Self::display_value_for_metrics(
1366            ctx,
1367            focused_id,
1368            semantics.value.as_deref().unwrap_or(value),
1369        );
1370        let metric_text = if semantics.masked {
1371            Self::mask_text_for_metrics(&display_value)
1372        } else {
1373            display_value.clone()
1374        };
1375        let (caret, anchor, active_handle) = {
1376            let state = ctx.text_edit.get_mut_or_default(focused_id);
1377            (state.caret, state.anchor, state.affordances.active_handle)
1378        };
1379
1380        let map_metric_index = |index: usize| {
1381            if semantics.masked {
1382                Self::masked_byte_offset_from_source(&display_value, &metric_text, index)
1383            } else {
1384                index.min(metric_text.len())
1385            }
1386        };
1387
1388        let scroll_offset = ctx.scroll.get_offset(scroll_id);
1389        let caret_point = Self::local_text_point_for_index(
1390            measurer,
1391            ctx.ir,
1392            focused_id,
1393            &wrapper_geom,
1394            &scroll_geom,
1395            scroll_direction,
1396            scroll_offset,
1397            &metric_text,
1398            map_metric_index(caret),
1399        );
1400        let anchor_point = Self::local_text_point_for_index(
1401            measurer,
1402            ctx.ir,
1403            focused_id,
1404            &wrapper_geom,
1405            &scroll_geom,
1406            scroll_direction,
1407            scroll_offset,
1408            &metric_text,
1409            map_metric_index(anchor),
1410        );
1411
1412        let selection_range = if caret == anchor {
1413            None
1414        } else {
1415            Some((caret.min(anchor), caret.max(anchor)))
1416        };
1417
1418        let toolbar_anchor = if let Some(override_point) = toolbar_anchor_override {
1419            Some(override_point)
1420        } else {
1421            match (caret_point, anchor_point, selection_range) {
1422                (Some(caret_point), Some(anchor_point), Some(_)) => {
1423                    Some(fission_layout::LayoutPoint::new(
1424                        (caret_point.x + anchor_point.x) * 0.5,
1425                        caret_point.y.min(anchor_point.y),
1426                    ))
1427                }
1428                (Some(point), _, None) => Some(point),
1429                _ => None,
1430            }
1431        };
1432
1433        let state = ctx.text_edit.get_mut_or_default(focused_id);
1434        state.affordances.toolbar_visible = toolbar_visible;
1435        state.affordances.toolbar_anchor = toolbar_anchor;
1436        state.affordances.magnifier_visible = active_handle.is_some();
1437        state.affordances.magnifier_anchor = match active_handle {
1438            Some(TextSelectionHandleKind::Caret) => caret_point,
1439            Some(TextSelectionHandleKind::Start) => anchor_point,
1440            Some(TextSelectionHandleKind::End) => caret_point,
1441            None => None,
1442        };
1443        if selection_range.is_some() {
1444            let (start_point, end_point) = if caret <= anchor {
1445                (caret_point, anchor_point)
1446            } else {
1447                (anchor_point, caret_point)
1448            };
1449            state.affordances.caret_handle = None;
1450            state.affordances.selection_start_handle = start_point;
1451            state.affordances.selection_end_handle = end_point;
1452        } else {
1453            state.affordances.caret_handle = caret_point;
1454            state.affordances.selection_start_handle = None;
1455            state.affordances.selection_end_handle = None;
1456        }
1457    }
1458
1459    fn trim_line_end(value: &str, end: usize) -> usize {
1460        let end = end.min(value.len());
1461        if end > 0 && value.as_bytes()[end - 1] == b'\n' {
1462            end - 1
1463        } else {
1464            end
1465        }
1466    }
1467
1468    fn current_line_bounds(
1469        ctx: &ControllerContext,
1470        focused_id: WidgetId,
1471        semantics: &Semantics,
1472        value: &str,
1473        caret: usize,
1474    ) -> (usize, usize) {
1475        let caret = caret.min(value.len());
1476        if semantics.multiline {
1477            if let Some(measurer) = ctx.measurer {
1478                if let Some((scroll_id, _text_op_node_id, _scroll_direction)) =
1479                    Self::find_scroll_container_and_text_op(ctx.ir, focused_id, semantics.multiline)
1480                {
1481                    if let Some(scroll_geom) = ctx.layout.get_node_geometry(scroll_id) {
1482                        let font_size = Self::extract_font_size(ctx.ir, focused_id).unwrap_or(16.0);
1483                        let line_metrics = measurer.get_line_metrics(
1484                            value,
1485                            font_size,
1486                            Some(scroll_geom.rect.size.width),
1487                        );
1488                        if let Some(line) = line_metrics
1489                            .iter()
1490                            .find(|line| caret >= line.start_index && caret <= line.end_index)
1491                            .or_else(|| line_metrics.last())
1492                        {
1493                            let start = line.start_index.min(value.len());
1494                            let end = Self::trim_line_end(value, line.end_index);
1495                            return (start.min(end), end);
1496                        }
1497                    }
1498                }
1499            }
1500
1501            let start = value[..caret].rfind('\n').map(|pos| pos + 1).unwrap_or(0);
1502            let end = value[caret..]
1503                .find('\n')
1504                .map(|offset| caret + offset)
1505                .unwrap_or(value.len());
1506            (start.min(end), end)
1507        } else {
1508            (0, value.len())
1509        }
1510    }
1511
1512    fn truncate_to_chars(text: &str, max_chars: usize) -> String {
1513        text.chars().take(max_chars).collect()
1514    }
1515
1516    fn apply_text_capitalization(mode: TextCapitalization, prefix: &str, inserted: &str) -> String {
1517        match mode {
1518            TextCapitalization::None => inserted.to_string(),
1519            TextCapitalization::Characters => inserted.to_uppercase(),
1520            TextCapitalization::Words => {
1521                let starts_new_word = prefix
1522                    .chars()
1523                    .next_back()
1524                    .map(|ch| ch.is_whitespace() || ch.is_ascii_punctuation())
1525                    .unwrap_or(true);
1526                if starts_new_word {
1527                    let mut chars = inserted.chars();
1528                    if let Some(first) = chars.next() {
1529                        let mut out = first.to_uppercase().to_string();
1530                        out.push_str(chars.as_str());
1531                        out
1532                    } else {
1533                        String::new()
1534                    }
1535                } else {
1536                    inserted.to_string()
1537                }
1538            }
1539            TextCapitalization::Sentences => {
1540                let starts_sentence = prefix
1541                    .chars()
1542                    .rev()
1543                    .find(|ch| !ch.is_whitespace())
1544                    .map(|ch| matches!(ch, '.' | '!' | '?'))
1545                    .unwrap_or(true);
1546                if starts_sentence {
1547                    let mut chars = inserted.chars();
1548                    if let Some(first) = chars.next() {
1549                        let mut out = first.to_uppercase().to_string();
1550                        out.push_str(chars.as_str());
1551                        out
1552                    } else {
1553                        String::new()
1554                    }
1555                } else {
1556                    inserted.to_string()
1557                }
1558            }
1559        }
1560    }
1561
1562    fn apply_input_type_filter(input_type: TextInputType, text: &str, multiline: bool) -> String {
1563        let mut filtered = String::new();
1564        for ch in text.chars() {
1565            let allowed = match input_type {
1566                TextInputType::Text | TextInputType::Name => multiline || ch != '\n',
1567                TextInputType::Multiline => true,
1568                TextInputType::Number => ch.is_ascii_digit() || matches!(ch, '.' | ',' | '-' | '+'),
1569                TextInputType::EmailAddress => !ch.is_whitespace(),
1570                TextInputType::Url => !ch.is_whitespace(),
1571                TextInputType::Phone => {
1572                    ch.is_ascii_digit() || matches!(ch, '+' | '-' | '(' | ')' | ' ')
1573                }
1574            };
1575            if allowed {
1576                filtered.push(ch);
1577            }
1578        }
1579        if !multiline {
1580            filtered = filtered.replace('\n', "");
1581        }
1582        filtered
1583    }
1584
1585    fn apply_formatters(text: &str, formatters: &[InputFormatter], multiline: bool) -> String {
1586        let mut out = text.to_string();
1587        for formatter in formatters {
1588            match formatter {
1589                InputFormatter::DigitsOnly => {
1590                    out = out.chars().filter(|ch| ch.is_ascii_digit()).collect();
1591                }
1592                InputFormatter::AsciiOnly => {
1593                    out = out.chars().filter(|ch| ch.is_ascii()).collect();
1594                }
1595                InputFormatter::InternalLowercase => {
1596                    out = out.to_lowercase();
1597                }
1598                InputFormatter::Uppercase => {
1599                    out = out.to_uppercase();
1600                }
1601                InputFormatter::TrimWhitespace => {
1602                    out = out.trim().to_string();
1603                }
1604                InputFormatter::SingleLine => {
1605                    out = out.replace('\n', "");
1606                }
1607            }
1608        }
1609        if !multiline {
1610            out = out.replace('\n', "");
1611        }
1612        out
1613    }
1614
1615    fn prepare_inserted_text(
1616        semantics: &Semantics,
1617        current_value: &str,
1618        replace_start: usize,
1619        replace_end: usize,
1620        raw_text: &str,
1621    ) -> Option<String> {
1622        let replace_start = replace_start.min(current_value.len());
1623        let replace_end = replace_end.min(current_value.len()).max(replace_start);
1624
1625        let mut inserted =
1626            Self::apply_input_type_filter(semantics.text_input_type, raw_text, semantics.multiline);
1627        inserted = Self::apply_text_capitalization(
1628            semantics.text_capitalization,
1629            &current_value[..replace_start],
1630            &inserted,
1631        );
1632        inserted =
1633            Self::apply_formatters(&inserted, &semantics.input_formatters, semantics.multiline);
1634
1635        if let Some(mask) = &semantics.input_mask {
1636            inserted = inserted
1637                .chars()
1638                .filter(|ch| mask.is_valid_char(*ch))
1639                .collect();
1640        }
1641
1642        if semantics.max_length_enforcement == MaxLengthEnforcement::Enforced {
1643            if let Some(max_length) = semantics.max_length {
1644                let current_chars = current_value.chars().count();
1645                let replaced_chars = current_value[replace_start..replace_end].chars().count();
1646                let available =
1647                    max_length.saturating_sub(current_chars.saturating_sub(replaced_chars));
1648                inserted = Self::truncate_to_chars(&inserted, available);
1649            }
1650        }
1651
1652        if inserted.is_empty() {
1653            None
1654        } else {
1655            Some(inserted)
1656        }
1657    }
1658
1659    fn handle_ime(&mut self, ctx: &mut ControllerContext, ime: &crate::event::ImeEvent) -> bool {
1660        match ime {
1661            crate::event::ImeEvent::Commit { text } => {
1662                if let Some(focused_id) = ctx.interaction.focused {
1663                    if let Some(node) = ctx.ir.nodes.get(&focused_id) {
1664                        if let Op::Semantics(semantics) = &node.op {
1665                            if semantics.role == fission_ir::semantics::Role::TextInput {
1666                                if semantics.disabled || semantics.read_only {
1667                                    return true;
1668                                }
1669                                let (value, _caret, _anchor) = Self::resolve_editing_value(
1670                                    ctx,
1671                                    focused_id,
1672                                    semantics.value.as_deref().unwrap_or(""),
1673                                );
1674                                let st = ctx.text_edit.get_mut_or_default(focused_id);
1675
1676                                let (start, end) = st
1677                                    .preedit
1678                                    .as_ref()
1679                                    .map(|preedit| preedit.range)
1680                                    .unwrap_or_else(|| st.selection_range());
1681
1682                                if let Some(filtered_text) =
1683                                    Self::prepare_inserted_text(semantics, &value, start, end, text)
1684                                {
1685                                    let new_caret = start + filtered_text.len();
1686                                    let new_text = st.apply_edit(
1687                                        start..end,
1688                                        &filtered_text,
1689                                        new_caret,
1690                                        new_caret,
1691                                    );
1692                                    self.dispatch_change(ctx, semantics, focused_id, new_text);
1693                                    Self::dispatch_cursor_change(
1694                                        ctx, semantics, focused_id, new_caret, new_caret,
1695                                    );
1696                                } else {
1697                                    st.clear_preedit();
1698                                }
1699
1700                                return true;
1701                            }
1702                        }
1703                    }
1704                }
1705            }
1706            crate::event::ImeEvent::Preedit { text, cursor } => {
1707                if let Some(focused_id) = ctx.interaction.focused {
1708                    if let Some(node) = ctx.ir.nodes.get(&focused_id) {
1709                        if let Op::Semantics(semantics) = &node.op {
1710                            if semantics.disabled || semantics.read_only {
1711                                return true;
1712                            }
1713                            Self::sync_runtime_state(
1714                                ctx,
1715                                focused_id,
1716                                semantics.value.as_deref().unwrap_or(""),
1717                            );
1718                        }
1719                    }
1720                    let st = ctx.text_edit.get_mut_or_default(focused_id);
1721                    st.set_preedit(text.clone(), *cursor);
1722                    Self::auto_scroll_textinput(ctx, focused_id);
1723                    return true;
1724                }
1725            }
1726            crate::event::ImeEvent::Cancel => {
1727                if let Some(focused_id) = ctx.interaction.focused {
1728                    if let Some(node) = ctx.ir.nodes.get(&focused_id) {
1729                        if let Op::Semantics(semantics) = &node.op {
1730                            if semantics.disabled || semantics.read_only {
1731                                return true;
1732                            }
1733                            Self::sync_runtime_state(
1734                                ctx,
1735                                focused_id,
1736                                semantics.value.as_deref().unwrap_or(""),
1737                            );
1738                        }
1739                    }
1740                    let st = ctx.text_edit.get_mut_or_default(focused_id);
1741                    st.clear_preedit();
1742                    Self::auto_scroll_textinput(ctx, focused_id);
1743                    return true;
1744                }
1745            }
1746        }
1747        false
1748    }
1749
1750    fn dispatch_change(
1751        &self,
1752        ctx: &mut ControllerContext,
1753        semantics: &fission_ir::Semantics,
1754        node_id: WidgetId,
1755        new_text: String,
1756    ) {
1757        Self::persist_runtime_state(ctx, node_id);
1758        if let Some(action_entry) = semantics.actions.entries.iter().find(|e| {
1759            matches!(
1760                e.trigger,
1761                fission_ir::semantics::ActionTrigger::Change
1762                    | fission_ir::semantics::ActionTrigger::NumberChange
1763            )
1764        }) {
1765            let payload = match action_entry.trigger {
1766                fission_ir::semantics::ActionTrigger::Change => serde_json::to_vec(&new_text)
1767                    .expect("serializing text input change payload should not fail"),
1768                fission_ir::semantics::ActionTrigger::NumberChange => {
1769                    let Ok(parsed) = new_text.trim().parse::<f32>() else {
1770                        return;
1771                    };
1772                    serde_json::to_vec(&parsed)
1773                        .expect("serializing numeric text input payload should not fail")
1774                }
1775                _ => unreachable!("filtered to text input change triggers"),
1776            };
1777
1778            let envelope = ActionEnvelope {
1779                id: ActionId::from_u128(action_entry.action_id),
1780                payload,
1781            };
1782            let input =
1783                crate::input::scoped_action_input(ctx.ir, node_id, crate::ActionInput::None);
1784            ctx.dispatched_actions.push((node_id, envelope, input));
1785
1786            // State update moved to handle_key to avoid double borrow
1787
1788            Self::auto_scroll_textinput(ctx, node_id);
1789        }
1790    }
1791
1792    fn dispatch_cursor_change(
1793        ctx: &mut ControllerContext,
1794        semantics: &fission_ir::Semantics,
1795        node_id: WidgetId,
1796        new_caret: usize,
1797        new_anchor: usize,
1798    ) {
1799        // Deduplicate: skip dispatch if cursor position hasn't actually changed
1800        // since our last dispatch. This prevents unnecessary model updates that
1801        // would trigger extra rebuild cycles.
1802        if let Some(st) = ctx.text_edit.states.get(&node_id) {
1803            if st.last_dispatched_cursor == Some((new_caret, new_anchor)) {
1804                return;
1805            }
1806        }
1807
1808        Self::persist_runtime_state(ctx, node_id);
1809
1810        if let Some(action_entry) = semantics
1811            .actions
1812            .entries
1813            .iter()
1814            .find(|e| e.trigger == fission_ir::semantics::ActionTrigger::CursorChange)
1815        {
1816            // Record the dispatched position before dispatching
1817            if let Some(st) = ctx.text_edit.states.get_mut(&node_id) {
1818                st.last_dispatched_cursor = Some((new_caret, new_anchor));
1819            }
1820
1821            let cursor_changed = crate::action::CursorChanged {
1822                caret: new_caret,
1823                anchor: new_anchor,
1824            };
1825            let payload = serde_json::to_vec(&cursor_changed).unwrap();
1826            let envelope = ActionEnvelope {
1827                id: ActionId::from_u128(action_entry.action_id),
1828                payload,
1829            };
1830            let input =
1831                crate::input::scoped_action_input(ctx.ir, node_id, crate::ActionInput::None);
1832            ctx.dispatched_actions.push((node_id, envelope, input));
1833        }
1834    }
1835
1836    fn dispatch_submit(
1837        ctx: &mut ControllerContext,
1838        semantics: &fission_ir::Semantics,
1839        node_id: WidgetId,
1840        current_value: &str,
1841    ) -> bool {
1842        let mut dispatched = false;
1843        for trigger in [
1844            fission_ir::semantics::ActionTrigger::EditingComplete,
1845            fission_ir::semantics::ActionTrigger::Submit,
1846        ] {
1847            dispatched |= Self::dispatch_action_for_trigger(
1848                ctx,
1849                semantics,
1850                node_id,
1851                trigger,
1852                Some(serde_json::to_vec(&current_value.to_string()).unwrap()),
1853            );
1854        }
1855        dispatched
1856    }
1857
1858    fn dispatch_action_for_trigger(
1859        ctx: &mut ControllerContext,
1860        semantics: &fission_ir::Semantics,
1861        node_id: WidgetId,
1862        trigger: fission_ir::semantics::ActionTrigger,
1863        fallback_payload: Option<Vec<u8>>,
1864    ) -> bool {
1865        let Some(action_entry) = semantics
1866            .actions
1867            .entries
1868            .iter()
1869            .find(|e| e.trigger == trigger)
1870        else {
1871            return false;
1872        };
1873        let payload = action_entry
1874            .payload_data
1875            .clone()
1876            .or(fallback_payload)
1877            .unwrap_or_else(|| serde_json::to_vec(&()).unwrap());
1878        let envelope = ActionEnvelope {
1879            id: ActionId::from_u128(action_entry.action_id),
1880            payload,
1881        };
1882        let input = crate::input::scoped_action_input(ctx.ir, node_id, crate::ActionInput::None);
1883        ctx.dispatched_actions.push((node_id, envelope, input));
1884        true
1885    }
1886
1887    fn resolve_editing_value(
1888        ctx: &mut ControllerContext,
1889        focused_id: WidgetId,
1890        semantic_value: &str,
1891    ) -> (String, usize, usize) {
1892        Self::sync_runtime_state(ctx, focused_id, semantic_value);
1893        let st = ctx.text_edit.get_mut_or_default(focused_id);
1894        let value = st.committed_text();
1895        (value, st.caret, st.anchor)
1896    }
1897
1898    fn display_value_for_metrics(
1899        ctx: &mut ControllerContext,
1900        focused_id: WidgetId,
1901        semantic_value: &str,
1902    ) -> String {
1903        Self::sync_runtime_state(ctx, focused_id, semantic_value);
1904        let state = ctx.text_edit.get_mut_or_default(focused_id);
1905        state.display_text().0
1906    }
1907
1908    fn mask_text_for_metrics(text: &str) -> String {
1909        let mut masked = String::new();
1910        for _ in text.graphemes(true) {
1911            masked.push('•');
1912        }
1913        masked
1914    }
1915
1916    fn masked_byte_offset_from_source(
1917        source: &str,
1918        masked: &str,
1919        source_byte_offset: usize,
1920    ) -> usize {
1921        let clamped = source_byte_offset.min(source.len());
1922        let grapheme_count = source[..clamped].graphemes(true).count();
1923        masked
1924            .grapheme_indices(true)
1925            .nth(grapheme_count)
1926            .map(|(idx, _)| idx)
1927            .unwrap_or(masked.len())
1928    }
1929
1930    fn source_byte_offset_from_masked(
1931        source: &str,
1932        masked: &str,
1933        masked_byte_offset: usize,
1934    ) -> usize {
1935        let clamped = masked_byte_offset.min(masked.len());
1936        let grapheme_count = masked[..clamped].graphemes(true).count();
1937        source
1938            .grapheme_indices(true)
1939            .nth(grapheme_count)
1940            .map(|(idx, _)| idx)
1941            .unwrap_or(source.len())
1942    }
1943
1944    fn clamp_caret_to_value(value: &str, caret: usize) -> usize {
1945        if caret > value.len() {
1946            value.len()
1947        } else {
1948            caret
1949        }
1950    }
1951
1952    fn prev_grapheme_boundary(value: &str, idx: usize) -> usize {
1953        let mut last = 0;
1954        for (pos, _) in value.grapheme_indices(true) {
1955            if pos >= idx {
1956                break;
1957            }
1958            last = pos;
1959        }
1960        last
1961    }
1962
1963    fn next_grapheme_boundary(value: &str, idx: usize) -> usize {
1964        for (pos, _) in value.grapheme_indices(true) {
1965            if pos > idx {
1966                return pos;
1967            }
1968        }
1969        value.len()
1970    }
1971
1972    fn prev_word_boundary(value: &str, idx: usize) -> usize {
1973        let at = idx.min(value.len());
1974        let segments: Vec<(usize, &str)> = value.split_word_bound_indices().collect();
1975        for (start, segment) in segments.into_iter().rev() {
1976            let end = start + segment.len();
1977            if end > at {
1978                continue;
1979            }
1980            if segment.chars().any(|ch| ch.is_alphanumeric() || ch == '_') {
1981                return start;
1982            }
1983        }
1984        0
1985    }
1986
1987    fn next_word_boundary(value: &str, idx: usize) -> usize {
1988        let at = idx.min(value.len());
1989        for (start, segment) in value.split_word_bound_indices() {
1990            let end = start + segment.len();
1991            if end <= at {
1992                continue;
1993            }
1994            if segment.chars().any(|ch| ch.is_alphanumeric() || ch == '_') {
1995                return end;
1996            }
1997        }
1998        value.len()
1999    }
2000
2001    fn find_scroll_container_and_text_op(
2002        ir: &fission_ir::CoreIR,
2003        root: WidgetId,
2004        multiline_semantics: bool,
2005    ) -> Option<(WidgetId, WidgetId, op::FlexDirection)> {
2006        let mut stack = vec![root];
2007        while let Some(id) = stack.pop() {
2008            if let Some(n) = ir.nodes.get(&id) {
2009                if let Op::Layout(op::LayoutOp::Scroll { direction, .. }) = &n.op {
2010                    let matches_multiline_config = (multiline_semantics
2011                        && *direction == op::FlexDirection::Column)
2012                        || (!multiline_semantics && *direction == op::FlexDirection::Row);
2013                    if matches_multiline_config {
2014                        let mut q = vec![id]; // Start BFS from scroll node to find text
2015                        while let Some(cid) = q.pop() {
2016                            if let Some(cn) = ir.nodes.get(&cid) {
2017                                if matches!(
2018                                    cn.op,
2019                                    Op::Paint(fission_ir::PaintOp::DrawText { .. })
2020                                        | Op::Paint(fission_ir::PaintOp::DrawRichText { .. })
2021                                ) {
2022                                    return Some((id, cid, *direction));
2023                                }
2024                                for &gc in &cn.children {
2025                                    q.push(gc);
2026                                }
2027                            }
2028                        }
2029                        return None; // Should find text inside. For now, assume it's directly related.
2030                    }
2031                }
2032                for &c in &n.children {
2033                    stack.push(c);
2034                }
2035            }
2036        }
2037        None
2038    }
2039
2040    /// Extract rich text runs from the TextInput's DrawRichText child.
2041    fn extract_rich_runs(
2042        ir: &fission_ir::CoreIR,
2043        semantics_id: WidgetId,
2044    ) -> Option<Vec<fission_ir::op::TextRun>> {
2045        fn walk(
2046            ir: &fission_ir::CoreIR,
2047            node_id: WidgetId,
2048            depth: usize,
2049        ) -> Option<Vec<fission_ir::op::TextRun>> {
2050            if depth > 20 {
2051                return None;
2052            }
2053            let node = ir.nodes.get(&node_id)?;
2054            match &node.op {
2055                Op::Paint(fission_ir::PaintOp::DrawRichText { runs, .. }) if !runs.is_empty() => {
2056                    Some(runs.clone())
2057                }
2058                _ => {
2059                    for child_id in &node.children {
2060                        if let Some(r) = walk(ir, *child_id, depth + 1) {
2061                            return Some(r);
2062                        }
2063                    }
2064                    None
2065                }
2066            }
2067        }
2068        walk(ir, semantics_id, 0)
2069    }
2070
2071    /// Extract the font size from the TextInput's DrawRichText or DrawText child.
2072    fn extract_font_size(ir: &fission_ir::CoreIR, semantics_id: WidgetId) -> Option<f32> {
2073        // Walk children of the semantics node to find a text paint op
2074        fn walk(ir: &fission_ir::CoreIR, node_id: WidgetId, depth: usize) -> Option<f32> {
2075            if depth > 10 {
2076                return None;
2077            }
2078            let node = ir.nodes.get(&node_id)?;
2079            match &node.op {
2080                Op::Paint(fission_ir::PaintOp::DrawText { size, .. }) => Some(*size),
2081                Op::Paint(fission_ir::PaintOp::DrawRichText { runs, .. }) => {
2082                    runs.first().map(|r| r.style.font_size)
2083                }
2084                _ => {
2085                    for child_id in &node.children {
2086                        if let Some(sz) = walk(ir, *child_id, depth + 1) {
2087                            return Some(sz);
2088                        }
2089                    }
2090                    None
2091                }
2092            }
2093        }
2094        walk(ir, semantics_id, 0)
2095    }
2096
2097    /// Shared hit-test logic for both PointerDown and PointerMove.
2098    ///
2099    /// Uses the rich-text layout path when styled runs are available, passing the
2100    /// same `available_width` that the renderer will use so both sides build (or
2101    /// look up) the same Parley `Layout`.  This ensures the Y-to-line and X-to-
2102    /// glyph mapping in hit-testing exactly matches the rendered text.
2103    fn hit_test_text(
2104        measurer: &std::sync::Arc<dyn fission_layout::TextMeasurer>,
2105        ir: &fission_ir::CoreIR,
2106        focused_id: WidgetId,
2107        prefer_plain_text: bool,
2108        text: &str,
2109        scroll_geom: &fission_layout::LayoutNodeGeometry,
2110        local_x: f32,
2111        local_y: f32,
2112    ) -> usize {
2113        let viewport_width = if scroll_geom.rect.size.width > 0.0 {
2114            Some(scroll_geom.rect.size.width)
2115        } else {
2116            None
2117        };
2118        let render_width = viewport_width;
2119        let font_size = Self::extract_font_size(ir, focused_id).unwrap_or(13.0);
2120        let paragraph = Self::extract_paragraph_style(ir, focused_id).unwrap_or_default();
2121
2122        if paragraph.text_align != TextAlign::Start {
2123            let line_metrics = measurer.get_line_metrics(text, font_size, render_width);
2124            if let (Some(width), Some(line)) = (
2125                viewport_width,
2126                Self::line_metric_for_local_y(&line_metrics, local_y),
2127            ) {
2128                let aligned_x =
2129                    local_x - Self::paragraph_line_x_offset(paragraph, width, line.width, false);
2130                return measurer.hit_test(text, font_size, render_width, aligned_x, local_y);
2131            }
2132        }
2133
2134        if !prefer_plain_text {
2135            if let Some(runs) = Self::extract_rich_runs(ir, focused_id) {
2136                return measurer.hit_test_rich(&runs, render_width, local_x, local_y);
2137            }
2138        }
2139        measurer.hit_test(text, font_size, render_width, local_x, local_y)
2140    }
2141
2142    fn caret_from_point_in_text_fallback(
2143        _value: &str,
2144        _font_size: f32,
2145        _viewport_x: f32,
2146        _viewport_w: f32,
2147        _content_w: f32,
2148        _scroll_offset: f32,
2149        _point_x: f32,
2150    ) -> usize {
2151        // Simplified fallback: always return 0 if no proper measurer is available.
2152        // In a real scenario, this would ideally not be hit in interactive UIs.
2153        0
2154    }
2155
2156    pub(crate) fn ime_cursor_area(
2157        ctx: &mut ControllerContext,
2158        text_root: WidgetId,
2159    ) -> Option<fission_layout::LayoutRect> {
2160        let measurer = ctx.measurer?;
2161        let node = ctx.ir.nodes.get(&text_root)?;
2162        let semantics = match &node.op {
2163            Op::Semantics(semantics) => semantics,
2164            _ => return None,
2165        };
2166
2167        let (scroll_id, _text_op_node_id, scroll_direction) =
2168            Self::find_scroll_container_and_text_op(ctx.ir, text_root, semantics.multiline)?;
2169        let scroll_geom = ctx.layout.get_node_geometry(scroll_id)?;
2170        let viewport_size = scroll_geom.rect.size;
2171        let font_size = Self::extract_font_size(ctx.ir, text_root).unwrap_or(16.0);
2172        let display_value = Self::display_value_for_metrics(
2173            ctx,
2174            text_root,
2175            semantics.value.as_deref().unwrap_or(""),
2176        );
2177        let metric_text = if semantics.masked {
2178            Self::mask_text_for_metrics(&display_value)
2179        } else {
2180            display_value.clone()
2181        };
2182
2183        let caret_idx = ctx
2184            .text_edit
2185            .get(text_root)
2186            .map(|state| {
2187                state
2188                    .display_preedit_cursor_range()
2189                    .map(|(_, end)| end)
2190                    .unwrap_or(state.caret)
2191            })
2192            .unwrap_or(0);
2193        let metric_caret_idx = if semantics.masked {
2194            Self::masked_byte_offset_from_source(&display_value, &metric_text, caret_idx)
2195        } else {
2196            caret_idx
2197        };
2198
2199        let paragraph = Self::extract_paragraph_style(ctx.ir, text_root).unwrap_or_default();
2200        let render_width = if scroll_direction == op::FlexDirection::Column {
2201            Some(viewport_size.width)
2202        } else {
2203            None
2204        };
2205        let (caret_x, caret_y) =
2206            measurer.get_caret_position(&metric_text, font_size, render_width, metric_caret_idx);
2207
2208        let line_metrics = measurer.get_line_metrics(&metric_text, font_size, render_width);
2209        let line = Self::line_metric_for_index(&line_metrics, metric_caret_idx)
2210            .map(|(_, line)| line)
2211            .or_else(|| Self::line_metric_for_local_y(&line_metrics, caret_y));
2212        let line_width = line
2213            .map(|line| line.width)
2214            .unwrap_or_else(|| measurer.measure(&metric_text, font_size, render_width).0);
2215        let line_height = line
2216            .map(|line| line.height.max(1.0))
2217            .unwrap_or_else(|| measurer.measure("Tg", font_size, render_width).1.max(1.0));
2218        let is_last_line = line_metrics
2219            .last()
2220            .is_some_and(|last| last.end_index <= metric_caret_idx);
2221        let line_x =
2222            Self::paragraph_line_x_offset(paragraph, viewport_size.width, line_width, is_last_line);
2223
2224        let mut origin_x = scroll_geom.rect.origin.x;
2225        let mut origin_y = scroll_geom.rect.origin.y;
2226        let mut walk = ctx.ir.nodes.get(&scroll_id).and_then(|node| node.parent);
2227        while let Some(parent_id) = walk {
2228            let Some(parent) = ctx.ir.nodes.get(&parent_id) else {
2229                break;
2230            };
2231            if let Op::Layout(LayoutOp::Scroll { direction, .. }) = &parent.op {
2232                let offset = ctx.scroll.get_offset(parent_id);
2233                match direction {
2234                    FlexDirection::Row => origin_x -= offset,
2235                    FlexDirection::Column => origin_y -= offset,
2236                }
2237            }
2238            walk = parent.parent;
2239        }
2240
2241        let own_offset = ctx.scroll.get_offset(scroll_id);
2242        let mut x = origin_x + line_x + caret_x;
2243        let mut y = origin_y + caret_y;
2244        match scroll_direction {
2245            op::FlexDirection::Row => x -= own_offset,
2246            op::FlexDirection::Column => y -= own_offset,
2247        }
2248
2249        if !(x.is_finite() && y.is_finite() && line_height.is_finite()) {
2250            return None;
2251        }
2252
2253        let right_limit = origin_x + viewport_size.width - 2.0;
2254        let bottom_limit = origin_y + viewport_size.height - line_height;
2255        if right_limit >= origin_x {
2256            x = x.clamp(origin_x, right_limit);
2257        }
2258        if bottom_limit >= origin_y {
2259            y = y.clamp(origin_y, bottom_limit);
2260        }
2261
2262        Some(fission_layout::LayoutRect::new(x, y, 2.0, line_height))
2263    }
2264
2265    fn auto_scroll_textinput(ctx: &mut ControllerContext, text_root: WidgetId) {
2266        let font_size = Self::extract_font_size(ctx.ir, text_root).unwrap_or(16.0);
2267        if let Some(measurer) = ctx.measurer {
2268            // Need to get multiline status from semantics here
2269            let is_multiline = if let Some(node) = ctx.ir.nodes.get(&text_root) {
2270                if let Op::Semantics(sem) = &node.op {
2271                    sem.multiline
2272                } else {
2273                    false
2274                }
2275            } else {
2276                false
2277            };
2278
2279            if let Some((scroll_id, _text_op_node_id, scroll_direction)) =
2280                Self::find_scroll_container_and_text_op(ctx.ir, text_root, is_multiline)
2281            {
2282                if let Some(scroll_geom) = ctx.layout.get_node_geometry(scroll_id) {
2283                    let viewport_size = scroll_geom.rect.size;
2284
2285                    let (current_text_value, metric_text, masked, scroll_padding) =
2286                        if let Some(node) = ctx.ir.nodes.get(&text_root) {
2287                            if let Op::Semantics(sem) = &node.op {
2288                                let display_value = Self::display_value_for_metrics(
2289                                    ctx,
2290                                    text_root,
2291                                    sem.value.as_deref().unwrap_or(""),
2292                                );
2293                                let metric_text = if sem.masked {
2294                                    Self::mask_text_for_metrics(&display_value)
2295                                } else {
2296                                    display_value.clone()
2297                                };
2298                                (
2299                                    display_value,
2300                                    metric_text,
2301                                    sem.masked,
2302                                    sem.scroll_padding.unwrap_or([2.0, 3.0, 2.0, 3.0]),
2303                                )
2304                            } else {
2305                                (String::new(), String::new(), false, [2.0, 3.0, 2.0, 3.0])
2306                            }
2307                        } else {
2308                            (String::new(), String::new(), false, [2.0, 3.0, 2.0, 3.0])
2309                        };
2310
2311                    let current_caret_idx = if let Some(st) = ctx.text_edit.get(text_root) {
2312                        st.display_preedit_cursor_range()
2313                            .map(|(_, end)| end)
2314                            .unwrap_or(st.caret)
2315                    } else {
2316                        0
2317                    };
2318                    let metric_caret_idx = if masked {
2319                        Self::masked_byte_offset_from_source(
2320                            &current_text_value,
2321                            &metric_text,
2322                            current_caret_idx,
2323                        )
2324                    } else {
2325                        current_caret_idx
2326                    };
2327                    let paragraph =
2328                        Self::extract_paragraph_style(ctx.ir, text_root).unwrap_or_default();
2329                    let measurer_width = if scroll_direction == op::FlexDirection::Column {
2330                        Some(viewport_size.width)
2331                    } else {
2332                        None
2333                    };
2334
2335                    let (caret_x, caret_y) = measurer.get_caret_position(
2336                        &metric_text,
2337                        font_size,
2338                        measurer_width,
2339                        metric_caret_idx,
2340                    );
2341
2342                    let mut offset = ctx.scroll.get_offset(scroll_id);
2343
2344                    if scroll_direction == op::FlexDirection::Row {
2345                        // Handle horizontal scrolling for single-line text
2346                        let line_width = measurer
2347                            .get_line_metrics(&metric_text, font_size, None)
2348                            .first()
2349                            .map(|line| line.width)
2350                            .unwrap_or_else(|| measurer.measure(&metric_text, font_size, None).0);
2351                        let caret_left = caret_x
2352                            + Self::paragraph_line_x_offset(
2353                                paragraph,
2354                                viewport_size.width,
2355                                line_width,
2356                                false,
2357                            );
2358                        let caret_width = 2.0f32;
2359                        let caret_right = caret_left + caret_width;
2360
2361                        let margin_left = scroll_padding[0].max(0.0);
2362                        let margin_right = scroll_padding[1].max(0.0);
2363
2364                        let visible_left = caret_left - offset;
2365                        let visible_right = caret_right - offset;
2366
2367                        if visible_right > (viewport_size.width - margin_right) {
2368                            offset =
2369                                (caret_right - (viewport_size.width - margin_right)).max(0.0f32);
2370                        } else if visible_left < margin_left {
2371                            offset = (caret_left - margin_left).max(0.0f32);
2372                        }
2373                        let content_w = scroll_geom.content_size.width.max(viewport_size.width);
2374                        let max_offset = (content_w - viewport_size.width).max(0.0f32);
2375                        offset = offset.clamp(0.0f32, max_offset);
2376                        ctx.scroll.set_offset(scroll_id, offset);
2377                    } else {
2378                        // op::FlexDirection::Column
2379                        // Handle vertical scrolling for multi-line text
2380                        let caret_top = caret_y;
2381                        let caret_height = measurer
2382                            .measure("Tg", font_size, Some(viewport_size.width))
2383                            .1;
2384                        let caret_bottom = caret_top + caret_height;
2385
2386                        let margin_top = scroll_padding[2].max(0.0);
2387                        let margin_bottom = scroll_padding[3].max(0.0);
2388
2389                        let visible_top = caret_top - offset;
2390                        let visible_bottom = caret_bottom - offset;
2391
2392                        if visible_bottom > (viewport_size.height - margin_bottom) {
2393                            offset =
2394                                (caret_bottom - (viewport_size.height - margin_bottom)).max(0.0f32);
2395                        } else if visible_top < margin_top {
2396                            offset = (caret_top - margin_top).max(0.0f32);
2397                        }
2398                        let content_h = scroll_geom.content_size.height.max(viewport_size.height);
2399                        let max_offset = (content_h - viewport_size.height).max(0.0f32);
2400                        offset = offset.clamp(0.0f32, max_offset);
2401                        ctx.scroll.set_offset(scroll_id, offset);
2402                    }
2403                }
2404            }
2405        }
2406    }
2407
2408    fn handle_vertical_navigation(
2409        &mut self,
2410        ctx: &mut ControllerContext,
2411        focused_id: WidgetId,
2412        semantics: &Semantics,
2413        value: &str,
2414        caret: usize,
2415        modifiers: u8,
2416        is_up: bool,
2417    ) {
2418        if let Some(measurer) = ctx.measurer {
2419            if let Some((scroll_id, _text_op_node_id, _scroll_direction)) =
2420                Self::find_scroll_container_and_text_op(ctx.ir, focused_id, semantics.multiline)
2421            {
2422                if let Some(scroll_geom) = ctx.layout.get_node_geometry(scroll_id) {
2423                    let viewport_w = scroll_geom.rect.size.width;
2424                    let font_size = Self::extract_font_size(ctx.ir, focused_id).unwrap_or(16.0);
2425
2426                    let (current_caret_x, _current_caret_y) =
2427                        measurer.get_caret_position(value, font_size, Some(viewport_w), caret);
2428
2429                    let line_metrics =
2430                        measurer.get_line_metrics(value, font_size, Some(viewport_w));
2431
2432                    let mut current_line_idx = 0;
2433                    for (idx, line) in line_metrics.iter().enumerate() {
2434                        if caret >= line.start_index && caret <= line.end_index {
2435                            current_line_idx = idx;
2436                            // Don't break: if the caret sits at the boundary
2437                            // between two lines (end of line N == start of
2438                            // line N+1), prefer the later line so empty lines
2439                            // are reachable.
2440                        }
2441                    }
2442
2443                    let target_line_idx = if is_up {
2444                        current_line_idx.saturating_sub(1)
2445                    } else {
2446                        (current_line_idx + 1).min(line_metrics.len().saturating_sub(1))
2447                    };
2448
2449                    if let Some(target_line) = line_metrics.get(target_line_idx) {
2450                        let target_y = target_line.baseline;
2451
2452                        let mut new_caret_pos = measurer.hit_test(
2453                            value,
2454                            font_size,
2455                            Some(viewport_w),
2456                            current_caret_x,
2457                            target_y,
2458                        );
2459
2460                        // Ensure we stay within the target line's bounds.
2461                        // For empty lines (start_index == end_index), this
2462                        // correctly places the cursor at start_index.
2463                        new_caret_pos = new_caret_pos.clamp(
2464                            target_line.start_index,
2465                            target_line.end_index.max(target_line.start_index),
2466                        );
2467
2468                        let st = ctx.text_edit.get_mut_or_default(focused_id);
2469                        st.caret = new_caret_pos;
2470                        if !Self::has_shift(modifiers) {
2471                            st.anchor = new_caret_pos;
2472                        } // If no shift, collapse selection
2473                        let final_anchor = st.anchor;
2474                        Self::auto_scroll_textinput(ctx, focused_id);
2475                        Self::dispatch_cursor_change(
2476                            ctx,
2477                            semantics,
2478                            focused_id,
2479                            new_caret_pos,
2480                            final_anchor,
2481                        );
2482                    }
2483                }
2484            }
2485        }
2486    }
2487
2488    fn handle_page_navigation(
2489        &mut self,
2490        ctx: &mut ControllerContext,
2491        focused_id: WidgetId,
2492        semantics: &Semantics,
2493        value: &str,
2494        caret: usize,
2495        modifiers: u8,
2496        is_page_up: bool,
2497    ) {
2498        if let Some(measurer) = ctx.measurer {
2499            if let Some((scroll_id, _text_op_node_id, _scroll_direction)) =
2500                Self::find_scroll_container_and_text_op(ctx.ir, focused_id, semantics.multiline)
2501            {
2502                if let Some(scroll_geom) = ctx.layout.get_node_geometry(scroll_id) {
2503                    let viewport_w = scroll_geom.rect.size.width;
2504                    let viewport_h = scroll_geom.rect.size.height.max(1.0);
2505                    let font_size = Self::extract_font_size(ctx.ir, focused_id).unwrap_or(16.0);
2506                    let (current_caret_x, _current_caret_y) =
2507                        measurer.get_caret_position(value, font_size, Some(viewport_w), caret);
2508                    let line_metrics =
2509                        measurer.get_line_metrics(value, font_size, Some(viewport_w));
2510
2511                    if line_metrics.is_empty() {
2512                        return;
2513                    }
2514
2515                    let mut current_line_idx = 0usize;
2516                    for (idx, line) in line_metrics.iter().enumerate() {
2517                        if caret >= line.start_index && caret <= line.end_index {
2518                            current_line_idx = idx;
2519                        }
2520                    }
2521
2522                    let line_height = line_metrics
2523                        .get(current_line_idx)
2524                        .map(|line| line.height.max(1.0))
2525                        .unwrap_or(20.0);
2526                    let lines_per_page = (viewport_h / line_height).floor().max(1.0) as isize;
2527                    let delta = if is_page_up {
2528                        -lines_per_page
2529                    } else {
2530                        lines_per_page
2531                    };
2532                    let target_line_idx = current_line_idx
2533                        .saturating_add_signed(delta)
2534                        .min(line_metrics.len().saturating_sub(1));
2535
2536                    if let Some(target_line) = line_metrics.get(target_line_idx) {
2537                        let target_y = target_line.baseline;
2538                        let mut new_caret_pos = measurer.hit_test(
2539                            value,
2540                            font_size,
2541                            Some(viewport_w),
2542                            current_caret_x,
2543                            target_y,
2544                        );
2545                        let target_end = Self::trim_line_end(
2546                            value,
2547                            target_line.end_index.max(target_line.start_index),
2548                        );
2549                        new_caret_pos = new_caret_pos.clamp(
2550                            target_line.start_index,
2551                            target_end.max(target_line.start_index),
2552                        );
2553
2554                        let st = ctx.text_edit.get_mut_or_default(focused_id);
2555                        st.caret = new_caret_pos;
2556                        if !Self::has_shift(modifiers) {
2557                            st.anchor = new_caret_pos;
2558                        }
2559                        let final_anchor = st.anchor;
2560                        Self::auto_scroll_textinput(ctx, focused_id);
2561                        Self::dispatch_cursor_change(
2562                            ctx,
2563                            semantics,
2564                            focused_id,
2565                            new_caret_pos,
2566                            final_anchor,
2567                        );
2568                    }
2569                }
2570            }
2571        }
2572    }
2573
2574    fn extract_paragraph_style(
2575        ir: &fission_ir::CoreIR,
2576        semantics_id: WidgetId,
2577    ) -> Option<TextParagraphStyle> {
2578        fn walk(
2579            ir: &fission_ir::CoreIR,
2580            node_id: WidgetId,
2581            depth: usize,
2582        ) -> Option<TextParagraphStyle> {
2583            if depth > 10 {
2584                return None;
2585            }
2586            let node = ir.nodes.get(&node_id)?;
2587            match &node.op {
2588                Op::Paint(fission_ir::PaintOp::DrawText {
2589                    paragraph_style,
2590                    caret_width,
2591                    ..
2592                }) => paragraph_style.or_else(|| decode_text_paragraph_style(*caret_width)),
2593                Op::Paint(fission_ir::PaintOp::DrawRichText {
2594                    paragraph_style,
2595                    caret_width,
2596                    ..
2597                }) => paragraph_style.or_else(|| decode_text_paragraph_style(*caret_width)),
2598                _ => {
2599                    for child_id in &node.children {
2600                        if let Some(style) = walk(ir, *child_id, depth + 1) {
2601                            return Some(style);
2602                        }
2603                    }
2604                    None
2605                }
2606            }
2607        }
2608        walk(ir, semantics_id, 0)
2609    }
2610
2611    fn line_metric_for_local_y<'a>(
2612        line_metrics: &'a [fission_layout::LineMetric],
2613        local_y: f32,
2614    ) -> Option<&'a fission_layout::LineMetric> {
2615        if line_metrics.is_empty() {
2616            return None;
2617        }
2618        let mut line_top = 0.0f32;
2619        for (index, line) in line_metrics.iter().enumerate() {
2620            let line_height = line.height.max(1.0);
2621            let line_bottom = line_top + line_height;
2622            if local_y < line_bottom || index + 1 == line_metrics.len() {
2623                return Some(line);
2624            }
2625            line_top = line_bottom;
2626        }
2627        line_metrics.last()
2628    }
2629
2630    fn paragraph_line_x_offset(
2631        paragraph: TextParagraphStyle,
2632        bounds_width: f32,
2633        line_width: f32,
2634        is_last_line: bool,
2635    ) -> f32 {
2636        if bounds_width <= 0.0 {
2637            return 0.0;
2638        }
2639
2640        match paragraph.text_align {
2641            TextAlign::Start | TextAlign::Left => 0.0,
2642            TextAlign::Center => (bounds_width - line_width) * 0.5,
2643            TextAlign::End | TextAlign::Right => bounds_width - line_width,
2644            TextAlign::Justify if is_last_line => 0.0,
2645            TextAlign::Justify => 0.0,
2646        }
2647    }
2648}
2649
2650// This pub fn is no longer needed since Controller uses measurer directly in handle_event
2651// But other parts of code might still call it, so keep it.
2652pub fn caret_from_point_in_text(
2653    measurer: Option<&std::sync::Arc<dyn fission_layout::TextMeasurer>>,
2654    value: &str,
2655    font_size: f32,
2656    viewport_x: f32,
2657    viewport_w: f32,
2658    content_w: f32,
2659    scroll_offset: f32,
2660    point_x: f32,
2661) -> usize {
2662    let local_x = (point_x - viewport_x) + scroll_offset;
2663    if local_x <= 0.0 {
2664        return 0;
2665    }
2666    let max_x = content_w.max(viewport_w);
2667    if local_x >= max_x {
2668        return value.len();
2669    }
2670
2671    if let Some(measurer) = measurer {
2672        // This function is for single line mostly. measurer.hit_test is better.
2673        // Single-line hit-testing should not wrap text to the viewport width.
2674        measurer.hit_test(value, font_size, None, local_x, 0.0)
2675    } else {
2676        TextInputController::caret_from_point_in_text_fallback(
2677            value,
2678            font_size,
2679            viewport_x,
2680            viewport_w,
2681            content_w,
2682            scroll_offset,
2683            point_x,
2684        )
2685    }
2686}