Skip to main content

fission_core/input/
text.rs

1use super::{ControllerContext, InputController};
2use crate::event::{InputEvent, KeyCode, KeyEvent, PointerEvent};
3use crate::ActionEnvelope;
4use crate::ActionId;
5use fission_diagnostics::prelude as diag;
6use fission_ir::semantics::InputMask;
7use fission_ir::{
8    op::{self, LayoutOp, Op},
9    NodeId, Semantics,
10};
11use fission_layout::LayoutSnapshot;
12use serde_json;
13use unicode_segmentation::UnicodeSegmentation;
14
15pub struct TextInputController;
16
17impl InputController for TextInputController {
18    fn handle_event(&mut self, ctx: &mut ControllerContext, event: &InputEvent) -> bool {
19        match event {
20            InputEvent::Keyboard(KeyEvent::Down {
21                key_code,
22                modifiers,
23            }) => self.handle_key(ctx, key_code.clone(), *modifiers),
24            InputEvent::Ime(ime) => self.handle_ime(ctx, ime),
25            InputEvent::Pointer(PointerEvent::Down { point, .. }) => {
26                if let Some(focused_id) = ctx.interaction.focused {
27                    if let Some(node) = ctx.ir.nodes.get(&focused_id) {
28                        if let Op::Semantics(sem) = &node.op {
29                            if sem.role == fission_ir::semantics::Role::TextInput {
30                                // Only handle pointer-down as a caret/selection update when the
31                                // pointer is inside the currently focused TextInput.
32                                //
33                                // Otherwise, allow the generic focus logic in `Runtime::handle_input`
34                                // to run so clicks can move focus to other widgets (including other
35                                // TextInputs, buttons, etc).
36                                if let Some(geom) = ctx.layout.get_node_geometry(focused_id) {
37                                    if !geom.rect.contains(*point) {
38                                        return false;
39                                    }
40                                }
41                                if let Some((scroll_id, _text_op_node_id, scroll_direction)) =
42                                    Self::find_scroll_container_and_text_op(
43                                        ctx.ir,
44                                        focused_id,
45                                        sem.multiline,
46                                    )
47                                {
48                                    if let Some(scroll_geom) =
49                                        ctx.layout.get_node_geometry(scroll_id)
50                                    {
51                                        let value = sem.value.as_deref().unwrap_or("");
52                                        let offset = ctx.scroll.get_offset(scroll_id);
53
54                                        let caret = if let Some(measurer) = ctx.measurer {
55                                            let font_size = 16.0;
56                                            let max_width = if sem.multiline
57                                                && scroll_geom.rect.width() > 0.0
58                                            {
59                                                Some(scroll_geom.rect.width())
60                                            } else {
61                                                None
62                                            };
63                                            measurer.hit_test(
64                                                value,
65                                                font_size,
66                                                max_width,
67                                                point.x - scroll_geom.rect.origin.x + offset,
68                                                point.y - scroll_geom.rect.origin.y,
69                                            )
70                                        } else {
71                                            Self::caret_from_point_in_text_fallback(
72                                                value,
73                                                16.0,
74                                                scroll_geom.rect.origin.x,
75                                                scroll_geom.rect.size.width,
76                                                scroll_geom.content_size.width,
77                                                offset,
78                                                point.x,
79                                            )
80                                        };
81                                        let st = ctx.text_edit.get_mut_or_default(focused_id);
82                                        st.caret = caret;
83                                        st.anchor = caret;
84                                        Self::dispatch_cursor_change(ctx, sem, focused_id, caret, caret);
85                                    }
86                                }
87                                return true;
88                            }
89                        }
90                    }
91                }
92                false
93            }
94            InputEvent::Pointer(PointerEvent::Move { point, .. }) => {
95                if let Some(focused_id) = ctx.interaction.focused {
96                    if let Some(node) = ctx.ir.nodes.get(&focused_id) {
97                        if let Op::Semantics(sem) = &node.op {
98                            if sem.role == fission_ir::semantics::Role::TextInput {
99                                if !ctx.interaction.pressed.is_empty() {
100                                    let mut moved_enough = true;
101                                    if let Some(start) = ctx.interaction.last_down_point {
102                                        let dx = point.x - start.x;
103                                        let dy = point.y - start.y;
104                                        if dx * dx + dy * dy < 4.0 {
105                                            moved_enough = false;
106                                        }
107                                    }
108                                    if moved_enough {
109                                        if let Some((
110                                            scroll_id,
111                                            _text_op_node_id,
112                                            scroll_direction,
113                                        )) = Self::find_scroll_container_and_text_op(
114                                            ctx.ir,
115                                            focused_id,
116                                            sem.multiline,
117                                        ) {
118                                            if let Some(scroll_geom) =
119                                                ctx.layout.get_node_geometry(scroll_id)
120                                            {
121                                                let value = sem.value.as_deref().unwrap_or("");
122                                                let offset = ctx.scroll.get_offset(scroll_id);
123                                                let new_caret = if let Some(measurer) = ctx.measurer
124                                                {
125                                                    let font_size = 16.0;
126                                                    let max_width = if sem.multiline
127                                                        && scroll_geom.rect.width() > 0.0
128                                                    {
129                                                        Some(scroll_geom.rect.width())
130                                                    } else {
131                                                        None
132                                                    };
133                                                    measurer.hit_test(
134                                                        value,
135                                                        font_size,
136                                                        max_width,
137                                                        point.x - scroll_geom.rect.origin.x
138                                                            + offset,
139                                                        point.y - scroll_geom.rect.origin.y,
140                                                    )
141                                                } else {
142                                                    Self::caret_from_point_in_text_fallback(
143                                                        value,
144                                                        16.0,
145                                                        scroll_geom.rect.origin.x,
146                                                        scroll_geom.rect.size.width,
147                                                        scroll_geom.content_size.width,
148                                                        offset,
149                                                        point.x,
150                                                    )
151                                                };
152                                                let st =
153                                                    ctx.text_edit.get_mut_or_default(focused_id);
154                                                st.caret = new_caret;
155                                                let current_anchor = st.anchor;
156                                                Self::auto_scroll_textinput(ctx, focused_id);
157                                                Self::dispatch_cursor_change(ctx, sem, focused_id, new_caret, current_anchor);
158                                            }
159                                        }
160                                    }
161                                }
162                                return true;
163                            }
164                        }
165                    }
166                }
167                false
168            }
169            _ => false,
170        }
171    }
172}
173
174impl TextInputController {
175    fn handle_key(
176        &mut self,
177        ctx: &mut ControllerContext,
178        key_code: KeyCode,
179        modifiers: u8,
180    ) -> bool {
181        let focused_id = if let Some(id) = ctx.interaction.focused {
182            id
183        } else {
184            return false;
185        };
186
187        let mut semantics_node = None;
188        let mut current_id = Some(focused_id);
189        while let Some(node_id) = current_id {
190            if let Some(node) = ctx.ir.nodes.get(&node_id) {
191                if let Op::Semantics(s) = &node.op {
192                    if s.role == fission_ir::semantics::Role::TextInput {
193                        semantics_node = Some(s);
194                        break;
195                    }
196                }
197                current_id = node.parent;
198            } else {
199                break;
200            }
201        }
202
203        let semantics = if let Some(s) = semantics_node {
204            s
205        } else {
206            return false;
207        };
208
209        let (value, mut caret, mut anchor) =
210            Self::resolve_editing_value(ctx, focused_id, semantics.value.as_deref().unwrap_or(""));
211
212        caret = Self::clamp_caret_to_value(&value, caret);
213        anchor = Self::clamp_caret_to_value(&value, anchor);
214
215        let sel = if caret != anchor {
216            Some((anchor, caret))
217        } else {
218            None
219        };
220
221        // Logic for state changes
222        let mut next_caret = caret;
223        let mut next_anchor = anchor;
224        let mut next_text: Option<String> = None;
225        let mut handled = false;
226
227        // Undo/Redo logic result
228        let mut undo_redo_result: Option<(String, usize, usize)> = None;
229
230        match key_code {
231            KeyCode::Space => {
232                let (txt, c) = Self::insert_text(&value, caret, sel, " ");
233                next_text = Some(txt);
234                next_caret = c;
235                next_anchor = c;
236                handled = true;
237            }
238            KeyCode::Char(ch) if ((modifiers & 4) != 0) || ((modifiers & 8) != 0) => {
239                let lower = ch.to_ascii_lowercase();
240                let (s, e) = if caret <= anchor {
241                    (caret, anchor)
242                } else {
243                    (anchor, caret)
244                };
245                match lower {
246                    'c' => {
247                        if s != e {
248                            let txt = value[s..e].to_string();
249                            if let Some(cb) = ctx.clipboard {
250                                cb.set_text(&txt);
251                            }
252                        }
253                        handled = true;
254                    }
255                    'x' => {
256                        if s != e {
257                            let txt = value[s..e].to_string();
258                            if let Some(cb) = ctx.clipboard {
259                                cb.set_text(&txt);
260                            }
261                            let mut out = String::with_capacity(value.len() - (e - s));
262                            out.push_str(&value[..s]);
263                            out.push_str(&value[e..]);
264                            next_text = Some(out);
265                            next_caret = s;
266                            next_anchor = s;
267                        }
268                        handled = true;
269                    }
270                    'v' => {
271                        let text_to_paste = if let Some(cb) = ctx.clipboard {
272                            cb.get_text().unwrap_or_default()
273                        } else {
274                            String::new()
275                        };
276                        if !text_to_paste.is_empty() {
277                            let (txt, c) = Self::insert_text(&value, caret, sel, &text_to_paste);
278                            next_text = Some(txt);
279                            next_caret = c;
280                            next_anchor = c;
281                        }
282                        handled = true;
283                    }
284                    'z' => {
285                        let (ctrl_or_super, shift) = (
286                            ((modifiers & 4) != 0) || ((modifiers & 8) != 0),
287                            (modifiers & 1) != 0,
288                        );
289                        if ctrl_or_super {
290                            let st = ctx.text_edit.get_mut_or_default(focused_id);
291                            if shift {
292                                if let Some((v, c, a)) = st.history.redo() {
293                                    undo_redo_result = Some((v.clone(), *c, *a));
294                                }
295                            } else {
296                                if let Some((v, c, a)) = st.history.undo() {
297                                    undo_redo_result = Some((v.clone(), *c, *a));
298                                }
299                            }
300                            handled = true;
301                        }
302                    }
303                    _ => {} // Do nothing for other shortcuts
304                }
305            }
306            KeyCode::Char(c) => {
307                // Check against input mask
308                if let Some(mask) = &semantics.input_mask {
309                    if !mask.is_valid_char(c) {
310                        return true; // Ignore invalid character
311                    }
312                }
313                let (txt, nc) = Self::insert_text(&value, caret, sel, &c.to_string());
314                next_text = Some(txt);
315                next_caret = nc;
316                next_anchor = nc;
317                handled = true;
318            }
319            KeyCode::Backspace => {
320                let (txt, nc) = if (modifiers & 2) != 0 && sel.is_none() {
321                    // Ctrl+Backspace
322                    let mut at = caret;
323                    while at > 0 {
324                        let prev = Self::prev_grapheme_boundary(&value, at);
325                        let ch = value[prev..].chars().next().unwrap_or('\0');
326                        if !ch.is_whitespace() {
327                            at = prev;
328                            break;
329                        }
330                        at = prev;
331                    }
332                    while at > 0 {
333                        let prev = Self::prev_grapheme_boundary(&value, at);
334                        let ch = value[prev..].chars().next().unwrap_or('\0');
335                        if ch.is_alphanumeric() || ch == '_' {
336                            at = prev;
337                        } else {
338                            break;
339                        }
340                    }
341                    let mut out = String::with_capacity(value.len() - (caret - at));
342                    out.push_str(&value[..at]);
343                    out.push_str(&value[caret..]);
344                    (out, at)
345                } else {
346                    Self::delete_prev_grapheme(&value, caret, sel)
347                };
348                next_text = Some(txt);
349                next_caret = nc;
350                next_anchor = nc;
351                handled = true;
352            }
353            KeyCode::Left => {
354                let prev = if (modifiers & 2) != 0 {
355                    // Ctrl+Left
356                    Self::prev_word_boundary(&value, caret)
357                } else {
358                    Self::prev_grapheme_boundary(&value, caret)
359                };
360                next_caret = prev;
361                if (modifiers & 1) != 0 {
362                    next_anchor = anchor;
363                } else {
364                    next_anchor = prev;
365                }
366                handled = true;
367            }
368            KeyCode::Right => {
369                let next = if (modifiers & 2) != 0 {
370                    // Ctrl+Right
371                    Self::next_word_boundary(&value, caret)
372                } else {
373                    Self::next_grapheme_boundary(&value, caret)
374                };
375                next_caret = next;
376                if (modifiers & 1) != 0 {
377                    next_anchor = anchor;
378                } else {
379                    next_anchor = next;
380                }
381                handled = true;
382            }
383            KeyCode::Home => {
384                next_caret = 0;
385                if (modifiers & 1) != 0 {
386                    next_anchor = anchor;
387                } else {
388                    next_anchor = 0;
389                }
390                handled = true;
391            }
392            KeyCode::End => {
393                let end = value.len();
394                next_caret = end;
395                if (modifiers & 1) != 0 {
396                    next_anchor = anchor;
397                } else {
398                    next_anchor = end;
399                }
400                handled = true;
401            }
402            KeyCode::Enter => {
403                if semantics.multiline {
404                    let insert_str = if semantics.auto_indent {
405                        // Find the leading whitespace of the current line
406                        let line_start = value[..caret].rfind('\n').map(|p| p + 1).unwrap_or(0);
407                        let leading: String = value[line_start..]
408                            .chars()
409                            .take_while(|c| *c == ' ' || *c == '\t')
410                            .collect();
411                        format!("\n{}", leading)
412                    } else {
413                        "\n".to_string()
414                    };
415                    let (txt, nc) = Self::insert_text(&value, caret, sel, &insert_str);
416                    next_text = Some(txt);
417                    next_caret = nc;
418                    next_anchor = nc;
419                    handled = true;
420                }
421            }
422            KeyCode::Up => {
423                if semantics.multiline {
424                    self.handle_vertical_navigation(
425                        ctx, focused_id, semantics, &value, caret, modifiers, true,
426                    );
427                    return true; // Return early as handle_vertical_navigation does its own state update
428                }
429            }
430            KeyCode::Down => {
431                if semantics.multiline {
432                    self.handle_vertical_navigation(
433                        ctx, focused_id, semantics, &value, caret, modifiers, false,
434                    );
435                    return true;
436                }
437            }
438            KeyCode::Tab => {
439                if semantics.capture_tab {
440                    let tab_str = "    "; // 4 spaces
441                    let (txt, nc) = Self::insert_text(&value, caret, sel, tab_str);
442                    next_text = Some(txt);
443                    next_caret = nc;
444                    next_anchor = nc;
445                    handled = true;
446                }
447                // If capture_tab is false, fall through (return false) so focus
448                // navigation can handle Tab normally.
449            }
450            _ => {} // Do nothing for other keys
451        }
452
453        if let Some((v, c, a)) = undo_redo_result {
454            // Apply undo/redo result
455            let st = ctx.text_edit.get_mut_or_default(focused_id);
456            st.caret = c;
457            st.anchor = a;
458            st.last_value = v.clone();
459            self.dispatch_change(ctx, semantics, focused_id, v, c);
460            Self::dispatch_cursor_change(ctx, semantics, focused_id, c, a);
461            return true;
462        }
463
464        if let Some(txt) = next_text {
465            // Apply text change
466            let st = ctx.text_edit.get_mut_or_default(focused_id);
467            st.caret = next_caret;
468            st.anchor = next_anchor;
469            st.history.push(txt.clone(), next_caret, next_anchor);
470            st.last_value = txt.clone();
471
472            self.dispatch_change(ctx, semantics, focused_id, txt, next_caret);
473            Self::dispatch_cursor_change(ctx, semantics, focused_id, next_caret, next_anchor);
474        } else if handled {
475            // Cursor movement only
476            let st = ctx.text_edit.get_mut_or_default(focused_id);
477            st.caret = next_caret;
478            st.anchor = next_anchor;
479            Self::auto_scroll_textinput(ctx, focused_id);
480            Self::dispatch_cursor_change(ctx, semantics, focused_id, next_caret, next_anchor);
481        }
482
483        handled
484    }
485
486    fn handle_ime(&mut self, ctx: &mut ControllerContext, ime: &crate::event::ImeEvent) -> bool {
487        match ime {
488            crate::event::ImeEvent::Commit { text } => {
489                if let Some(focused_id) = ctx.interaction.focused {
490                    if let Some(node) = ctx.ir.nodes.get(&focused_id) {
491                        if let Op::Semantics(semantics) = &node.op {
492                            if semantics.role == fission_ir::semantics::Role::TextInput {
493                                let (value, caret, anchor) = Self::resolve_editing_value(
494                                    ctx,
495                                    focused_id,
496                                    semantics.value.as_deref().unwrap_or(""),
497                                );
498                                let st = ctx.text_edit.get_mut_or_default(focused_id);
499                                let caret = Self::clamp_caret_to_value(&value, caret);
500                                let sel = if caret != anchor {
501                                    Some((anchor, caret))
502                                } else {
503                                    None
504                                };
505
506                                let mut filtered_text = String::new();
507                                if let Some(mask) = &semantics.input_mask {
508                                    for ch in text.chars() {
509                                        if mask.is_valid_char(ch) {
510                                            filtered_text.push(ch);
511                                        }
512                                    }
513                                } else {
514                                    filtered_text = text.clone();
515                                }
516
517                                if !filtered_text.is_empty() {
518                                    // Only insert if something valid
519                                    let (new_text, new_caret) =
520                                        Self::insert_text(&value, caret, sel, &filtered_text);
521                                    st.caret = new_caret;
522                                    st.anchor = new_caret;
523                                    st.last_value = new_text.clone();
524                                    st.history.push(new_text.clone(), new_caret, new_caret);
525                                    self.dispatch_change(
526                                        ctx, semantics, focused_id, new_text, new_caret,
527                                    );
528                                }
529
530                                *ctx.ime_preedit = None;
531                                return true;
532                            }
533                        }
534                    }
535                }
536            }
537            crate::event::ImeEvent::Preedit { text } => {
538                if let Some(focused_id) = ctx.interaction.focused {
539                    *ctx.ime_preedit = Some((focused_id, text.clone()));
540                    Self::auto_scroll_textinput(ctx, focused_id);
541                    return true;
542                }
543            }
544        }
545        false
546    }
547
548    fn dispatch_change(
549        &self,
550        ctx: &mut ControllerContext,
551        semantics: &fission_ir::Semantics,
552        node_id: NodeId,
553        new_text: String,
554        new_caret: usize,
555    ) {
556        if let Some(st) = ctx.text_edit.states.get_mut(&node_id) {
557            st.last_value = new_text.clone();
558            st.pending_model_sync = true;
559        }
560
561        if let Some(action_entry) = semantics.actions.entries.iter().find(|e| {
562            e.trigger == fission_ir::semantics::ActionTrigger::Change
563        }) {
564            let payload = serde_json::to_vec(&new_text).unwrap();
565            let envelope = ActionEnvelope {
566                id: ActionId::from_u128(action_entry.action_id),
567                payload,
568            };
569            ctx.dispatched_actions
570                .push((node_id, envelope, crate::ActionInput::None));
571
572            // State update moved to handle_key to avoid double borrow
573
574            Self::auto_scroll_textinput(ctx, node_id);
575        }
576    }
577
578    fn dispatch_cursor_change(
579        ctx: &mut ControllerContext,
580        semantics: &fission_ir::Semantics,
581        node_id: NodeId,
582        new_caret: usize,
583        new_anchor: usize,
584    ) {
585        // Deduplicate: skip dispatch if cursor position hasn't actually changed
586        // since our last dispatch. This prevents unnecessary model updates that
587        // would trigger extra rebuild cycles.
588        if let Some(st) = ctx.text_edit.states.get(&node_id) {
589            if st.last_dispatched_cursor == Some((new_caret, new_anchor)) {
590                return;
591            }
592        }
593
594        if let Some(action_entry) = semantics.actions.entries.iter().find(|e| {
595            e.trigger == fission_ir::semantics::ActionTrigger::CursorChange
596        }) {
597            // Record the dispatched position before dispatching
598            if let Some(st) = ctx.text_edit.states.get_mut(&node_id) {
599                st.last_dispatched_cursor = Some((new_caret, new_anchor));
600            }
601
602            let cursor_changed = crate::action::CursorChanged {
603                caret: new_caret,
604                anchor: new_anchor,
605            };
606            let payload = serde_json::to_vec(&cursor_changed).unwrap();
607            let envelope = ActionEnvelope {
608                id: ActionId::from_u128(action_entry.action_id),
609                payload,
610            };
611            ctx.dispatched_actions
612                .push((node_id, envelope, crate::ActionInput::None));
613        }
614    }
615
616    fn resolve_editing_value(
617        ctx: &mut ControllerContext,
618        focused_id: NodeId,
619        semantic_value: &str,
620    ) -> (String, usize, usize) {
621        let st = ctx.text_edit.get_mut_or_default(focused_id);
622
623        // If the latest lowered semantics value has caught up with local edits,
624        // stop treating local state as newer-than-model.
625        if st.pending_model_sync && st.last_value == semantic_value {
626            st.pending_model_sync = false;
627        }
628
629        // When we are not waiting for model sync, semantics is authoritative.
630        // This picks up external state changes (e.g. programmatic clears/sets).
631        if !st.pending_model_sync && st.last_value != semantic_value {
632            st.last_value = semantic_value.to_string();
633            st.caret = st.caret.min(st.last_value.len());
634            st.anchor = st.anchor.min(st.last_value.len());
635            st.history.push(st.last_value.clone(), st.caret, st.anchor);
636        }
637
638        let value = if st.pending_model_sync {
639            st.last_value.clone()
640        } else {
641            semantic_value.to_string()
642        };
643
644        if st.history.stack.is_empty() {
645            st.history.push(value.clone(), st.caret, st.anchor);
646        }
647
648        (value, st.caret, st.anchor)
649    }
650
651    fn clamp_caret_to_value(value: &str, caret: usize) -> usize {
652        if caret > value.len() {
653            value.len()
654        } else {
655            caret
656        }
657    }
658
659    fn prev_grapheme_boundary(value: &str, idx: usize) -> usize {
660        let mut last = 0;
661        for (pos, _) in value.grapheme_indices(true) {
662            if pos >= idx {
663                break;
664            }
665            last = pos;
666        }
667        last
668    }
669
670    fn next_grapheme_boundary(value: &str, idx: usize) -> usize {
671        for (pos, _) in value.grapheme_indices(true) {
672            if pos > idx {
673                return pos;
674            }
675        }
676        value.len()
677    }
678
679    fn prev_word_boundary(value: &str, idx: usize) -> usize {
680        let mut at = idx.min(value.len());
681        while at > 0 {
682            let prev = Self::prev_grapheme_boundary(value, at);
683            let ch = value[prev..].chars().next().unwrap_or('\0');
684            if !ch.is_whitespace() {
685                at = prev;
686                break;
687            }
688            at = prev;
689        }
690        while at > 0 {
691            let prev = Self::prev_grapheme_boundary(value, at);
692            let ch = value[prev..].chars().next().unwrap_or('\0');
693            if ch.is_alphanumeric() || ch == '_' {
694                at = prev;
695            } else {
696                break;
697            }
698        }
699        at
700    }
701
702    fn next_word_boundary(value: &str, idx: usize) -> usize {
703        let mut at = idx.min(value.len());
704        while at < value.len() {
705            let next = Self::next_grapheme_boundary(value, at);
706            let ch = value[at..].chars().next().unwrap_or('\0');
707            if !ch.is_whitespace() {
708                at = next;
709                break;
710            }
711            at = next;
712        }
713        while at < value.len() {
714            let next = Self::next_grapheme_boundary(value, at);
715            let ch = value[at..].chars().next().unwrap_or('\0');
716            if ch.is_alphanumeric() || ch == '_' {
717                at = next;
718            } else {
719                break;
720            }
721        }
722        at
723    }
724
725    fn delete_prev_grapheme(
726        value: &str,
727        caret: usize,
728        sel: Option<(usize, usize)>,
729    ) -> (String, usize) {
730        if let Some((a, b)) = sel {
731            let (s, e) = if a <= b { (a, b) } else { (b, a) };
732            let mut out = String::with_capacity(value.len() - (e - s));
733            out.push_str(&value[..s]);
734            out.push_str(&value[e..]);
735            return (out, s);
736        }
737        let at = caret.min(value.len());
738        if at == 0 {
739            return (value.to_string(), 0);
740        }
741        let prev = Self::prev_grapheme_boundary(value, at);
742        let mut out = String::with_capacity(value.len() - (at - prev));
743        out.push_str(&value[..prev]);
744        out.push_str(&value[at..]);
745        (out, prev)
746    }
747
748    fn insert_text(
749        value: &str,
750        caret: usize,
751        sel: Option<(usize, usize)>,
752        text: &str,
753    ) -> (String, usize) {
754        let (s, e) = sel
755            .map(|(a, b)| if a <= b { (a, b) } else { (b, a) })
756            .unwrap_or((caret, caret));
757        let mut out = String::with_capacity(value.len() - (e - s) + text.len());
758        out.push_str(&value[..s]);
759        out.push_str(text);
760        out.push_str(&value[e..]);
761        (out, s + text.len())
762    }
763
764    fn find_scroll_container_and_text_op(
765        ir: &fission_ir::CoreIR,
766        root: NodeId,
767        multiline_semantics: bool,
768    ) -> Option<(NodeId, NodeId, op::FlexDirection)> {
769        let mut stack = vec![root];
770        while let Some(id) = stack.pop() {
771            if let Some(n) = ir.nodes.get(&id) {
772                if let Op::Layout(op::LayoutOp::Scroll { direction, .. }) = &n.op {
773                    let matches_multiline_config = (multiline_semantics
774                        && *direction == op::FlexDirection::Column)
775                        || (!multiline_semantics && *direction == op::FlexDirection::Row);
776                    if matches_multiline_config {
777                        let mut q = vec![id]; // Start BFS from scroll node to find text
778                        while let Some(cid) = q.pop() {
779                            if let Some(cn) = ir.nodes.get(&cid) {
780                                if matches!(
781                                    cn.op,
782                                    Op::Paint(fission_ir::PaintOp::DrawText { .. })
783                                        | Op::Paint(fission_ir::PaintOp::DrawRichText { .. })
784                                ) {
785                                    return Some((id, cid, *direction));
786                                }
787                                for &gc in &cn.children {
788                                    q.push(gc);
789                                }
790                            }
791                        }
792                        return None; // Should find text inside. For now, assume it's directly related.
793                    }
794                }
795                for &c in &n.children {
796                    stack.push(c);
797                }
798            }
799        }
800        None
801    }
802
803    fn find_caret_in_scroll(ir: &fission_ir::CoreIR, scroll_id: NodeId) -> Option<NodeId> {
804        let mut q = vec![scroll_id];
805        while let Some(id) = q.pop() {
806            if let Some(n) = ir.nodes.get(&id) {
807                if let Op::Layout(op::LayoutOp::Box { width: Some(w), .. }) = &n.op {
808                    if (*w - 2.0).abs() < 0.01 {
809                        let mut has_paint = false;
810                        for &cid in &n.children {
811                            if let Some(cn) = ir.nodes.get(&cid) {
812                                if let Op::Paint(fission_ir::PaintOp::DrawRect { .. }) = cn.op {
813                                    has_paint = true;
814                                    break;
815                                }
816                            }
817                        }
818                        if has_paint {
819                            return Some(id);
820                        }
821                    }
822                }
823                for &c in &n.children {
824                    q.push(c);
825                }
826            }
827        }
828        None
829    }
830
831    fn caret_from_point_in_text_fallback(
832        value: &str,
833        font_size: f32,
834        viewport_x: f32,
835        viewport_w: f32,
836        content_w: f32,
837        scroll_offset: f32,
838        point_x: f32,
839    ) -> usize {
840        // Simplified fallback: always return 0 if no proper measurer is available.
841        // In a real scenario, this would ideally not be hit in interactive UIs.
842        0
843    }
844
845    fn auto_scroll_textinput(ctx: &mut ControllerContext, text_root: NodeId) {
846        let font_size = 16.0; // Default font size
847        if let Some(measurer) = ctx.measurer {
848            // Need to get multiline status from semantics here
849            let is_multiline = if let Some(node) = ctx.ir.nodes.get(&text_root) {
850                if let Op::Semantics(sem) = &node.op {
851                    sem.multiline
852                } else {
853                    false
854                }
855            } else {
856                false
857            };
858
859            if let Some((scroll_id, _text_op_node_id, scroll_direction)) =
860                Self::find_scroll_container_and_text_op(ctx.ir, text_root, is_multiline)
861            {
862                if let Some(scroll_geom) = ctx.layout.get_node_geometry(scroll_id) {
863                    let viewport_size = scroll_geom.rect.size;
864
865                    let current_text_value = if let Some(node) = ctx.ir.nodes.get(&text_root) {
866                        if let Op::Semantics(sem) = &node.op {
867                            sem.value.clone().unwrap_or_default()
868                        } else {
869                            String::new()
870                        }
871                    } else {
872                        String::new()
873                    };
874
875                    let current_caret_idx = if let Some(st) = ctx.text_edit.get(text_root) {
876                        st.caret
877                    } else {
878                        0
879                    };
880                    let measurer_width = if scroll_direction == op::FlexDirection::Column {
881                        Some(viewport_size.width)
882                    } else {
883                        None
884                    };
885
886                    let (caret_x, caret_y) = measurer.get_caret_position(
887                        &current_text_value,
888                        font_size,
889                        measurer_width,
890                        current_caret_idx,
891                    );
892
893                    let mut offset = ctx.scroll.get_offset(scroll_id);
894
895                    if scroll_direction == op::FlexDirection::Row {
896                        // Handle horizontal scrolling for single-line text
897                        let caret_left = caret_x;
898                        let caret_width = 2.0f32;
899                        let caret_right = caret_left + caret_width;
900
901                        let margin_left = 2.0f32;
902                        let margin_right = 3.0f32;
903
904                        let visible_left = caret_left - offset;
905                        let visible_right = caret_right - offset;
906
907                        if visible_right > (viewport_size.width - margin_right) {
908                            offset =
909                                (caret_right - (viewport_size.width - margin_right)).max(0.0f32);
910                        } else if visible_left < margin_left {
911                            offset = (caret_left - margin_left).max(0.0f32);
912                        }
913                        let content_w = scroll_geom.content_size.width.max(viewport_size.width);
914                        let max_offset = (content_w - viewport_size.width).max(0.0f32);
915                        offset = offset.clamp(0.0f32, max_offset);
916                        ctx.scroll.set_offset(scroll_id, offset);
917                    } else {
918                        // op::FlexDirection::Column
919                        // Handle vertical scrolling for multi-line text
920                        let caret_top = caret_y;
921                        let caret_height = measurer
922                            .measure("Tg", font_size, Some(viewport_size.width))
923                            .1;
924                        let caret_bottom = caret_top + caret_height;
925
926                        let margin_top = 2.0f32;
927                        let margin_bottom = 3.0f32;
928
929                        let visible_top = caret_top - offset;
930                        let visible_bottom = caret_bottom - offset;
931
932                        if visible_bottom > (viewport_size.height - margin_bottom) {
933                            offset =
934                                (caret_bottom - (viewport_size.height - margin_bottom)).max(0.0f32);
935                        } else if visible_top < margin_top {
936                            offset = (caret_top - margin_top).max(0.0f32);
937                        }
938                        let content_h = scroll_geom.content_size.height.max(viewport_size.height);
939                        let max_offset = (content_h - viewport_size.height).max(0.0f32);
940                        offset = offset.clamp(0.0f32, max_offset);
941                        ctx.scroll.set_offset(scroll_id, offset);
942                    }
943                }
944            }
945        }
946    }
947
948    fn handle_vertical_navigation(
949        &mut self,
950        ctx: &mut ControllerContext,
951        focused_id: NodeId,
952        semantics: &Semantics,
953        value: &str,
954        caret: usize,
955        modifiers: u8,
956        is_up: bool,
957    ) {
958        if let Some(measurer) = ctx.measurer {
959            if let Some((scroll_id, _text_op_node_id, scroll_direction)) =
960                Self::find_scroll_container_and_text_op(ctx.ir, focused_id, semantics.multiline)
961            {
962                if let Some(scroll_geom) = ctx.layout.get_node_geometry(scroll_id) {
963                    let viewport_w = scroll_geom.rect.size.width;
964                    let font_size = 16.0;
965
966                    let (current_caret_x, current_caret_y) =
967                        measurer.get_caret_position(value, font_size, Some(viewport_w), caret);
968
969                    let line_metrics =
970                        measurer.get_line_metrics(value, font_size, Some(viewport_w));
971
972                    let mut current_line_idx = 0;
973                    for (idx, line) in line_metrics.iter().enumerate() {
974                        if caret >= line.start_index && caret <= line.end_index {
975                            current_line_idx = idx;
976                            break;
977                        }
978                    }
979
980                    let target_line_idx = if is_up {
981                        current_line_idx.saturating_sub(1)
982                    } else {
983                        (current_line_idx + 1).min(line_metrics.len().saturating_sub(1))
984                    };
985
986                    if let Some(target_line) = line_metrics.get(target_line_idx) {
987                        let target_y = target_line.baseline;
988
989                        let mut new_caret_pos = measurer.hit_test(
990                            value,
991                            font_size,
992                            Some(viewport_w),
993                            current_caret_x,
994                            target_y,
995                        );
996
997                        // Ensure we stay within the target line's bounds
998                        new_caret_pos = new_caret_pos.max(target_line.start_index).min(target_line.end_index);
999
1000                        let st = ctx.text_edit.get_mut_or_default(focused_id);
1001                        st.caret = new_caret_pos;
1002                        if (modifiers & 1) == 0 {
1003                            st.anchor = new_caret_pos;
1004                        } // If no shift, collapse selection
1005                        let final_anchor = st.anchor;
1006                        Self::auto_scroll_textinput(ctx, focused_id);
1007                        Self::dispatch_cursor_change(ctx, semantics, focused_id, new_caret_pos, final_anchor);
1008                    }
1009                }
1010            }
1011        }
1012    }
1013}
1014
1015// This pub fn is no longer needed since Controller uses measurer directly in handle_event
1016// But other parts of code might still call it, so keep it.
1017pub fn caret_from_point_in_text(
1018    measurer: Option<&std::sync::Arc<dyn fission_layout::TextMeasurer>>,
1019    value: &str,
1020    font_size: f32,
1021    viewport_x: f32,
1022    viewport_w: f32,
1023    content_w: f32,
1024    scroll_offset: f32,
1025    point_x: f32,
1026) -> usize {
1027    let local_x = (point_x - viewport_x) + scroll_offset;
1028    if local_x <= 0.0 {
1029        return 0;
1030    }
1031    let max_x = content_w.max(viewport_w);
1032    if local_x >= max_x {
1033        return value.len();
1034    }
1035
1036    if let Some(measurer) = measurer {
1037        // This function is for single line mostly. measurer.hit_test is better.
1038        // Single-line hit-testing should not wrap text to the viewport width.
1039        measurer.hit_test(value, font_size, None, local_x, 0.0)
1040    } else {
1041        TextInputController::caret_from_point_in_text_fallback(
1042            value,
1043            font_size,
1044            viewport_x,
1045            viewport_w,
1046            content_w,
1047            scroll_offset,
1048            point_x,
1049        )
1050    }
1051}