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