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