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