Skip to main content

llimphi_widget_text_input/
lib.rs

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}