tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ 92dd0e5bfb2ca4456006da99437f4100e156d856
widgets/text-input/src/lib.rs
History
Who touched what
raw
refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel
1//! `llimphi-widget-text-input` — input de texto single-line para Llimphi.
2//!
3//! Después del refactor 2026-05-25, [`TextInputState`] es un wrapper fino
4//! sobre [`llimphi_widget_text_editor::EditorState`] con
5//! `options.single_line = true` + un flag `masked` para passwords. La
6//! API pública (`new`, `masked`, `text`, `set_text`, `clear`, `apply_key`,
7//! `is_empty`, `push_str`, `pop`, `is_masked`) se mantiene salvo que
8//! `text()` ahora devuelve `String` (antes `&str`) — los callers que
9//! hacían `.text().trim().to_string()` siguen funcionando idénticos.
10//!
11//! Beneficios heredados del editor: selección con Shift+arrows, undo/
12//! redo con Ctrl+Z/Y, salto de palabra con Ctrl+arrows, Home/End,
13//! Delete (además de Backspace). Tab/Enter siguen ignorados (single_line).
14
15#![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/// Paleta del input. Defaults son una variante dark con borde tenue que
27/// se enciende al focar, equivalente conceptual al `nahual-theme` dark.
28#[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 /// Color del caret (cursor de inserción) que se pinta cuando el input
37 /// está focado. Default = `fg_text` (sigue al texto, como `caret-color:
38 /// auto` en CSS).
39 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 /// Construye la paleta desde un `Theme` semántico.
50 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/// Estado del input. Wrappea un `EditorState` single-line.
64#[derive(Debug, Clone, Default)]
65pub struct TextInputState {
66 inner: EditorState,
67 masked: bool,
68}
69
70impl TextInputState {
71 /// Input vacío visible (texto plano).
72 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 /// Input enmascarado — para campos de contraseña.
83 pub fn masked() -> Self {
84 Self { masked: true, ..Self::new() }
85 }
86
87 /// Texto actual. Devuelve `String` (antes `&str` — el rope no expone
88 /// slice borrowed sin clone). Para evitar copias innecesarias, los
89 /// callers que sólo necesitan derivar `.trim()` o `.is_empty()`
90 /// pueden hacerlo directo sobre el `String` devuelto.
91 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 /// Aplica una tecla al estado. Devuelve `true` si cambió el contenido
125 /// **o** sólo se movió el cursor (cualquier cosa que requiera repintar).
126 pub fn apply_key(&mut self, event: &KeyEvent) -> bool {
127 self.inner.apply_key(event).touched()
128 }
129
130 /// Acceso de bajo nivel al editor interno — útil si el caller
131 /// quiere consultar cursor/selección o aplicar ops avanzadas.
132 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
140/// Compone el input box: borde de 1 px (rect padre coloreado), relleno
141/// interno, texto o placeholder, y el caret (cursor de inserción) sobre el
142/// texto si está focado. Caret v3 (Fase 7.1255): cuando está focado la hoja
143/// pinta texto+caret en un `paint_over` con **scroll horizontal** — el texto
144/// se desplaza para mantener el caret a la vista cuando desborda la caja, y se
145/// recorta al área de contenido. Sin foco usa un nodo-hijo de texto (sin caret).
146/// Click sobre el box emite `on_focus` (típicamente `Msg::Focus(Field)`).
147pub 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 // Prefijo del texto visible hasta el caret (cursor de inserción), para
165 // medir su ancho y posicionar la barra del caret. La columna es índice de
166 // carácter (single-line ⇒ `line == 0`); `take(col)` sobre el texto MOSTRADO
167 // (placeholder/`•`/crudo) alinea el caret con lo que se ve. Cuando el input
168 // está vacío el `col` es 0 ⇒ prefijo vacío ⇒ caret al inicio (no se mide el
169 // placeholder).
170 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 // Caret v3 — scroll horizontal (Fase 7.1255). Cuando el input está
204 // focado, la propia hoja pinta el texto Y el caret en un solo `paint_over`
205 // (pasada vello FINAL): así puede DESPLAZAR el texto a la izquierda cuando
206 // el cursor se saldría por el borde derecho, manteniéndolo visible — el
207 // clásico scroll del caret de los `<input>`. El offset (`scroll`) depende
208 // del ancho de layout y de la posición del caret, ambos conocidos sólo en
209 // tiempo de pintado (acá `rect.w` ya está resuelto y `ts` puede medir), no
210 // en `view()` — por eso no se hace con un nodo hijo + `transform` estático.
211 // Sin foco se usa el camino de nodo-hijo de abajo (sin caret, sin scroll).
212 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 // Ancho visible interno (entre los dos paddings de 10 px).
222 let vis_w = (rect.w as f64 - 2.0 * pad).max(0.0);
223 // Layout del texto completo en una sola línea (sin wrap).
224 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 // Ancho del prefijo hasta el caret = posición x del caret en el texto.
230 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 // Scroll: si el caret cae más allá del ancho visible, corre el texto a
240 // la izquierda lo justo para que el caret quede al borde (con 2 px de
241 // aire). Texto que entra ⇒ scroll 0 (anclado al padding-left).
242 let scroll = (caret_w - vis_w + 2.0).max(0.0);
243 let cx0 = rect.x as f64 + pad;
244 // Recorte al área de contenido para que el texto desplazado no se
245 // derrame sobre el padding ni fuera de la caja.
246 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 // Caret: barra vertical en la posición del caret, desplazada por el
257 // mismo scroll. Fuera del clip para que nunca se recorte en el borde.
258 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 // Sin foco: el texto va en un nodo HIJO de alto automático, centrado
266 // verticalmente por el contenedor (`align_items: Center`). (`align_items`
267 // no centra el texto PROPIO de un nodo — por eso el hijo.)
268 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 // Semántica: input de texto + el valor crudo como `value` (no el "•"
295 // del modo masked — los lectores no deben dictar la contraseña en
296 // voz alta; AccessKit ya marca el control como TextInput y el lector
297 // sustituye por "punto" cuando el contexto lo requiere). El
298 // placeholder va como `description` cuando el campo está vacío para
299 // que el lector lo enuncie como pista. `value` queda vacío en masked.
300 .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 // El caret por default sigue al color del texto (`caret-color: auto`):
326 // `from_theme` y `Default` lo igualan a `fg_text`.
327 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 // Caret v2 (Fase 7.1249): el caret se pinta con `paint_over` (pasada
337 // FINAL sobre el glifo). Verificamos el wiring montando la vista: con
338 // foco hay un over-painter registrado; sin foco no hay ninguno.
339 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 // El refactor agrega esta capacidad — antes no había movimiento.
395 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}