llimphi-widget-text-input 0.1.0

llimphi-widget-text-input — input de texto single-line para Llimphi. Wrappea el llimphi-widget-text-editor en modo single_line para heredar selección con shift+arrows, undo/redo, word-jump con Ctrl, sin perder la API compacta original.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
//! `llimphi-widget-text-input` — input de texto single-line para Llimphi.
//!
//! Después del refactor 2026-05-25, [`TextInputState`] es un wrapper fino
//! sobre [`llimphi_widget_text_editor::EditorState`] con
//! `options.single_line = true` + un flag `masked` para passwords. La
//! API pública (`new`, `masked`, `text`, `set_text`, `clear`, `apply_key`,
//! `is_empty`, `push_str`, `pop`, `is_masked`) se mantiene salvo que
//! `text()` ahora devuelve `String` (antes `&str`) — los callers que
//! hacían `.text().trim().to_string()` siguen funcionando idénticos.
//!
//! Beneficios heredados del editor: selección con Shift+arrows, undo/
//! redo con Ctrl+Z/Y, salto de palabra con Ctrl+arrows, Home/End,
//! Delete (además de Backspace). Tab/Enter siguen ignorados (single_line).

#![forbid(unsafe_code)]

use llimphi_ui::llimphi_layout::taffy::{
    prelude::{auto, length, percent, Size, Style},
    AlignItems, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{KeyEvent, View};
use llimphi_widget_text_editor::{EditorOptions, EditorState};

/// Paleta del input. Defaults son una variante dark con borde tenue que
/// se enciende al focar, equivalente conceptual al `nahual-theme` dark.
#[derive(Debug, Clone, Copy)]
pub struct TextInputPalette {
    pub bg: Color,
    pub bg_focus: Color,
    pub border: Color,
    pub border_focus: Color,
    pub fg_text: Color,
    pub fg_placeholder: Color,
    /// Color del caret (cursor de inserción) que se pinta cuando el input
    /// está focado. Default = `fg_text` (sigue al texto, como `caret-color:
    /// auto` en CSS).
    pub caret: Color,
}

impl Default for TextInputPalette {
    fn default() -> Self {
        Self::from_theme(&llimphi_theme::Theme::dark())
    }
}

impl TextInputPalette {
    /// Construye la paleta desde un `Theme` semántico.
    pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
        Self {
            bg: t.bg_input,
            bg_focus: t.bg_input_focus,
            border: t.border,
            border_focus: t.border_focus,
            fg_text: t.fg_text,
            fg_placeholder: t.fg_placeholder,
            caret: t.fg_text,
        }
    }
}

/// Estado del input. Wrappea un `EditorState` single-line.
#[derive(Debug, Clone, Default)]
pub struct TextInputState {
    inner: EditorState,
    masked: bool,
}

impl TextInputState {
    /// Input vacío visible (texto plano).
    pub fn new() -> Self {
        Self {
            inner: EditorState::with_options(EditorOptions {
                single_line: true,
                ..EditorOptions::default()
            }),
            masked: false,
        }
    }

    /// Input enmascarado — para campos de contraseña.
    pub fn masked() -> Self {
        Self { masked: true, ..Self::new() }
    }

    /// Texto actual. Devuelve `String` (antes `&str` — el rope no expone
    /// slice borrowed sin clone). Para evitar copias innecesarias, los
    /// callers que sólo necesitan derivar `.trim()` o `.is_empty()`
    /// pueden hacerlo directo sobre el `String` devuelto.
    pub fn text(&self) -> String {
        self.inner.text()
    }

    pub fn is_empty(&self) -> bool {
        self.inner.is_empty()
    }

    pub fn is_masked(&self) -> bool {
        self.masked
    }

    pub fn clear(&mut self) {
        self.inner.set_text("");
    }

    pub fn set_text(&mut self, s: impl Into<String>) {
        let s = s.into();
        self.inner.set_text(&s);
    }

