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