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