    pub fn push_str(&mut self, s: &str) {
        let combined = format!("{}{}", self.inner.text(), s);
        self.inner.set_text(&combined);
    }

    pub fn pop(&mut self) -> Option<char> {
        let mut t = self.inner.text();
        let ch = t.pop()?;
        self.inner.set_text(&t);
        Some(ch)
    }

    /// Aplica una tecla al estado. Devuelve `true` si cambió el contenido
    /// **o** sólo se movió el cursor (cualquier cosa que requiera repintar).
    pub fn apply_key(&mut self, event: &KeyEvent) -> bool {
        self.inner.apply_key(event).touched()
    }

    /// Acceso de bajo nivel al editor interno — útil si el caller
    /// quiere consultar cursor/selección o aplicar ops avanzadas.
    pub fn editor(&self) -> &EditorState {
        &self.inner
    }
    pub fn editor_mut(&mut self) -> &mut EditorState {
        &mut self.inner
    }
}

/// Compone el input box: borde de 1 px (rect padre coloreado), relleno
/// interno, texto o placeholder, y el caret (cursor de inserción) sobre el
/// texto si está focado. Caret v3 (Fase 7.1255): cuando está focado la hoja
/// pinta texto+caret en un `paint_over` con **scroll horizontal** — el texto
/// se desplaza para mantener el caret a la vista cuando desborda la caja, y se
/// recorta al área de contenido. Sin foco usa un nodo-hijo de texto (sin caret).
/// Click sobre el box emite `on_focus` (típicamente `Msg::Focus(Field)`).
pub fn text_input_view<Msg: Clone + 'static>(
    state: &TextInputState,
    placeholder: &str,
    focused: bool,
    palette: &TextInputPalette,
    on_focus: Msg,
) -> View<Msg> {
    let raw = state.text();
    let is_empty = raw.is_empty();
    let shown = if is_empty {
        placeholder.to_string()
    } else if state.masked {
        "".repeat(raw.chars().count())
    } else {
        raw
    };
    let display = shown;
    // Prefijo del texto visible hasta el caret (cursor de inserción), para
    // medir su ancho y posicionar la barra del caret. La columna es índice de
    // carácter (single-line ⇒ `line == 0`); `take(col)` sobre el texto MOSTRADO
    // (placeholder/`•`/crudo) alinea el caret con lo que se ve. Cuando el input
    // está vacío el `col` es 0 ⇒ prefijo vacío ⇒ caret al inicio (no se mide el
    // placeholder).
    let caret_prefix: String = if focused {
        display.chars().take(state.editor().cursor.caret.col).collect()
    } else {
        String::new()
    };
    let text_color = if is_empty {
        palette.fg_placeholder
    } else {
        palette.fg_text
    };
    let (bg, border) = if focused {
        (palette.bg_focus, palette.border_focus)
    } else {
        (palette.bg, palette.border)
    };

    let mut inner = View::new(Style {
        size: Size {
            width: percent(1.0_f32),
            height: percent(1.0_f32),
        },
        padding: Rect {
            left: length(10.0_f32),
            right: length(10.0_f32),
            top: length(0.0_f32),
            bottom: length(0.0_f32),
        },
        align_items: Some(AlignItems::Center),
        ..Default::default()
    })
    .fill(bg)
    .radius(3.0);
    let inner = if focused {
        // Caret v3 — scroll horizontal (Fase 7.1255). Cuando el input está
        // focado, la propia hoja pinta el texto Y el caret en un solo `paint_over`
        // (pasada vello FINAL): así puede DESPLAZAR el texto a la izquierda cuando
        // el cursor se saldría por el borde derecho, manteniéndolo visible — el
        // clásico scroll del caret de los `<input>`. El offset (`scroll`) depende
        // del ancho de layout y de la posición del caret, ambos conocidos sólo en
        // tiempo de pintado (acá `rect.w` ya está resuelto y `ts` puede medir), no
        // en `view()` — por eso no se hace con un nodo hijo + `transform` estático.
        // Sin foco se usa el camino de nodo-hijo de abajo (sin caret, sin scroll).
        let caret_color = palette.caret;
        let display_c = display;
        let caret_prefix_c = caret_prefix;
        let tcolor = text_color;
        inner.paint_over(move |scene, ts, rect| {
            use llimphi_ui::llimphi_raster::kurbo::{Affine, Rect as KRect};
            use llimphi_ui::llimphi_raster::peniko::{BlendMode, Fill};
            use llimphi_ui::llimphi_text::{draw_layout, measurement, Alignment};
            let pad = 10.0_f64;
            // Ancho visible interno (entre los dos paddings de 10 px).
            let vis_w = (rect.w as f64 - 2.0 * pad).max(0.0);
            // Layout del texto completo en una sola línea (sin wrap).
            let layout = ts.layout(
                &display_c, 13.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false,
                0.0, 0.0,
            );
            let th = measurement(&layout).height as f64;
            // Ancho del prefijo hasta el caret = posición x del caret en el texto.
            let caret_w = if caret_prefix_c.is_empty() {
                0.0
            } else {
                let lp = ts.layout(
                    &caret_prefix_c, 13.0, None, Alignment::Start, 1.2, false, None, 400.0, false,
                    false, 0.0, 0.0,
                );
                measurement(&lp).width as f64
            };
            // Scroll: si el caret cae más allá del ancho visible, corre el texto a
            // la izquierda lo justo para que el caret quede al borde (con 2 px de
            // aire). Texto que entra ⇒ scroll 0 (anclado al padding-left).
            let scroll = (caret_w - vis_w + 2.0).max(0.0);
            let cx0 = rect.x as f64 + pad;
            // Recorte al área de contenido para que el texto desplazado no se
            // derrame sobre el padding ni fuera de la caja.
            let clip = KRect::new(
                cx0,
                rect.y as f64,
                rect.x as f64 + rect.w as f64 - pad,
                rect.y as f64 + rect.h as f64,
            );
            scene.push_layer(Fill::NonZero, BlendMode::default(), 1.0, Affine::IDENTITY, &clip);
            let oy = rect.y as f64 + (rect.h as f64 - th) * 0.5;
            draw_layout(scene, &layout, tcolor, (cx0 - scroll, oy));
            scene.pop_layer();
            // Caret: barra vertical en la posición del caret, desplazada por el
            // mismo scroll. Fuera del clip para que nunca se recorte en el borde.
            let x = cx0 + caret_w - scroll;
            let h = 16.0_f64;
            let cy = rect.y as f64 + rect.h as f64 * 0.5;
            let bar = KRect::new(x, cy - h * 0.5, x + 1.5, cy + h * 0.5);
            scene.fill(Fill::NonZero, Affine::IDENTITY, caret_color, None, &bar);
        })
    } else {
        // Sin foco: el texto va en un nodo HIJO de alto automático, centrado
        // verticalmente por el contenedor (`align_items: Center`). (`align_items`
        // no centra el texto PROPIO de un nodo — por eso el hijo.)
        let texto = View::new(Style {
            size: Size {
                width: percent(1.0_f32),
                height: auto(),
            },
            ..Default::default()
        })
        .text_aligned(display, 13.0, text_color, Alignment::Start);
        inner.children(vec![texto])
    };

    View::new(Style {
        size: Size {
            width: percent(1.0_f32),
            height: length(34.0_f32),
        },
        padding: Rect {
            left: length(1.0_f32),
            right: length(1.0_f32),
            top: length(1.0_f32),
            bottom: length(1.0_f32),
        },
        ..Default::default()
    })
    .fill(border)
    .radius(4.0)
    // Semántica: input de texto + el valor crudo como `value` (no el "•"
    // del modo masked — los lectores no deben dictar la contraseña en
    // voz alta; AccessKit ya marca el control como TextInput y el lector
    // sustituye por "punto" cuando el contexto lo requiere). El
    // placeholder va como `description` cuando el campo está vacío para
    // que el lector lo enuncie como pista. `value` queda vacío en masked.
    .role(llimphi_ui::Role::TextInput)
    .aria_value(if state.masked { String::new() } else { state.text() })
    .aria_description(if is_empty { placeholder.to_string() } else { String::new() })
    .on_click(on_focus)
    .cursor(llimphi_ui::Cursor::Text)
    .children(vec![inner])
}

