Skip to main content

fission_core/ui/widgets/
text_input.rs

1use crate::env::TextSelectionHandleKind;
2use crate::lowering::{LoweringContext, NodeBuilder};
3use crate::ui::{
4    traits::Lower, Button, ButtonContentAlign, ButtonVariant, Container, Node, Positioned, Row,
5    Spacer, Text, TextContent, TextFontStyle,
6};
7use crate::ActionEnvelope;
8use fission_ir::{
9    op::{
10        Color as IrColor, Fill, LayoutOp, Op, PaintOp, Stroke, TextAlign as IrTextAlign,
11        TextParagraphStyle,
12    },
13    semantics::{
14        InputFormatter, MaxLengthEnforcement, MouseCursor as SemanticsMouseCursor,
15        TextCapitalization, TextInputAction, TextInputType,
16    },
17    AnyRenderObject, FlexDirection, FlexWrap, NodeId, Role, Semantics,
18};
19use fission_theme::{ComponentSize, ComponentState};
20use serde::{Deserialize, Serialize};
21use std::sync::Arc;
22use unicode_segmentation::UnicodeSegmentation;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
25pub enum TextAlignVertical {
26    Top,
27    #[default]
28    Center,
29    Bottom,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
33pub enum DragStartBehavior {
34    #[default]
35    Start,
36    Down,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct TextUndoController {
41    pub capacity: usize,
42}
43
44impl Default for TextUndoController {
45    fn default() -> Self {
46        Self { capacity: 100 }
47    }
48}
49
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51pub struct SpellCheckConfiguration {
52    pub enabled: bool,
53    pub underline_color: Option<IrColor>,
54    pub show_suggestions: bool,
55}
56
57impl Default for SpellCheckConfiguration {
58    fn default() -> Self {
59        Self {
60            enabled: true,
61            underline_color: Some(IrColor {
62                r: 255,
63                g: 59,
64                b: 48,
65                a: 255,
66            }),
67            show_suggestions: true,
68        }
69    }
70}
71
72#[doc(hidden)]
73#[derive(Debug, Clone, PartialEq)]
74pub struct TextInputRuntimeConfig {
75    pub drag_start_behavior: DragStartBehavior,
76    pub undo_controller: Option<TextUndoController>,
77    pub restoration_id: Option<String>,
78    pub spell_check_configuration: Option<SpellCheckConfiguration>,
79}
80
81#[doc(hidden)]
82pub fn downcast_text_input_runtime_config(
83    any: &AnyRenderObject,
84) -> Option<&TextInputRuntimeConfig> {
85    any.downcast_ref::<TextInputRuntimeConfig>()
86}
87
88impl TextAlignVertical {
89    fn justify_content(self) -> fission_ir::op::JustifyContent {
90        match self {
91            Self::Top => fission_ir::op::JustifyContent::Start,
92            Self::Center => fission_ir::op::JustifyContent::Center,
93            Self::Bottom => fission_ir::op::JustifyContent::End,
94        }
95    }
96
97    fn align_items(self) -> fission_ir::op::AlignItems {
98        match self {
99            Self::Top => fission_ir::op::AlignItems::Start,
100            Self::Center => fission_ir::op::AlignItems::Center,
101            Self::Bottom => fission_ir::op::AlignItems::End,
102        }
103    }
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107pub enum TextContextMenuAction {
108    Copy,
109    Cut,
110    Paste,
111    SelectAll,
112}
113
114impl TextContextMenuAction {
115    pub fn label(self) -> &'static str {
116        match self {
117            Self::Copy => "Copy",
118            Self::Cut => "Cut",
119            Self::Paste => "Paste",
120            Self::SelectAll => "Select All",
121        }
122    }
123}
124
125#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
126pub struct TextContextMenuConfig {
127    pub enabled: bool,
128    pub actions: Vec<TextContextMenuAction>,
129    pub padding: [f32; 4],
130    pub gap: f32,
131    pub border_radius: f32,
132}
133
134impl Default for TextContextMenuConfig {
135    fn default() -> Self {
136        Self {
137            enabled: true,
138            actions: vec![
139                TextContextMenuAction::Copy,
140                TextContextMenuAction::Cut,
141                TextContextMenuAction::Paste,
142                TextContextMenuAction::SelectAll,
143            ],
144            padding: [10.0, 10.0, 8.0, 8.0],
145            gap: 6.0,
146            border_radius: 12.0,
147        }
148    }
149}
150
151#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
152pub struct TextSelectionControls {
153    pub show_collapsed_handle: bool,
154    pub handle_radius: f32,
155    pub handle_fill: IrColor,
156    pub handle_stroke: Option<IrColor>,
157    pub handle_stroke_width: f32,
158}
159
160impl Default for TextSelectionControls {
161    fn default() -> Self {
162        Self {
163            show_collapsed_handle: true,
164            handle_radius: 7.0,
165            handle_fill: IrColor {
166                r: 0,
167                g: 122,
168                b: 255,
169                a: 255,
170            },
171            handle_stroke: Some(IrColor {
172                r: 255,
173                g: 255,
174                b: 255,
175                a: 255,
176            }),
177            handle_stroke_width: 1.0,
178        }
179    }
180}
181
182#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
183pub struct TextMagnifierConfiguration {
184    pub enabled: bool,
185    pub diameter: f32,
186    pub scale: f32,
187    pub border_radius: f32,
188    pub border_color: Option<IrColor>,
189    pub border_width: f32,
190}
191
192impl Default for TextMagnifierConfiguration {
193    fn default() -> Self {
194        Self {
195            enabled: true,
196            diameter: 84.0,
197            scale: 1.4,
198            border_radius: 18.0,
199            border_color: Some(IrColor {
200                r: 210,
201                g: 214,
202                b: 224,
203                a: 255,
204            }),
205            border_width: 1.0,
206        }
207    }
208}
209
210pub(crate) fn text_input_selection_handle_id(
211    input_id: NodeId,
212    kind: TextSelectionHandleKind,
213) -> NodeId {
214    let suffix = match kind {
215        TextSelectionHandleKind::Caret => 0,
216        TextSelectionHandleKind::Start => 1,
217        TextSelectionHandleKind::End => 2,
218    };
219    NodeId::derived(input_id.as_u128(), &[900, suffix])
220}
221
222pub(crate) fn text_input_toolbar_button_id(
223    input_id: NodeId,
224    action: TextContextMenuAction,
225) -> NodeId {
226    let suffix = match action {
227        TextContextMenuAction::Copy => 0,
228        TextContextMenuAction::Cut => 1,
229        TextContextMenuAction::Paste => 2,
230        TextContextMenuAction::SelectAll => 3,
231    };
232    NodeId::derived(input_id.as_u128(), &[901, suffix])
233}
234
235/// 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<NodeId>,
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<Box<Node>>,
409    /// Optional trailing decoration node.
410    pub suffix: Option<Box<Node>>,
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: Node) -> Self {
750        self.prefix = Some(Box::new(node));
751        self
752    }
753
754    pub fn suffix(mut self, node: Node) -> Self {
755        self.suffix = Some(Box::new(node));
756        self
757    }
758
759    pub fn mouse_cursor(mut self, mouse_cursor: SemanticsMouseCursor) -> Self {
760        self.mouse_cursor = Some(mouse_cursor);
761        self
762    }
763
764    pub fn scroll_padding(mut self, scroll_padding: [f32; 4]) -> Self {
765        self.scroll_padding = Some(scroll_padding);
766        self
767    }
768
769    pub fn into_node(self) -> crate::ui::Node {
770        crate::ui::Node::TextInput(self)
771    }
772}
773
774impl Default for TextInput {
775    fn default() -> Self {
776        Self {
777            id: None,
778            value: String::new(),
779            label: None,
780            placeholder: None,
781            helper_text: None,
782            error_text: None,
783            counter_text: None,
784            on_change: None,
785            on_submit: None,
786            on_editing_complete: None,
787            on_tap_outside: None,
788            width: None,
789            height: None,
790            size: ComponentSize::Md,
791            padding: None,
792            multiline: false,
793            autofocus: false,
794            enabled: true,
795            read_only: false,
796            min_lines: None,
797            max_lines: None,
798            obscure_text: false,
799            obscuring_character: '•',
800            mask: None,
801            styled_runs: None,
802            borderless: false,
803            capture_tab: false,
804            auto_indent: false,
805            on_cursor_change: None,
806            highlight_ranges: Vec::new(),
807            background_fill: None,
808            border_color: None,
809            focus_border_color: None,
810            border_width: None,
811            focus_border_width: None,
812            border_radius: None,
813            font_size: None,
814            text_color: None,
815            placeholder_color: None,
816            label_color: None,
817            helper_color: None,
818            error_color: None,
819            counter_color: None,
820            selection_color: None,
821            selection_text_color: None,
822            text_align: fission_ir::op::TextAlign::Start,
823            text_align_vertical: TextAlignVertical::Center,
824            expands: false,
825            cursor_color: None,
826            cursor_width: None,
827            cursor_height: None,
828            cursor_radius: None,
829            font_family: None,
830            locale: None,
831            font_weight: None,
832            font_style: TextFontStyle::Normal,
833            text_scale: None,
834            line_height: None,
835            letter_spacing: None,
836            text_direction: fission_ir::op::TextDirection::Auto,
837            strut_line_height: None,
838            text_height_behavior: fission_ir::op::TextHeightBehavior::default(),
839            prefix: None,
840            suffix: None,
841            mouse_cursor: None,
842            keyboard_type: TextInputType::Text,
843            text_input_action: TextInputAction::Done,
844            text_capitalization: TextCapitalization::None,
845            max_length: None,
846            max_length_enforcement: MaxLengthEnforcement::Enforced,
847            input_formatters: Vec::new(),
848            autocorrect: true,
849            enable_suggestions: true,
850            spell_check: true,
851            smart_dashes: true,
852            smart_quotes: true,
853            autofill_hints: Vec::new(),
854            scroll_padding: None,
855            drag_start_behavior: DragStartBehavior::Start,
856            context_menu: TextContextMenuConfig::default(),
857            selection_controls: TextSelectionControls::default(),
858            magnifier_configuration: TextMagnifierConfiguration::default(),
859            undo_controller: None,
860            spell_check_configuration: None,
861            restoration_id: None,
862        }
863    }
864}
865
866impl TextInput {
867    fn resolve_text_content(content: &TextContent, cx: &LoweringContext<'_>) -> String {
868        match content {
869            TextContent::Literal(s) => s.clone(),
870            TextContent::Key(key) => cx
871                .env
872                .i18n
873                .get(&cx.env.locale, key)
874                .map(|s| s.to_string())
875                .unwrap_or_else(|| format!("MISSING:{}", key)),
876        }
877    }
878
879    fn mask_text(text: &str, obscuring_character: char) -> String {
880        let mut masked = String::new();
881        for _ in text.graphemes(true) {
882            masked.push(obscuring_character);
883        }
884        masked
885    }
886
887    fn masked_byte_offset(source: &str, masked: &str, source_byte_offset: usize) -> usize {
888        let clamped = source_byte_offset.min(source.len());
889        let grapheme_count = source[..clamped].graphemes(true).count();
890        masked
891            .grapheme_indices(true)
892            .nth(grapheme_count)
893            .map(|(idx, _)| idx)
894            .unwrap_or(masked.len())
895    }
896
897    fn supporting_counter_text(
898        &self,
899        cx: &LoweringContext<'_>,
900        current_text: &str,
901    ) -> Option<String> {
902        self.counter_text
903            .as_ref()
904            .map(|content| Self::resolve_text_content(content, cx))
905            .or_else(|| {
906                self.max_length
907                    .map(|max_length| format!("{}/{}", current_text.chars().count(), max_length))
908            })
909    }
910
911    fn build_selection_handle_overlay(
912        &self,
913        cx: &mut LoweringContext,
914        input_id: NodeId,
915        kind: TextSelectionHandleKind,
916        point: fission_layout::LayoutPoint,
917    ) -> NodeId {
918        let controls = &self.selection_controls;
919        let diameter = controls.handle_radius * 2.0;
920        let handle_node = Button {
921            id: Some(text_input_selection_handle_id(input_id, kind)),
922            child: Some(Box::new(
923                Container::new(
924                    Spacer {
925                        width: Some(diameter),
926                        height: Some(diameter),
927                        ..Default::default()
928                    }
929                    .into_node(),
930                )
931                .bg_fill(Fill::Solid(controls.handle_fill))
932                .border(
933                    controls.handle_stroke.unwrap_or(IrColor {
934                        r: 0,
935                        g: 0,
936                        b: 0,
937                        a: 0,
938                    }),
939                    controls.handle_stroke_width,
940                )
941                .border_radius(controls.handle_radius)
942                .into_node(),
943            )),
944            width: Some(diameter),
945            height: Some(diameter),
946            padding: Some([0.0; 4]),
947            content_align: ButtonContentAlign::Center,
948            variant: ButtonVariant::Ghost,
949            ..Default::default()
950        }
951        .into_node();
952
953        Positioned {
954            left: Some((point.x - controls.handle_radius).max(0.0)),
955            top: Some((point.y - controls.handle_radius).max(0.0)),
956            width: Some(diameter),
957            height: Some(diameter),
958            child: Some(Box::new(handle_node)),
959            ..Default::default()
960        }
961        .lower(cx)
962    }
963
964    fn build_toolbar_overlay(
965        &self,
966        cx: &mut LoweringContext,
967        input_id: NodeId,
968        anchor: fission_layout::LayoutPoint,
969    ) -> NodeId {
970        let tokens = &cx.env.theme.tokens;
971        let mut row = Row::default().gap(self.context_menu.gap);
972        for action in &self.context_menu.actions {
973            row.children.push(
974                Button {
975                    id: Some(text_input_toolbar_button_id(input_id, *action)),
976                    child: Some(Box::new(
977                        Text::new(action.label())
978                            .size(tokens.typography.label_large_size)
979                            .color(tokens.colors.text_primary)
980                            .into_node(),
981                    )),
982                    padding: Some([10.0, 10.0, 6.0, 6.0]),
983                    content_align: ButtonContentAlign::Center,
984                    variant: ButtonVariant::Ghost,
985                    ..Default::default()
986                }
987                .into_node(),
988            );
989        }
990
991        let toolbar = Container::new(row.into_node())
992            .bg_fill(Fill::Solid(tokens.colors.surface))
993            .border(tokens.colors.border, 1.0)
994            .border_radius(self.context_menu.border_radius)
995            .padding(self.context_menu.padding)
996            .into_node();
997
998        Positioned {
999            left: Some(anchor.x.max(0.0)),
1000            top: Some((anchor.y - 44.0).max(0.0)),
1001            child: Some(Box::new(toolbar)),
1002            ..Default::default()
1003        }
1004        .lower(cx)
1005    }
1006
1007    fn magnifier_snippet(display_text: &str, caret: usize) -> String {
1008        let mut graphemes = Vec::new();
1009        for (idx, grapheme) in display_text.grapheme_indices(true) {
1010            graphemes.push((idx, grapheme));
1011        }
1012        if graphemes.is_empty() {
1013            return String::new();
1014        }
1015
1016        let caret_grapheme = graphemes
1017            .iter()
1018            .position(|(idx, _)| *idx >= caret.min(display_text.len()))
1019            .unwrap_or(graphemes.len().saturating_sub(1));
1020        let start = caret_grapheme.saturating_sub(4);
1021        let end = (caret_grapheme + 5).min(graphemes.len());
1022        graphemes[start..end]
1023            .iter()
1024            .map(|(_, grapheme)| *grapheme)
1025            .collect::<String>()
1026    }
1027
1028    fn build_magnifier_overlay(
1029        &self,
1030        cx: &mut LoweringContext,
1031        anchor: fission_layout::LayoutPoint,
1032        display_text: &str,
1033        caret: usize,
1034        base_text_style: &fission_ir::op::TextStyle,
1035    ) -> NodeId {
1036        let cfg = &self.magnifier_configuration;
1037        let tokens = &cx.env.theme.tokens;
1038        let preview = Self::magnifier_snippet(display_text, caret);
1039        let preview_text = Text::new(preview)
1040            .size(base_text_style.font_size * cfg.scale)
1041            .color(base_text_style.color)
1042            .family(
1043                base_text_style
1044                    .font_family
1045                    .clone()
1046                    .unwrap_or_else(|| "system-ui".to_string()),
1047            )
1048            .weight(base_text_style.font_weight)
1049            .italic(base_text_style.font_style == fission_ir::op::FontStyle::Italic)
1050            .line_height(
1051                base_text_style
1052                    .line_height
1053                    .unwrap_or(base_text_style.font_size * 1.25)
1054                    * cfg.scale,
1055            )
1056            .letter_spacing(base_text_style.letter_spacing * cfg.scale)
1057            .into_node();
1058
1059        let magnifier = Container::new(preview_text)
1060            .width(cfg.diameter)
1061            .height(cfg.diameter)
1062            .bg_fill(Fill::Solid(tokens.colors.surface))
1063            .border(
1064                cfg.border_color.unwrap_or(tokens.colors.border),
1065                cfg.border_width,
1066            )
1067            .border_radius(cfg.border_radius)
1068            .padding_all(8.0)
1069            .into_node();
1070
1071        Positioned {
1072            left: Some((anchor.x - cfg.diameter * 0.5).max(0.0)),
1073            top: Some((anchor.y - cfg.diameter - 18.0).max(0.0)),
1074            width: Some(cfg.diameter),
1075            height: Some(cfg.diameter),
1076            child: Some(Box::new(magnifier)),
1077            ..Default::default()
1078        }
1079        .lower(cx)
1080    }
1081}
1082
1083impl Lower for TextInput {
1084    fn lower(&self, cx: &mut LoweringContext) -> NodeId {
1085        let input_id = self.id.unwrap_or_else(|| cx.next_node_id());
1086        let is_focused = cx.runtime_state.interaction.is_focused(input_id);
1087
1088        let theme = &cx.env.theme.components.text_input;
1089        let tokens = &cx.env.theme.tokens;
1090        let component_state = if !self.enabled {
1091            ComponentState::Disabled
1092        } else if self.error_text.is_some() {
1093            ComponentState::Error
1094        } else if is_focused {
1095            ComponentState::Focus
1096        } else {
1097            ComponentState::Default
1098        };
1099        let component_style = theme.resolve(self.size, component_state);
1100
1101        let text_scale = self.text_scale.unwrap_or(1.0).max(0.0);
1102        let font_size = self
1103            .font_size
1104            .unwrap_or(component_style.font_size.unwrap_or(theme.font_size))
1105            * text_scale;
1106        let text_color = self
1107            .text_color
1108            .unwrap_or(component_style.text_color.unwrap_or(theme.text_color));
1109        let selection_color = self
1110            .selection_color
1111            .unwrap_or(tokens.colors.primary.with_alpha(52));
1112        let selection_text_color = self.selection_text_color.unwrap_or(text_color);
1113        let placeholder_color = self.placeholder_color.unwrap_or(
1114            theme
1115                .placeholder_style
1116                .text_color
1117                .unwrap_or(theme.placeholder_color),
1118        );
1119        let cursor_color = self.cursor_color.unwrap_or(theme.focus_color);
1120        let cursor_width = self.cursor_width.unwrap_or(2.0);
1121        let font_weight = self
1122            .font_weight
1123            .unwrap_or(component_style.font_weight.unwrap_or(theme.font_weight));
1124        let line_height = self
1125            .line_height
1126            .or(component_style.line_height)
1127            .map(|value| value * text_scale);
1128        let letter_spacing = self.letter_spacing.unwrap_or(0.0) * text_scale;
1129        let style_border = component_style.border.clone();
1130        let border_color = if is_focused {
1131            self.focus_border_color.unwrap_or_else(|| {
1132                style_border
1133                    .as_ref()
1134                    .and_then(|border| match &border.fill {
1135                        Fill::Solid(color) => Some(*color),
1136                        _ => None,
1137                    })
1138                    .unwrap_or(theme.focus_color)
1139            })
1140        } else {
1141            self.border_color.unwrap_or_else(|| {
1142                style_border
1143                    .as_ref()
1144                    .and_then(|border| match &border.fill {
1145                        Fill::Solid(color) => Some(*color),
1146                        _ => None,
1147                    })
1148                    .unwrap_or(theme.border_color)
1149            })
1150        };
1151        let border_width = if is_focused {
1152            self.focus_border_width.unwrap_or(
1153                style_border
1154                    .as_ref()
1155                    .map(|border| border.width)
1156                    .unwrap_or(2.0),
1157            )
1158        } else {
1159            self.border_width.unwrap_or(
1160                style_border
1161                    .as_ref()
1162                    .map(|border| border.width)
1163                    .unwrap_or(theme.border_width),
1164            )
1165        };
1166        let border_radius = self
1167            .border_radius
1168            .unwrap_or(component_style.radius.unwrap_or(theme.radius));
1169        let content_padding = self.padding.unwrap_or(component_style.padding_box(
1170            component_style.padding_x.unwrap_or(theme.padding_h),
1171            component_style.padding_y.unwrap_or(4.0),
1172        ));
1173        let base_text_style = fission_ir::op::TextStyle {
1174            font_size,
1175            color: text_color,
1176            underline: false,
1177            font_family: self.font_family.clone(),
1178            locale: self.locale.clone(),
1179            font_weight,
1180            font_style: self.font_style.into(),
1181            line_height,
1182            letter_spacing,
1183            background_color: None,
1184        };
1185
1186        let resolved_label = self
1187            .label
1188            .as_ref()
1189            .map(|label| Self::resolve_text_content(label, cx));
1190        let resolved_placeholder = self
1191            .placeholder
1192            .as_ref()
1193            .map(|placeholder| Self::resolve_text_content(placeholder, cx));
1194
1195        // 1. Background (skipped in borderless mode)
1196        let background_id = if self.borderless {
1197            None
1198        } else {
1199            Some(
1200                NodeBuilder::new(
1201                    cx.next_node_id(),
1202                    Op::Paint(PaintOp::DrawRect {
1203                        fill: Some(
1204                            self.background_fill
1205                                .clone()
1206                                .or_else(|| component_style.background.clone())
1207                                .unwrap_or(Fill::Solid(tokens.colors.background)),
1208                        ),
1209                        stroke: Some(Stroke {
1210                            fill: Fill::Solid(border_color),
1211                            width: border_width,
1212                            dash_array: None,
1213                            line_cap: fission_ir::op::LineCap::Butt,
1214                            line_join: fission_ir::op::LineJoin::Miter,
1215                        }),
1216                        corner_radius: border_radius,
1217                        shadow: component_style.outer_shadows().first().copied(),
1218                    }),
1219                )
1220                .build(cx),
1221            )
1222        };
1223
1224        // 2. Text Preparation
1225        let session = cx.runtime_state.text_edit.get(input_id);
1226        let session_display = if is_focused {
1227            session.map(|st| st.display_text())
1228        } else {
1229            None
1230        };
1231
1232        let (display_text, preedit_range, caret, anchor) = if self.obscure_text {
1233            let mut combined = self.value.clone();
1234            if let Some((display, _)) = &session_display {
1235                combined = display.clone();
1236            }
1237            let (caret, anchor) = session.map(|st| (st.caret, st.anchor)).unwrap_or((0, 0));
1238            let masked = Self::mask_text(&combined, self.obscuring_character);
1239            let mapped_caret = Self::masked_byte_offset(&combined, &masked, caret);
1240            let mapped_anchor = Self::masked_byte_offset(&combined, &masked, anchor);
1241            (masked, None, mapped_caret, mapped_anchor)
1242        } else {
1243            match session_display {
1244                Some((combined, preedit_range)) => {
1245                    let (caret, anchor) = session.map(|st| (st.caret, st.anchor)).unwrap_or((0, 0));
1246                    (combined, preedit_range, caret, anchor)
1247                }
1248                None => {
1249                    let (caret, anchor) = session.map(|st| (st.caret, st.anchor)).unwrap_or((0, 0));
1250                    (self.value.clone(), None, caret, anchor)
1251                }
1252            }
1253        };
1254
1255        // Construct Runs
1256        let mut runs = Vec::new();
1257        if is_focused && caret != anchor {
1258            let (s, e) = if caret < anchor {
1259                (caret, anchor)
1260            } else {
1261                (anchor, caret)
1262            };
1263            let s = s.min(display_text.len());
1264            let e = e.min(display_text.len());
1265
1266            if s > 0 {
1267                runs.push(fission_ir::op::TextRun {
1268                    text: display_text[..s].to_string(),
1269                    style: base_text_style.clone(),
1270                });
1271            }
1272            if s < e {
1273                runs.push(fission_ir::op::TextRun {
1274                    text: display_text[s..e].to_string(),
1275                    style: fission_ir::op::TextStyle {
1276                        color: selection_text_color,
1277                        background_color: Some(selection_color),
1278                        ..base_text_style.clone()
1279                    },
1280                });
1281            }
1282            if e < display_text.len() {
1283                runs.push(fission_ir::op::TextRun {
1284                    text: display_text[e..].to_string(),
1285                    style: base_text_style.clone(),
1286                });
1287            }
1288        } else if let Some(styled) = &self.styled_runs {
1289            // Preserve syntax colouring while letting the widget-level typography
1290            // define the default family/weight/spacing.
1291            runs = styled
1292                .iter()
1293                .cloned()
1294                .map(|mut run| {
1295                    if run.style.font_family.is_none() {
1296                        run.style.font_family = base_text_style.font_family.clone();
1297                    }
1298                    if run.style.font_weight == 400 {
1299                        run.style.font_weight = base_text_style.font_weight;
1300                    }
1301                    if run.style.font_style == fission_ir::op::FontStyle::Normal {
1302                        run.style.font_style = base_text_style.font_style;
1303                    }
1304                    if run.style.line_height.is_none() {
1305                        run.style.line_height = base_text_style.line_height;
1306                    }
1307                    if run.style.letter_spacing == 0.0 {
1308                        run.style.letter_spacing = base_text_style.letter_spacing;
1309                    }
1310                    run
1311                })
1312                .collect();
1313        } else {
1314            runs.push(fission_ir::op::TextRun {
1315                text: display_text.clone(),
1316                style: base_text_style.clone(),
1317            });
1318        }
1319
1320        // Apply highlight_ranges by splitting existing runs at highlight boundaries
1321        if !self.highlight_ranges.is_empty() && !runs.is_empty() {
1322            let mut final_runs = Vec::new();
1323            let mut run_start_byte: usize = 0;
1324
1325            for run in runs {
1326                let run_end_byte = run_start_byte + run.text.len();
1327                let mut cuts = Vec::new();
1328
1329                for &(hs, he, color) in &self.highlight_ranges {
1330                    let overlap_start = hs.max(run_start_byte);
1331                    let overlap_end = he.min(run_end_byte);
1332                    if overlap_start < overlap_end {
1333                        cuts.push((
1334                            overlap_start - run_start_byte,
1335                            overlap_end - run_start_byte,
1336                            color,
1337                        ));
1338                    }
1339                }
1340
1341                if cuts.is_empty() {
1342                    final_runs.push(run);
1343                } else {
1344                    cuts.sort_by_key(|c| c.0);
1345                    let mut pos = 0usize;
1346                    for (cs, ce, bg_color) in cuts {
1347                        if cs > pos {
1348                            final_runs.push(fission_ir::op::TextRun {
1349                                text: run.text[pos..cs].to_string(),
1350                                style: run.style.clone(),
1351                            });
1352                        }
1353                        let mut hl_style = run.style.clone();
1354                        hl_style.background_color = Some(bg_color);
1355                        final_runs.push(fission_ir::op::TextRun {
1356                            text: run.text[cs..ce].to_string(),
1357                            style: hl_style,
1358                        });
1359                        pos = ce;
1360                    }
1361                    if pos < run.text.len() {
1362                        final_runs.push(fission_ir::op::TextRun {
1363                            text: run.text[pos..].to_string(),
1364                            style: run.style.clone(),
1365                        });
1366                    }
1367                }
1368                run_start_byte = run_end_byte;
1369            }
1370            runs = final_runs;
1371        }
1372
1373        if display_text.is_empty() && resolved_placeholder.is_some() {
1374            runs = vec![fission_ir::op::TextRun {
1375                text: resolved_placeholder.clone().unwrap(),
1376                style: fission_ir::op::TextStyle {
1377                    color: placeholder_color,
1378                    ..base_text_style.clone()
1379                },
1380            }];
1381        }
1382
1383        let caret_idx = if is_focused {
1384            let show = cx
1385                .runtime_state
1386                .caret_visible
1387                .get(&input_id)
1388                .copied()
1389                .unwrap_or(true);
1390            if show {
1391                Some(
1392                    preedit_range
1393                        .map(|(_, end)| end)
1394                        .unwrap_or(caret)
1395                        .min(display_text.len()),
1396                )
1397            } else {
1398                None
1399            }
1400        } else {
1401            None
1402        };
1403
1404        let paragraph_overflow = if self.multiline {
1405            fission_ir::op::TextOverflow::Clip
1406        } else {
1407            fission_ir::op::TextOverflow::Visible
1408        };
1409        let paragraph_style = Some(TextParagraphStyle {
1410            text_align: self.text_align,
1411            max_lines: None,
1412            overflow: paragraph_overflow,
1413            text_direction: self.text_direction,
1414            text_width_basis: fission_ir::op::TextWidthBasis::Parent,
1415            strut_line_height: self.strut_line_height,
1416            text_height_behavior: self.text_height_behavior,
1417        })
1418        .filter(|style| {
1419            *style
1420                != TextParagraphStyle {
1421                    text_align: IrTextAlign::Start,
1422                    max_lines: None,
1423                    overflow: paragraph_overflow,
1424                    text_direction: self.text_direction,
1425                    text_width_basis: fission_ir::op::TextWidthBasis::Parent,
1426                    strut_line_height: self.strut_line_height,
1427                    text_height_behavior: self.text_height_behavior,
1428                }
1429        });
1430
1431        let text_id = NodeBuilder::new(
1432            cx.next_node_id(),
1433            Op::Paint(PaintOp::DrawRichText {
1434                runs,
1435                wrap: self.multiline,
1436                caret_index: caret_idx,
1437                caret_color: Some(cursor_color),
1438                caret_width: Some(cursor_width),
1439                caret_height: self.cursor_height,
1440                caret_radius: self.cursor_radius,
1441                paragraph_style,
1442            }),
1443        )
1444        .build(cx);
1445
1446        let mut text_box = NodeBuilder::new(
1447            cx.next_node_id(),
1448            Op::Layout(LayoutOp::Box {
1449                width: None,
1450                height: None,
1451                min_width: None,
1452                max_width: None,
1453                min_height: None,
1454                max_height: None,
1455                padding: [0.0; 4],
1456                flex_grow: 0.0,
1457                flex_shrink: 0.0,
1458                aspect_ratio: None,
1459            }),
1460        );
1461        text_box.add_child(text_id);
1462        let text_layout_id = text_box.build(cx);
1463
1464        // 3. Scroll Container
1465        let mut scroll = NodeBuilder::new(
1466            cx.next_node_id(),
1467            Op::Layout(LayoutOp::Scroll {
1468                direction: if self.multiline {
1469                    FlexDirection::Column
1470                } else {
1471                    FlexDirection::Row
1472                },
1473                show_scrollbar: false,
1474                width: None, // Let it fill parent padding box
1475                height: None,
1476                min_width: None,
1477                max_width: None,
1478                min_height: None,
1479                max_height: None,
1480                padding: [0.0; 4],
1481                flex_grow: 1.0,
1482                flex_shrink: 1.0,
1483            }),
1484        );
1485        scroll.add_child(text_layout_id);
1486        let scroll_id = scroll.build(cx);
1487
1488        // 4. Editable content row and vertical alignment container.
1489        let mut content_row = NodeBuilder::new(
1490            cx.next_node_id(),
1491            Op::Layout(LayoutOp::Flex {
1492                direction: FlexDirection::Row,
1493                wrap: FlexWrap::NoWrap,
1494                flex_grow: if self.expands { 1.0 } else { 0.0 },
1495                flex_shrink: 1.0,
1496                padding: [0.0; 4],
1497                gap: if self.prefix.is_some() || self.suffix.is_some() {
1498                    Some(theme.padding_h * 0.75)
1499                } else {
1500                    None
1501                },
1502                align_items: self.text_align_vertical.align_items(),
1503                justify_content: fission_ir::op::JustifyContent::Start,
1504            }),
1505        );
1506        if let Some(prefix) = &self.prefix {
1507            content_row.add_child(prefix.lower(cx));
1508        }
1509        content_row.add_child(scroll_id);
1510        if let Some(suffix) = &self.suffix {
1511            content_row.add_child(suffix.lower(cx));
1512        }
1513        let content_row_id = content_row.build(cx);
1514
1515        let mut content_alignment = NodeBuilder::new(
1516            cx.next_node_id(),
1517            Op::Layout(LayoutOp::Flex {
1518                direction: FlexDirection::Column,
1519                wrap: FlexWrap::NoWrap,
1520                flex_grow: 1.0,
1521                flex_shrink: 1.0,
1522                padding: [0.0; 4],
1523                gap: None,
1524                align_items: fission_ir::op::AlignItems::Stretch,
1525                justify_content: self.text_align_vertical.justify_content(),
1526            }),
1527        );
1528        content_alignment.add_child(content_row_id);
1529        let content_id = content_alignment.build(cx);
1530
1531        let effective_line_height = line_height.unwrap_or((font_size * 1.35).max(font_size + 4.0));
1532        let min_height = if self.height.is_some() || self.expands {
1533            None
1534        } else if self.multiline {
1535            Some(
1536                content_padding[2]
1537                    + content_padding[3]
1538                    + effective_line_height * self.min_lines.unwrap_or(1) as f32,
1539            )
1540        } else {
1541            Some(
1542                theme
1543                    .height
1544                    .max(content_padding[2] + content_padding[3] + effective_line_height),
1545            )
1546        };
1547        let max_height = if self.height.is_some() || !self.multiline || self.expands {
1548            None
1549        } else {
1550            self.max_lines.map(|lines| {
1551                content_padding[2] + content_padding[3] + effective_line_height * lines as f32
1552            })
1553        };
1554
1555        // 5. Wrapper (Border + Padding)
1556        let wrapper_id = cx.next_node_id();
1557        let mut wrapper = NodeBuilder::new(
1558            wrapper_id,
1559            Op::Layout(LayoutOp::Box {
1560                width: self.width,
1561                height: self.height.or(if self.multiline || self.expands {
1562                    None
1563                } else {
1564                    Some(theme.height)
1565                }),
1566                min_width: None,
1567                max_width: None,
1568                min_height,
1569                max_height,
1570                padding: content_padding,
1571                flex_grow: if self.width.is_none() || self.expands {
1572                    1.0
1573                } else {
1574                    0.0
1575                },
1576                flex_shrink: 1.0,
1577                aspect_ratio: None,
1578            }),
1579        );
1580        if let Some(bg_id) = background_id {
1581            wrapper.add_child(bg_id); // Fill
1582        }
1583        wrapper.add_child(content_id); // Content
1584
1585        let wrapper_visual_id = wrapper.build(cx);
1586        let mut final_visual_id = wrapper_visual_id;
1587
1588        if is_focused && self.enabled {
1589            if let Some(session_state) = session {
1590                let affordances = &session_state.affordances;
1591                let mut overlay_children = Vec::new();
1592
1593                if caret == anchor {
1594                    if self.selection_controls.show_collapsed_handle {
1595                        if let Some(point) = affordances.caret_handle {
1596                            overlay_children.push(self.build_selection_handle_overlay(
1597                                cx,
1598                                input_id,
1599                                TextSelectionHandleKind::Caret,
1600                                point,
1601                            ));
1602                        }
1603                    }
1604                } else {
1605                    if let Some(point) = affordances.selection_start_handle {
1606                        overlay_children.push(self.build_selection_handle_overlay(
1607                            cx,
1608                            input_id,
1609                            TextSelectionHandleKind::Start,
1610                            point,
1611                        ));
1612                    }
1613                    if let Some(point) = affordances.selection_end_handle {
1614                        overlay_children.push(self.build_selection_handle_overlay(
1615                            cx,
1616                            input_id,
1617                            TextSelectionHandleKind::End,
1618                            point,
1619                        ));
1620                    }
1621                }
1622
1623                if self.context_menu.enabled && affordances.toolbar_visible {
1624                    if let Some(anchor_point) = affordances.toolbar_anchor {
1625                        overlay_children.push(self.build_toolbar_overlay(
1626                            cx,
1627                            input_id,
1628                            anchor_point,
1629                        ));
1630                    }
1631                }
1632
1633                if self.magnifier_configuration.enabled && affordances.magnifier_visible {
1634                    if let Some(anchor_point) = affordances.magnifier_anchor {
1635                        overlay_children.push(self.build_magnifier_overlay(
1636                            cx,
1637                            anchor_point,
1638                            &display_text,
1639                            caret.max(anchor),
1640                            &base_text_style,
1641                        ));
1642                    }
1643                }
1644
1645                if !overlay_children.is_empty() {
1646                    let mut stack =
1647                        NodeBuilder::new(cx.next_node_id(), Op::Layout(LayoutOp::ZStack));
1648                    stack.add_child(wrapper_visual_id);
1649                    for child in overlay_children {
1650                        stack.add_child(child);
1651                    }
1652                    final_visual_id = stack.build(cx);
1653                }
1654            }
1655        }
1656
1657        let supporting_text = self
1658            .error_text
1659            .as_ref()
1660            .map(|text| Self::resolve_text_content(text, cx))
1661            .or_else(|| {
1662                self.helper_text
1663                    .as_ref()
1664                    .map(|text| Self::resolve_text_content(text, cx))
1665            });
1666        let counter_text = self.supporting_counter_text(cx, &self.value);
1667
1668        let field_body_id =
1669            if resolved_label.is_some() || supporting_text.is_some() || counter_text.is_some() {
1670                let label_color = self.label_color.unwrap_or(if is_focused {
1671                    theme.focus_color
1672                } else {
1673                    theme
1674                        .label_style
1675                        .text_color
1676                        .unwrap_or(tokens.colors.text_secondary)
1677                });
1678                let supporting_color = if self.error_text.is_some() {
1679                    self.error_color.unwrap_or(tokens.colors.error)
1680                } else {
1681                    self.helper_color.unwrap_or(
1682                        theme
1683                            .helper_style
1684                            .text_color
1685                            .unwrap_or(tokens.colors.text_secondary),
1686                    )
1687                };
1688                let counter_color = self.counter_color.unwrap_or(
1689                    theme
1690                        .helper_style
1691                        .text_color
1692                        .unwrap_or(tokens.colors.text_secondary),
1693                );
1694                let mut column = NodeBuilder::new(
1695                    cx.next_node_id(),
1696                    Op::Layout(LayoutOp::Flex {
1697                        direction: FlexDirection::Column,
1698                        wrap: FlexWrap::NoWrap,
1699                        flex_grow: 0.0,
1700                        flex_shrink: 1.0,
1701                        padding: [0.0; 4],
1702                        gap: Some(6.0),
1703                        align_items: fission_ir::op::AlignItems::Stretch,
1704                        justify_content: fission_ir::op::JustifyContent::Start,
1705                    }),
1706                );
1707
1708                if let Some(label) = &resolved_label {
1709                    column.add_child(
1710                        Text::new(label.clone())
1711                            .size(
1712                                theme
1713                                    .label_style
1714                                    .font_size
1715                                    .unwrap_or(tokens.typography.label_large_size),
1716                            )
1717                            .weight(
1718                                theme
1719                                    .label_style
1720                                    .font_weight
1721                                    .unwrap_or(tokens.typography.font_weight_medium),
1722                            )
1723                            .color(label_color)
1724                            .lower(cx),
1725                    );
1726                }
1727
1728                column.add_child(final_visual_id);
1729
1730                if supporting_text.is_some() || counter_text.is_some() {
1731                    let mut row = Row::default().gap(8.0);
1732                    if let Some(supporting_text) = supporting_text {
1733                        row.children.push(
1734                            Text::new(supporting_text)
1735                                .size(
1736                                    theme
1737                                        .helper_style
1738                                        .font_size
1739                                        .unwrap_or(tokens.typography.label_large_size),
1740                                )
1741                                .color(supporting_color)
1742                                .into_node(),
1743                        );
1744                    }
1745                    row.children.push(
1746                        Spacer {
1747                            flex_grow: 1.0,
1748                            ..Default::default()
1749                        }
1750                        .into_node(),
1751                    );
1752                    if let Some(counter_text) = counter_text {
1753                        row.children.push(
1754                            Text::new(counter_text)
1755                                .size(
1756                                    theme
1757                                        .helper_style
1758                                        .font_size
1759                                        .unwrap_or(tokens.typography.label_large_size),
1760                                )
1761                                .color(counter_color)
1762                                .into_node(),
1763                        );
1764                    }
1765                    column.add_child(row.lower(cx));
1766                }
1767
1768                column.build(cx)
1769            } else {
1770                final_visual_id
1771            };
1772
1773        // 5. Semantics
1774        let spell_check_enabled = self
1775            .spell_check_configuration
1776            .as_ref()
1777            .map_or(self.spell_check, |cfg| cfg.enabled);
1778        let suggestions_enabled = self
1779            .spell_check_configuration
1780            .as_ref()
1781            .map_or(self.enable_suggestions, |cfg| {
1782                self.enable_suggestions && cfg.show_suggestions
1783            });
1784
1785        let mut semantics = Semantics {
1786            role: Role::TextInput,
1787            label: resolved_label.clone().or(resolved_placeholder.clone()),
1788            identifier: None,
1789            value: Some(self.value.clone()),
1790            actions: Default::default(),
1791            action_scope_id: None,
1792            focusable: self.enabled,
1793            multiline: self.multiline,
1794            masked: self.obscure_text,
1795            input_mask: self.mask.clone(),
1796            ime_preedit_range: preedit_range,
1797            checked: None,
1798            disabled: !self.enabled,
1799            read_only: self.read_only,
1800            autofocus: self.autofocus,
1801            draggable: false,
1802            scrollable_x: false,
1803            scrollable_y: false,
1804            min_value: None,
1805            max_value: None,
1806            current_value: None,
1807            is_focus_scope: false,
1808            is_focus_barrier: false,
1809            drag_payload: None,
1810            hero_tag: None,
1811            focus_index: None,
1812            text_input_type: if self.multiline {
1813                TextInputType::Multiline
1814            } else {
1815                self.keyboard_type
1816            },
1817            text_input_action: self.text_input_action,
1818            text_capitalization: self.text_capitalization,
1819            max_length: self.max_length,
1820            max_length_enforcement: self.max_length_enforcement,
1821            input_formatters: self.input_formatters.clone(),
1822            autocorrect: self.autocorrect,
1823            enable_suggestions: suggestions_enabled,
1824            spell_check: spell_check_enabled,
1825            smart_dashes: self.smart_dashes,
1826            smart_quotes: self.smart_quotes,
1827            autofill_hints: self.autofill_hints.clone(),
1828            scroll_padding: self.scroll_padding,
1829            capture_tab: self.capture_tab,
1830            auto_indent: self.auto_indent,
1831        };
1832        if let Some(env) = &self.on_change {
1833            semantics.actions.entries.push(fission_ir::ActionEntry {
1834                trigger: fission_ir::semantics::ActionTrigger::Change,
1835                action_id: env.id.as_u128(),
1836                payload_data: None,
1837            });
1838        }
1839        if let Some(env) = &self.on_cursor_change {
1840            semantics.actions.entries.push(fission_ir::ActionEntry {
1841                trigger: fission_ir::semantics::ActionTrigger::CursorChange,
1842                action_id: env.id.as_u128(),
1843                payload_data: None,
1844            });
1845        }
1846        if let Some(env) = &self.on_submit {
1847            semantics.actions.entries.push(fission_ir::ActionEntry {
1848                trigger: fission_ir::semantics::ActionTrigger::Submit,
1849                action_id: env.id.as_u128(),
1850                payload_data: Some(env.payload.clone()),
1851            });
1852        }
1853        if let Some(env) = &self.on_editing_complete {
1854            semantics.actions.entries.push(fission_ir::ActionEntry {
1855                trigger: fission_ir::semantics::ActionTrigger::EditingComplete,
1856                action_id: env.id.as_u128(),
1857                payload_data: Some(env.payload.clone()),
1858            });
1859        }
1860        if let Some(env) = &self.on_tap_outside {
1861            semantics.actions.entries.push(fission_ir::ActionEntry {
1862                trigger: fission_ir::semantics::ActionTrigger::TapOutside,
1863                action_id: env.id.as_u128(),
1864                payload_data: Some(env.payload.clone()),
1865            });
1866        }
1867        if let Some(mouse_cursor) = self.mouse_cursor {
1868            semantics
1869                .actions
1870                .entries
1871                .push(fission_ir::ActionEntry::hover_cursor(mouse_cursor));
1872        }
1873        let mut semantics_builder = NodeBuilder::new(input_id, Op::Semantics(semantics));
1874        semantics_builder.add_child(field_body_id);
1875        let semantics_id = semantics_builder.build(cx);
1876        cx.ir.custom_render_objects.insert(
1877            semantics_id,
1878            Arc::new(TextInputRuntimeConfig {
1879                drag_start_behavior: self.drag_start_behavior,
1880                undo_controller: self.undo_controller.clone(),
1881                restoration_id: self.restoration_id.clone(),
1882                spell_check_configuration: self.spell_check_configuration.clone(),
1883            }),
1884        );
1885        semantics_id
1886    }
1887}