Skip to main content

fission_core/ui/widgets/
text_input.rs

1use crate::lowering::{LoweringContext, NodeBuilder};
2use crate::ui::traits::Lower;
3use crate::ui::TextContent;
4use crate::ActionEnvelope;
5use fission_ir::{
6    op::{Color as IrColor, Fill, LayoutOp, Op, PaintOp, Stroke},
7    NodeId, Role, Semantics, FlexDirection
8};
9use serde::{Deserialize, Serialize};
10use unicode_segmentation::UnicodeSegmentation;
11
12/// An editable text field with support for single-line and multiline input,
13/// syntax highlighting, password masking, and IME composition.
14///
15/// `TextInput` is the primary text-editing widget. It manages its own scroll
16/// container, caret, selection, and (when `styled_runs` is provided)
17/// multi-colour syntax-highlighted rendering.
18///
19/// # Example
20///
21/// ```rust,ignore
22/// let on_change = ctx.bind(TextChanged { .. }, handle_text as fn(&mut S, TextChanged));
23///
24/// TextInput {
25///     value: view.state.query.clone(),
26///     placeholder: Some("Search...".into()),
27///     on_change: Some(on_change),
28///     ..Default::default()
29/// }
30/// ```
31///
32/// # Code editor mode
33///
34/// For embedding in a code editor, enable `borderless`, `capture_tab`,
35/// `auto_indent`, and provide `styled_runs` for syntax highlighting:
36///
37/// ```rust,ignore
38/// TextInput {
39///     value: source_code.clone(),
40///     multiline: true,
41///     borderless: true,
42///     capture_tab: true,
43///     auto_indent: true,
44///     styled_runs: Some(highlighted_runs),
45///     ..Default::default()
46/// }
47/// ```
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct TextInput {
50    /// Explicit node identity (used for focus tracking and scroll state).
51    pub id: Option<NodeId>,
52    /// The current text value (controlled by the application).
53    pub value: String,
54    /// Placeholder text shown when `value` is empty.
55    pub placeholder: Option<TextContent>,
56    /// Action dispatched when the text changes.
57    pub on_change: Option<ActionEnvelope>,
58    /// Fixed width in layout points.
59    pub width: Option<f32>,
60    /// Fixed height in layout points.
61    pub height: Option<f32>,
62    /// When `true`, the input accepts newlines and scrolls vertically.
63    pub multiline: bool,
64    /// Minimum number of visible lines (multiline only).
65    pub min_lines: Option<usize>,
66    /// Maximum number of visible lines (multiline only).
67    pub max_lines: Option<usize>,
68    /// When `true`, display each grapheme as `obscuring_character` (password mode).
69    pub obscure_text: bool,
70    /// The character used when `obscure_text` is `true` (default: `'•'`).
71    pub obscuring_character: char,
72    /// Structural input mask (e.g. phone number, date).
73    pub mask: Option<fission_ir::semantics::InputMask>,
74    /// Pre-styled text runs for syntax highlighting.
75    ///
76    /// When provided and no selection is active, these runs are rendered instead
77    /// of the default single-colour text. The concatenated text of all runs
78    /// **must** match `value` exactly.
79    pub styled_runs: Option<Vec<fission_ir::op::TextRun>>,
80    /// When `true`, the background rect and border are omitted (for embedding
81    /// in editor chrome).
82    pub borderless: bool,
83    /// When `true`, the Tab key inserts whitespace instead of moving focus.
84    pub capture_tab: bool,
85    /// When `true`, pressing Enter copies the leading whitespace of the current
86    /// line (auto-indentation).
87    pub auto_indent: bool,
88    /// Action dispatched when the caret or selection anchor changes.
89    pub on_cursor_change: Option<ActionEnvelope>,
90    /// Ranges to highlight in the text (e.g. find-match results).
91    ///
92    /// Each entry is `(start_byte, end_byte, background_color)`.
93    pub highlight_ranges: Vec<(usize, usize, IrColor)>,
94}
95
96impl TextInput {
97    pub fn value(mut self, v: impl Into<String>) -> Self {
98        self.value = v.into();
99        self
100    }
101
102    pub fn into_node(self) -> crate::ui::Node {
103        crate::ui::Node::TextInput(self)
104    }
105}
106
107impl Default for TextInput {
108    fn default() -> Self {
109        Self {
110            id: None,
111            value: String::new(),
112            placeholder: None,
113            on_change: None,
114            width: None,
115            height: None,
116            multiline: false,
117            min_lines: None,
118            max_lines: None,
119            obscure_text: false,
120            obscuring_character: '•',
121            mask: None,
122            styled_runs: None,
123            borderless: false,
124            capture_tab: false,
125            auto_indent: false,
126            on_cursor_change: None,
127            highlight_ranges: Vec::new(),
128        }
129    }
130}
131
132impl Lower for TextInput {
133    fn lower(&self, cx: &mut LoweringContext) -> NodeId {
134        let input_id = self.id.unwrap_or_else(|| cx.next_node_id());
135        let is_focused = cx.runtime_state.interaction.is_focused(input_id);
136        
137        let theme = &cx.env.theme.components.text_input;
138        let tokens = &cx.env.theme.tokens;
139
140        let font_size = theme.font_size;
141        let text_color = theme.text_color;
142        let selection_color = theme.focus_color;
143        let border_color = if is_focused { theme.focus_color } else { theme.border_color };
144        let border_width = if is_focused { 2.0 } else { theme.border_width };
145
146        // Resolve placeholder
147        let resolved_placeholder = if let Some(ph) = &self.placeholder {
148            match ph {
149                TextContent::Literal(s) => Some(s.clone()),
150                TextContent::Key(key) => Some(cx
151                    .env
152                    .i18n
153                    .get(&cx.env.locale, key)
154                    .map(|s| s.to_string())
155                    .unwrap_or_else(|| format!("MISSING:{}", key))),
156            }
157        } else {
158            None
159        };
160
161        // 1. Background (skipped in borderless mode)
162        let background_id = if self.borderless {
163            None
164        } else {
165            Some(NodeBuilder::new(
166                cx.next_node_id(),
167                Op::Paint(PaintOp::DrawRect {
168                    fill: Some(Fill { color: tokens.colors.background }),
169                    stroke: Some(Stroke {
170                        color: border_color,
171                        width: border_width
172                    }),
173                    corner_radius: theme.radius,
174                    shadow: None,
175                })
176            ).build(cx))
177        };
178
179        // 2. Text Preparation
180        let preedit_text = if is_focused {
181            cx.runtime_state.ime_preedit.clone().filter(|(id, _)| *id == input_id).map(|(_, t)| t)
182        } else { None };
183
184        let (display_text, caret, anchor) = if self.obscure_text {
185            let obs = self.obscuring_character.to_string();
186            let obs_len = obs.len();
187            let mut combined = self.value.clone();
188            if let Some(pre) = &preedit_text { combined.push_str(pre); }
189            let g_count = combined.graphemes(true).count();
190            let masked = obs.repeat(g_count);
191            
192            // Caret mapping not implemented for masked yet, defaulting to end
193            (masked, 0, 0) 
194        } else {
195            let mut combined = self.value.clone();
196            if let Some(pre) = &preedit_text { combined.push_str(pre); }
197            let (caret, anchor) = if let Some(st) = cx.runtime_state.text_edit.get(input_id) {
198                (st.caret, st.anchor)
199            } else {
200                (0, 0)
201            };
202            (combined, caret, anchor)
203        };
204
205        // Construct Runs
206        let mut runs = Vec::new();
207        if is_focused && caret != anchor {
208            let (s, e) = if caret < anchor { (caret, anchor) } else { (anchor, caret) };
209            let s = s.min(display_text.len());
210            let e = e.min(display_text.len());
211
212            if s > 0 {
213                runs.push(fission_ir::op::TextRun {
214                    text: display_text[..s].to_string(),
215                    style: fission_ir::op::TextStyle { font_size, color: text_color, underline: false, background_color: None },
216                });
217            }
218            if s < e {
219                runs.push(fission_ir::op::TextRun {
220                    text: display_text[s..e].to_string(),
221                    style: fission_ir::op::TextStyle { font_size, color: selection_color, underline: true, background_color: None }, // Visual cue for selection
222                });
223            }
224            if e < display_text.len() {
225                runs.push(fission_ir::op::TextRun {
226                    text: display_text[e..].to_string(),
227                    style: fission_ir::op::TextStyle { font_size, color: text_color, underline: false, background_color: None },
228                });
229            }
230        } else if let Some(styled) = &self.styled_runs {
231            // Use pre-styled syntax-highlighted runs
232            runs = styled.clone();
233        } else {
234            runs.push(fission_ir::op::TextRun {
235                text: display_text.clone(),
236                style: fission_ir::op::TextStyle { font_size, color: text_color, underline: false, background_color: None },
237            });
238        }
239
240        // Apply highlight_ranges by splitting existing runs at highlight boundaries
241        if !self.highlight_ranges.is_empty() && !runs.is_empty() {
242            let mut final_runs = Vec::new();
243            let mut run_start_byte: usize = 0;
244
245            for run in runs {
246                let run_end_byte = run_start_byte + run.text.len();
247                let mut cuts = Vec::new();
248
249                for &(hs, he, color) in &self.highlight_ranges {
250                    let overlap_start = hs.max(run_start_byte);
251                    let overlap_end = he.min(run_end_byte);
252                    if overlap_start < overlap_end {
253                        cuts.push((overlap_start - run_start_byte, overlap_end - run_start_byte, color));
254                    }
255                }
256
257                if cuts.is_empty() {
258                    final_runs.push(run);
259                } else {
260                    cuts.sort_by_key(|c| c.0);
261                    let mut pos = 0usize;
262                    for (cs, ce, bg_color) in cuts {
263                        if cs > pos {
264                            final_runs.push(fission_ir::op::TextRun {
265                                text: run.text[pos..cs].to_string(),
266                                style: run.style.clone(),
267                            });
268                        }
269                        let mut hl_style = run.style.clone();
270                        hl_style.background_color = Some(bg_color);
271                        final_runs.push(fission_ir::op::TextRun {
272                            text: run.text[cs..ce].to_string(),
273                            style: hl_style,
274                        });
275                        pos = ce;
276                    }
277                    if pos < run.text.len() {
278                        final_runs.push(fission_ir::op::TextRun {
279                            text: run.text[pos..].to_string(),
280                            style: run.style.clone(),
281                        });
282                    }
283                }
284                run_start_byte = run_end_byte;
285            }
286            runs = final_runs;
287        }
288
289        if display_text.is_empty() && resolved_placeholder.is_some() {
290             runs = vec![fission_ir::op::TextRun {
291                text: resolved_placeholder.unwrap(),
292                style: fission_ir::op::TextStyle { font_size, color: theme.placeholder_color, underline: false, background_color: None },
293            }];
294        }
295
296        let caret_idx = if is_focused && !self.obscure_text { 
297            let show = cx.runtime_state.caret_visible.get(&input_id).copied().unwrap_or(true);
298            if show { Some(caret.min(display_text.len())) } else { None }
299        } else { None };
300
301        let text_id = NodeBuilder::new(
302            cx.next_node_id(),
303            Op::Paint(PaintOp::DrawRichText {
304                runs,
305                caret_index: caret_idx,
306            })
307        ).build(cx);
308        
309        let mut text_box = NodeBuilder::new(
310            cx.next_node_id(),
311            Op::Layout(LayoutOp::Box {
312                width: None, height: None, min_width: None, max_width: None, min_height: None, max_height: None,
313                padding: [0.0; 4],
314                flex_grow: 0.0,
315                flex_shrink: 0.0,
316                aspect_ratio: None,
317            })
318        );
319        text_box.add_child(text_id);
320        let text_layout_id = text_box.build(cx);
321
322        // 3. Scroll Container
323        let scroll_id = cx.next_node_id();
324        let mut scroll = NodeBuilder::new(
325            scroll_id,
326            Op::Layout(LayoutOp::Scroll {
327                direction: if self.multiline { FlexDirection::Column } else { FlexDirection::Row },
328                show_scrollbar: false,
329                width: None, // Let it fill parent padding box
330                height: None, 
331                min_width: None, max_width: None, min_height: None, max_height: None,
332                padding: [0.0; 4],
333                flex_grow: 1.0,
334                flex_shrink: 1.0,
335            })
336        );
337        scroll.add_child(text_layout_id);
338        let scroll_id = scroll.build(cx);
339
340        // 4. Wrapper (Border + Padding)
341        let wrapper_id = cx.next_node_id();
342        let mut wrapper = NodeBuilder::new(
343            wrapper_id,
344            Op::Layout(LayoutOp::Box {
345                width: self.width,
346                height: self.height.or(if self.multiline { None } else { Some(theme.height) }),
347                min_width: None,
348                max_width: None,
349                min_height: None,
350                max_height: None,
351                padding: [theme.padding_h, theme.padding_h, 4.0, 4.0], // Padding applied here
352                flex_grow: if self.width.is_none() { 1.0 } else { 0.0 },
353                flex_shrink: 1.0,
354                aspect_ratio: None,
355            })
356        );
357        if let Some(bg_id) = background_id {
358            wrapper.add_child(bg_id); // Fill
359        }
360        wrapper.add_child(scroll_id);     // Content
361        
362        let final_id = wrapper.build(cx);
363
364        // 5. Semantics
365        let mut semantics = Semantics {
366            role: Role::TextInput,
367            label: None,
368            value: Some(self.value.clone()),
369            actions: Default::default(), 
370            focusable: true,
371            multiline: self.multiline,
372            masked: self.obscure_text,
373            input_mask: self.mask.clone(),
374            ime_preedit_range: None, // TODO: Fix preedit highlighting
375            checked: None,
376            disabled: false,
377            draggable: false,
378            scrollable_x: false,
379            scrollable_y: false,
380            min_value: None,
381            max_value: None,
382            current_value: None,
383            is_focus_scope: false,
384            is_focus_barrier: false,
385            drag_payload: None,
386            hero_tag: None,
387            focus_index: None,
388            capture_tab: self.capture_tab,
389            auto_indent: self.auto_indent,
390        };
391        if let Some(env) = &self.on_change {
392             semantics.actions.entries.push(fission_ir::ActionEntry {
393                 trigger: fission_ir::semantics::ActionTrigger::Change,
394                 action_id: env.id.as_u128(),
395                 payload_data: None,
396             });
397        }
398        if let Some(env) = &self.on_cursor_change {
399             semantics.actions.entries.push(fission_ir::ActionEntry {
400                 trigger: fission_ir::semantics::ActionTrigger::CursorChange,
401                 action_id: env.id.as_u128(),
402                 payload_data: None,
403             });
404        }
405        let mut semantics_builder = NodeBuilder::new(input_id, Op::Semantics(semantics));
406        semantics_builder.add_child(final_id);
407        semantics_builder.build(cx)
408    }
409}