#[cfg(test)]
mod tests {
    use super::*;
    use llimphi_ui::{Key, KeyState, NamedKey};

    fn key_press(key: Key, text: Option<&str>) -> KeyEvent {
        KeyEvent {
            key,
            state: KeyState::Pressed,
            text: text.map(|s| s.to_string()),
            modifiers: Default::default(),
            repeat: false,
        }
    }

    #[test]
    fn palette_caret_default_sigue_al_texto() {
        // El caret por default sigue al color del texto (`caret-color: auto`):
        // `from_theme` y `Default` lo igualan a `fg_text`.
        let t = llimphi_theme::Theme::dark();
        let pal = TextInputPalette::from_theme(&t);
        assert_eq!(pal.caret, pal.fg_text);
        assert_eq!(pal.caret, t.fg_text);
        assert_eq!(TextInputPalette::default().caret, TextInputPalette::default().fg_text);
    }

    #[test]
    fn caret_se_registra_como_over_painter_solo_focado() {
        // Caret v2 (Fase 7.1249): el caret se pinta con `paint_over` (pasada
        // FINAL sobre el glifo). Verificamos el wiring montando la vista: con
        // foco hay un over-painter registrado; sin foco no hay ninguno.
        use llimphi_ui::llimphi_layout::LayoutTree;
        use llimphi_ui::{has_over_painter, mount};
        let mut st = TextInputState::new();
        st.set_text("hola");
        let pal = TextInputPalette::default();

        let mut lt = LayoutTree::new();
        let focado = mount(&mut lt, text_input_view(&st, "ph", true, &pal, ()));
        assert!(
            has_over_painter(&focado),
            "input focado debe registrar el caret como over-painter"
        );

        let mut lt2 = LayoutTree::new();
        let sin_foco = mount(&mut lt2, text_input_view(&st, "ph", false, &pal, ()));
        assert!(
            !has_over_painter(&sin_foco),
            "input sin foco no pinta caret"
        );
    }

