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