llimphi_widget_text_input/
lib.rs1#![forbid(unsafe_code)]
16
17use llimphi_ui::llimphi_layout::taffy::{
18 prelude::{auto, length, percent, Size, Style},
19 AlignItems, Rect,
20};
21use llimphi_ui::llimphi_raster::peniko::Color;
22use llimphi_ui::llimphi_text::Alignment;
23use llimphi_ui::{KeyEvent, View};
24use llimphi_widget_text_editor::{EditorOptions, EditorState};
25
26#[derive(Debug, Clone, Copy)]
29pub struct TextInputPalette {
30 pub bg: Color,
31 pub bg_focus: Color,
32 pub border: Color,
33 pub border_focus: Color,
34 pub fg_text: Color,
35 pub fg_placeholder: Color,
36 pub caret: Color,
40}
41
42impl Default for TextInputPalette {
43 fn default() -> Self {
44 Self::from_theme(&llimphi_theme::Theme::dark())
45 }
46}
47
48impl TextInputPalette {
49 pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
51 Self {
52 bg: t.bg_input,
53 bg_focus: t.bg_input_focus,
54 border: t.border,
55 border_focus: t.border_focus,
56 fg_text: t.fg_text,
57 fg_placeholder: t.fg_placeholder,
58 caret: t.fg_text,
59 }
60 }
61}
62
63#[derive(Debug, Clone, Default)]
65pub struct TextInputState {
66 inner: EditorState,
67 masked: bool,
68}
69
70impl TextInputState {
71 pub fn new() -> Self {
73 Self {
74 inner: EditorState::with_options(EditorOptions {
75 single_line: true,
76 ..EditorOptions::default()
77 }),
78 masked: false,
79 }
80 }
81
82 pub fn masked() -> Self {
84 Self { masked: true, ..Self::new() }
85 }
86
87 pub fn text(&self) -> String {
92 self.inner.text()
93 }
94
95 pub fn is_empty(&self) -> bool {
96 self.inner.is_empty()
97 }
98
99 pub fn is_masked(&self) -> bool {
100 self.masked
101 }
102
103 pub fn clear(&mut self) {
104 self.inner.set_text("");
105 }
106
107 pub fn set_text(&mut self, s: impl Into<String>) {
108 let s = s.into();
109 self.inner.set_text(&s);
110 }
111
112 pub fn push_str(&mut self, s: &str) {
113 let combined = format!("{}{}", self.inner.text(), s);
114 self.inner.set_text(&combined);
115 }
116
117 pub fn pop(&mut self) -> Option<char> {
118 let mut t = self.inner.text();
119 let ch = t.pop()?;
120 self.inner.set_text(&t);
121 Some(ch)
122 }
123
124 pub fn apply_key(&mut self, event: &KeyEvent) -> bool {
127 self.inner.apply_key(event).touched()
128 }
129
130 pub fn editor(&self) -> &EditorState {
133 &self.inner
134 }
135 pub fn editor_mut(&mut self) -> &mut EditorState {
136 &mut self.inner
137 }
138}
139
140pub fn text_input_view<Msg: Clone + 'static>(
148 state: &TextInputState,
149 placeholder: &str,
150 focused: bool,
151 palette: &TextInputPalette,
152 on_focus: Msg,
153) -> View<Msg> {
154 let raw = state.text();
155 let is_empty = raw.is_empty();
156 let shown = if is_empty {
157 placeholder.to_string()
158 } else if state.masked {
159 "•".repeat(raw.chars().count())
160 } else {
161 raw
162 };
163 let display = shown;
164 let caret_prefix: String = if focused {
171 display.chars().take(state.editor().cursor.caret.col).collect()
172 } else {
173 String::new()
174 };
175 let text_color = if is_empty {
176 palette.fg_placeholder
177 } else {
178 palette.fg_text
179 };
180 let (bg, border) = if focused {
181 (palette.bg_focus, palette.border_focus)
182 } else {
183 (palette.bg, palette.border)
184 };
185
186 let mut inner = View::new(Style {
187 size: Size {
188 width: percent(1.0_f32),
189 height: percent(1.0_f32),
190 },
191 padding: Rect {
192 left: length(10.0_f32),
193 right: length(10.0_f32),
194 top: length(0.0_f32),
195 bottom: length(0.0_f32),
196 },
197 align_items: Some(AlignItems::Center),
198 ..Default::default()
199 })
200 .fill(bg)
201 .radius(3.0);
202 let inner = if focused {
203 let caret_color = palette.caret;
213 let display_c = display;
214 let caret_prefix_c = caret_prefix;
215 let tcolor = text_color;
216 inner.paint_over(move |scene, ts, rect| {
217 use llimphi_ui::llimphi_raster::kurbo::{Affine, Rect as KRect};
218 use llimphi_ui::llimphi_raster::peniko::{BlendMode, Fill};
219 use llimphi_ui::llimphi_text::{draw_layout, measurement, Alignment};
220 let pad = 10.0_f64;
221 let vis_w = (rect.w as f64 - 2.0 * pad).max(0.0);
223 let layout = ts.layout(
225 &display_c, 13.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false,
226 0.0, 0.0,
227 );
228 let th = measurement(&layout).height as f64;
229 let caret_w = if caret_prefix_c.is_empty() {
231 0.0
232 } else {
233 let lp = ts.layout(
234 &caret_prefix_c, 13.0, None, Alignment::Start, 1.2, false, None, 400.0, false,
235 false, 0.0, 0.0,
236 );
237 measurement(&lp).width as f64
238 };
239 let scroll = (caret_w - vis_w + 2.0).max(0.0);
243 let cx0 = rect.x as f64 + pad;
244 let clip = KRect::new(
247 cx0,
248 rect.y as f64,
249 rect.x as f64 + rect.w as f64 - pad,
250 rect.y as f64 + rect.h as f64,
251 );
252 scene.push_layer(Fill::NonZero, BlendMode::default(), 1.0, Affine::IDENTITY, &clip);
253 let oy = rect.y as f64 + (rect.h as f64 - th) * 0.5;
254 draw_layout(scene, &layout, tcolor, (cx0 - scroll, oy));
255 scene.pop_layer();
256 let x = cx0 + caret_w - scroll;
259 let h = 16.0_f64;
260 let cy = rect.y as f64 + rect.h as f64 * 0.5;
261 let bar = KRect::new(x, cy - h * 0.5, x + 1.5, cy + h * 0.5);
262 scene.fill(Fill::NonZero, Affine::IDENTITY, caret_color, None, &bar);
263 })
264 } else {
265 let texto = View::new(Style {
269 size: Size {
270 width: percent(1.0_f32),
271 height: auto(),
272 },
273 ..Default::default()
274 })
275 .text_aligned(display, 13.0, text_color, Alignment::Start);
276 inner.children(vec![texto])
277 };
278
279 View::new(Style {
280 size: Size {
281 width: percent(1.0_f32),
282 height: length(34.0_f32),
283 },
284 padding: Rect {
285 left: length(1.0_f32),
286 right: length(1.0_f32),
287 top: length(1.0_f32),
288 bottom: length(1.0_f32),
289 },
290 ..Default::default()
291 })
292 .fill(border)
293 .radius(4.0)
294 .role(llimphi_ui::Role::TextInput)
301 .aria_value(if state.masked { String::new() } else { state.text() })
302 .aria_description(if is_empty { placeholder.to_string() } else { String::new() })
303 .on_click(on_focus)
304 .cursor(llimphi_ui::Cursor::Text)
305 .children(vec![inner])
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311 use llimphi_ui::{Key, KeyState, NamedKey};
312
313 fn key_press(key: Key, text: Option<&str>) -> KeyEvent {
314 KeyEvent {
315 key,
316 state: KeyState::Pressed,
317 text: text.map(|s| s.to_string()),
318 modifiers: Default::default(),
319 repeat: false,
320 }
321 }
322
323 #[test]
324 fn palette_caret_default_sigue_al_texto() {
325 let t = llimphi_theme::Theme::dark();
328 let pal = TextInputPalette::from_theme(&t);
329 assert_eq!(pal.caret, pal.fg_text);
330 assert_eq!(pal.caret, t.fg_text);
331 assert_eq!(TextInputPalette::default().caret, TextInputPalette::default().fg_text);
332 }
333
334 #[test]
335 fn caret_se_registra_como_over_painter_solo_focado() {
336 use llimphi_ui::llimphi_layout::LayoutTree;
340 use llimphi_ui::{has_over_painter, mount};
341 let mut st = TextInputState::new();
342 st.set_text("hola");
343 let pal = TextInputPalette::default();
344
345 let mut lt = LayoutTree::new();
346 let focado = mount(&mut lt, text_input_view(&st, "ph", true, &pal, ()));
347 assert!(
348 has_over_painter(&focado),
349 "input focado debe registrar el caret como over-painter"
350 );
351
352 let mut lt2 = LayoutTree::new();
353 let sin_foco = mount(&mut lt2, text_input_view(&st, "ph", false, &pal, ()));
354 assert!(
355 !has_over_painter(&sin_foco),
356 "input sin foco no pinta caret"
357 );
358 }
359
360 #[test]
361 fn apply_key_inserts_printable_chars() {
362 let mut s = TextInputState::new();
363 let ev = key_press(Key::Character("a".into()), Some("a"));
364 assert!(s.apply_key(&ev));
365 assert_eq!(s.text(), "a");
366 }
367
368 #[test]
369 fn apply_key_backspace_pops() {
370 let mut s = TextInputState::new();
371 s.set_text("hola");
372 let ev = key_press(Key::Named(NamedKey::Backspace), None);
373 assert!(s.apply_key(&ev));
374 assert_eq!(s.text(), "hol");
375 }
376
377 #[test]
378 fn enter_ignorado_en_single_line() {
379 let mut s = TextInputState::new();
380 s.set_text("hola");
381 let enter = key_press(Key::Named(NamedKey::Enter), None);
382 assert!(!s.apply_key(&enter));
383 assert_eq!(s.text(), "hola");
384 }
385
386 #[test]
387 fn masked_state_is_masked() {
388 let s = TextInputState::masked();
389 assert!(s.is_masked());
390 }
391
392 #[test]
393 fn flecha_izquierda_mueve_cursor() {
394 let mut s = TextInputState::new();
396 s.set_text("hola");
397 let arr = key_press(Key::Named(NamedKey::ArrowLeft), None);
398 assert!(s.apply_key(&arr));
399 assert_eq!(s.editor().cursor.caret.col, 3);
400 }
401
402 #[test]
403 fn push_str_y_pop_funcionan() {
404 let mut s = TextInputState::new();
405 s.push_str("hola");
406 assert_eq!(s.text(), "hola");
407 assert_eq!(s.pop(), Some('a'));
408 assert_eq!(s.text(), "hol");
409 }
410}