    #[test]
    fn apply_key_inserts_printable_chars() {
        let mut s = TextInputState::new();
        let ev = key_press(Key::Character("a".into()), Some("a"));
        assert!(s.apply_key(&ev));
        assert_eq!(s.text(), "a");
    }

    #[test]
    fn apply_key_backspace_pops() {
        let mut s = TextInputState::new();
        s.set_text("hola");
        let ev = key_press(Key::Named(NamedKey::Backspace), None);
        assert!(s.apply_key(&ev));
        assert_eq!(s.text(), "hol");
    }

    #[test]
    fn enter_ignorado_en_single_line() {
        let mut s = TextInputState::new();
        s.set_text("hola");
        let enter = key_press(Key::Named(NamedKey::Enter), None);
        assert!(!s.apply_key(&enter));
        assert_eq!(s.text(), "hola");
    }

    #[test]
    fn masked_state_is_masked() {
        let s = TextInputState::masked();
        assert!(s.is_masked());
    }

    #[test]
    fn flecha_izquierda_mueve_cursor() {
        // El refactor agrega esta capacidad — antes no había movimiento.
        let mut s = TextInputState::new();
        s.set_text("hola");
        let arr = key_press(Key::Named(NamedKey::ArrowLeft), None);
        assert!(s.apply_key(&arr));
        assert_eq!(s.editor().cursor.caret.col, 3);
    }

    #[test]
    fn push_str_y_pop_funcionan() {
        let mut s = TextInputState::new();
        s.push_str("hola");
        assert_eq!(s.text(), "hola");
        assert_eq!(s.pop(), Some('a'));
        assert_eq!(s.text(), "hol");
    }
}