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        let (new_caret, new_anchor) = ctx
1759            .text_edit
1760            .get(node_id)
1761            .map(|state| (state.caret, state.anchor))
1762            .unwrap_or((new_text.len(), new_text.len()));
1763        if let Some((envelope, input)) = crate::input::prepare_scoped_text_input_change(
1764            ctx.ir, semantics, node_id, new_text, new_caret, new_anchor,
1765        ) {
1766            ctx.dispatched_actions.push((node_id, envelope, input));
1767
1768            // State update moved to handle_key to avoid double borrow
1769
1770            Self::auto_scroll_textinput(ctx, node_id);
1771        }
1772    }
1773
1774    fn dispatch_cursor_change(
1775        ctx: &mut ControllerContext,
1776        semantics: &fission_ir::Semantics,
1777        node_id: WidgetId,
1778        new_caret: usize,
1779        new_anchor: usize,
1780    ) {
1781        // Deduplicate: skip dispatch if cursor position hasn't actually changed
1782        // since our last dispatch. This prevents unnecessary model updates that
1783        // would trigger extra rebuild cycles.
1784        if let Some(st) = ctx.text_edit.states.get(&node_id) {
1785            if st.last_dispatched_cursor == Some((new_caret, new_anchor)) {
1786                return;
1787            }
1788        }
1789
1790        Self::persist_runtime_state(ctx, node_id);
1791
1792        if let Some(action_entry) = semantics
1793            .actions
1794            .entries
1795            .iter()
1796            .find(|e| e.trigger == fission_ir::semantics::ActionTrigger::CursorChange)
1797        {
1798            // Record the dispatched position before dispatching
1799            if let Some(st) = ctx.text_edit.states.get_mut(&node_id) {
1800                st.last_dispatched_cursor = Some((new_caret, new_anchor));
1801            }
1802
1803            let cursor_changed = crate::action::CursorChanged {
1804                caret: new_caret,
1805                anchor: new_anchor,
1806            };
1807            let payload = serde_json::to_vec(&cursor_changed).unwrap();
1808            let envelope = ActionEnvelope {
1809                id: ActionId::from_u128(action_entry.action_id),
1810                payload,
1811            };
1812            let input =
1813                crate::input::scoped_action_input(ctx.ir, node_id, crate::ActionInput::None);
1814            ctx.dispatched_actions.push((node_id, envelope, input));
1815        }
1816    }
1817
1818    fn dispatch_submit(
1819        ctx: &mut ControllerContext,
1820        semantics: &fission_ir::Semantics,
1821        node_id: WidgetId,
1822        current_value: &str,
1823    ) -> bool {
1824        let mut dispatched = false;
1825        for trigger in [
1826            fission_ir::semantics::ActionTrigger::EditingComplete,
1827            fission_ir::semantics::ActionTrigger::Submit,
1828        ] {
1829            dispatched |= Self::dispatch_action_for_trigger(
1830                ctx,
1831                semantics,
1832                node_id,
1833                trigger,
1834                Some(serde_json::to_vec(&current_value.to_string()).unwrap()),
1835            );
1836        }
1837        dispatched
1838    }
1839
1840    fn dispatch_action_for_trigger(
1841        ctx: &mut ControllerContext,
1842        semantics: &fission_ir::Semantics,
1843        node_id: WidgetId,
1844        trigger: fission_ir::semantics::ActionTrigger,
1845        fallback_payload: Option<Vec<u8>>,
1846    ) -> bool {
1847        let Some(action_entry) = semantics
1848            .actions
1849            .entries
1850            .iter()
1851            .find(|e| e.trigger == trigger)
1852        else {
1853            return false;
1854        };
1855        let payload = action_entry
1856            .payload_data
1857            .clone()
1858            .or(fallback_payload)
1859            .unwrap_or_else(|| serde_json::to_vec(&()).unwrap());
1860        let envelope = ActionEnvelope {
1861            id: ActionId::from_u128(action_entry.action_id),
1862            payload,
1863        };
1864        let input = crate::input::scoped_action_input(ctx.ir, node_id, crate::ActionInput::None);
1865        ctx.dispatched_actions.push((node_id, envelope, input));
1866        true
1867    }
1868
1869    fn resolve_editing_value(
1870        ctx: &mut ControllerContext,
1871        focused_id: WidgetId,
1872        semantic_value: &str,
1873    ) -> (String, usize, usize) {
1874        Self::sync_runtime_state(ctx, focused_id, semantic_value);
1875        let st = ctx.text_edit.get_mut_or_default(focused_id);
1876        let value = st.committed_text();
1877        (value, st.caret, st.anchor)
1878    }
1879
1880    fn display_value_for_metrics(
1881        ctx: &mut ControllerContext,
1882        focused_id: WidgetId,
1883        semantic_value: &str,
1884    ) -> String {
1885        Self::sync_runtime_state(ctx, focused_id, semantic_value);
1886        let state = ctx.text_edit.get_mut_or_default(focused_id);
1887        state.display_text().0
1888    }
1889
1890    fn mask_text_for_metrics(text: &str) -> String {
1891        let mut masked = String::new();
1892        for _ in text.graphemes(true) {
1893            masked.push('•');
1894        }
1895        masked
1896    }
1897
1898    fn masked_byte_offset_from_source(
1899        source: &str,
1900        masked: &str,
1901        source_byte_offset: usize,
1902    ) -> usize {
1903        let clamped = source_byte_offset.min(source.len());
1904        let grapheme_count = source[..clamped].graphemes(true).count();
1905        masked
1906            .grapheme_indices(true)
1907            .nth(grapheme_count)
1908            .map(|(idx, _)| idx)
1909            .unwrap_or(masked.len())
1910    }
1911
1912    fn source_byte_offset_from_masked(
1913        source: &str,
1914        masked: &str,
1915        masked_byte_offset: usize,
1916    ) -> usize {
1917        let clamped = masked_byte_offset.min(masked.len());
1918        let grapheme_count = masked[..clamped].graphemes(true).count();
1919        source
1920            .grapheme_indices(true)
1921            .nth(grapheme_count)
1922            .map(|(idx, _)| idx)
1923            .unwrap_or(source.len())
1924    }
1925
1926    fn clamp_caret_to_value(value: &str, caret: usize) -> usize {
1927        if caret > value.len() {
1928            value.len()
1929        } else {
1930            caret
1931        }
1932    }
1933
1934    fn prev_grapheme_boundary(value: &str, idx: usize) -> usize {
1935        let mut last = 0;
1936        for (pos, _) in value.grapheme_indices(true) {
1937            if pos >= idx {
1938                break;
1939            }
1940            last = pos;
1941        }
1942        last
1943    }
1944
1945    fn next_grapheme_boundary(value: &str, idx: usize) -> usize {
1946        for (pos, _) in value.grapheme_indices(true) {
1947            if pos > idx {
1948                return pos;
1949            }
1950        }
1951        value.len()
1952    }
1953
1954    fn prev_word_boundary(value: &str, idx: usize) -> usize {
1955        let at = idx.min(value.len());
1956        let segments: Vec<(usize, &str)> = value.split_word_bound_indices().collect();
1957        for (start, segment) in segments.into_iter().rev() {
1958            let end = start + segment.len();
1959            if end > at {
1960                continue;
1961            }
1962            if segment.chars().any(|ch| ch.is_alphanumeric() || ch == '_') {
1963                return start;
1964            }
1965        }
1966        0
1967    }
1968
1969    fn next_word_boundary(value: &str, idx: usize) -> usize {
1970        let at = idx.min(value.len());
1971        for (start, segment) in value.split_word_bound_indices() {
1972            let end = start + segment.len();
1973            if end <= at {
1974                continue;
1975            }
1976            if segment.chars().any(|ch| ch.is_alphanumeric() || ch == '_') {
1977                return end;
1978            }
1979        }
1980        value.len()
1981    }
1982
1983    fn find_scroll_container_and_text_op(
1984        ir: &fission_ir::CoreIR,
1985        root: WidgetId,
1986        multiline_semantics: bool,
1987    ) -> Option<(WidgetId, WidgetId, op::FlexDirection)> {
1988        let mut stack = vec![root];
1989        while let Some(id) = stack.pop() {
1990            if let Some(n) = ir.nodes.get(&id) {
1991                if let Op::Layout(op::LayoutOp::Scroll { direction, .. }) = &n.op {
1992                    let matches_multiline_config = (multiline_semantics
1993                        && *direction == op::FlexDirection::Column)
1994                        || (!multiline_semantics && *direction == op::FlexDirection::Row);
1995                    if matches_multiline_config {
1996                        let mut q = vec![id]; // Start BFS from scroll node to find text
1997                        while let Some(cid) = q.pop() {
1998                            if let Some(cn) = ir.nodes.get(&cid) {
1999                                if matches!(
2000                                    cn.op,
2001                                    Op::Paint(fission_ir::PaintOp::DrawText { .. })
2002                                        | Op::Paint(fission_ir::PaintOp::DrawRichText { .. })
2003                                ) {
2004                                    return Some((id, cid, *direction));
2005                                }
2006                                for &gc in &cn.children {
2007                                    q.push(gc);
2008                                }
2009                            }
2010                        }
2011                        return None; // Should find text inside. For now, assume it's directly related.
2012                    }
2013                }
2014                for &c in &n.children {
2015                    stack.push(c);
2016                }
2017            }
2018        }
2019        None
2020    }
2021
2022    /// Extract rich text runs from the TextInput's DrawRichText child.
2023    fn extract_rich_runs(
2024        ir: &fission_ir::CoreIR,
2025        semantics_id: WidgetId,
2026    ) -> Option<Vec<fission_ir::op::TextRun>> {
2027        fn walk(
2028            ir: &fission_ir::CoreIR,
2029            node_id: WidgetId,
2030            depth: usize,
2031        ) -> Option<Vec<fission_ir::op::TextRun>> {
2032            if depth > 20 {
2033                return None;
2034            }
2035            let node = ir.nodes.get(&node_id)?;
2036            match &node.op {
2037                Op::Paint(fission_ir::PaintOp::DrawRichText { runs, .. }) if !runs.is_empty() => {
2038                    Some(runs.clone())
2039                }
2040                _ => {
2041                    for child_id in &node.children {
2042                        if let Some(r) = walk(ir, *child_id, depth + 1) {
2043                            return Some(r);
2044                        }
2045                    }
2046                    None
2047                }
2048            }
2049        }
2050        walk(ir, semantics_id, 0)
2051    }
2052
2053    /// Extract the font size from the TextInput's DrawRichText or DrawText child.
2054    fn extract_font_size(ir: &fission_ir::CoreIR, semantics_id: WidgetId) -> Option<f32> {
2055        // Walk children of the semantics node to find a text paint op
2056        fn walk(ir: &fission_ir::CoreIR, node_id: WidgetId, depth: usize) -> Option<f32> {
2057            if depth > 10 {
2058                return None;
2059            }
2060            let node = ir.nodes.get(&node_id)?;
2061            match &node.op {
2062                Op::Paint(fission_ir::PaintOp::DrawText { size, .. }) => Some(*size),
2063                Op::Paint(fission_ir::PaintOp::DrawRichText { runs, .. }) => {
2064                    runs.first().map(|r| r.style.font_size)
2065                }
2066                _ => {
2067                    for child_id in &node.children {
2068                        if let Some(sz) = walk(ir, *child_id, depth + 1) {
2069                            return Some(sz);
2070                        }
2071                    }
2072                    None
2073                }
2074            }
2075        }
2076        walk(ir, semantics_id, 0)
2077    }
2078
2079    /// Shared hit-test logic for both PointerDown and PointerMove.
2080    ///
2081    /// Uses the rich-text layout path when styled runs are available, passing the
2082    /// same `available_width` that the renderer will use so both sides build (or
2083    /// look up) the same Parley `Layout`.  This ensures the Y-to-line and X-to-
2084    /// glyph mapping in hit-testing exactly matches the rendered text.
2085    fn hit_test_text(
2086        measurer: &std::sync::Arc<dyn fission_layout::TextMeasurer>,
2087        ir: &fission_ir::CoreIR,
2088        focused_id: WidgetId,
2089        prefer_plain_text: bool,
2090        text: &str,
2091        scroll_geom: &fission_layout::LayoutNodeGeometry,
2092        local_x: f32,
2093        local_y: f32,
2094    ) -> usize {
2095        let viewport_width = if scroll_geom.rect.size.width > 0.0 {
2096            Some(scroll_geom.rect.size.width)
2097        } else {
2098            None
2099        };
2100        let render_width = viewport_width;
2101        let font_size = Self::extract_font_size(ir, focused_id).unwrap_or(13.0);
2102        let paragraph = Self::extract_paragraph_style(ir, focused_id).unwrap_or_default();
2103
2104        if paragraph.text_align != TextAlign::Start {
2105            let line_metrics = measurer.get_line_metrics(text, font_size, render_width);
2106            if let (Some(width), Some(line)) = (
2107                viewport_width,
2108                Self::line_metric_for_local_y(&line_metrics, local_y),
2109            ) {
2110                let aligned_x =
2111                    local_x - Self::paragraph_line_x_offset(paragraph, width, line.width, false);
2112                return measurer.hit_test(text, font_size, render_width, aligned_x, local_y);
2113            }
2114        }
2115
2116        if !prefer_plain_text {
2117            if let Some(runs) = Self::extract_rich_runs(ir, focused_id) {
2118                return measurer.hit_test_rich(&runs, render_width, local_x, local_y);
2119            }
2120        }
2121        measurer.hit_test(text, font_size, render_width, local_x, local_y)
2122    }
2123
2124    fn caret_from_point_in_text_fallback(
2125        _value: &str,
2126        _font_size: f32,
2127        _viewport_x: f32,
2128        _viewport_w: f32,
2129        _content_w: f32,
2130        _scroll_offset: f32,
2131        _point_x: f32,
2132    ) -> usize {
2133        // Simplified fallback: always return 0 if no proper measurer is available.
2134        // In a real scenario, this would ideally not be hit in interactive UIs.
2135        0
2136    }
2137
2138    pub(crate) fn ime_cursor_area(
2139        ctx: &mut ControllerContext,
2140        text_root: WidgetId,
2141    ) -> Option<fission_layout::LayoutRect> {
2142        let measurer = ctx.measurer?;
2143        let node = ctx.ir.nodes.get(&text_root)?;
2144        let semantics = match &node.op {
2145            Op::Semantics(semantics) => semantics,
2146            _ => return None,
2147        };
2148
2149        let (scroll_id, _text_op_node_id, scroll_direction) =
2150            Self::find_scroll_container_and_text_op(ctx.ir, text_root, semantics.multiline)?;
2151        let scroll_geom = ctx.layout.get_node_geometry(scroll_id)?;
2152        let viewport_size = scroll_geom.rect.size;
2153        let font_size = Self::extract_font_size(ctx.ir, text_root).unwrap_or(16.0);
2154        let display_value = Self::display_value_for_metrics(
2155            ctx,
2156            text_root,
2157            semantics.value.as_deref().unwrap_or(""),
2158        );
2159        let metric_text = if semantics.masked {
2160            Self::mask_text_for_metrics(&display_value)
2161        } else {
2162            display_value.clone()
2163        };
2164
2165        let caret_idx = ctx
2166            .text_edit
2167            .get(text_root)
2168            .map(|state| {
2169                state
2170                    .display_preedit_cursor_range()
2171                    .map(|(_, end)| end)
2172                    .unwrap_or(state.caret)
2173            })
2174            .unwrap_or(0);
2175        let metric_caret_idx = if semantics.masked {
2176            Self::masked_byte_offset_from_source(&display_value, &metric_text, caret_idx)
2177        } else {
2178            caret_idx
2179        };
2180
2181        let paragraph = Self::extract_paragraph_style(ctx.ir, text_root).unwrap_or_default();
2182        let render_width = if scroll_direction == op::FlexDirection::Column {
2183            Some(viewport_size.width)
2184        } else {
2185            None
2186        };
2187        let (caret_x, caret_y) =
2188            measurer.get_caret_position(&metric_text, font_size, render_width, metric_caret_idx);
2189
2190        let line_metrics = measurer.get_line_metrics(&metric_text, font_size, render_width);
2191        let line = Self::line_metric_for_index(&line_metrics, metric_caret_idx)
2192            .map(|(_, line)| line)
2193            .or_else(|| Self::line_metric_for_local_y(&line_metrics, caret_y));
2194        let line_width = line
2195            .map(|line| line.width)
2196            .unwrap_or_else(|| measurer.measure(&metric_text, font_size, render_width).0);
2197        let line_height = line
2198            .map(|line| line.height.max(1.0))
2199            .unwrap_or_else(|| measurer.measure("Tg", font_size, render_width).1.max(1.0));
2200        let is_last_line = line_metrics
2201            .last()
2202            .is_some_and(|last| last.end_index <= metric_caret_idx);
2203        let line_x =
2204            Self::paragraph_line_x_offset(paragraph, viewport_size.width, line_width, is_last_line);
2205
2206        let mut origin_x = scroll_geom.rect.origin.x;
2207        let mut origin_y = scroll_geom.rect.origin.y;
2208        let mut walk = ctx.ir.nodes.get(&scroll_id).and_then(|node| node.parent);
2209        while let Some(parent_id) = walk {
2210            let Some(parent) = ctx.ir.nodes.get(&parent_id) else {
2211                break;
2212            };
2213            if let Op::Layout(LayoutOp::Scroll { direction, .. }) = &parent.op {
2214                let offset = ctx.scroll.get_offset(parent_id);
2215                match direction {
2216                    FlexDirection::Row => origin_x -= offset,
2217                    FlexDirection::Column => origin_y -= offset,
2218                }
2219            }
2220            walk = parent.parent;
2221        }
2222
2223        let own_offset = ctx.scroll.get_offset(scroll_id);
2224        let mut x = origin_x + line_x + caret_x;
2225        let mut y = origin_y + caret_y;
2226        match scroll_direction {
2227            op::FlexDirection::Row => x -= own_offset,
2228            op::FlexDirection::Column => y -= own_offset,
2229        }
2230
2231        if !(x.is_finite() && y.is_finite() && line_height.is_finite()) {
2232            return None;
2233        }
2234
2235        let right_limit = origin_x + viewport_size.width - 2.0;
2236        let bottom_limit = origin_y + viewport_size.height - line_height;
2237        if right_limit >= origin_x {
2238            x = x.clamp(origin_x, right_limit);
2239        }
2240        if bottom_limit >= origin_y {
2241            y = y.clamp(origin_y, bottom_limit);
2242        }
2243
2244        Some(fission_layout::LayoutRect::new(x, y, 2.0, line_height))
2245    }
2246
2247    fn auto_scroll_textinput(ctx: &mut ControllerContext, text_root: WidgetId) {
2248        let font_size = Self::extract_font_size(ctx.ir, text_root).unwrap_or(16.0);
2249        if let Some(measurer) = ctx.measurer {
2250            // Need to get multiline status from semantics here
2251            let is_multiline = if let Some(node) = ctx.ir.nodes.get(&text_root) {
2252                if let Op::Semantics(sem) = &node.op {
2253                    sem.multiline
2254                } else {
2255                    false
2256                }
2257            } else {
2258                false
2259            };
2260
2261            if let Some((scroll_id, _text_op_node_id, scroll_direction)) =
2262                Self::find_scroll_container_and_text_op(ctx.ir, text_root, is_multiline)
2263            {
2264                if let Some(scroll_geom) = ctx.layout.get_node_geometry(scroll_id) {
2265                    let viewport_size = scroll_geom.rect.size;
2266
2267                    let (current_text_value, metric_text, masked, scroll_padding) =
2268                        if let Some(node) = ctx.ir.nodes.get(&text_root) {
2269                            if let Op::Semantics(sem) = &node.op {
2270                                let display_value = Self::display_value_for_metrics(
2271                                    ctx,
2272                                    text_root,
2273                                    sem.value.as_deref().unwrap_or(""),
2274                                );
2275                                let metric_text = if sem.masked {
2276                                    Self::mask_text_for_metrics(&display_value)
2277                                } else {
2278                                    display_value.clone()
2279                                };
2280                                (
2281                                    display_value,
2282                                    metric_text,
2283                                    sem.masked,
2284                                    sem.scroll_padding.unwrap_or([2.0, 3.0, 2.0, 3.0]),
2285                                )
2286                            } else {
2287                                (String::new(), String::new(), false, [2.0, 3.0, 2.0, 3.0])
2288                            }
2289                        } else {
2290                            (String::new(), String::new(), false, [2.0, 3.0, 2.0, 3.0])
2291                        };
2292
2293                    let current_caret_idx = if let Some(st) = ctx.text_edit.get(text_root) {
2294                        st.display_preedit_cursor_range()
2295                            .map(|(_, end)| end)
2296                            .unwrap_or(st.caret)
2297                    } else {
2298                        0
2299                    };
2300                    let metric_caret_idx = if masked {
2301                        Self::masked_byte_offset_from_source(
2302                            &current_text_value,
2303                            &metric_text,
2304                            current_caret_idx,
2305                        )
2306                    } else {
2307                        current_caret_idx
2308                    };
2309                    let paragraph =
2310                        Self::extract_paragraph_style(ctx.ir, text_root).unwrap_or_default();
2311                    let measurer_width = if scroll_direction == op::FlexDirection::Column {
2312                        Some(viewport_size.width)
2313                    } else {
2314                        None
2315                    };
2316
2317                    let (caret_x, caret_y) = measurer.get_caret_position(
2318                        &metric_text,
2319                        font_size,
2320                        measurer_width,
2321                        metric_caret_idx,
2322                    );
2323
2324                    let mut offset = ctx.scroll.get_offset(scroll_id);
2325
2326                    if scroll_direction == op::FlexDirection::Row {
2327                        // Handle horizontal scrolling for single-line text
2328                        let line_width = measurer
2329                            .get_line_metrics(&metric_text, font_size, None)
2330                            .first()
2331                            .map(|line| line.width)
2332                            .unwrap_or_else(|| measurer.measure(&metric_text, font_size, None).0);
2333                        let caret_left = caret_x
2334                            + Self::paragraph_line_x_offset(
2335                                paragraph,
2336                                viewport_size.width,
2337                                line_width,
2338                                false,
2339                            );
2340                        let caret_width = 2.0f32;
2341                        let caret_right = caret_left + caret_width;
2342
2343                        let margin_left = scroll_padding[0].max(0.0);
2344                        let margin_right = scroll_padding[1].max(0.0);
2345
2346                        let visible_left = caret_left - offset;
2347                        let visible_right = caret_right - offset;
2348
2349                        if visible_right > (viewport_size.width - margin_right) {
2350                            offset =
2351                                (caret_right - (viewport_size.width - margin_right)).max(0.0f32);
2352                        } else if visible_left < margin_left {
2353                            offset = (caret_left - margin_left).max(0.0f32);
2354                        }
2355                        let content_w = scroll_geom.content_size.width.max(viewport_size.width);
2356                        let max_offset = (content_w - viewport_size.width).max(0.0f32);
2357                        offset = offset.clamp(0.0f32, max_offset);
2358                        ctx.scroll.set_offset(scroll_id, offset);
2359                    } else {
2360                        // op::FlexDirection::Column
2361                        // Handle vertical scrolling for multi-line text
2362                        let caret_top = caret_y;
2363                        let caret_height = measurer
2364                            .measure("Tg", font_size, Some(viewport_size.width))
2365                            .1;
2366                        let caret_bottom = caret_top + caret_height;
2367
2368                        let margin_top = scroll_padding[2].max(0.0);
2369                        let margin_bottom = scroll_padding[3].max(0.0);
2370
2371                        let visible_top = caret_top - offset;
2372                        let visible_bottom = caret_bottom - offset;
2373
2374                        if visible_bottom > (viewport_size.height - margin_bottom) {
2375                            offset =
2376                                (caret_bottom - (viewport_size.height - margin_bottom)).max(0.0f32);
2377                        } else if visible_top < margin_top {
2378                            offset = (caret_top - margin_top).max(0.0f32);
2379                        }
2380                        let content_h = scroll_geom.content_size.height.max(viewport_size.height);
2381                        let max_offset = (content_h - viewport_size.height).max(0.0f32);
2382                        offset = offset.clamp(0.0f32, max_offset);
2383                        ctx.scroll.set_offset(scroll_id, offset);
2384                    }
2385                }
2386            }
2387        }
2388    }
2389
2390    fn handle_vertical_navigation(
2391        &mut self,
2392        ctx: &mut ControllerContext,
2393        focused_id: WidgetId,
2394        semantics: &Semantics,
2395        value: &str,
2396        caret: usize,
2397        modifiers: u8,
2398        is_up: bool,
2399    ) {
2400        if let Some(measurer) = ctx.measurer {
2401            if let Some((scroll_id, _text_op_node_id, _scroll_direction)) =
2402                Self::find_scroll_container_and_text_op(ctx.ir, focused_id, semantics.multiline)
2403            {
2404                if let Some(scroll_geom) = ctx.layout.get_node_geometry(scroll_id) {
2405                    let viewport_w = scroll_geom.rect.size.width;
2406                    let font_size = Self::extract_font_size(ctx.ir, focused_id).unwrap_or(16.0);
2407
2408                    let (current_caret_x, _current_caret_y) =
2409                        measurer.get_caret_position(value, font_size, Some(viewport_w), caret);
2410
2411                    let line_metrics =
2412                        measurer.get_line_metrics(value, font_size, Some(viewport_w));
2413
2414                    let mut current_line_idx = 0;
2415                    for (idx, line) in line_metrics.iter().enumerate() {
2416                        if caret >= line.start_index && caret <= line.end_index {
2417                            current_line_idx = idx;
2418                            // Don't break: if the caret sits at the boundary
2419                            // between two lines (end of line N == start of
2420                            // line N+1), prefer the later line so empty lines
2421                            // are reachable.
2422                        }
2423                    }
2424
2425                    let target_line_idx = if is_up {
2426                        current_line_idx.saturating_sub(1)
2427                    } else {
2428                        (current_line_idx + 1).min(line_metrics.len().saturating_sub(1))
2429                    };
2430
2431                    if let Some(target_line) = line_metrics.get(target_line_idx) {
2432                        let target_y = target_line.baseline;
2433
2434                        let mut new_caret_pos = measurer.hit_test(
2435                            value,
2436                            font_size,
2437                            Some(viewport_w),
2438                            current_caret_x,
2439                            target_y,
2440                        );
2441
2442                        // Ensure we stay within the target line's bounds.
2443                        // For empty lines (start_index == end_index), this
2444                        // correctly places the cursor at start_index.
2445                        new_caret_pos = new_caret_pos.clamp(
2446                            target_line.start_index,
2447                            target_line.end_index.max(target_line.start_index),
2448                        );
2449
2450                        let st = ctx.text_edit.get_mut_or_default(focused_id);
2451                        st.caret = new_caret_pos;
2452                        if !Self::has_shift(modifiers) {
2453                            st.anchor = new_caret_pos;
2454                        } // If no shift, collapse selection
2455                        let final_anchor = st.anchor;
2456                        Self::auto_scroll_textinput(ctx, focused_id);
2457                        Self::dispatch_cursor_change(
2458                            ctx,
2459                            semantics,
2460                            focused_id,
2461                            new_caret_pos,
2462                            final_anchor,
2463                        );
2464                    }
2465                }
2466            }
2467        }
2468    }
2469
2470    fn handle_page_navigation(
2471        &mut self,
2472        ctx: &mut ControllerContext,
2473        focused_id: WidgetId,
2474        semantics: &Semantics,
2475        value: &str,
2476        caret: usize,
2477        modifiers: u8,
2478        is_page_up: bool,
2479    ) {
2480        if let Some(measurer) = ctx.measurer {
2481            if let Some((scroll_id, _text_op_node_id, _scroll_direction)) =
2482                Self::find_scroll_container_and_text_op(ctx.ir, focused_id, semantics.multiline)
2483            {
2484                if let Some(scroll_geom) = ctx.layout.get_node_geometry(scroll_id) {
2485                    let viewport_w = scroll_geom.rect.size.width;
2486                    let viewport_h = scroll_geom.rect.size.height.max(1.0);
2487                    let font_size = Self::extract_font_size(ctx.ir, focused_id).unwrap_or(16.0);
2488                    let (current_caret_x, _current_caret_y) =
2489                        measurer.get_caret_position(value, font_size, Some(viewport_w), caret);
2490                    let line_metrics =
2491                        measurer.get_line_metrics(value, font_size, Some(viewport_w));
2492
2493                    if line_metrics.is_empty() {
2494                        return;
2495                    }
2496
2497                    let mut current_line_idx = 0usize;
2498                    for (idx, line) in line_metrics.iter().enumerate() {
2499                        if caret >= line.start_index && caret <= line.end_index {
2500                            current_line_idx = idx;
2501                        }
2502                    }
2503
2504                    let line_height = line_metrics
2505                        .get(current_line_idx)
2506                        .map(|line| line.height.max(1.0))
2507                        .unwrap_or(20.0);
2508                    let lines_per_page = (viewport_h / line_height).floor().max(1.0) as isize;
2509                    let delta = if is_page_up {
2510                        -lines_per_page
2511                    } else {
2512                        lines_per_page
2513                    };
2514                    let target_line_idx = current_line_idx
2515                        .saturating_add_signed(delta)
2516                        .min(line_metrics.len().saturating_sub(1));
2517
2518                    if let Some(target_line) = line_metrics.get(target_line_idx) {
2519                        let target_y = target_line.baseline;
2520                        let mut new_caret_pos = measurer.hit_test(
2521                            value,
2522                            font_size,
2523                            Some(viewport_w),
2524                            current_caret_x,
2525                            target_y,
2526                        );
2527                        let target_end = Self::trim_line_end(
2528                            value,
2529                            target_line.end_index.max(target_line.start_index),
2530                        );
2531                        new_caret_pos = new_caret_pos.clamp(
2532                            target_line.start_index,
2533                            target_end.max(target_line.start_index),
2534                        );
2535
2536                        let st = ctx.text_edit.get_mut_or_default(focused_id);
2537                        st.caret = new_caret_pos;
2538                        if !Self::has_shift(modifiers) {
2539                            st.anchor = new_caret_pos;
2540                        }
2541                        let final_anchor = st.anchor;
2542                        Self::auto_scroll_textinput(ctx, focused_id);
2543                        Self::dispatch_cursor_change(
2544                            ctx,
2545                            semantics,
2546                            focused_id,
2547                            new_caret_pos,
2548                            final_anchor,
2549                        );
2550                    }
2551                }
2552            }
2553        }
2554    }
2555
2556    fn extract_paragraph_style(
2557        ir: &fission_ir::CoreIR,
2558        semantics_id: WidgetId,
2559    ) -> Option<TextParagraphStyle> {
2560        fn walk(
2561            ir: &fission_ir::CoreIR,
2562            node_id: WidgetId,
2563            depth: usize,
2564        ) -> Option<TextParagraphStyle> {
2565            if depth > 10 {
2566                return None;
2567            }
2568            let node = ir.nodes.get(&node_id)?;
2569            match &node.op {
2570                Op::Paint(fission_ir::PaintOp::DrawText {
2571                    paragraph_style,
2572                    caret_width,
2573                    ..
2574                }) => paragraph_style.or_else(|| decode_text_paragraph_style(*caret_width)),
2575                Op::Paint(fission_ir::PaintOp::DrawRichText {
2576                    paragraph_style,
2577                    caret_width,
2578                    ..
2579                }) => paragraph_style.or_else(|| decode_text_paragraph_style(*caret_width)),
2580                _ => {
2581                    for child_id in &node.children {
2582                        if let Some(style) = walk(ir, *child_id, depth + 1) {
2583                            return Some(style);
2584                        }
2585                    }
2586                    None
2587                }
2588            }
2589        }
2590        walk(ir, semantics_id, 0)
2591    }
2592
2593    fn line_metric_for_local_y<'a>(
2594        line_metrics: &'a [fission_layout::LineMetric],
2595        local_y: f32,
2596    ) -> Option<&'a fission_layout::LineMetric> {
2597        if line_metrics.is_empty() {
2598            return None;
2599        }
2600        let mut line_top = 0.0f32;
2601        for (index, line) in line_metrics.iter().enumerate() {
2602            let line_height = line.height.max(1.0);
2603            let line_bottom = line_top + line_height;
2604            if local_y < line_bottom || index + 1 == line_metrics.len() {
2605                return Some(line);
2606            }
2607            line_top = line_bottom;
2608        }
2609        line_metrics.last()
2610    }
2611
2612    fn paragraph_line_x_offset(
2613        paragraph: TextParagraphStyle,
2614        bounds_width: f32,
2615        line_width: f32,
2616        is_last_line: bool,
2617    ) -> f32 {
2618        if bounds_width <= 0.0 {
2619            return 0.0;
2620        }
2621
2622        match paragraph.text_align {
2623            TextAlign::Start | TextAlign::Left => 0.0,
2624            TextAlign::Center => (bounds_width - line_width) * 0.5,
2625            TextAlign::End | TextAlign::Right => bounds_width - line_width,
2626            TextAlign::Justify if is_last_line => 0.0,
2627            TextAlign::Justify => 0.0,
2628        }
2629    }
2630}
2631
2632// This pub fn is no longer needed since Controller uses measurer directly in handle_event
2633// But other parts of code might still call it, so keep it.
2634pub fn caret_from_point_in_text(
2635    measurer: Option<&std::sync::Arc<dyn fission_layout::TextMeasurer>>,
2636    value: &str,
2637    font_size: f32,
2638    viewport_x: f32,
2639    viewport_w: f32,
2640    content_w: f32,
2641    scroll_offset: f32,
2642    point_x: f32,
2643) -> usize {
2644    let local_x = (point_x - viewport_x) + scroll_offset;
2645    if local_x <= 0.0 {
2646        return 0;
2647    }
2648    let max_x = content_w.max(viewport_w);
2649    if local_x >= max_x {
2650        return value.len();
2651    }
2652
2653    if let Some(measurer) = measurer {
2654        // This function is for single line mostly. measurer.hit_test is better.
2655        // Single-line hit-testing should not wrap text to the viewport width.
2656        measurer.hit_test(value, font_size, None, local_x, 0.0)
2657    } else {
2658        TextInputController::caret_from_point_in_text_fallback(
2659            value,
2660            font_size,
2661            viewport_x,
2662            viewport_w,
2663            content_w,
2664            scroll_offset,
2665            point_x,
2666        )
2667    }
2668}