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 let (new_caret, new_anchor) = ctx
1759 .text_edit
1760 .get(node_id)
1761 .map(|state| (state.caret, state.anchor))
1762 .unwrap_or((new_text.len(), new_text.len()));
1763 if let Some((envelope, input)) = crate::input::prepare_scoped_text_input_change(
1764 ctx.ir, semantics, node_id, new_text, new_caret, new_anchor,
1765 ) {
1766 ctx.dispatched_actions.push((node_id, envelope, input));
1767
1768 Self::auto_scroll_textinput(ctx, node_id);
1771 }
1772 }
1773
1774 fn dispatch_cursor_change(
1775 ctx: &mut ControllerContext,
1776 semantics: &fission_ir::Semantics,
1777 node_id: WidgetId,
1778 new_caret: usize,
1779 new_anchor: usize,
1780 ) {
1781 if let Some(st) = ctx.text_edit.states.get(&node_id) {
1785 if st.last_dispatched_cursor == Some((new_caret, new_anchor)) {
1786 return;
1787 }
1788 }
1789
1790 Self::persist_runtime_state(ctx, node_id);
1791
1792 if let Some(action_entry) = semantics
1793 .actions
1794 .entries
1795 .iter()
1796 .find(|e| e.trigger == fission_ir::semantics::ActionTrigger::CursorChange)
1797 {
1798 if let Some(st) = ctx.text_edit.states.get_mut(&node_id) {
1800 st.last_dispatched_cursor = Some((new_caret, new_anchor));
1801 }
1802
1803 let cursor_changed = crate::action::CursorChanged {
1804 caret: new_caret,
1805 anchor: new_anchor,
1806 };
1807 let payload = serde_json::to_vec(&cursor_changed).unwrap();
1808 let envelope = ActionEnvelope {
1809 id: ActionId::from_u128(action_entry.action_id),
1810 payload,
1811 };
1812 let input =
1813 crate::input::scoped_action_input(ctx.ir, node_id, crate::ActionInput::None);
1814 ctx.dispatched_actions.push((node_id, envelope, input));
1815 }
1816 }
1817
1818 fn dispatch_submit(
1819 ctx: &mut ControllerContext,
1820 semantics: &fission_ir::Semantics,
1821 node_id: WidgetId,
1822 current_value: &str,
1823 ) -> bool {
1824 let mut dispatched = false;
1825 for trigger in [
1826 fission_ir::semantics::ActionTrigger::EditingComplete,
1827 fission_ir::semantics::ActionTrigger::Submit,
1828 ] {
1829 dispatched |= Self::dispatch_action_for_trigger(
1830 ctx,
1831 semantics,
1832 node_id,
1833 trigger,
1834 Some(serde_json::to_vec(¤t_value.to_string()).unwrap()),
1835 );
1836 }
1837 dispatched
1838 }
1839
1840 fn dispatch_action_for_trigger(
1841 ctx: &mut ControllerContext,
1842 semantics: &fission_ir::Semantics,
1843 node_id: WidgetId,
1844 trigger: fission_ir::semantics::ActionTrigger,
1845 fallback_payload: Option<Vec<u8>>,
1846 ) -> bool {
1847 let Some(action_entry) = semantics
1848 .actions
1849 .entries
1850 .iter()
1851 .find(|e| e.trigger == trigger)
1852 else {
1853 return false;
1854 };
1855 let payload = action_entry
1856 .payload_data
1857 .clone()
1858 .or(fallback_payload)
1859 .unwrap_or_else(|| serde_json::to_vec(&()).unwrap());
1860 let envelope = ActionEnvelope {
1861 id: ActionId::from_u128(action_entry.action_id),
1862 payload,
1863 };
1864 let input = crate::input::scoped_action_input(ctx.ir, node_id, crate::ActionInput::None);
1865 ctx.dispatched_actions.push((node_id, envelope, input));
1866 true
1867 }
1868
1869 fn resolve_editing_value(
1870 ctx: &mut ControllerContext,
1871 focused_id: WidgetId,
1872 semantic_value: &str,
1873 ) -> (String, usize, usize) {
1874 Self::sync_runtime_state(ctx, focused_id, semantic_value);
1875 let st = ctx.text_edit.get_mut_or_default(focused_id);
1876 let value = st.committed_text();
1877 (value, st.caret, st.anchor)
1878 }
1879
1880 fn display_value_for_metrics(
1881 ctx: &mut ControllerContext,
1882 focused_id: WidgetId,
1883 semantic_value: &str,
1884 ) -> String {
1885 Self::sync_runtime_state(ctx, focused_id, semantic_value);
1886 let state = ctx.text_edit.get_mut_or_default(focused_id);
1887 state.display_text().0
1888 }
1889
1890 fn mask_text_for_metrics(text: &str) -> String {
1891 let mut masked = String::new();
1892 for _ in text.graphemes(true) {
1893 masked.push('•');
1894 }
1895 masked
1896 }
1897
1898 fn masked_byte_offset_from_source(
1899 source: &str,
1900 masked: &str,
1901 source_byte_offset: usize,
1902 ) -> usize {
1903 let clamped = source_byte_offset.min(source.len());
1904 let grapheme_count = source[..clamped].graphemes(true).count();
1905 masked
1906 .grapheme_indices(true)
1907 .nth(grapheme_count)
1908 .map(|(idx, _)| idx)
1909 .unwrap_or(masked.len())
1910 }
1911
1912 fn source_byte_offset_from_masked(
1913 source: &str,
1914 masked: &str,
1915 masked_byte_offset: usize,
1916 ) -> usize {
1917 let clamped = masked_byte_offset.min(masked.len());
1918 let grapheme_count = masked[..clamped].graphemes(true).count();
1919 source
1920 .grapheme_indices(true)
1921 .nth(grapheme_count)
1922 .map(|(idx, _)| idx)
1923 .unwrap_or(source.len())
1924 }
1925
1926 fn clamp_caret_to_value(value: &str, caret: usize) -> usize {
1927 if caret > value.len() {
1928 value.len()
1929 } else {
1930 caret
1931 }
1932 }
1933
1934 fn prev_grapheme_boundary(value: &str, idx: usize) -> usize {
1935 let mut last = 0;
1936 for (pos, _) in value.grapheme_indices(true) {
1937 if pos >= idx {
1938 break;
1939 }
1940 last = pos;
1941 }
1942 last
1943 }
1944
1945 fn next_grapheme_boundary(value: &str, idx: usize) -> usize {
1946 for (pos, _) in value.grapheme_indices(true) {
1947 if pos > idx {
1948 return pos;
1949 }
1950 }
1951 value.len()
1952 }
1953
1954 fn prev_word_boundary(value: &str, idx: usize) -> usize {
1955 let at = idx.min(value.len());
1956 let segments: Vec<(usize, &str)> = value.split_word_bound_indices().collect();
1957 for (start, segment) in segments.into_iter().rev() {
1958 let end = start + segment.len();
1959 if end > at {
1960 continue;
1961 }
1962 if segment.chars().any(|ch| ch.is_alphanumeric() || ch == '_') {
1963 return start;
1964 }
1965 }
1966 0
1967 }
1968
1969 fn next_word_boundary(value: &str, idx: usize) -> usize {
1970 let at = idx.min(value.len());
1971 for (start, segment) in value.split_word_bound_indices() {
1972 let end = start + segment.len();
1973 if end <= at {
1974 continue;
1975 }
1976 if segment.chars().any(|ch| ch.is_alphanumeric() || ch == '_') {
1977 return end;
1978 }
1979 }
1980 value.len()
1981 }
1982
1983 fn find_scroll_container_and_text_op(
1984 ir: &fission_ir::CoreIR,
1985 root: WidgetId,
1986 multiline_semantics: bool,
1987 ) -> Option<(WidgetId, WidgetId, op::FlexDirection)> {
1988 let mut stack = vec![root];
1989 while let Some(id) = stack.pop() {
1990 if let Some(n) = ir.nodes.get(&id) {
1991 if let Op::Layout(op::LayoutOp::Scroll { direction, .. }) = &n.op {
1992 let matches_multiline_config = (multiline_semantics
1993 && *direction == op::FlexDirection::Column)
1994 || (!multiline_semantics && *direction == op::FlexDirection::Row);
1995 if matches_multiline_config {
1996 let mut q = vec![id]; while let Some(cid) = q.pop() {
1998 if let Some(cn) = ir.nodes.get(&cid) {
1999 if matches!(
2000 cn.op,
2001 Op::Paint(fission_ir::PaintOp::DrawText { .. })
2002 | Op::Paint(fission_ir::PaintOp::DrawRichText { .. })
2003 ) {
2004 return Some((id, cid, *direction));
2005 }
2006 for &gc in &cn.children {
2007 q.push(gc);
2008 }
2009 }
2010 }
2011 return None; }
2013 }
2014 for &c in &n.children {
2015 stack.push(c);
2016 }
2017 }
2018 }
2019 None
2020 }
2021
2022 fn extract_rich_runs(
2024 ir: &fission_ir::CoreIR,
2025 semantics_id: WidgetId,
2026 ) -> Option<Vec<fission_ir::op::TextRun>> {
2027 fn walk(
2028 ir: &fission_ir::CoreIR,
2029 node_id: WidgetId,
2030 depth: usize,
2031 ) -> Option<Vec<fission_ir::op::TextRun>> {
2032 if depth > 20 {
2033 return None;
2034 }
2035 let node = ir.nodes.get(&node_id)?;
2036 match &node.op {
2037 Op::Paint(fission_ir::PaintOp::DrawRichText { runs, .. }) if !runs.is_empty() => {
2038 Some(runs.clone())
2039 }
2040 _ => {
2041 for child_id in &node.children {
2042 if let Some(r) = walk(ir, *child_id, depth + 1) {
2043 return Some(r);
2044 }
2045 }
2046 None
2047 }
2048 }
2049 }
2050 walk(ir, semantics_id, 0)
2051 }
2052
2053 fn extract_font_size(ir: &fission_ir::CoreIR, semantics_id: WidgetId) -> Option<f32> {
2055 fn walk(ir: &fission_ir::CoreIR, node_id: WidgetId, depth: usize) -> Option<f32> {
2057 if depth > 10 {
2058 return None;
2059 }
2060 let node = ir.nodes.get(&node_id)?;
2061 match &node.op {
2062 Op::Paint(fission_ir::PaintOp::DrawText { size, .. }) => Some(*size),
2063 Op::Paint(fission_ir::PaintOp::DrawRichText { runs, .. }) => {
2064 runs.first().map(|r| r.style.font_size)
2065 }
2066 _ => {
2067 for child_id in &node.children {
2068 if let Some(sz) = walk(ir, *child_id, depth + 1) {
2069 return Some(sz);
2070 }
2071 }
2072 None
2073 }
2074 }
2075 }
2076 walk(ir, semantics_id, 0)
2077 }
2078
2079 fn hit_test_text(
2086 measurer: &std::sync::Arc<dyn fission_layout::TextMeasurer>,
2087 ir: &fission_ir::CoreIR,
2088 focused_id: WidgetId,
2089 prefer_plain_text: bool,
2090 text: &str,
2091 scroll_geom: &fission_layout::LayoutNodeGeometry,
2092 local_x: f32,
2093 local_y: f32,
2094 ) -> usize {
2095 let viewport_width = if scroll_geom.rect.size.width > 0.0 {
2096 Some(scroll_geom.rect.size.width)
2097 } else {
2098 None
2099 };
2100 let render_width = viewport_width;
2101 let font_size = Self::extract_font_size(ir, focused_id).unwrap_or(13.0);
2102 let paragraph = Self::extract_paragraph_style(ir, focused_id).unwrap_or_default();
2103
2104 if paragraph.text_align != TextAlign::Start {
2105 let line_metrics = measurer.get_line_metrics(text, font_size, render_width);
2106 if let (Some(width), Some(line)) = (
2107 viewport_width,
2108 Self::line_metric_for_local_y(&line_metrics, local_y),
2109 ) {
2110 let aligned_x =
2111 local_x - Self::paragraph_line_x_offset(paragraph, width, line.width, false);
2112 return measurer.hit_test(text, font_size, render_width, aligned_x, local_y);
2113 }
2114 }
2115
2116 if !prefer_plain_text {
2117 if let Some(runs) = Self::extract_rich_runs(ir, focused_id) {
2118 return measurer.hit_test_rich(&runs, render_width, local_x, local_y);
2119 }
2120 }
2121 measurer.hit_test(text, font_size, render_width, local_x, local_y)
2122 }
2123
2124 fn caret_from_point_in_text_fallback(
2125 _value: &str,
2126 _font_size: f32,
2127 _viewport_x: f32,
2128 _viewport_w: f32,
2129 _content_w: f32,
2130 _scroll_offset: f32,
2131 _point_x: f32,
2132 ) -> usize {
2133 0
2136 }
2137
2138 pub(crate) fn ime_cursor_area(
2139 ctx: &mut ControllerContext,
2140 text_root: WidgetId,
2141 ) -> Option<fission_layout::LayoutRect> {
2142 let measurer = ctx.measurer?;
2143 let node = ctx.ir.nodes.get(&text_root)?;
2144 let semantics = match &node.op {
2145 Op::Semantics(semantics) => semantics,
2146 _ => return None,
2147 };
2148
2149 let (scroll_id, _text_op_node_id, scroll_direction) =
2150 Self::find_scroll_container_and_text_op(ctx.ir, text_root, semantics.multiline)?;
2151 let scroll_geom = ctx.layout.get_node_geometry(scroll_id)?;
2152 let viewport_size = scroll_geom.rect.size;
2153 let font_size = Self::extract_font_size(ctx.ir, text_root).unwrap_or(16.0);
2154 let display_value = Self::display_value_for_metrics(
2155 ctx,
2156 text_root,
2157 semantics.value.as_deref().unwrap_or(""),
2158 );
2159 let metric_text = if semantics.masked {
2160 Self::mask_text_for_metrics(&display_value)
2161 } else {
2162 display_value.clone()
2163 };
2164
2165 let caret_idx = ctx
2166 .text_edit
2167 .get(text_root)
2168 .map(|state| {
2169 state
2170 .display_preedit_cursor_range()
2171 .map(|(_, end)| end)
2172 .unwrap_or(state.caret)
2173 })
2174 .unwrap_or(0);
2175 let metric_caret_idx = if semantics.masked {
2176 Self::masked_byte_offset_from_source(&display_value, &metric_text, caret_idx)
2177 } else {
2178 caret_idx
2179 };
2180
2181 let paragraph = Self::extract_paragraph_style(ctx.ir, text_root).unwrap_or_default();
2182 let render_width = if scroll_direction == op::FlexDirection::Column {
2183 Some(viewport_size.width)
2184 } else {
2185 None
2186 };
2187 let (caret_x, caret_y) =
2188 measurer.get_caret_position(&metric_text, font_size, render_width, metric_caret_idx);
2189
2190 let line_metrics = measurer.get_line_metrics(&metric_text, font_size, render_width);
2191 let line = Self::line_metric_for_index(&line_metrics, metric_caret_idx)
2192 .map(|(_, line)| line)
2193 .or_else(|| Self::line_metric_for_local_y(&line_metrics, caret_y));
2194 let line_width = line
2195 .map(|line| line.width)
2196 .unwrap_or_else(|| measurer.measure(&metric_text, font_size, render_width).0);
2197 let line_height = line
2198 .map(|line| line.height.max(1.0))
2199 .unwrap_or_else(|| measurer.measure("Tg", font_size, render_width).1.max(1.0));
2200 let is_last_line = line_metrics
2201 .last()
2202 .is_some_and(|last| last.end_index <= metric_caret_idx);
2203 let line_x =
2204 Self::paragraph_line_x_offset(paragraph, viewport_size.width, line_width, is_last_line);
2205
2206 let mut origin_x = scroll_geom.rect.origin.x;
2207 let mut origin_y = scroll_geom.rect.origin.y;
2208 let mut walk = ctx.ir.nodes.get(&scroll_id).and_then(|node| node.parent);
2209 while let Some(parent_id) = walk {
2210 let Some(parent) = ctx.ir.nodes.get(&parent_id) else {
2211 break;
2212 };
2213 if let Op::Layout(LayoutOp::Scroll { direction, .. }) = &parent.op {
2214 let offset = ctx.scroll.get_offset(parent_id);
2215 match direction {
2216 FlexDirection::Row => origin_x -= offset,
2217 FlexDirection::Column => origin_y -= offset,
2218 }
2219 }
2220 walk = parent.parent;
2221 }
2222
2223 let own_offset = ctx.scroll.get_offset(scroll_id);
2224 let mut x = origin_x + line_x + caret_x;
2225 let mut y = origin_y + caret_y;
2226 match scroll_direction {
2227 op::FlexDirection::Row => x -= own_offset,
2228 op::FlexDirection::Column => y -= own_offset,
2229 }
2230
2231 if !(x.is_finite() && y.is_finite() && line_height.is_finite()) {
2232 return None;
2233 }
2234
2235 let right_limit = origin_x + viewport_size.width - 2.0;
2236 let bottom_limit = origin_y + viewport_size.height - line_height;
2237 if right_limit >= origin_x {
2238 x = x.clamp(origin_x, right_limit);
2239 }
2240 if bottom_limit >= origin_y {
2241 y = y.clamp(origin_y, bottom_limit);
2242 }
2243
2244 Some(fission_layout::LayoutRect::new(x, y, 2.0, line_height))
2245 }
2246
2247 fn auto_scroll_textinput(ctx: &mut ControllerContext, text_root: WidgetId) {
2248 let font_size = Self::extract_font_size(ctx.ir, text_root).unwrap_or(16.0);
2249 if let Some(measurer) = ctx.measurer {
2250 let is_multiline = if let Some(node) = ctx.ir.nodes.get(&text_root) {
2252 if let Op::Semantics(sem) = &node.op {
2253 sem.multiline
2254 } else {
2255 false
2256 }
2257 } else {
2258 false
2259 };
2260
2261 if let Some((scroll_id, _text_op_node_id, scroll_direction)) =
2262 Self::find_scroll_container_and_text_op(ctx.ir, text_root, is_multiline)
2263 {
2264 if let Some(scroll_geom) = ctx.layout.get_node_geometry(scroll_id) {
2265 let viewport_size = scroll_geom.rect.size;
2266
2267 let (current_text_value, metric_text, masked, scroll_padding) =
2268 if let Some(node) = ctx.ir.nodes.get(&text_root) {
2269 if let Op::Semantics(sem) = &node.op {
2270 let display_value = Self::display_value_for_metrics(
2271 ctx,
2272 text_root,
2273 sem.value.as_deref().unwrap_or(""),
2274 );
2275 let metric_text = if sem.masked {
2276 Self::mask_text_for_metrics(&display_value)
2277 } else {
2278 display_value.clone()
2279 };
2280 (
2281 display_value,
2282 metric_text,
2283 sem.masked,
2284 sem.scroll_padding.unwrap_or([2.0, 3.0, 2.0, 3.0]),
2285 )
2286 } else {
2287 (String::new(), String::new(), false, [2.0, 3.0, 2.0, 3.0])
2288 }
2289 } else {
2290 (String::new(), String::new(), false, [2.0, 3.0, 2.0, 3.0])
2291 };
2292
2293 let current_caret_idx = if let Some(st) = ctx.text_edit.get(text_root) {
2294 st.display_preedit_cursor_range()
2295 .map(|(_, end)| end)
2296 .unwrap_or(st.caret)
2297 } else {
2298 0
2299 };
2300 let metric_caret_idx = if masked {
2301 Self::masked_byte_offset_from_source(
2302 ¤t_text_value,
2303 &metric_text,
2304 current_caret_idx,
2305 )
2306 } else {
2307 current_caret_idx
2308 };
2309 let paragraph =
2310 Self::extract_paragraph_style(ctx.ir, text_root).unwrap_or_default();
2311 let measurer_width = if scroll_direction == op::FlexDirection::Column {
2312 Some(viewport_size.width)
2313 } else {
2314 None
2315 };
2316
2317 let (caret_x, caret_y) = measurer.get_caret_position(
2318 &metric_text,
2319 font_size,
2320 measurer_width,
2321 metric_caret_idx,
2322 );
2323
2324 let mut offset = ctx.scroll.get_offset(scroll_id);
2325
2326 if scroll_direction == op::FlexDirection::Row {
2327 let line_width = measurer
2329 .get_line_metrics(&metric_text, font_size, None)
2330 .first()
2331 .map(|line| line.width)
2332 .unwrap_or_else(|| measurer.measure(&metric_text, font_size, None).0);
2333 let caret_left = caret_x
2334 + Self::paragraph_line_x_offset(
2335 paragraph,
2336 viewport_size.width,
2337 line_width,
2338 false,
2339 );
2340 let caret_width = 2.0f32;
2341 let caret_right = caret_left + caret_width;
2342
2343 let margin_left = scroll_padding[0].max(0.0);
2344 let margin_right = scroll_padding[1].max(0.0);
2345
2346 let visible_left = caret_left - offset;
2347 let visible_right = caret_right - offset;
2348
2349 if visible_right > (viewport_size.width - margin_right) {
2350 offset =
2351 (caret_right - (viewport_size.width - margin_right)).max(0.0f32);
2352 } else if visible_left < margin_left {
2353 offset = (caret_left - margin_left).max(0.0f32);
2354 }
2355 let content_w = scroll_geom.content_size.width.max(viewport_size.width);
2356 let max_offset = (content_w - viewport_size.width).max(0.0f32);
2357 offset = offset.clamp(0.0f32, max_offset);
2358 ctx.scroll.set_offset(scroll_id, offset);
2359 } else {
2360 let caret_top = caret_y;
2363 let caret_height = measurer
2364 .measure("Tg", font_size, Some(viewport_size.width))
2365 .1;
2366 let caret_bottom = caret_top + caret_height;
2367
2368 let margin_top = scroll_padding[2].max(0.0);
2369 let margin_bottom = scroll_padding[3].max(0.0);
2370
2371 let visible_top = caret_top - offset;
2372 let visible_bottom = caret_bottom - offset;
2373
2374 if visible_bottom > (viewport_size.height - margin_bottom) {
2375 offset =
2376 (caret_bottom - (viewport_size.height - margin_bottom)).max(0.0f32);
2377 } else if visible_top < margin_top {
2378 offset = (caret_top - margin_top).max(0.0f32);
2379 }
2380 let content_h = scroll_geom.content_size.height.max(viewport_size.height);
2381 let max_offset = (content_h - viewport_size.height).max(0.0f32);
2382 offset = offset.clamp(0.0f32, max_offset);
2383 ctx.scroll.set_offset(scroll_id, offset);
2384 }
2385 }
2386 }
2387 }
2388 }
2389
2390 fn handle_vertical_navigation(
2391 &mut self,
2392 ctx: &mut ControllerContext,
2393 focused_id: WidgetId,
2394 semantics: &Semantics,
2395 value: &str,
2396 caret: usize,
2397 modifiers: u8,
2398 is_up: bool,
2399 ) {
2400 if let Some(measurer) = ctx.measurer {
2401 if let Some((scroll_id, _text_op_node_id, _scroll_direction)) =
2402 Self::find_scroll_container_and_text_op(ctx.ir, focused_id, semantics.multiline)
2403 {
2404 if let Some(scroll_geom) = ctx.layout.get_node_geometry(scroll_id) {
2405 let viewport_w = scroll_geom.rect.size.width;
2406 let font_size = Self::extract_font_size(ctx.ir, focused_id).unwrap_or(16.0);
2407
2408 let (current_caret_x, _current_caret_y) =
2409 measurer.get_caret_position(value, font_size, Some(viewport_w), caret);
2410
2411 let line_metrics =
2412 measurer.get_line_metrics(value, font_size, Some(viewport_w));
2413
2414 let mut current_line_idx = 0;
2415 for (idx, line) in line_metrics.iter().enumerate() {
2416 if caret >= line.start_index && caret <= line.end_index {
2417 current_line_idx = idx;
2418 }
2423 }
2424
2425 let target_line_idx = if is_up {
2426 current_line_idx.saturating_sub(1)
2427 } else {
2428 (current_line_idx + 1).min(line_metrics.len().saturating_sub(1))
2429 };
2430
2431 if let Some(target_line) = line_metrics.get(target_line_idx) {
2432 let target_y = target_line.baseline;
2433
2434 let mut new_caret_pos = measurer.hit_test(
2435 value,
2436 font_size,
2437 Some(viewport_w),
2438 current_caret_x,
2439 target_y,
2440 );
2441
2442 new_caret_pos = new_caret_pos.clamp(
2446 target_line.start_index,
2447 target_line.end_index.max(target_line.start_index),
2448 );
2449
2450 let st = ctx.text_edit.get_mut_or_default(focused_id);
2451 st.caret = new_caret_pos;
2452 if !Self::has_shift(modifiers) {
2453 st.anchor = new_caret_pos;
2454 } let final_anchor = st.anchor;
2456 Self::auto_scroll_textinput(ctx, focused_id);
2457 Self::dispatch_cursor_change(
2458 ctx,
2459 semantics,
2460 focused_id,
2461 new_caret_pos,
2462 final_anchor,
2463 );
2464 }
2465 }
2466 }
2467 }
2468 }
2469
2470 fn handle_page_navigation(
2471 &mut self,
2472 ctx: &mut ControllerContext,
2473 focused_id: WidgetId,
2474 semantics: &Semantics,
2475 value: &str,
2476 caret: usize,
2477 modifiers: u8,
2478 is_page_up: bool,
2479 ) {
2480 if let Some(measurer) = ctx.measurer {
2481 if let Some((scroll_id, _text_op_node_id, _scroll_direction)) =
2482 Self::find_scroll_container_and_text_op(ctx.ir, focused_id, semantics.multiline)
2483 {
2484 if let Some(scroll_geom) = ctx.layout.get_node_geometry(scroll_id) {
2485 let viewport_w = scroll_geom.rect.size.width;
2486 let viewport_h = scroll_geom.rect.size.height.max(1.0);
2487 let font_size = Self::extract_font_size(ctx.ir, focused_id).unwrap_or(16.0);
2488 let (current_caret_x, _current_caret_y) =
2489 measurer.get_caret_position(value, font_size, Some(viewport_w), caret);
2490 let line_metrics =
2491 measurer.get_line_metrics(value, font_size, Some(viewport_w));
2492
2493 if line_metrics.is_empty() {
2494 return;
2495 }
2496
2497 let mut current_line_idx = 0usize;
2498 for (idx, line) in line_metrics.iter().enumerate() {
2499 if caret >= line.start_index && caret <= line.end_index {
2500 current_line_idx = idx;
2501 }
2502 }
2503
2504 let line_height = line_metrics
2505 .get(current_line_idx)
2506 .map(|line| line.height.max(1.0))
2507 .unwrap_or(20.0);
2508 let lines_per_page = (viewport_h / line_height).floor().max(1.0) as isize;
2509 let delta = if is_page_up {
2510 -lines_per_page
2511 } else {
2512 lines_per_page
2513 };
2514 let target_line_idx = current_line_idx
2515 .saturating_add_signed(delta)
2516 .min(line_metrics.len().saturating_sub(1));
2517
2518 if let Some(target_line) = line_metrics.get(target_line_idx) {
2519 let target_y = target_line.baseline;
2520 let mut new_caret_pos = measurer.hit_test(
2521 value,
2522 font_size,
2523 Some(viewport_w),
2524 current_caret_x,
2525 target_y,
2526 );
2527 let target_end = Self::trim_line_end(
2528 value,
2529 target_line.end_index.max(target_line.start_index),
2530 );
2531 new_caret_pos = new_caret_pos.clamp(
2532 target_line.start_index,
2533 target_end.max(target_line.start_index),
2534 );
2535
2536 let st = ctx.text_edit.get_mut_or_default(focused_id);
2537 st.caret = new_caret_pos;
2538 if !Self::has_shift(modifiers) {
2539 st.anchor = new_caret_pos;
2540 }
2541 let final_anchor = st.anchor;
2542 Self::auto_scroll_textinput(ctx, focused_id);
2543 Self::dispatch_cursor_change(
2544 ctx,
2545 semantics,
2546 focused_id,
2547 new_caret_pos,
2548 final_anchor,
2549 );
2550 }
2551 }
2552 }
2553 }
2554 }
2555
2556 fn extract_paragraph_style(
2557 ir: &fission_ir::CoreIR,
2558 semantics_id: WidgetId,
2559 ) -> Option<TextParagraphStyle> {
2560 fn walk(
2561 ir: &fission_ir::CoreIR,
2562 node_id: WidgetId,
2563 depth: usize,
2564 ) -> Option<TextParagraphStyle> {
2565 if depth > 10 {
2566 return None;
2567 }
2568 let node = ir.nodes.get(&node_id)?;
2569 match &node.op {
2570 Op::Paint(fission_ir::PaintOp::DrawText {
2571 paragraph_style,
2572 caret_width,
2573 ..
2574 }) => paragraph_style.or_else(|| decode_text_paragraph_style(*caret_width)),
2575 Op::Paint(fission_ir::PaintOp::DrawRichText {
2576 paragraph_style,
2577 caret_width,
2578 ..
2579 }) => paragraph_style.or_else(|| decode_text_paragraph_style(*caret_width)),
2580 _ => {
2581 for child_id in &node.children {
2582 if let Some(style) = walk(ir, *child_id, depth + 1) {
2583 return Some(style);
2584 }
2585 }
2586 None
2587 }
2588 }
2589 }
2590 walk(ir, semantics_id, 0)
2591 }
2592
2593 fn line_metric_for_local_y<'a>(
2594 line_metrics: &'a [fission_layout::LineMetric],
2595 local_y: f32,
2596 ) -> Option<&'a fission_layout::LineMetric> {
2597 if line_metrics.is_empty() {
2598 return None;
2599 }
2600 let mut line_top = 0.0f32;
2601 for (index, line) in line_metrics.iter().enumerate() {
2602 let line_height = line.height.max(1.0);
2603 let line_bottom = line_top + line_height;
2604 if local_y < line_bottom || index + 1 == line_metrics.len() {
2605 return Some(line);
2606 }
2607 line_top = line_bottom;
2608 }
2609 line_metrics.last()
2610 }
2611
2612 fn paragraph_line_x_offset(
2613 paragraph: TextParagraphStyle,
2614 bounds_width: f32,
2615 line_width: f32,
2616 is_last_line: bool,
2617 ) -> f32 {
2618 if bounds_width <= 0.0 {
2619 return 0.0;
2620 }
2621
2622 match paragraph.text_align {
2623 TextAlign::Start | TextAlign::Left => 0.0,
2624 TextAlign::Center => (bounds_width - line_width) * 0.5,
2625 TextAlign::End | TextAlign::Right => bounds_width - line_width,
2626 TextAlign::Justify if is_last_line => 0.0,
2627 TextAlign::Justify => 0.0,
2628 }
2629 }
2630}
2631
2632pub fn caret_from_point_in_text(
2635 measurer: Option<&std::sync::Arc<dyn fission_layout::TextMeasurer>>,
2636 value: &str,
2637 font_size: f32,
2638 viewport_x: f32,
2639 viewport_w: f32,
2640 content_w: f32,
2641 scroll_offset: f32,
2642 point_x: f32,
2643) -> usize {
2644 let local_x = (point_x - viewport_x) + scroll_offset;
2645 if local_x <= 0.0 {
2646 return 0;
2647 }
2648 let max_x = content_w.max(viewport_w);
2649 if local_x >= max_x {
2650 return value.len();
2651 }
2652
2653 if let Some(measurer) = measurer {
2654 measurer.hit_test(value, font_size, None, local_x, 0.0)
2657 } else {
2658 TextInputController::caret_from_point_in_text_fallback(
2659 value,
2660 font_size,
2661 viewport_x,
2662 viewport_w,
2663 content_w,
2664 scroll_offset,
2665 point_x,
2666 )
2667 }
2668}