Skip to main content

fission_core/ui/widgets/
text_input.rs

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