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