1use crate::env::TextSelectionHandleKind;
2use crate::lowering::{InternalIrBuilder, InternalLoweringCx};
3use crate::ui::{
4 traits::InternalLower,
5 widgets::context_menu::{TextContextMenuAction, TextContextMenuConfig},
6 Button, ButtonContentAlign, ButtonVariant, Container, Positioned, Row, Spacer, Text,
7 TextContent, TextFontStyle, Widget,
8};
9use crate::ActionEnvelope;
10use fission_ir::{
11 op::{
12 Color as IrColor, Fill, LayoutOp, Op, PaintOp, Stroke, TextAlign as IrTextAlign,
13 TextParagraphStyle,
14 },
15 semantics::{
16 InputFormatter, MaxLengthEnforcement, MouseCursor as SemanticsMouseCursor,
17 TextCapitalization, TextInputAction, TextInputType,
18 },
19 AnyRenderObject, FlexDirection, FlexWrap, Role, Semantics, WidgetId,
20};
21use fission_theme::{ComponentSize, ComponentState};
22use serde::{Deserialize, Serialize};
23use std::sync::Arc;
24use unicode_segmentation::UnicodeSegmentation;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
27pub enum TextAlignVertical {
28 Top,
29 #[default]
30 Center,
31 Bottom,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
35pub enum DragStartBehavior {
36 #[default]
37 Start,
38 Down,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct TextUndoController {
43 pub capacity: usize,
44}
45
46impl Default for TextUndoController {
47 fn default() -> Self {
48 Self { capacity: 100 }
49 }
50}
51
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
53pub struct SpellCheckConfiguration {
54 pub enabled: bool,
55 pub underline_color: Option<IrColor>,
56 pub show_suggestions: bool,
57}
58
59impl Default for SpellCheckConfiguration {
60 fn default() -> Self {
61 Self {
62 enabled: true,
63 underline_color: Some(IrColor {
64 r: 255,
65 g: 59,
66 b: 48,
67 a: 255,
68 }),
69 show_suggestions: true,
70 }
71 }
72}
73
74#[doc(hidden)]
75#[derive(Debug, Clone, PartialEq)]
76pub struct TextInputRuntimeConfig {
77 pub drag_start_behavior: DragStartBehavior,
78 pub undo_controller: Option<TextUndoController>,
79 pub restoration_id: Option<String>,
80 pub spell_check_configuration: Option<SpellCheckConfiguration>,
81}
82
83#[doc(hidden)]
84pub fn downcast_text_input_runtime_config(
85 any: &AnyRenderObject,
86) -> Option<&TextInputRuntimeConfig> {
87 any.downcast_ref::<TextInputRuntimeConfig>()
88}
89
90impl TextAlignVertical {
91 fn justify_content(self) -> fission_ir::op::JustifyContent {
92 match self {
93 Self::Top => fission_ir::op::JustifyContent::Start,
94 Self::Center => fission_ir::op::JustifyContent::Center,
95 Self::Bottom => fission_ir::op::JustifyContent::End,
96 }
97 }
98
99 fn align_items(self) -> fission_ir::op::AlignItems {
100 match self {
101 Self::Top => fission_ir::op::AlignItems::Start,
102 Self::Center => fission_ir::op::AlignItems::Center,
103 Self::Bottom => fission_ir::op::AlignItems::End,
104 }
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
109pub struct TextSelectionControls {
110 #[serde(default = "default_selection_controls_enabled")]
112 pub enabled: bool,
113 pub show_collapsed_handle: bool,
114 pub handle_radius: f32,
115 pub handle_fill: IrColor,
116 pub handle_stroke: Option<IrColor>,
117 pub handle_stroke_width: f32,
118}
119
120fn default_selection_controls_enabled() -> bool {
121 true
122}
123
124impl Default for TextSelectionControls {
125 fn default() -> Self {
126 Self {
127 enabled: true,
128 show_collapsed_handle: true,
129 handle_radius: 7.0,
130 handle_fill: IrColor {
131 r: 0,
132 g: 122,
133 b: 255,
134 a: 255,
135 },
136 handle_stroke: Some(IrColor {
137 r: 255,
138 g: 255,
139 b: 255,
140 a: 255,
141 }),
142 handle_stroke_width: 1.0,
143 }
144 }
145}
146
147#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
148pub struct TextMagnifierConfiguration {
149 pub enabled: bool,
150 pub diameter: f32,
151 pub scale: f32,
152 pub border_radius: f32,
153 pub border_color: Option<IrColor>,
154 pub border_width: f32,
155}
156
157impl Default for TextMagnifierConfiguration {
158 fn default() -> Self {
159 Self {
160 enabled: true,
161 diameter: 84.0,
162 scale: 1.4,
163 border_radius: 18.0,
164 border_color: Some(IrColor {
165 r: 210,
166 g: 214,
167 b: 224,
168 a: 255,
169 }),
170 border_width: 1.0,
171 }
172 }
173}
174
175pub(crate) fn text_input_selection_handle_id(
176 input_id: WidgetId,
177 kind: TextSelectionHandleKind,
178) -> WidgetId {
179 let suffix = match kind {
180 TextSelectionHandleKind::Caret => 0,
181 TextSelectionHandleKind::Start => 1,
182 TextSelectionHandleKind::End => 2,
183 };
184 WidgetId::derived(input_id.as_u128(), &[900, suffix])
185}
186
187pub(crate) fn text_input_toolbar_button_id(
188 input_id: WidgetId,
189 action: TextContextMenuAction,
190) -> WidgetId {
191 let suffix = match action {
192 TextContextMenuAction::Copy => 0,
193 TextContextMenuAction::Cut => 1,
194 TextContextMenuAction::Paste => 2,
195 TextContextMenuAction::SelectAll => 3,
196 };
197 WidgetId::derived(input_id.as_u128(), &[901, suffix])
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct TextInput {
238 pub id: Option<WidgetId>,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub semantics_identifier: Option<String>,
243 pub value: String,
245 pub label: Option<TextContent>,
247 pub placeholder: Option<TextContent>,
249 pub helper_text: Option<TextContent>,
251 pub error_text: Option<TextContent>,
253 pub counter_text: Option<TextContent>,
255 pub on_input: Option<ActionEnvelope>,
260 pub on_submit: Option<ActionEnvelope>,
263 pub on_editing_complete: Option<ActionEnvelope>,
265 pub on_tap_outside: Option<ActionEnvelope>,
267 pub width: Option<f32>,
269 pub height: Option<f32>,
271 #[serde(default)]
273 pub size: ComponentSize,
274 pub padding: Option<[f32; 4]>,
276 pub multiline: bool,
278 pub autofocus: bool,
280 pub enabled: bool,
282 pub read_only: bool,
284 pub min_lines: Option<usize>,
286 pub max_lines: Option<usize>,
288 pub obscure_text: bool,
290 pub obscuring_character: char,
292 pub mask: Option<fission_ir::semantics::InputMask>,
294 pub styled_runs: Option<Vec<fission_ir::op::TextRun>>,
300 pub borderless: bool,
303 pub capture_tab: bool,
305 pub auto_indent: bool,
308 pub on_cursor_change: Option<ActionEnvelope>,
310 pub highlight_ranges: Vec<(usize, usize, IrColor)>,
314 pub background_fill: Option<Fill>,
316 pub border_color: Option<IrColor>,
318 pub focus_border_color: Option<IrColor>,
320 pub border_width: Option<f32>,
322 pub focus_border_width: Option<f32>,
324 pub border_radius: Option<f32>,
326 pub font_size: Option<f32>,
328 pub text_color: Option<IrColor>,
330 pub placeholder_color: Option<IrColor>,
332 pub label_color: Option<IrColor>,
334 pub helper_color: Option<IrColor>,
336 pub error_color: Option<IrColor>,
338 pub counter_color: Option<IrColor>,
340 pub selection_color: Option<IrColor>,
342 pub selection_text_color: Option<IrColor>,
344 pub text_align: fission_ir::op::TextAlign,
346 pub text_align_vertical: TextAlignVertical,
348 pub expands: bool,
350 pub cursor_color: Option<IrColor>,
352 pub cursor_width: Option<f32>,
354 pub cursor_height: Option<f32>,
356 pub cursor_radius: Option<f32>,
358 pub font_family: Option<String>,
360 pub locale: Option<String>,
362 pub font_weight: Option<u16>,
364 pub font_style: TextFontStyle,
366 pub text_scale: Option<f32>,
368 pub line_height: Option<f32>,
370 pub letter_spacing: Option<f32>,
372 pub text_direction: fission_ir::op::TextDirection,
374 pub strut_line_height: Option<f32>,
376 pub text_height_behavior: fission_ir::op::TextHeightBehavior,
378 pub prefix: Option<Widget>,
380 pub suffix: Option<Widget>,
382 pub mouse_cursor: Option<SemanticsMouseCursor>,
384 pub keyboard_type: TextInputType,
386 pub text_input_action: TextInputAction,
388 pub text_capitalization: TextCapitalization,
390 pub max_length: Option<usize>,
392 pub max_length_enforcement: MaxLengthEnforcement,
394 pub input_formatters: Vec<InputFormatter>,
396 pub autocorrect: bool,
398 pub enable_suggestions: bool,
400 pub spell_check: bool,
402 pub smart_dashes: bool,
404 pub smart_quotes: bool,
406 pub autofill_hints: Vec<String>,
408 pub scroll_padding: Option<[f32; 4]>,
410 pub drag_start_behavior: DragStartBehavior,
412 pub context_menu: TextContextMenuConfig,
414 pub selection_controls: TextSelectionControls,
416 pub magnifier_configuration: TextMagnifierConfiguration,
418 pub undo_controller: Option<TextUndoController>,
420 pub spell_check_configuration: Option<SpellCheckConfiguration>,
422 pub restoration_id: Option<String>,
424}
425
426impl TextInput {
427 pub fn semantics_identifier(mut self, identifier: impl Into<String>) -> Self {
429 self.semantics_identifier = Some(identifier.into());
430 self
431 }
432
433 pub fn value(mut self, v: impl Into<String>) -> Self {
434 self.value = v.into();
435 self
436 }
437
438 pub fn label(mut self, label: impl Into<TextContent>) -> Self {
439 self.label = Some(label.into());
440 self
441 }
442
443 pub fn padding(mut self, padding: [f32; 4]) -> Self {
444 self.padding = Some(padding);
445 self
446 }
447
448 pub fn background_fill(mut self, fill: Fill) -> Self {
449 self.background_fill = Some(fill);
450 self
451 }
452
453 pub fn text_color(mut self, color: IrColor) -> Self {
454 self.text_color = Some(color);
455 self
456 }
457
458 pub fn placeholder_color(mut self, color: IrColor) -> Self {
459 self.placeholder_color = Some(color);
460 self
461 }
462
463 pub fn helper_text(mut self, helper_text: impl Into<TextContent>) -> Self {
464 self.helper_text = Some(helper_text.into());
465 self
466 }
467
468 pub fn error_text(mut self, error_text: impl Into<TextContent>) -> Self {
469 self.error_text = Some(error_text.into());
470 self
471 }
472
473 pub fn counter_text(mut self, counter_text: impl Into<TextContent>) -> Self {
474 self.counter_text = Some(counter_text.into());
475 self
476 }
477
478 pub fn label_color(mut self, color: IrColor) -> Self {
479 self.label_color = Some(color);
480 self
481 }
482
483 pub fn helper_color(mut self, color: IrColor) -> Self {
484 self.helper_color = Some(color);
485 self
486 }
487
488 pub fn error_color(mut self, color: IrColor) -> Self {
489 self.error_color = Some(color);
490 self
491 }
492
493 pub fn counter_color(mut self, color: IrColor) -> Self {
494 self.counter_color = Some(color);
495 self
496 }
497
498 pub fn selection_color(mut self, color: IrColor) -> Self {
499 self.selection_color = Some(color);
500 self
501 }
502
503 pub fn selection_text_color(mut self, color: IrColor) -> Self {
504 self.selection_text_color = Some(color);
505 self
506 }
507
508 pub fn text_align(mut self, text_align: fission_ir::op::TextAlign) -> Self {
509 self.text_align = text_align;
510 self
511 }
512
513 pub fn text_align_vertical(mut self, text_align_vertical: TextAlignVertical) -> Self {
514 self.text_align_vertical = text_align_vertical;
515 self
516 }
517
518 pub fn expands(mut self, expands: bool) -> Self {
519 self.expands = expands;
520 self
521 }
522
523 pub fn cursor_color(mut self, color: IrColor) -> Self {
524 self.cursor_color = Some(color);
525 self
526 }
527
528 pub fn cursor_width(mut self, width: f32) -> Self {
529 self.cursor_width = Some(width);
530 self
531 }
532
533 pub fn cursor_height(mut self, height: f32) -> Self {
534 self.cursor_height = Some(height);
535 self
536 }
537
538 pub fn cursor_radius(mut self, radius: f32) -> Self {
539 self.cursor_radius = Some(radius);
540 self
541 }
542
543 pub fn enabled(mut self, enabled: bool) -> Self {
544 self.enabled = enabled;
545 self
546 }
547
548 pub fn autofocus(mut self, autofocus: bool) -> Self {
549 self.autofocus = autofocus;
550 self
551 }
552
553 pub fn read_only(mut self, read_only: bool) -> Self {
554 self.read_only = read_only;
555 self
556 }
557
558 pub fn keyboard_type(mut self, keyboard_type: TextInputType) -> Self {
559 self.keyboard_type = keyboard_type;
560 self
561 }
562
563 pub fn text_input_action(mut self, action: TextInputAction) -> Self {
564 self.text_input_action = action;
565 self
566 }
567
568 pub fn text_capitalization(mut self, capitalization: TextCapitalization) -> Self {
569 self.text_capitalization = capitalization;
570 self
571 }
572
573 pub fn max_length(mut self, max_length: usize) -> Self {
574 self.max_length = Some(max_length);
575 self
576 }
577
578 pub fn max_length_enforcement(mut self, enforcement: MaxLengthEnforcement) -> Self {
579 self.max_length_enforcement = enforcement;
580 self
581 }
582
583 pub fn input_formatters(mut self, input_formatters: Vec<InputFormatter>) -> Self {
584 self.input_formatters = input_formatters;
585 self
586 }
587
588 pub fn autocorrect(mut self, autocorrect: bool) -> Self {
589 self.autocorrect = autocorrect;
590 self
591 }
592
593 pub fn enable_suggestions(mut self, enable_suggestions: bool) -> Self {
594 self.enable_suggestions = enable_suggestions;
595 self
596 }
597
598 pub fn spell_check(mut self, spell_check: bool) -> Self {
599 self.spell_check = spell_check;
600 self
601 }
602
603 pub fn smart_dashes(mut self, smart_dashes: bool) -> Self {
604 self.smart_dashes = smart_dashes;
605 self
606 }
607
608 pub fn smart_quotes(mut self, smart_quotes: bool) -> Self {
609 self.smart_quotes = smart_quotes;
610 self
611 }
612
613 pub fn autofill_hints(mut self, autofill_hints: Vec<String>) -> Self {
614 self.autofill_hints = autofill_hints;
615 self
616 }
617
618 pub fn context_menu(mut self, context_menu: TextContextMenuConfig) -> Self {
619 self.context_menu = context_menu;
620 self
621 }
622
623 pub fn drag_start_behavior(mut self, drag_start_behavior: DragStartBehavior) -> Self {
624 self.drag_start_behavior = drag_start_behavior;
625 self
626 }
627
628 pub fn selection_controls(mut self, selection_controls: TextSelectionControls) -> Self {
629 self.selection_controls = selection_controls;
630 self
631 }
632
633 pub fn magnifier_configuration(
634 mut self,
635 magnifier_configuration: TextMagnifierConfiguration,
636 ) -> Self {
637 self.magnifier_configuration = magnifier_configuration;
638 self
639 }
640
641 pub fn on_tap_outside(mut self, action: ActionEnvelope) -> Self {
642 self.on_tap_outside = Some(action);
643 self
644 }
645
646 pub fn undo_controller(mut self, undo_controller: TextUndoController) -> Self {
647 self.undo_controller = Some(undo_controller);
648 self
649 }
650
651 pub fn spell_check_configuration(
652 mut self,
653 spell_check_configuration: SpellCheckConfiguration,
654 ) -> Self {
655 self.spell_check_configuration = Some(spell_check_configuration);
656 self
657 }
658
659 pub fn restoration_id(mut self, restoration_id: impl Into<String>) -> Self {
660 self.restoration_id = Some(restoration_id.into());
661 self
662 }
663
664 pub fn family(mut self, family: impl Into<String>) -> Self {
665 self.font_family = Some(family.into());
666 self
667 }
668
669 pub fn locale(mut self, locale: impl Into<String>) -> Self {
670 self.locale = Some(locale.into());
671 self
672 }
673
674 pub fn weight(mut self, weight: u16) -> Self {
675 self.font_weight = Some(weight);
676 self
677 }
678
679 pub fn italic(mut self, italic: bool) -> Self {
680 self.font_style = if italic {
681 TextFontStyle::Italic
682 } else {
683 TextFontStyle::Normal
684 };
685 self
686 }
687
688 pub fn font_size(mut self, size: f32) -> Self {
689 self.font_size = Some(size);
690 self
691 }
692
693 pub fn text_scale(mut self, text_scale: f32) -> Self {
694 self.text_scale = Some(text_scale);
695 self
696 }
697
698 pub fn line_height(mut self, line_height: f32) -> Self {
699 self.line_height = Some(line_height);
700 self
701 }
702
703 pub fn letter_spacing(mut self, letter_spacing: f32) -> Self {
704 self.letter_spacing = Some(letter_spacing);
705 self
706 }
707
708 pub fn text_direction(mut self, text_direction: fission_ir::op::TextDirection) -> Self {
709 self.text_direction = text_direction;
710 self
711 }
712
713 pub fn strut_line_height(mut self, strut_line_height: f32) -> Self {
714 self.strut_line_height = Some(strut_line_height);
715 self
716 }
717
718 pub fn text_height_behavior(
719 mut self,
720 text_height_behavior: fission_ir::op::TextHeightBehavior,
721 ) -> Self {
722 self.text_height_behavior = text_height_behavior;
723 self
724 }
725
726 pub fn prefix(mut self, node: impl Into<Widget>) -> Self {
727 self.prefix = Some(node.into());
728 self
729 }
730
731 pub fn suffix(mut self, node: impl Into<Widget>) -> Self {
732 self.suffix = Some(node.into());
733 self
734 }
735
736 pub fn mouse_cursor(mut self, mouse_cursor: SemanticsMouseCursor) -> Self {
737 self.mouse_cursor = Some(mouse_cursor);
738 self
739 }
740
741 pub fn scroll_padding(mut self, scroll_padding: [f32; 4]) -> Self {
742 self.scroll_padding = Some(scroll_padding);
743 self
744 }
745}
746
747impl Default for TextInput {
748 fn default() -> Self {
749 Self {
750 id: None,
751 semantics_identifier: None,
752 value: String::new(),
753 label: None,
754 placeholder: None,
755 helper_text: None,
756 error_text: None,
757 counter_text: None,
758 on_input: None,
759 on_submit: None,
760 on_editing_complete: None,
761 on_tap_outside: None,
762 width: None,
763 height: None,
764 size: ComponentSize::Md,
765 padding: None,
766 multiline: false,
767 autofocus: false,
768 enabled: true,
769 read_only: false,
770 min_lines: None,
771 max_lines: None,
772 obscure_text: false,
773 obscuring_character: '•',
774 mask: None,
775 styled_runs: None,
776 borderless: false,
777 capture_tab: false,
778 auto_indent: false,
779 on_cursor_change: None,
780 highlight_ranges: Vec::new(),
781 background_fill: None,
782 border_color: None,
783 focus_border_color: None,
784 border_width: None,
785 focus_border_width: None,
786 border_radius: None,
787 font_size: None,
788 text_color: None,
789 placeholder_color: None,
790 label_color: None,
791 helper_color: None,
792 error_color: None,
793 counter_color: None,
794 selection_color: None,
795 selection_text_color: None,
796 text_align: fission_ir::op::TextAlign::Start,
797 text_align_vertical: TextAlignVertical::Center,
798 expands: false,
799 cursor_color: None,
800 cursor_width: None,
801 cursor_height: None,
802 cursor_radius: None,
803 font_family: None,
804 locale: None,
805 font_weight: None,
806 font_style: TextFontStyle::Normal,
807 text_scale: None,
808 line_height: None,
809 letter_spacing: None,
810 text_direction: fission_ir::op::TextDirection::Auto,
811 strut_line_height: None,
812 text_height_behavior: fission_ir::op::TextHeightBehavior::default(),
813 prefix: None,
814 suffix: None,
815 mouse_cursor: None,
816 keyboard_type: TextInputType::Text,
817 text_input_action: TextInputAction::Done,
818 text_capitalization: TextCapitalization::None,
819 max_length: None,
820 max_length_enforcement: MaxLengthEnforcement::Enforced,
821 input_formatters: Vec::new(),
822 autocorrect: true,
823 enable_suggestions: true,
824 spell_check: true,
825 smart_dashes: true,
826 smart_quotes: true,
827 autofill_hints: Vec::new(),
828 scroll_padding: None,
829 drag_start_behavior: DragStartBehavior::Start,
830 context_menu: TextContextMenuConfig::editing(),
831 selection_controls: TextSelectionControls::default(),
832 magnifier_configuration: TextMagnifierConfiguration::default(),
833 undo_controller: None,
834 spell_check_configuration: None,
835 restoration_id: None,
836 }
837 }
838}
839
840impl TextInput {
841 fn resolve_text_content(content: &TextContent, cx: &InternalLoweringCx<'_>) -> String {
842 match content {
843 TextContent::Literal(s) => s.clone(),
844 TextContent::Key(key) => cx
845 .env
846 .i18n
847 .get(&cx.env.locale, key)
848 .map(|s| s.to_string())
849 .unwrap_or_else(|| format!("MISSING:{}", key)),
850 TextContent::KeyWithFallback { key, fallback } => cx
851 .env
852 .i18n
853 .get(&cx.env.locale, key)
854 .map(|s| s.to_string())
855 .unwrap_or_else(|| fallback.clone()),
856 }
857 }
858
859 fn mask_text(text: &str, obscuring_character: char) -> String {
860 let mut masked = String::new();
861 for _ in text.graphemes(true) {
862 masked.push(obscuring_character);
863 }
864 masked
865 }
866
867 fn masked_byte_offset(source: &str, masked: &str, source_byte_offset: usize) -> usize {
868 let clamped = source_byte_offset.min(source.len());
869 let grapheme_count = source[..clamped].graphemes(true).count();
870 masked
871 .grapheme_indices(true)
872 .nth(grapheme_count)
873 .map(|(idx, _)| idx)
874 .unwrap_or(masked.len())
875 }
876
877 fn supporting_counter_text(
878 &self,
879 cx: &InternalLoweringCx<'_>,
880 current_text: &str,
881 ) -> Option<String> {
882 self.counter_text
883 .as_ref()
884 .map(|content| Self::resolve_text_content(content, cx))
885 .or_else(|| {
886 self.max_length
887 .map(|max_length| format!("{}/{}", current_text.chars().count(), max_length))
888 })
889 }
890
891 fn build_selection_handle_overlay(
892 &self,
893 cx: &mut InternalLoweringCx,
894 input_id: WidgetId,
895 kind: TextSelectionHandleKind,
896 point: fission_layout::LayoutPoint,
897 ) -> WidgetId {
898 let controls = &self.selection_controls;
899 let diameter = controls.handle_radius * 2.0;
900 let handle_node = Button {
901 id: Some(text_input_selection_handle_id(input_id, kind).into()),
902 semantics: Some(Semantics {
903 role: Role::Generic,
904 draggable: true,
905 ..Semantics::default()
906 }),
907 child: Some(
908 Container::new(Spacer {
909 width: Some(diameter),
910 height: Some(diameter),
911 ..Default::default()
912 })
913 .bg_fill(Fill::Solid(controls.handle_fill))
914 .border(
915 controls.handle_stroke.unwrap_or(IrColor {
916 r: 0,
917 g: 0,
918 b: 0,
919 a: 0,
920 }),
921 controls.handle_stroke_width,
922 )
923 .border_radius(controls.handle_radius)
924 .into(),
925 ),
926 width: Some(diameter),
927 height: Some(diameter),
928 padding: Some([0.0; 4]),
929 content_align: ButtonContentAlign::Center,
930 variant: ButtonVariant::Ghost,
931 ..Default::default()
932 }
933 .into();
934
935 Positioned {
936 left: Some((point.x - controls.handle_radius).max(0.0)),
937 top: Some((point.y - controls.handle_radius).max(0.0)),
938 width: Some(diameter),
939 height: Some(diameter),
940 child: Some(handle_node),
941 ..Default::default()
942 }
943 .lower(cx)
944 }
945
946 fn build_toolbar_overlay(
947 &self,
948 cx: &mut InternalLoweringCx,
949 input_id: WidgetId,
950 anchor: fission_layout::LayoutPoint,
951 ) -> WidgetId {
952 let tokens = &cx.env.theme.tokens;
953 let mut row = Row::default().gap(self.context_menu.menu.gap);
954 for action in &self.context_menu.actions {
955 row.children.push(
956 Button {
957 id: Some(text_input_toolbar_button_id(input_id, *action).into()),
958 semantics: Some(Semantics {
959 role: Role::Button,
960 label: Some(action.fallback_label().into()),
961 focusable: true,
962 focus_policy: fission_ir::FocusPolicy::PreserveCurrentOnPointer,
963 ..Semantics::default()
964 }),
965 focus_policy: fission_ir::FocusPolicy::PreserveCurrentOnPointer,
966 child: Some(
967 Text::new(TextContent::KeyWithFallback {
968 key: action.label_key().to_string(),
969 fallback: action.fallback_label().to_string(),
970 })
971 .size(tokens.typography.label_large_size)
972 .color(tokens.colors.text_primary)
973 .into(),
974 ),
975 padding: Some([10.0, 10.0, 6.0, 6.0]),
976 content_align: ButtonContentAlign::Center,
977 variant: ButtonVariant::Ghost,
978 ..Default::default()
979 }
980 .into(),
981 );
982 }
983
984 let toolbar: Widget = Container::new(row)
985 .bg_fill(Fill::Solid(tokens.colors.surface))
986 .border(tokens.colors.border, 1.0)
987 .border_radius(self.context_menu.menu.border_radius)
988 .padding(self.context_menu.menu.padding)
989 .into();
990
991 Positioned {
992 left: Some(anchor.x.max(0.0)),
993 top: Some((anchor.y - 44.0).max(0.0)),
994 child: Some(toolbar),
995 ..Default::default()
996 }
997 .lower(cx)
998 }
999
1000 fn magnifier_snippet(display_text: &str, caret: usize) -> String {
1001 let mut graphemes = Vec::new();
1002 for (idx, grapheme) in display_text.grapheme_indices(true) {
1003 graphemes.push((idx, grapheme));
1004 }
1005 if graphemes.is_empty() {
1006 return String::new();
1007 }
1008
1009 let caret_grapheme = graphemes
1010 .iter()
1011 .position(|(idx, _)| *idx >= caret.min(display_text.len()))
1012 .unwrap_or(graphemes.len().saturating_sub(1));
1013 let start = caret_grapheme.saturating_sub(4);
1014 let end = (caret_grapheme + 5).min(graphemes.len());
1015 graphemes[start..end]
1016 .iter()
1017 .map(|(_, grapheme)| *grapheme)
1018 .collect::<String>()
1019 }
1020
1021 fn build_magnifier_overlay(
1022 &self,
1023 cx: &mut InternalLoweringCx,
1024 anchor: fission_layout::LayoutPoint,
1025 display_text: &str,
1026 caret: usize,
1027 base_text_style: &fission_ir::op::TextStyle,
1028 ) -> WidgetId {
1029 let cfg = &self.magnifier_configuration;
1030 let tokens = &cx.env.theme.tokens;
1031 let preview = Self::magnifier_snippet(display_text, caret);
1032 let preview_text = Text::new(preview)
1033 .size(base_text_style.font_size * cfg.scale)
1034 .color(base_text_style.color)
1035 .family(
1036 base_text_style
1037 .font_family
1038 .clone()
1039 .unwrap_or_else(|| "system-ui".to_string()),
1040 )
1041 .weight(base_text_style.font_weight)
1042 .italic(base_text_style.font_style == fission_ir::op::FontStyle::Italic)
1043 .line_height(
1044 base_text_style
1045 .line_height
1046 .unwrap_or(base_text_style.font_size * 1.25)
1047 * cfg.scale,
1048 )
1049 .letter_spacing(base_text_style.letter_spacing * cfg.scale);
1050
1051 let magnifier: Widget = Container::new(preview_text)
1052 .width(cfg.diameter)
1053 .height(cfg.diameter)
1054 .bg_fill(Fill::Solid(tokens.colors.surface))
1055 .border(
1056 cfg.border_color.unwrap_or(tokens.colors.border),
1057 cfg.border_width,
1058 )
1059 .border_radius(cfg.border_radius)
1060 .padding_all(8.0)
1061 .into();
1062
1063 Positioned {
1064 left: Some((anchor.x - cfg.diameter * 0.5).max(0.0)),
1065 top: Some((anchor.y - cfg.diameter - 18.0).max(0.0)),
1066 width: Some(cfg.diameter),
1067 height: Some(cfg.diameter),
1068 child: Some(magnifier),
1069 ..Default::default()
1070 }
1071 .lower(cx)
1072 }
1073}
1074
1075impl InternalLower for TextInput {
1076 fn lower(&self, cx: &mut InternalLoweringCx) -> WidgetId {
1077 let input_id = self.id.map(Into::into).unwrap_or_else(|| cx.next_node_id());
1078 let is_focused = cx.runtime_state.interaction.is_focused(input_id);
1079
1080 let theme = &cx.env.theme.components.text_input;
1081 let tokens = &cx.env.theme.tokens;
1082 let component_state = if !self.enabled {
1083 ComponentState::Disabled
1084 } else if self.error_text.is_some() {
1085 ComponentState::Error
1086 } else if is_focused {
1087 ComponentState::Focus
1088 } else {
1089 ComponentState::Default
1090 };
1091 let component_style = theme.resolve(self.size, component_state);
1092
1093 let text_scale = self.text_scale.unwrap_or(1.0).max(0.0);
1094 let font_size = self
1095 .font_size
1096 .unwrap_or(component_style.font_size.unwrap_or(theme.font_size))
1097 * text_scale;
1098 let text_color = self
1099 .text_color
1100 .unwrap_or(component_style.text_color.unwrap_or(theme.text_color));
1101 let selection_color = self
1102 .selection_color
1103 .unwrap_or(tokens.colors.primary.with_alpha(52));
1104 let selection_text_color = self.selection_text_color.unwrap_or(text_color);
1105 let placeholder_color = self.placeholder_color.unwrap_or(
1106 theme
1107 .placeholder_style
1108 .text_color
1109 .unwrap_or(theme.placeholder_color),
1110 );
1111 let cursor_color = self.cursor_color.unwrap_or(theme.focus_color);
1112 let cursor_width = self.cursor_width.unwrap_or(2.0);
1113 let font_weight = self
1114 .font_weight
1115 .unwrap_or(component_style.font_weight.unwrap_or(theme.font_weight));
1116 let line_height = self
1117 .line_height
1118 .or(component_style.line_height)
1119 .map(|value| value * text_scale);
1120 let letter_spacing = self.letter_spacing.unwrap_or(0.0) * text_scale;
1121 let style_border = component_style.border.clone();
1122 let border_color = if is_focused {
1123 self.focus_border_color.unwrap_or_else(|| {
1124 style_border
1125 .as_ref()
1126 .and_then(|border| match &border.fill {
1127 Fill::Solid(color) => Some(*color),
1128 _ => None,
1129 })
1130 .unwrap_or(theme.focus_color)
1131 })
1132 } else {
1133 self.border_color.unwrap_or_else(|| {
1134 style_border
1135 .as_ref()
1136 .and_then(|border| match &border.fill {
1137 Fill::Solid(color) => Some(*color),
1138 _ => None,
1139 })
1140 .unwrap_or(theme.border_color)
1141 })
1142 };
1143 let border_width = if is_focused {
1144 self.focus_border_width.unwrap_or(
1145 style_border
1146 .as_ref()
1147 .map(|border| border.width)
1148 .unwrap_or(2.0),
1149 )
1150 } else {
1151 self.border_width.unwrap_or(
1152 style_border
1153 .as_ref()
1154 .map(|border| border.width)
1155 .unwrap_or(theme.border_width),
1156 )
1157 };
1158 let border_radius = self
1159 .border_radius
1160 .unwrap_or(component_style.radius.unwrap_or(theme.radius));
1161 let content_padding = self.padding.unwrap_or(component_style.padding_box(
1162 component_style.padding_x.unwrap_or(theme.padding_h),
1163 component_style.padding_y.unwrap_or(4.0),
1164 ));
1165 let base_text_style = fission_ir::op::TextStyle {
1166 font_size,
1167 color: text_color,
1168 underline: false,
1169 font_family: self.font_family.clone(),
1170 locale: self.locale.clone(),
1171 font_weight,
1172 font_style: self.font_style.into(),
1173 line_height,
1174 letter_spacing,
1175 background_color: None,
1176 };
1177
1178 let resolved_label = self
1179 .label
1180 .as_ref()
1181 .map(|label| Self::resolve_text_content(label, cx));
1182 let resolved_placeholder = self
1183 .placeholder
1184 .as_ref()
1185 .map(|placeholder| Self::resolve_text_content(placeholder, cx));
1186
1187 let background_id = if self.borderless {
1189 None
1190 } else {
1191 Some(
1192 InternalIrBuilder::new(
1193 cx.next_node_id(),
1194 Op::Paint(PaintOp::DrawRect {
1195 fill: Some(
1196 self.background_fill
1197 .clone()
1198 .or_else(|| component_style.background.clone())
1199 .unwrap_or(Fill::Solid(tokens.colors.background)),
1200 ),
1201 stroke: Some(Stroke {
1202 fill: Fill::Solid(border_color),
1203 width: border_width,
1204 dash_array: None,
1205 line_cap: fission_ir::op::LineCap::Butt,
1206 line_join: fission_ir::op::LineJoin::Miter,
1207 }),
1208 corner_radius: border_radius,
1209 shadow: component_style.outer_shadows().first().copied(),
1210 }),
1211 )
1212 .build(cx),
1213 )
1214 };
1215
1216 let session = cx.runtime_state.text_edit.get(input_id);
1218 let pending_model_transform = session.is_some_and(|state| {
1219 state.pending_model_sync
1220 && state.preedit.is_none()
1221 && state.committed_text() != self.value
1222 && state.last_model_text != self.value
1223 });
1224 let retained_session = if is_focused {
1225 session.filter(|state| {
1226 (state.pending_model_sync
1227 && (state.committed_text() == self.value
1228 || state.last_model_text == self.value))
1229 || state.preedit.is_some()
1230 || (self.restoration_id.is_some() && self.value.is_empty())
1231 || state.committed_text() == self.value
1232 })
1233 } else {
1234 None
1235 };
1236 let session_display = retained_session.map(|state| state.display_text());
1237 let model_selection = session
1238 .map(|state| {
1239 if pending_model_transform && state.caret == state.anchor {
1240 (self.value.len(), self.value.len())
1241 } else {
1242 (
1243 clamp_text_offset(&self.value, state.caret),
1244 clamp_text_offset(&self.value, state.anchor),
1245 )
1246 }
1247 })
1248 .unwrap_or((self.value.len(), self.value.len()));
1249 let semantic_value = retained_session
1250 .map(|state| state.committed_text())
1251 .unwrap_or_else(|| self.value.clone());
1252
1253 let (display_text, preedit_range, preedit_cursor_range, caret, anchor) =
1254 if self.obscure_text {
1255 let mut combined = self.value.clone();
1256 if let Some((display, _)) = &session_display {
1257 combined = display.clone();
1258 }
1259 let (caret, anchor) = retained_session
1260 .map(|state| (state.caret, state.anchor))
1261 .unwrap_or(model_selection);
1262 let masked = Self::mask_text(&combined, self.obscuring_character);
1263 let mapped_caret = Self::masked_byte_offset(&combined, &masked, caret);
1264 let mapped_anchor = Self::masked_byte_offset(&combined, &masked, anchor);
1265 (masked, None, None, mapped_caret, mapped_anchor)
1266 } else {
1267 match session_display {
1268 Some((combined, preedit_range)) => {
1269 let (caret, anchor) = retained_session
1270 .map(|state| (state.caret, state.anchor))
1271 .unwrap_or(model_selection);
1272 let cursor_range =
1273 retained_session.and_then(|state| state.display_preedit_cursor_range());
1274 (combined, preedit_range, cursor_range, caret, anchor)
1275 }
1276 None => (
1277 self.value.clone(),
1278 None,
1279 None,
1280 model_selection.0,
1281 model_selection.1,
1282 ),
1283 }
1284 };
1285
1286 let mut runs = Vec::new();
1288 if is_focused && caret != anchor {
1289 let (s, e) = if caret < anchor {
1290 (caret, anchor)
1291 } else {
1292 (anchor, caret)
1293 };
1294 let s = s.min(display_text.len());
1295 let e = e.min(display_text.len());
1296
1297 if s > 0 {
1298 runs.push(fission_ir::op::TextRun {
1299 text: display_text[..s].to_string(),
1300 style: base_text_style.clone(),
1301 });
1302 }
1303 if s < e {
1304 runs.push(fission_ir::op::TextRun {
1305 text: display_text[s..e].to_string(),
1306 style: fission_ir::op::TextStyle {
1307 color: selection_text_color,
1308 background_color: Some(selection_color),
1309 ..base_text_style.clone()
1310 },
1311 });
1312 }
1313 if e < display_text.len() {
1314 runs.push(fission_ir::op::TextRun {
1315 text: display_text[e..].to_string(),
1316 style: base_text_style.clone(),
1317 });
1318 }
1319 } else if let Some(styled) = &self.styled_runs {
1320 let styled_text = styled
1323 .iter()
1324 .map(|run| run.text.as_str())
1325 .collect::<String>();
1326 if styled_text == display_text {
1327 runs = styled
1328 .iter()
1329 .cloned()
1330 .map(|mut run| {
1331 if run.style.font_family.is_none() {
1332 run.style.font_family = base_text_style.font_family.clone();
1333 }
1334 if run.style.font_weight == 400 {
1335 run.style.font_weight = base_text_style.font_weight;
1336 }
1337 if run.style.font_style == fission_ir::op::FontStyle::Normal {
1338 run.style.font_style = base_text_style.font_style;
1339 }
1340 if run.style.line_height.is_none() {
1341 run.style.line_height = base_text_style.line_height;
1342 }
1343 if run.style.letter_spacing == 0.0 {
1344 run.style.letter_spacing = base_text_style.letter_spacing;
1345 }
1346 run
1347 })
1348 .collect();
1349 } else {
1350 runs.push(fission_ir::op::TextRun {
1351 text: display_text.clone(),
1352 style: base_text_style.clone(),
1353 });
1354 }
1355 } else {
1356 runs.push(fission_ir::op::TextRun {
1357 text: display_text.clone(),
1358 style: base_text_style.clone(),
1359 });
1360 }
1361
1362 if !self.highlight_ranges.is_empty() && !runs.is_empty() {
1364 let mut final_runs = Vec::new();
1365 let mut run_start_byte: usize = 0;
1366
1367 for run in runs {
1368 let run_end_byte = run_start_byte + run.text.len();
1369 let mut cuts = Vec::new();
1370
1371 for &(hs, he, color) in &self.highlight_ranges {
1372 let overlap_start = hs.max(run_start_byte);
1373 let overlap_end = he.min(run_end_byte);
1374 if overlap_start < overlap_end {
1375 cuts.push((
1376 overlap_start - run_start_byte,
1377 overlap_end - run_start_byte,
1378 color,
1379 ));
1380 }
1381 }
1382
1383 if cuts.is_empty() {
1384 final_runs.push(run);
1385 } else {
1386 cuts.sort_by_key(|c| c.0);
1387 let mut pos = 0usize;
1388 for (cs, ce, bg_color) in cuts {
1389 if cs > pos {
1390 final_runs.push(fission_ir::op::TextRun {
1391 text: run.text[pos..cs].to_string(),
1392 style: run.style.clone(),
1393 });
1394 }
1395 let mut hl_style = run.style.clone();
1396 hl_style.background_color = Some(bg_color);
1397 final_runs.push(fission_ir::op::TextRun {
1398 text: run.text[cs..ce].to_string(),
1399 style: hl_style,
1400 });
1401 pos = ce;
1402 }
1403 if pos < run.text.len() {
1404 final_runs.push(fission_ir::op::TextRun {
1405 text: run.text[pos..].to_string(),
1406 style: run.style.clone(),
1407 });
1408 }
1409 }
1410 run_start_byte = run_end_byte;
1411 }
1412 runs = final_runs;
1413 }
1414
1415 if let Some((start, end)) = preedit_range {
1416 runs = split_runs_for_range(&runs, start, end, |style| {
1417 style.underline = true;
1418 if style.background_color.is_none() {
1419 style.background_color = Some(IrColor {
1420 r: 100,
1421 g: 130,
1422 b: 190,
1423 a: 48,
1424 });
1425 }
1426 });
1427 }
1428
1429 if display_text.is_empty() && resolved_placeholder.is_some() {
1430 runs = vec![fission_ir::op::TextRun {
1431 text: resolved_placeholder.clone().unwrap(),
1432 style: fission_ir::op::TextStyle {
1433 color: placeholder_color,
1434 ..base_text_style.clone()
1435 },
1436 }];
1437 }
1438
1439 let caret_idx = if is_focused {
1440 let show = cx
1441 .runtime_state
1442 .caret_visible
1443 .get(&input_id)
1444 .copied()
1445 .unwrap_or(true);
1446 if show {
1447 Some(
1448 preedit_range
1449 .map(|(_, end)| end)
1450 .unwrap_or(caret)
1451 .min(display_text.len()),
1452 )
1453 } else {
1454 None
1455 }
1456 } else {
1457 None
1458 };
1459
1460 let paragraph_overflow = if self.multiline {
1461 fission_ir::op::TextOverflow::Clip
1462 } else {
1463 fission_ir::op::TextOverflow::Visible
1464 };
1465 let paragraph_style = Some(TextParagraphStyle {
1466 text_align: self.text_align,
1467 max_lines: None,
1468 overflow: paragraph_overflow,
1469 text_direction: self.text_direction,
1470 text_width_basis: fission_ir::op::TextWidthBasis::Parent,
1471 strut_line_height: self.strut_line_height,
1472 text_height_behavior: self.text_height_behavior,
1473 })
1474 .filter(|style| {
1475 *style
1476 != TextParagraphStyle {
1477 text_align: IrTextAlign::Start,
1478 max_lines: None,
1479 overflow: paragraph_overflow,
1480 text_direction: self.text_direction,
1481 text_width_basis: fission_ir::op::TextWidthBasis::Parent,
1482 strut_line_height: self.strut_line_height,
1483 text_height_behavior: self.text_height_behavior,
1484 }
1485 });
1486
1487 let text_id = InternalIrBuilder::new(
1488 cx.next_node_id(),
1489 Op::Paint(PaintOp::DrawRichText {
1490 runs,
1491 wrap: self.multiline,
1492 caret_index: caret_idx,
1493 caret_color: Some(cursor_color),
1494 caret_width: Some(cursor_width),
1495 caret_height: self.cursor_height,
1496 caret_radius: self.cursor_radius,
1497 paragraph_style,
1498 }),
1499 )
1500 .build(cx);
1501
1502 let mut text_box = InternalIrBuilder::new(
1503 cx.next_node_id(),
1504 Op::Layout(LayoutOp::Box {
1505 width: None,
1506 height: None,
1507 min_width: None,
1508 max_width: None,
1509 min_height: None,
1510 max_height: None,
1511 padding: [0.0; 4],
1512 flex_grow: 0.0,
1513 flex_shrink: 0.0,
1514 aspect_ratio: None,
1515 }),
1516 );
1517 text_box.add_child(text_id);
1518 let text_layout_id = text_box.build(cx);
1519
1520 let mut scroll = InternalIrBuilder::new(
1522 cx.next_node_id(),
1523 Op::Layout(LayoutOp::Scroll {
1524 direction: if self.multiline {
1525 FlexDirection::Column
1526 } else {
1527 FlexDirection::Row
1528 },
1529 show_scrollbar: false,
1530 width: None, height: None,
1532 min_width: None,
1533 max_width: None,
1534 min_height: None,
1535 max_height: None,
1536 padding: [0.0; 4],
1537 flex_grow: 1.0,
1538 flex_shrink: 1.0,
1539 }),
1540 );
1541 scroll.add_child(text_layout_id);
1542 let scroll_id = scroll.build(cx);
1543
1544 let mut content_row = InternalIrBuilder::new(
1546 cx.next_node_id(),
1547 Op::Layout(LayoutOp::Flex {
1548 direction: FlexDirection::Row,
1549 wrap: FlexWrap::NoWrap,
1550 flex_grow: if self.expands { 1.0 } else { 0.0 },
1551 flex_shrink: 1.0,
1552 padding: [0.0; 4],
1553 gap: if self.prefix.is_some() || self.suffix.is_some() {
1554 Some(theme.padding_h * 0.75)
1555 } else {
1556 None
1557 },
1558 align_items: self.text_align_vertical.align_items(),
1559 justify_content: fission_ir::op::JustifyContent::Start,
1560 }),
1561 );
1562 if let Some(prefix) = &self.prefix {
1563 content_row.add_child(prefix.lower(cx));
1564 }
1565 content_row.add_child(scroll_id);
1566 if let Some(suffix) = &self.suffix {
1567 content_row.add_child(suffix.lower(cx));
1568 }
1569 let content_row_id = content_row.build(cx);
1570
1571 let mut content_alignment = InternalIrBuilder::new(
1572 cx.next_node_id(),
1573 Op::Layout(LayoutOp::Flex {
1574 direction: FlexDirection::Column,
1575 wrap: FlexWrap::NoWrap,
1576 flex_grow: 1.0,
1577 flex_shrink: 1.0,
1578 padding: [0.0; 4],
1579 gap: None,
1580 align_items: fission_ir::op::AlignItems::Stretch,
1581 justify_content: self.text_align_vertical.justify_content(),
1582 }),
1583 );
1584 content_alignment.add_child(content_row_id);
1585 let content_id = content_alignment.build(cx);
1586
1587 let effective_line_height = line_height.unwrap_or((font_size * 1.35).max(font_size + 4.0));
1588 let min_height = if self.height.is_some() || self.expands {
1589 None
1590 } else if self.multiline {
1591 Some(
1592 content_padding[2]
1593 + content_padding[3]
1594 + effective_line_height * self.min_lines.unwrap_or(1) as f32,
1595 )
1596 } else {
1597 Some(
1598 theme
1599 .height
1600 .max(content_padding[2] + content_padding[3] + effective_line_height),
1601 )
1602 };
1603 let max_height = if self.height.is_some() || !self.multiline || self.expands {
1604 None
1605 } else {
1606 self.max_lines.map(|lines| {
1607 content_padding[2] + content_padding[3] + effective_line_height * lines as f32
1608 })
1609 };
1610
1611 let wrapper_id = cx.next_node_id();
1613 let mut wrapper = InternalIrBuilder::new(
1614 wrapper_id,
1615 Op::Layout(LayoutOp::Box {
1616 width: self.width,
1617 height: self.height.or(if self.multiline || self.expands {
1618 None
1619 } else {
1620 Some(theme.height)
1621 }),
1622 min_width: None,
1623 max_width: None,
1624 min_height,
1625 max_height,
1626 padding: content_padding,
1627 flex_grow: if self.width.is_none() || self.expands {
1628 1.0
1629 } else {
1630 0.0
1631 },
1632 flex_shrink: 1.0,
1633 aspect_ratio: None,
1634 }),
1635 );
1636 if let Some(bg_id) = background_id {
1637 wrapper.add_child(bg_id); }
1639 wrapper.add_child(content_id); let wrapper_visual_id = wrapper.build(cx);
1642 let mut final_visual_id = wrapper_visual_id;
1643
1644 if is_focused && self.enabled {
1645 if let Some(session_state) = session {
1646 let affordances = &session_state.affordances;
1647 let mut overlay_children = Vec::new();
1648
1649 if self.selection_controls.enabled {
1650 if caret == anchor {
1651 if self.selection_controls.show_collapsed_handle {
1652 if let Some(point) = affordances.caret_handle {
1653 overlay_children.push(self.build_selection_handle_overlay(
1654 cx,
1655 input_id,
1656 TextSelectionHandleKind::Caret,
1657 point,
1658 ));
1659 }
1660 }
1661 } else {
1662 if let Some(point) = affordances.selection_start_handle {
1663 overlay_children.push(self.build_selection_handle_overlay(
1664 cx,
1665 input_id,
1666 TextSelectionHandleKind::Start,
1667 point,
1668 ));
1669 }
1670 if let Some(point) = affordances.selection_end_handle {
1671 overlay_children.push(self.build_selection_handle_overlay(
1672 cx,
1673 input_id,
1674 TextSelectionHandleKind::End,
1675 point,
1676 ));
1677 }
1678 }
1679 }
1680
1681 if self.context_menu.enabled && affordances.toolbar_visible {
1682 if let Some(anchor_point) = affordances.toolbar_anchor {
1683 overlay_children.push(self.build_toolbar_overlay(
1684 cx,
1685 input_id,
1686 anchor_point,
1687 ));
1688 }
1689 }
1690
1691 if self.magnifier_configuration.enabled && affordances.magnifier_visible {
1692 if let Some(anchor_point) = affordances.magnifier_anchor {
1693 overlay_children.push(self.build_magnifier_overlay(
1694 cx,
1695 anchor_point,
1696 &display_text,
1697 caret.max(anchor),
1698 &base_text_style,
1699 ));
1700 }
1701 }
1702
1703 if !overlay_children.is_empty() {
1704 let mut stack =
1705 InternalIrBuilder::new(cx.next_node_id(), Op::Layout(LayoutOp::ZStack));
1706 stack.add_child(wrapper_visual_id);
1707 for child in overlay_children {
1708 stack.add_child(child);
1709 }
1710 final_visual_id = stack.build(cx);
1711 }
1712 }
1713 }
1714
1715 let supporting_text = self
1716 .error_text
1717 .as_ref()
1718 .map(|text| Self::resolve_text_content(text, cx))
1719 .or_else(|| {
1720 self.helper_text
1721 .as_ref()
1722 .map(|text| Self::resolve_text_content(text, cx))
1723 });
1724 let counter_text = self.supporting_counter_text(cx, &self.value);
1725
1726 let field_body_id =
1727 if resolved_label.is_some() || supporting_text.is_some() || counter_text.is_some() {
1728 let label_color = self.label_color.unwrap_or(if is_focused {
1729 theme.focus_color
1730 } else {
1731 theme
1732 .label_style
1733 .text_color
1734 .unwrap_or(tokens.colors.text_secondary)
1735 });
1736 let supporting_color = if self.error_text.is_some() {
1737 self.error_color.unwrap_or(tokens.colors.error)
1738 } else {
1739 self.helper_color.unwrap_or(
1740 theme
1741 .helper_style
1742 .text_color
1743 .unwrap_or(tokens.colors.text_secondary),
1744 )
1745 };
1746 let counter_color = self.counter_color.unwrap_or(
1747 theme
1748 .helper_style
1749 .text_color
1750 .unwrap_or(tokens.colors.text_secondary),
1751 );
1752 let mut column = InternalIrBuilder::new(
1753 cx.next_node_id(),
1754 Op::Layout(LayoutOp::Flex {
1755 direction: FlexDirection::Column,
1756 wrap: FlexWrap::NoWrap,
1757 flex_grow: 0.0,
1758 flex_shrink: 1.0,
1759 padding: [0.0; 4],
1760 gap: Some(6.0),
1761 align_items: fission_ir::op::AlignItems::Stretch,
1762 justify_content: fission_ir::op::JustifyContent::Start,
1763 }),
1764 );
1765
1766 if let Some(label) = &resolved_label {
1767 column.add_child(
1768 Text::new(label.clone())
1769 .size(
1770 theme
1771 .label_style
1772 .font_size
1773 .unwrap_or(tokens.typography.label_large_size),
1774 )
1775 .weight(
1776 theme
1777 .label_style
1778 .font_weight
1779 .unwrap_or(tokens.typography.font_weight_medium),
1780 )
1781 .color(label_color)
1782 .lower(cx),
1783 );
1784 }
1785
1786 column.add_child(final_visual_id);
1787
1788 if supporting_text.is_some() || counter_text.is_some() {
1789 let mut row = Row::default().gap(8.0);
1790 if let Some(supporting_text) = supporting_text {
1791 row.children.push(
1792 Text::new(supporting_text)
1793 .size(
1794 theme
1795 .helper_style
1796 .font_size
1797 .unwrap_or(tokens.typography.label_large_size),
1798 )
1799 .color(supporting_color)
1800 .into(),
1801 );
1802 }
1803 row.children.push(
1804 Spacer {
1805 flex_grow: 1.0,
1806 ..Default::default()
1807 }
1808 .into(),
1809 );
1810 if let Some(counter_text) = counter_text {
1811 row.children.push(
1812 Text::new(counter_text)
1813 .size(
1814 theme
1815 .helper_style
1816 .font_size
1817 .unwrap_or(tokens.typography.label_large_size),
1818 )
1819 .color(counter_color)
1820 .into(),
1821 );
1822 }
1823 column.add_child(row.lower(cx));
1824 }
1825
1826 column.build(cx)
1827 } else {
1828 final_visual_id
1829 };
1830
1831 let spell_check_enabled = self
1833 .spell_check_configuration
1834 .as_ref()
1835 .map_or(self.spell_check, |cfg| cfg.enabled);
1836 let suggestions_enabled = self
1837 .spell_check_configuration
1838 .as_ref()
1839 .map_or(self.enable_suggestions, |cfg| {
1840 self.enable_suggestions && cfg.show_suggestions
1841 });
1842
1843 let mut semantics = Semantics {
1844 role: Role::TextInput,
1845 label: resolved_label.clone().or(resolved_placeholder.clone()),
1846 identifier: self.semantics_identifier.clone(),
1847 value: Some(semantic_value),
1848 actions: Default::default(),
1849 canvas_target: None,
1850 action_scope_id: None,
1851 focusable: self.enabled,
1852 focus_policy: fission_ir::FocusPolicy::FocusOnPointer,
1853 multiline: self.multiline,
1854 masked: self.obscure_text,
1855 input_mask: self.mask.clone(),
1856 ime_preedit_range: preedit_range,
1857 ime_preedit_cursor_range: preedit_cursor_range,
1858 text_selection: Some((anchor, caret)),
1859 selectable_text: false,
1860 context_menu: false,
1861 checked: None,
1862 disabled: !self.enabled,
1863 read_only: self.read_only,
1864 autofocus: self.autofocus,
1865 draggable: false,
1866 scrollable_x: false,
1867 scrollable_y: false,
1868 min_value: None,
1869 max_value: None,
1870 current_value: None,
1871 is_focus_scope: false,
1872 is_focus_barrier: false,
1873 drag_payload: None,
1874 hero_tag: None,
1875 focus_index: None,
1876 text_input_type: if self.multiline {
1877 TextInputType::Multiline
1878 } else {
1879 self.keyboard_type
1880 },
1881 text_input_action: self.text_input_action,
1882 text_capitalization: self.text_capitalization,
1883 max_length: self.max_length,
1884 max_length_enforcement: self.max_length_enforcement,
1885 input_formatters: self.input_formatters.clone(),
1886 autocorrect: self.autocorrect,
1887 enable_suggestions: suggestions_enabled,
1888 spell_check: spell_check_enabled,
1889 smart_dashes: self.smart_dashes,
1890 smart_quotes: self.smart_quotes,
1891 autofill_hints: self.autofill_hints.clone(),
1892 scroll_padding: self.scroll_padding,
1893 capture_tab: self.capture_tab,
1894 auto_indent: self.auto_indent,
1895 };
1896 if let Some(env) = &self.on_input {
1897 semantics.actions.entries.push(fission_ir::ActionEntry {
1898 trigger: fission_ir::semantics::ActionTrigger::TextChanged,
1899 action_id: env.id.as_u128(),
1900 payload_data: Some(env.payload.clone()),
1901 });
1902 }
1903 if let Some(env) = &self.on_cursor_change {
1904 semantics.actions.entries.push(fission_ir::ActionEntry {
1905 trigger: fission_ir::semantics::ActionTrigger::CursorChange,
1906 action_id: env.id.as_u128(),
1907 payload_data: None,
1908 });
1909 }
1910 if let Some(env) = &self.on_submit {
1911 semantics.actions.entries.push(fission_ir::ActionEntry {
1912 trigger: fission_ir::semantics::ActionTrigger::Submit,
1913 action_id: env.id.as_u128(),
1914 payload_data: Some(env.payload.clone()),
1915 });
1916 }
1917 if let Some(env) = &self.on_editing_complete {
1918 semantics.actions.entries.push(fission_ir::ActionEntry {
1919 trigger: fission_ir::semantics::ActionTrigger::EditingComplete,
1920 action_id: env.id.as_u128(),
1921 payload_data: Some(env.payload.clone()),
1922 });
1923 }
1924 if let Some(env) = &self.on_tap_outside {
1925 semantics.actions.entries.push(fission_ir::ActionEntry {
1926 trigger: fission_ir::semantics::ActionTrigger::TapOutside,
1927 action_id: env.id.as_u128(),
1928 payload_data: Some(env.payload.clone()),
1929 });
1930 }
1931 if let Some(mouse_cursor) = self.mouse_cursor {
1932 semantics
1933 .actions
1934 .entries
1935 .push(fission_ir::ActionEntry::hover_cursor(mouse_cursor));
1936 }
1937 let mut semantics_builder = InternalIrBuilder::new(input_id, Op::Semantics(semantics));
1938 semantics_builder.add_child(field_body_id);
1939 let semantics_id = semantics_builder.build(cx);
1940 cx.ir.custom_render_objects.insert(
1941 semantics_id,
1942 Arc::new(TextInputRuntimeConfig {
1943 drag_start_behavior: self.drag_start_behavior,
1944 undo_controller: self.undo_controller.clone(),
1945 restoration_id: self.restoration_id.clone(),
1946 spell_check_configuration: self.spell_check_configuration.clone(),
1947 }),
1948 );
1949 semantics_id
1950 }
1951}
1952
1953fn clamp_text_offset(value: &str, mut offset: usize) -> usize {
1954 offset = offset.min(value.len());
1955 while offset > 0 && !value.is_char_boundary(offset) {
1956 offset -= 1;
1957 }
1958 offset
1959}
1960
1961fn split_runs_for_range(
1962 runs: &[fission_ir::op::TextRun],
1963 start: usize,
1964 end: usize,
1965 mut apply: impl FnMut(&mut fission_ir::op::TextStyle),
1966) -> Vec<fission_ir::op::TextRun> {
1967 if start >= end {
1968 return runs.to_vec();
1969 }
1970
1971 let mut out = Vec::new();
1972 let mut run_start = 0usize;
1973 for run in runs {
1974 let run_end = run_start + run.text.len();
1975 let overlap_start = start.max(run_start);
1976 let overlap_end = end.min(run_end);
1977 if overlap_start >= overlap_end {
1978 out.push(run.clone());
1979 run_start = run_end;
1980 continue;
1981 }
1982
1983 let local_start = overlap_start - run_start;
1984 let local_end = overlap_end - run_start;
1985 if local_start > 0 {
1986 out.push(fission_ir::op::TextRun {
1987 text: run.text[..local_start].to_string(),
1988 style: run.style.clone(),
1989 });
1990 }
1991
1992 let mut styled = run.style.clone();
1993 apply(&mut styled);
1994 out.push(fission_ir::op::TextRun {
1995 text: run.text[local_start..local_end].to_string(),
1996 style: styled,
1997 });
1998
1999 if local_end < run.text.len() {
2000 out.push(fission_ir::op::TextRun {
2001 text: run.text[local_end..].to_string(),
2002 style: run.style.clone(),
2003 });
2004 }
2005
2006 run_start = run_end;
2007 }
2008 out
2009}