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