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#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct TextInput {
50 pub id: Option<NodeId>,
52 pub value: String,
54 pub placeholder: Option<TextContent>,
56 pub on_change: Option<ActionEnvelope>,
58 pub width: Option<f32>,
60 pub height: Option<f32>,
62 pub multiline: bool,
64 pub min_lines: Option<usize>,
66 pub max_lines: Option<usize>,
68 pub obscure_text: bool,
70 pub obscuring_character: char,
72 pub mask: Option<fission_ir::semantics::InputMask>,
74 pub styled_runs: Option<Vec<fission_ir::op::TextRun>>,
80 pub borderless: bool,
83 pub capture_tab: bool,
85 pub auto_indent: bool,
88 pub on_cursor_change: Option<ActionEnvelope>,
90 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 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 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 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 (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 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 }, });
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 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 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 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, 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 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], 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); }
360 wrapper.add_child(scroll_id); let final_id = wrapper.build(cx);
363
364 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, 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}