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