Skip to main content

fission_core/ui/widgets/
text_input.rs

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