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