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 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(¤t_value.to_string()).unwrap(),
116 ),
117 );
118 }
119 }
120 }
121 Self::clear_text_input_affordances(ctx, focused_id);
122 None
123 }
124 } else {
125 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 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 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 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 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 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 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 }
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 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 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 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 ¤t_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 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 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 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(¤t_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]; 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; }
2007 }
2008 for &c in &n.children {
2009 stack.push(c);
2010 }
2011 }
2012 }
2013 None
2014 }
2015
2016 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 fn extract_font_size(ir: &fission_ir::CoreIR, semantics_id: NodeId) -> Option<f32> {
2049 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 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 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 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 ¤t_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 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 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 }
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 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 } 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
2515pub 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 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}