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