tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 484Markdown 258TOML 131RON 38118 Sutichaykuna

⎇ main widgets/text-input/src/lib.rs Rust · 1735 siq'ikuna · 75.0 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · Sergio ·

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//! `no_std` + alloc sin la feature `std` (default-on): con `wire` + `libm`
16//! el campo entero —y el text-area de [`area`]— entra a la jaula WASM.
17
18#![cfg_attr(not(any(feature = "std", test)), no_std)]
19#![forbid(unsafe_code)]
20
21extern crate alloc;
22
23use alloc::format;
24use alloc::string::{String, ToString};
25use alloc::vec;
26
27#[cfg(feature = "std")]
28use core::cell::RefCell;
29use core::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
30use alloc::sync::Arc;
31use spin::Mutex;
32
33use llimphi_compositor::llimphi_layout::taffy::{
34    prelude::{auto, length, percent, Size, Style},
35    AlignItems, Position, Rect,
36};
37use llimphi_compositor::peniko::Color;
38use llimphi_compositor::llimphi_text::Alignment;
39use llimphi_compositor::{Instant, DragPhase, ImeEvent, KeyEvent, View};
40use llimphi_widget_text_editor::{EditorOptions, EditorState};
41
42// Backends de clipboard re-exportados para que los callers puedan sostener
43// uno en su Model (`MemClipboard` intra-app; `NullClipboard` si no quieren
44// copiar/pegar) y pasarlo a [`TextInputState::handle`] / [`TextInputState::
45// apply_key_with_clipboard`]. Para portapapeles del SO, el caller puentea su
46// propio backend (p. ej. `arboard`) implementando el trait `Clipboard`.
47pub use llimphi_widget_text_editor::{Clipboard, MemClipboard, NullClipboard};
48
49/// Variante **multilínea** (text-area): mismo motor `EditorState`, hit-testing
50/// 2D real (renglón + columna con glifos medidos), caret y selección pintados
51/// a través de líneas. Reemplaza al viejo `llimphi-widget-text-area` de `String`
52/// plano. Ver [`area::text_area_view`] y [`area::TextAreaEvent`].
53mod area;
54pub use area::{TextAreaEvent, AREA_LINE_H};
55pub use area::text_area_view;
56
57/// Vista **rica** del text-area: la misma edición, más los adornos de
58/// presentación que hacían que una app se dibujara su propio input a mano
59/// (métrica mono/zoom, color por tramos, sugerencia fantasma, placeholder con
60/// ícono, caret con estela). Ver [`rico::text_area_view_rico`].
61mod rico;
62pub use rico::{text_area_view_rico, Adornos, CaretEstilo, Placeholder};
63
64/// Paleta del input. Defaults son una variante dark con borde tenue que
65/// se enciende al focar, equivalente conceptual al `nahual-theme` dark.
66#[derive(Debug, Clone, Copy)]
67pub struct TextInputPalette {
68    pub bg: Color,
69    pub bg_focus: Color,
70    pub border: Color,
71    pub border_focus: Color,
72    pub fg_text: Color,
73    pub fg_placeholder: Color,
74    /// Color del caret (cursor de inserción) que se pinta cuando el input
75    /// está focado. Default = `fg_text` (sigue al texto, como `caret-color:
76    /// auto` en CSS).
77    pub caret: Color,
78    /// Fase del parpadeo del caret: `true` = visible, `false` = apagado. El
79    /// caret sólo se pinta cuando el input está focado **y** esta fase está en
80    /// `true`. Default `true` (visible sin parpadeo — un consumidor que quiera
81    /// blink alterna este flag con un timer). Fase 7.1267.
82    pub caret_visible: bool,
83    /// Fondo del texto seleccionado (con Shift+flechas o doble/triple click).
84    pub selection: Color,
85    // --- Decoración (themizable: sale de los tokens del theme, no hardcodeada) ---
86    /// Radio de las esquinas (px). Sale de `llimphi_theme::radius::*`. Un theme
87    /// con inputs cuadrados lo baja a `XS`; uno redondeado, a `MD`/`LG`.
88    pub radius: f32,
89    /// Sombra de elevación `(alpha 0–255, blur px, dy px)` — de
90    /// `llimphi_theme::elevation::*`. Sólo se pinta si [`Self::relieve`].
91    pub elevation: (u8, f64, f64),
92    /// Si el input lleva **relieve** (sombra + sheen). `false` = plano. Es el
93    /// interruptor de carácter: un theme flat lo apaga, uno con dimensión lo deja.
94    pub relieve: bool,
95    /// **Alto de la caja en px.** 34 es la medida de la casa (un campo de
96    /// formulario); lo baja quien lo monte en un chrome más fino — el buscador del
97    /// `rag-sidebar` vive en una fila de 40 y en una de 28. Antes era un literal
98    /// adentro de `build_input`, y un campo que no entra en su fila no se recorta:
99    /// desborda sobre lo de abajo.
100    pub alto: f32,
101}
102
103impl Default for TextInputPalette {
104    fn default() -> Self {
105        Self::from_theme(&llimphi_theme::Theme::dark())
106    }
107}
108
109impl TextInputPalette {
110    /// Construye la paleta desde un `Theme` semántico.
111    pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
112        Self {
113            bg: t.bg_input,
114            bg_focus: t.bg_input_focus,
115            border: t.border,
116            border_focus: t.border_focus,
117            fg_text: t.fg_text,
118            fg_placeholder: t.fg_placeholder,
119            caret: t.fg_text,
120            caret_visible: true,
121            selection: t.bg_selected,
122            // Decoración desde los tokens canónicos del theme.
123            radius: llimphi_theme::radius::MD as f32,
124            elevation: llimphi_theme::elevation::E1,
125            relieve: true,
126            alto: 34.0,
127        }
128    }
129}
130
131/// **Métrica del texto**: tamaño, alto de renglón, familia y paddings. Existe
132/// porque el widget tenía estos cuatro valores clavados en constantes, y con
133/// ellos clavados no puede ser un input *monoespaciado* (una terminal, un prompt
134/// de shell) ni acompañar un **zoom de fuente**: el ajuste blando, el caret, la
135/// selección y el mapeo click→columna se calculan todos con esta métrica, así
136/// que o cambian los cuatro juntos o se desalinean entre sí.
137///
138/// [`Metricas::default`] reproduce **exactamente** las constantes históricas, de
139/// modo que las vistas que no la mencionan se comportan igual que siempre.
140#[derive(Debug, Clone, PartialEq)]
141pub struct Metricas {
142    /// Tamaño de fuente (px) del texto, el caret y las mediciones.
143    pub font_size: f32,
144    /// Alto (px) de cada renglón visual. Fijo a propósito (no derivado de las
145    /// métricas internas de parley): el render pinta un nodo por renglón con
146    /// esta altura, así el hit-testing `y → renglón` coincide con lo dibujado.
147    pub line_h: f32,
148    /// Familia CSS-like de la fuente. `None` = la sans de la UI. Para un input
149    /// de terminal, `Some(llimphi_text::MONOSPACE.into())`.
150    ///
151    /// **No es cosmético:** sin familia, el camino de texto multicolor shapea
152    /// con la sans aunque el resto del input sea mono, y las columnas se
153    /// desalinean en silencio (la misma trampa que torcía las tablas).
154    pub font_family: Option<String>,
155    /// Padding izquierdo del texto dentro del renglón (px).
156    pub pad_x: f32,
157    /// Padding derecho del bloque (px) — aire antes del borde.
158    pub pad_r: f32,
159    /// Padding superior/inferior del bloque de renglones (px).
160    pub pad_y: f32,
161}
162
163impl Default for Metricas {
164    fn default() -> Self {
165        Self {
166            font_size: FONT_SIZE,
167            line_h: 19.0,
168            font_family: None,
169            pad_x: 8.0,
170            pad_r: 6.0,
171            pad_y: 5.0,
172        }
173    }
174}
175
176impl Metricas {
177    /// Métrica **monoespaciada** a `font_size` px, con el alto de renglón en la
178    /// proporción clásica de terminal (1.4×) y la familia mono embebida.
179    pub fn mono(font_size: f32) -> Self {
180        Self {
181            font_size,
182            line_h: redondear(font_size * 1.4),
183            font_family: Some(llimphi_compositor::llimphi_text::MONOSPACE.to_string()),
184            ..Self::default()
185        }
186    }
187
188    /// La misma métrica escalada por un factor de zoom (clampeado a 0.5–3×).
189    /// Escala tamaño y alto de renglón; los paddings quedan (son aire del marco,
190    /// no del texto).
191    pub fn con_zoom(mut self, zoom: f32) -> Self {
192        let z = zoom.clamp(0.5, 3.0);
193        self.font_size *= z;
194        self.line_h = redondear(self.line_h * z);
195        self
196    }
197}
198
199/// Tamaño de fuente (px) con el que el widget renderiza el texto. El caret, la
200/// selección y el hit-testing del mouse miden con ESTE tamaño para coincidir al
201/// píxel — el render (`build_input`) y el `paint_over` usan el mismo valor.
202const FONT_SIZE: f32 = 13.0;
203/// Padding izquierdo del texto dentro del box: borde de 1 px + padding interno
204/// de 10 px. `col_from_x` lo resta antes de mapear a columna.
205const PAD_X: f32 = 11.0;
206
207#[cfg(feature = "std")]
208thread_local! {
209    /// Typesetter dedicado a medir texto en el hilo de UI para el hit-testing
210    /// del mouse (x→columna). `Typesetter::new` instala la MISMA fuente UI que
211    /// usa el compositor, así el click cae exactamente donde se dibuja el glifo.
212    /// El costo de instalar la fuente se paga una sola vez por hilo.
213    static MEASURE_TS: RefCell<llimphi_compositor::llimphi_text::Typesetter> =
214        RefCell::new(llimphi_compositor::llimphi_text::Typesetter::new());
215}
216
217/// Ancho (px) del prefijo de `text` hasta la columna `cols` (nº de chars),
218/// medido con la fuente real — el MISMO cálculo que hace el `paint_over` para
219/// posicionar el caret. `cols == 0` → 0.0. Es la fuente de verdad del mapeo
220/// x↔columna: sustituye el viejo `font_size * 0.5` (ancho monoespaciado falso
221/// sobre una fuente proporcional), que era la causa de que la selección
222/// "agarrara" texto equivocado y derivara al arrastrar.
223fn prefix_width(text: &str, cols: usize) -> f32 {
224    prefix_width_m(text, cols, &Metricas::default())
225}
226
227/// [`prefix_width`] con **métrica explícita** — mide con el tamaño y la familia
228/// que realmente se van a pintar. Es el que usa el camino multilínea/rico: medir
229/// mono con la métrica de la sans (o al revés) desplaza el caret y el ajuste
230/// blando respecto de los glifos dibujados.
231fn prefix_width_m(text: &str, cols: usize, m: &Metricas) -> f32 {
232    if cols == 0 {
233        return 0.0;
234    }
235    let prefix: String = text.chars().take(cols).collect();
236    if !prefix.ends_with(char::is_whitespace) {
237        return medir_crudo(&prefix, m);
238    }
239    // parley —como CSS— le da ancho **cero** al whitespace que queda al final de
240    // lo que mide. Medido: en mono de 13 px, `"ab"` y `"ab "` dan los dos 15.603.
241    //
242    // Para envolver eso es lo correcto (el espacio cuelga en el margen). Para
243    // **posicionar** es un error de un carácter entero: el caret parado al
244    // principio de una palabra se dibujaba antes del espacio que la precede, y
245    // entonces Delete parecía borrar la letra equivocada — el caret estaba bien,
246    // mentía el dibujo. Se mide con un ancla detrás y se le resta el ancla.
247    const ANCLA: &str = "0";
248    medir_crudo(&format!("{prefix}{ANCLA}"), m) - medir_crudo(ANCLA, m)
249}
250
251/// Mide un texto tal cual lo shapea parley — con el **colapso del espacio
252/// final**, que es justo lo que quiere el ajuste blando: una línea que termina
253/// en un espacio no debe abrir un renglón por ese espacio (colgaría un renglón
254/// vacío al tipear en el borde).
255///
256/// (Antes había un `prefix_width_colapsando` que envolvía esto midiendo desde
257/// el carácter 0. Lo usaba el ajuste blando del text-area, y era el origen del
258/// costo cuadrático de T11 — ver `area::seg_width`, que ahora mide el tramo.)
259#[cfg(feature = "std")]
260fn medir_crudo(prefix: &str, m: &Metricas) -> f32 {
261    MEASURE_TS.with(|ts| {
262        let mut ts = ts.borrow_mut();
263        let lp = ts.layout(
264            prefix,
265            m.font_size,
266            None,
267            Alignment::Start,
268            1.2,
269            false,
270            m.font_family.as_deref(),
271            400.0,
272            false,
273            false,
274            0.0,
275            0.0,
276        );
277        llimphi_compositor::llimphi_text::measurement(&lp).width as f32
278    })
279}
280
281/// Sin `std` no hay shaper de este lado (doctrina W4.1): `text_width` bajo
282/// `wire` es la estimación documentada en W4.6 — el caret y el click quedan
283/// aproximados; la medida buena la da el kernel vía el medidor de la jaula.
284/// Si la aproximación molesta, el camino es pasar el `Typesetter`
285/// (`text_width_with`), no inventar un global.
286#[cfg(not(feature = "std"))]
287fn medir_crudo(prefix: &str, m: &Metricas) -> f32 {
288    llimphi_compositor::llimphi_text::text_width(prefix, m.font_size)
289}
290
291/// Mapea `local_x` (px desde el borde izquierdo del box) a la columna de
292/// carácter cuyo **borde real** cae más cerca del punto de inserción, midiendo
293/// los glifos con la fuente proporcional. `display` es el texto MOSTRADO (crudo,
294/// o `•` en masked) — se mide lo que se ve, así el click coincide con el caret
295/// dibujado. Escaneo lineal (los inputs son cortos): avanza hasta pasarse de
296/// `local_x` y elige, entre la columna previa y la actual, el borde más próximo
297/// (punto medio del glifo). Devuelve la columna del punto de inserción.
298fn col_from_x(display: &str, local_x: f32) -> usize {
299    col_from_x_pad(display, local_x, PAD_X)
300}
301
302/// Como [`col_from_x`] pero con el padding izquierdo del texto explícito — lo
303/// usa el text-area (multilínea), cuyos renglones tienen su propio pad interno
304/// distinto del box single-line. Mide glifos reales por línea (misma fuente).
305fn col_from_x_pad(display: &str, local_x: f32, pad: f32) -> usize {
306    col_from_x_pad_m(display, local_x, pad, &Metricas::default())
307}
308
309/// [`col_from_x_pad`] con métrica explícita — ver [`prefix_width_m`].
310fn col_from_x_pad_m(display: &str, local_x: f32, pad: f32, m: &Metricas) -> usize {
311    let target = (local_x - pad).max(0.0);
312    let n = display.chars().count();
313    if n == 0 || target <= 0.0 {
314        return 0;
315    }
316    // `prefix_width_m(·, col)` es monótona no decreciente en `col`, así que
317    // `target < w(col)` es un predicado monótono: falso hasta el primer borde
318    // que se pasa del punto, verdadero desde ahí. Sale por **búsqueda binaria**,
319    // en `log n` mediciones.
320    //
321    // El escaneo lineal de antes medía una vez por carácter, y cada medición
322    // shapea el prefijo ENTERO desde el carácter 0: el mismo O(n²) de shaping
323    // que colgaba el ajuste blando (T11 de COLA-SHUMA). Ahí el texto largo
324    // llegaba partido en renglones cortos; acá, en el camino de **una sola
325    // línea**, llega entero — y con el caret al final (como queda tras pegar)
326    // el punto clickeado cae al fondo del texto, así que el escaneo los
327    // recorría todos. El resultado es idéntico carácter a carácter.
328    let mut lo = 1usize;
329    let mut hi = n;
330    let mut primero: Option<usize> = None;
331    while lo <= hi {
332        let mid = lo + (hi - lo) / 2;
333        if target < prefix_width_m(display, mid, m) {
334            primero = Some(mid);
335            hi = mid - 1; // `mid >= lo >= 1`, no se pasa de vuelta
336        } else {
337            lo = mid + 1;
338        }
339    }
340    let Some(col) = primero else {
341        return n; // el punto cae más allá del último glifo
342    };
343    // Entre la columna previa y la que se pasó, la del borde más cercano
344    // (punto medio del glifo) — misma regla que el escaneo viejo.
345    let prev_w = prefix_width_m(display, col - 1, m);
346    let w = prefix_width_m(display, col, m);
347    if target < (prev_w + w) * 0.5 {
348        col - 1
349    } else {
350        col
351    }
352}
353
354/// Luminancia perceptual [0,1] de un color (para modular el relieve según el
355/// theme sin mirar su nombre, igual que el `llimphi-button`).
356fn luminance(c: Color) -> f32 {
357    let [r, g, b, _] = c.components;
358    0.2126 * r + 0.7152 * g + 0.0722 * b
359}
360
361/// Interpolación lineal escalar.
362fn lerp(a: f32, b: f32, t: f32) -> f32 {
363    a + (b - a) * t
364}
365
366/// Redondeo half-away-from-zero sin `std` — `f32::round` es un método de la
367/// stdlib, no una operación del lenguaje (misma razón que el `ceil_f32` del
368/// compositor; NUNCA `FloatFuncs` de kurbo, es una trampa de cfg).
369/// Ancho de `texto` medido con el `Typesetter` del pintado, a 13 px (el
370/// tamaño fijo del single-line). Con `std` shapea con parley (exacto); sin
371/// `std` delega en `text_width_with` — que bajo `wire` le pregunta al
372/// medidor de la jaula, o sea al kernel: también exacto.
373fn ancho_con_ts(ts: &mut llimphi_compositor::llimphi_text::Typesetter, texto: &str) -> f64 {
374    #[cfg(feature = "std")]
375    {
376        let lp = ts.layout(
377            texto, 13.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0,
378        );
379        llimphi_compositor::llimphi_text::measurement(&lp).width as f64
380    }
381    #[cfg(not(feature = "std"))]
382    {
383        llimphi_compositor::llimphi_text::text_width_with(ts, texto, 13.0) as f64
384    }
385}
386
387#[inline]
388fn redondear(x: f32) -> f32 {
389    if x >= 0.0 {
390        (x + 0.5) as i64 as f32
391    } else {
392        (x - 0.5) as i64 as f32
393    }
394}
395
396/// Mezcla lineal `a`→`b` por `t∈[0,1]`, conservando el alpha de `a`.
397fn mix(a: Color, b: Color, t: f32) -> Color {
398    let [ar, ag, ab, aa] = a.components;
399    let [br, bg, bb, _] = b.components;
400    Color {
401        components: [
402            ar + (br - ar) * t,
403            ag + (bg - ag) * t,
404            ab + (bb - ab) * t,
405            aa,
406        ],
407        ..a
408    }
409}
410
411/// Estado del input. Wrappea un `EditorState` single-line.
412#[derive(Debug, Clone)]
413pub struct TextInputState {
414    inner: EditorState,
415    masked: bool,
416    /// Ancho (px) de la caja tal como se pintó el último frame — lo escribe el
417    /// `paint_over` y lo lee el render/hit-testing para el **scroll horizontal**
418    /// (mantener el caret a la vista cuando el texto desborda). `Arc<AtomicU32>`
419    /// (bits de un `f32`) para que el estado siga `Clone + Send + Sync` y el
420    /// closure de pintado pueda escribirlo. 0 = todavía sin pintar (sin scroll).
421    box_w: Arc<AtomicU32>,
422    /// Estado del arrastre de selección con mouse. `drag_x` es la x acumulada
423    /// (px desde el borde) del extremo vivo; `drag_active` marca si ya se
424    /// ancló el otro extremo en el press. Ver [`Self::handle`].
425    drag_x: f32,
426    /// Componente vertical acumulada del arrastre — sólo la usa el text-area
427    /// (multilínea) para extender la selección en 2D. Ver [`area`].
428    drag_y: f32,
429    drag_active: bool,
430    /// Offset vertical (px, bits de `f32`) del scroll del text-area multilínea —
431    /// lo escribe la vista (para mantener el caret a la vista cuando hay más
432    /// renglones visuales que `rows`) y lo lee el hit-testing 2D del próximo
433    /// frame. Sólo lo usa [`area`]; en single-line queda en 0.
434    area_scroll_y: Arc<AtomicU32>,
435    /// Columna "meta" en **px** (offset horizontal desde el inicio del renglón
436    /// visual) que preserva la posición al navegar ↑/↓ por renglones visuales en
437    /// el text-area — el análogo de `desired_col` pero en píxeles, así no deriva
438    /// sobre fuente proporcional / wrap. `None` = recalcular del caret. La resetea
439    /// cualquier movimiento no-vertical. Sólo lo usa [`area`].
440    area_goal_x: Option<f32>,
441    /// La [`Metricas`] con la que la vista pintó el último frame. El
442    /// hit-testing (`x → columna`, `y → renglón`) corre en el `update`, donde no
443    /// hay vista a mano: sin esto mediría con la métrica default aunque el input
444    /// se esté pintando en mono a 3× de zoom, y el click caería en otra letra.
445    /// La escribe la vista, la lee el `update`.
446    metricas: Arc<Mutex<Metricas>>,
447    /// Unix-ms de la última actividad del usuario (tecla o click), que la
448    /// escribe el host con [`Self::marcar_actividad`]. El caret la usa para
449    /// quedarse **sólido** mientras se escribe y recién después titilar, y para
450    /// arrancar la estela. `0` = todavía sin actividad.
451    actividad_ms: Arc<AtomicU64>,
452    /// Posición animada del caret `(x, y)` en px del bloque de texto, más el
453    /// unix-ms en que se muestreó — el estado de la **estela**. Lo escribe el
454    /// pintor cada cuadro (persigue al caret real) y lo lee el cuadro siguiente.
455    /// Empaquetado en atómicos para que el estado siga `Clone + Send + Sync`.
456    caret_anim: Arc<CaretAnim>,
457}
458
459/// Posición animada del caret — el estado que hace posible la **estela**: sin
460/// recordar dónde estaba y cuándo, cada cuadro dibujaría el caret ya en su
461/// destino y no habría nada que arrastrar.
462#[derive(Debug)]
463struct CaretAnim {
464    /// x actual (bits de `f32`), px desde el inicio del bloque de texto.
465    x: AtomicU32,
466    /// y actual (bits de `f32`), px desde el tope del bloque de texto.
467    y: AtomicU32,
468    /// Alto del caret (bits de `f32`) — se anima junto con la posición para que
469    /// un cambio de zoom no lo haga saltar.
470    h: AtomicU32,
471    /// Unix-ms del último muestreo. `0` = sin muestrear (el primer cuadro se
472    /// clava en el destino, sin estela de entrada).
473    t_ms: AtomicU64,
474    /// `true` mientras el rastro va en camino al caret. Distingue «arrancó un
475    /// viaje» de «el caret se corrió un poco»: sin esto, el umbral de salto
476    /// cortaría la estela a mitad de camino, justo cuando lo que falta ya es
477    /// menos que el umbral.
478    persiguiendo: AtomicBool,
479}
480
481impl Default for CaretAnim {
482    fn default() -> Self {
483        Self {
484            x: AtomicU32::new(0),
485            y: AtomicU32::new(0),
486            h: AtomicU32::new(0),
487            t_ms: AtomicU64::new(0),
488            persiguiendo: AtomicBool::new(false),
489        }
490    }
491}
492
493impl Default for TextInputState {
494    /// **Single-line** como [`Self::new`] — importa porque `EditorState::
495    /// default()` es multilínea; derivar `Default` dejaba el input aceptando
496    /// `Enter`/saltos de línea. Un campo default es un campo vacío single-line.
497    fn default() -> Self {
498        Self::new()
499    }
500}
501
502impl TextInputState {
503    /// Input vacío visible (texto plano).
504    pub fn new() -> Self {
505        Self {
506            inner: EditorState::with_options(EditorOptions {
507                single_line: true,
508                ..EditorOptions::default()
509            }),
510            masked: false,
511            box_w: Arc::new(AtomicU32::new(0)),
512            drag_x: 0.0,
513            drag_y: 0.0,
514            drag_active: false,
515            area_scroll_y: Arc::new(AtomicU32::new(0)),
516            area_goal_x: None,
517            metricas: Arc::new(Mutex::new(Metricas::default())),
518            actividad_ms: Arc::new(AtomicU64::new(0)),
519            caret_anim: Arc::new(CaretAnim::default()),
520        }
521    }
522
523    /// Estampa "el usuario acaba de tocar el input" en unix-ms. El host la llama
524    /// en cada tecla/click aceptado; el caret la usa para quedarse **sólido**
525    /// mientras se escribe y titilar recién después.
526    ///
527    /// Va por afuera de [`Self::handle`] a propósito: el widget no tiene reloj
528    /// (ni debe tenerlo — sería un `SystemTime::now()` escondido dentro de una
529    /// función pura y sin forma de testearlo), así que la hora la trae el host,
530    /// que ya la tiene.
531    pub fn marcar_actividad(&self, ahora_ms: u64) {
532        self.actividad_ms.store(ahora_ms, Ordering::Relaxed);
533    }
534
535    /// Unix-ms de la última actividad (0 = ninguna todavía).
536    pub fn actividad_ms(&self) -> u64 {
537        self.actividad_ms.load(Ordering::Relaxed)
538    }
539
540    /// La [`Metricas`] con la que se pintó el último cuadro. El `update` la lee
541    /// para hit-testear con la misma métrica que se dibujó.
542    pub fn metricas(&self) -> Metricas {
543        self.metricas.lock().clone()
544    }
545
546    /// Fija la métrica **antes** de pintar. La vista la publica sola en cada
547    /// cuadro; esto es para el host que necesita hit-testear (o preguntar
548    /// cuántos renglones ocupa el texto) *antes* del primer pintado — si no, el
549    /// primer click de la sesión mediría con la métrica default.
550    pub fn aplicar_metricas(&self, m: &Metricas) {
551        self.set_metricas(m);
552    }
553
554    /// Inserta texto en el caret, reemplazando la selección viva si la hay, y
555    /// registrándolo en el **undo**. Es el equivalente programático de tipear:
556    /// lo usa el host para pegar, aplicar un autocompletado o insertar un salto
557    /// de línea sin fabricar un `KeyEvent` falso.
558    /// Ojo: insertar la cadena **vacía** es un no-op, NO borra la selección —
559    /// para eso está [`Self::borrar_seleccion`]. Un host que quiera cortar tiene
560    /// que pedir borrar explícitamente; `insertar("")` no alcanza.
561    pub fn insertar(&mut self, s: &str) {
562        if s.is_empty() {
563            return;
564        }
565        let ed = &mut self.inner;
566        let d = llimphi_widget_text_editor::ops::replace_selection(&mut ed.buffer, &mut ed.cursor, s);
567        ed.undo.push(d);
568        ed.bump_edit_seq();
569    }
570
571    /// Borra la selección viva (una sola entrada de undo) y devuelve `true` si
572    /// había algo que borrar. Es la mitad que le faltaba a «cortar»: copiar lo
573    /// hace el host, borrar lo hace el motor.
574    ///
575    /// Existe porque `insertar("")` **no** borra: sale temprano con la cadena
576    /// vacía. Quien cortaba con `insertar("")` copiaba y dejaba el texto puesto.
577    pub fn borrar_seleccion(&mut self) -> bool {
578        let ed = &mut self.inner;
579        let (start, end) = ed.cursor.selection_range(&ed.buffer);
580        if start == end {
581            return false;
582        }
583        let d = llimphi_widget_text_editor::ops::replace_selection(
584            &mut ed.buffer,
585            &mut ed.cursor,
586            "",
587        );
588        ed.undo.push(d);
589        ed.bump_edit_seq();
590        true
591    }
592
593    /// Guarda la métrica del cuadro (la llama la vista).
594    fn set_metricas(&self, m: &Metricas) {
595        let mut g = self.metricas.lock();
596        if *g != *m {
597            *g = m.clone();
598        }
599    }
600
601    /// Input enmascarado — para campos de contraseña.
602    pub fn masked() -> Self {
603        Self { masked: true, ..Self::new() }
604    }
605
606    /// Input **multilínea** — acepta `Enter` como salto de línea (no "submit").
607    /// Es el estado que usa `llimphi-widget-text-area`: hereda del mismo motor
608    /// [`EditorState`] toda la edición (selección, undo/redo, word-jump, Home/End,
609    /// copiar/pegar, IME) — la misma `handle`/`apply_key` que el single-line, sin
610    /// una implementación paralela. El caret/selección con mouse se posicionan en
611    /// 2D con [`Self::place_caret_2d`] / [`Self::extend_sel_2d`].
612    pub fn multiline() -> Self {
613        Self {
614            inner: EditorState::with_options(EditorOptions {
615                single_line: false,
616                ..EditorOptions::default()
617            }),
618            ..Self::new()
619        }
620    }
621
622    /// `true` si el estado acepta saltos de línea (creado con [`Self::multiline`]).
623    pub fn is_multiline(&self) -> bool {
624        !self.inner.options.single_line
625    }
626
627    /// Texto actual. Devuelve `String` (antes `&str` — el rope no expone
628    /// slice borrowed sin clone). Para evitar copias innecesarias, los
629    /// callers que sólo necesitan derivar `.trim()` o `.is_empty()`
630    /// pueden hacerlo directo sobre el `String` devuelto.
631    pub fn text(&self) -> String {
632        self.inner.text()
633    }
634
635    pub fn is_empty(&self) -> bool {
636        self.inner.is_empty()
637    }
638
639    /// Enciende o apaga el enmascarado sobre un estado ya existente.
640    ///
641    /// Existe para que un HOST genérico —uno que arma sus campos desde un
642    /// esquema, como `allichay`— pueda garantizar el enmascarado en el momento
643    /// de PINTAR, sin depender de que quien creó el estado se acordara. Un
644    /// panel que se olvida de pedir `masked()` al enfocar deja la contraseña a
645    /// la vista, y eso no se nota hasta que alguien mira la pantalla.
646    pub fn set_masked(&mut self, masked: bool) {
647        self.masked = masked;
648    }
649
650    pub fn is_masked(&self) -> bool {
651        self.masked
652    }
653
654    pub fn clear(&mut self) {
655        self.inner.set_text("");
656    }
657
658    pub fn set_text(&mut self, s: impl Into<String>) {
659        let s = s.into();
660        self.inner.set_text(&s);
661    }
662
663    pub fn push_str(&mut self, s: &str) {
664        let combined = format!("{}{}", self.inner.text(), s);
665        self.inner.set_text(&combined);
666    }
667
668    pub fn pop(&mut self) -> Option<char> {
669        let mut t = self.inner.text();
670        let ch = t.pop()?;
671        self.inner.set_text(&t);
672        Some(ch)
673    }
674
675    /// Aplica una tecla al estado. Devuelve `true` si cambió el contenido
676    /// **o** sólo se movió el cursor (cualquier cosa que requiera repintar).
677    pub fn apply_key(&mut self, event: &KeyEvent) -> bool {
678        self.inner.apply_key(event).touched()
679    }
680
681    /// Acceso de bajo nivel al editor interno — útil si el caller
682    /// quiere consultar cursor/selección o aplicar ops avanzadas.
683    pub fn editor(&self) -> &EditorState {
684        &self.inner
685    }
686    pub fn editor_mut(&mut self) -> &mut EditorState {
687        &mut self.inner
688    }
689
690    /// El texto tal como se MUESTRA: `•` por carácter en masked, o el crudo. Es
691    /// lo que se mide para el hit-testing del mouse (así el click coincide con lo
692    /// que se ve). Vacío ⇒ cadena vacía (no se hit-testea el placeholder).
693    fn display_text(&self) -> String {
694        let raw = self.inner.text();
695        if self.masked {
696            "\u{2022}".repeat(raw.chars().count())
697        } else {
698            raw
699        }
700    }
701
702    /// Offset horizontal (px) del texto para mantener el **caret a la vista**
703    /// cuando el contenido desborda la caja. `0` si el texto entra o si la caja
704    /// aún no se pintó (no sabemos su ancho). Stateless: lo deriva del ancho del
705    /// último frame ([`Self::box_w`]) + la posición real del caret. Lo comparten
706    /// el render (traslada el nodo de texto), el `paint_over` (caret/selección) y
707    /// el hit-testing (x↔columna) para que todo quede alineado.
708    fn scroll_x(&self, display: &str) -> f32 {
709        let box_w = f32::from_bits(self.box_w.load(Ordering::Relaxed));
710        if box_w <= 1.0 {
711            return 0.0;
712        }
713        let area = box_w - 2.0 * PAD_X;
714        let total = prefix_width(display, display.chars().count());
715        if area <= 1.0 || total <= area {
716            return 0.0;
717        }
718        let n = display.chars().count();
719        let caret_x = prefix_width(display, self.inner.cursor.caret.col.min(n));
720        const MARGIN: f32 = 8.0;
721        (caret_x - area + MARGIN).clamp(0.0, total - area)
722    }
723
724    /// Registra un click del mouse a `local_x` px del borde izquierdo del box
725    /// (coords del nodo, incluido el borde de 1 px + el padding de 10 px).
726    /// Mapea x → columna midiendo los **glifos reales** ([`col_from_x`]) y delega
727    /// en [`EditorState::register_click`], que escala **1 click = caret**,
728    /// **2 = palabra**, **3 = todo** por ventana de tiempo. El parámetro
729    /// `_font_size` quedó por compatibilidad de firma: el render es a
730    /// [`FONT_SIZE`] fijo y la medición usa la fuente real, no un ancho fijo.
731    #[cfg(feature = "std")]
732    pub fn pointer_click(&mut self, local_x: f32, _font_size: f32) {
733        self.pointer_click_at(local_x, _font_size, Instant::now());
734    }
735
736    /// Como [`Self::pointer_click`] pero con el instante inyectado — el perfil
737    /// sin `std` (la jaula) no tiene reloj propio; entra el del fotograma.
738    pub fn pointer_click_at(&mut self, local_x: f32, _font_size: f32, now: Instant) {
739        let col = col_from_x(&self.display_text(), local_x);
740        // Single-line ⇒ línea 0; register_click clampa la columna al texto.
741        self.inner.register_click_at(0, col, now);
742    }
743
744    /// Selecciona todo el texto (equivalente a triple-click / Ctrl+A).
745    pub fn select_all(&mut self) {
746        self.inner.select_all();
747    }
748
749    /// Mapea `local_x` (px desde el borde izquierdo del box) a columna de
750    /// carácter midiendo los **glifos reales** de la fuente ([`col_from_x`]) — no
751    /// un ancho fijo. Suma el [`Self::scroll_x`] para que el click acierte aun
752    /// con el texto desplazado (caret a la vista). Coincide con la posición donde
753    /// el `paint_over` dibuja el caret. Usado por el press y el arrastre.
754    fn col_at_x(&self, local_x: f32) -> usize {
755        let display = self.display_text();
756        col_from_x(&display, local_x + self.scroll_x(&display))
757    }
758
759    /// Aplica una tecla con **clipboard activo**: Ctrl+C copia la selección,
760    /// Ctrl+X la corta, Ctrl+V pega. El resto es idéntico a [`Self::apply_key`]
761    /// (que pasa un `NullClipboard`, por eso ahí copiar/pegar no hacen nada).
762    /// Devuelve `true` si hay que repintar. El caller sostiene el `Clipboard`
763    /// (típico: un [`MemClipboard`] en su Model, o un puente al portapapeles
764    /// del SO).
765    pub fn apply_key_with_clipboard(
766        &mut self,
767        event: &KeyEvent,
768        clipboard: &mut dyn Clipboard,
769    ) -> bool {
770        self.inner.apply_key_with_clipboard(event, clipboard).touched()
771    }
772
773    /// Aplica un evento de IME — el camino de los acentos compuestos (dead
774    /// keys: `´` + `a` → `á`), CJK y el emoji picker cuando la app habilita
775    /// `App::ime_allowed`. Devuelve `true` si cambió algo visible.
776    pub fn apply_ime(&mut self, event: &ImeEvent) -> bool {
777        self.inner.apply_ime_event(event).touched()
778    }
779
780    /// Extiende la selección hasta `local_x` (px desde el borde) — el extremo
781    /// vivo del arrastre. El otro extremo quedó anclado por el press. Pensado
782    /// para el arrastre con mouse; ver [`Self::handle`] con
783    /// [`TextInputEvent::Drag`], que lo maneja acumulando el delta.
784    pub fn pointer_drag(&mut self, local_x: f32) {
785        let col = self.col_at_x(local_x);
786        self.inner.extend_selection_to(0, col);
787    }
788
789    /// Procesa un [`TextInputEvent`] (teclado, IME o mouse) y devuelve `true`
790    /// si hay que repintar. Es el **camino sin fricción**: el caller envuelve
791    /// este enum en su propio `Msg` y en `update` hace
792    /// `state.handle(ev, &mut clipboard)` — un solo `Msg` cubre escribir,
793    /// mover, seleccionar, copiar/pegar, acentos, click, doble/triple-click y
794    /// arrastre. Combinar con [`text_input_view_full`], que emite los eventos
795    /// de mouse ya envueltos.
796    #[cfg(feature = "std")]
797    pub fn handle(&mut self, ev: TextInputEvent, clipboard: &mut dyn Clipboard) -> bool {
798        self.handle_at(ev, clipboard, Instant::now())
799    }
800
801    /// Como [`Self::handle`] pero con el instante inyectado (la ventana del
802    /// doble/triple click se mide contra él) — el perfil sin `std` no tiene
803    /// reloj propio; el runtime de la jaula pasa el del fotograma.
804    pub fn handle_at(&mut self, ev: TextInputEvent, clipboard: &mut dyn Clipboard, now: Instant) -> bool {
805        match ev {
806            TextInputEvent::Key(e) => self.apply_key_with_clipboard(&e, clipboard),
807            TextInputEvent::Ime(e) => self.apply_ime(&e),
808            TextInputEvent::Press(x) => {
809                // Click plano: coloca el caret y escala a palabra/todo por la
810                // ventana de tiempo de `register_click` (2 = palabra, 3 = todo).
811                self.drag_active = false;
812                self.drag_x = x;
813                self.inner.register_click_at(0, self.col_at_x(x), now);
814                true
815            }
816            TextInputEvent::Drag { anchor_x, dx } => {
817                // Primer `Move` del arrastre: ancla el caret en la posición del
818                // press (`anchor_x`) antes de extender. Los siguientes acumulan
819                // el delta sobre `drag_x` (el runtime da deltas, no absolutos).
820                if !self.drag_active {
821                    self.inner.set_caret_at(0, self.col_at_x(anchor_x));
822                    self.drag_x = anchor_x;
823                    self.drag_active = true;
824                }
825                self.drag_x += dx;
826                self.pointer_drag(self.drag_x);
827                true
828            }
829            TextInputEvent::DragEnd => {
830                self.drag_active = false;
831                false
832            }
833            // Click derecho: no toca el estado del input. El host lo intercepta
834            // (antes de llamar a `handle`) para abrir el menú de edición en la
835            // posición. Si llega hasta aquí, es no-op — nada que repintar.
836            TextInputEvent::Context { .. } => false,
837        }
838    }
839}
840
841/// Evento de entrada dirigido a un text-input: teclado, IME o mouse. El caller
842/// lo envuelve en su propio `Msg` (típicamente `Msg::Field(id, TextInputEvent)`)
843/// y lo reenvía a [`TextInputState::handle`] — así **un campo se cablea con un
844/// solo `Msg`** en vez de uno por gesto. Los eventos de mouse ([`Self::Press`]
845/// y [`Self::Drag`]/[`Self::DragEnd`]) los produce [`text_input_view_full`]; los
846/// de teclado/IME los inyecta el handler global de la app.
847/// Deriva `Debug` (sus campos `KeyEvent`/`ImeEvent`/`f32` lo son) para que los
848/// `enum Msg` de las apps que lo envuelvan puedan seguir derivando `Debug`.
849#[derive(Debug, Clone)]
850pub enum TextInputEvent {
851    /// Tecla (con modificadores). Ctrl+C/X/V operan sobre el clipboard pasado.
852    Key(KeyEvent),
853    /// Evento de IME (acentos compuestos, CJK, emoji).
854    Ime(ImeEvent),
855    /// Mouse-down a `local_x` px del borde: coloca el caret y escala a
856    /// palabra/todo por doble/triple-click.
857    Press(f32),
858    /// Arrastre: `anchor_x` = posición del press (px), `dx` = delta desde el
859    /// evento anterior. Extiende la selección.
860    Drag { anchor_x: f32, dx: f32 },
861    /// Fin del arrastre (soltar el botón).
862    DragEnd,
863    /// Click **derecho** a `(x, y)` (coords **absolutas de ventana**, vía
864    /// `on_right_click_screen`): pide abrir el menú de edición
865    /// (cortar/copiar/pegar/…) anclado al puntero. El estado del input no cambia — es un
866    /// evento de *overlay* que el host resuelve montando
867    /// [`llimphi_widget_edit_menu`] en su `view_overlay` con `(x, y)` de ancla.
868    /// Por eso [`TextInputState::handle`] lo trata como no-op (devuelve `false`):
869    /// el host lo intercepta antes de delegar en `handle`.
870    Context { x: f32, y: f32 },
871}
872
873/// Compone el input box: borde de 1 px (rect padre coloreado), relleno
874/// interno, texto o placeholder, y el caret (cursor de inserción) sobre el
875/// texto si está focado. Caret v3 (Fase 7.1255): cuando está focado la hoja
876/// pinta texto+caret en un `paint_over` con **scroll horizontal** — el texto
877/// se desplaza para mantener el caret a la vista cuando desborda la caja, y se
878/// recorta al área de contenido. Sin foco usa un nodo-hijo de texto (sin caret).
879/// Click sobre el box emite `on_focus` (típicamente `Msg::Focus(Field)`).
880pub fn text_input_view<Msg: Clone + 'static>(
881    state: &TextInputState,
882    placeholder: &str,
883    focused: bool,
884    palette: &TextInputPalette,
885    on_focus: Msg,
886) -> View<Msg> {
887    build_input(state, placeholder, focused, palette, ClickHandler::Focus(on_focus), true)
888}
889
890/// Cómo responde el box al click.
891enum ClickHandler<Msg> {
892    /// Click plano → un solo `Msg` (toma foco). Sin selección por mouse: es el
893    /// render en **reposo** (campo sin foco). Para el campo focado se usa
894    /// [`ClickHandler::Full`], que da caret/selección/arrastre.
895    Focus(Msg),
896    /// Camino completo: el box emite [`TextInputEvent`]s de mouse
897    /// (`Press`/`Drag`/`DragEnd`) ya envueltos por la closure. Habilita
898    /// click→caret, doble/triple-click→palabra/todo y **arrastre→selección**
899    /// sin que el caller cablee cada gesto. Ver [`text_input_view_full`].
900    Full(Arc<dyn Fn(TextInputEvent) -> Msg + Send + Sync>),
901}
902
903/// Input con **paridad de comportamiento** tipo `TextField`: el box emite
904/// [`TextInputEvent`]s de mouse (click→caret, doble/triple-click→palabra/todo,
905/// **arrastre→selección**) ya envueltos por `wrap`, y el caller sólo hace
906/// `state.handle(ev, &mut clipboard)` en `update`. Combinado con reenviar
907/// `TextInputEvent::Key`/`Ime` desde el handler global, un campo queda con
908/// selección, copiar/pegar, undo/redo, salto de palabra y acentos — todo con
909/// **un solo `Msg`** (`Msg::Field(id, TextInputEvent)`), sin un mensaje por
910/// gesto. Es la vista de un campo **focado** (editable con paridad TextField);
911/// para el render en reposo (sin foco, click→foco) está [`text_input_view`].
912pub fn text_input_view_full<Msg, F>(
913    state: &TextInputState,
914    placeholder: &str,
915    focused: bool,
916    palette: &TextInputPalette,
917    wrap: F,
918) -> View<Msg>
919where
920    Msg: Clone + 'static,
921    F: Fn(TextInputEvent) -> Msg + Send + Sync + 'static,
922{
923    build_input(state, placeholder, focused, palette, ClickHandler::Full(Arc::new(wrap)), true)
924}
925
926/// Campo de texto **con label flotante** — el look característico de Material /
927/// Flutter. El `label` descansa dentro del campo (como un placeholder) cuando
928/// está vacío y sin foco, y **flota hacia arriba** (más chico, en el color de
929/// foco) al focar o cuando hay texto. `float_t ∈ [0,1]` controla la posición:
930/// `0` = descansando, `1` = flotado; el host puede animarlo con un tween (o
931/// pasar `if focused || !state.is_empty() { 1.0 } else { 0.0 }` para el salto
932/// sin animar — [`float_target`] lo calcula). El campo en sí es el mismo
933/// [`text_input_view_full`] (scroll, selección, menú, relieve), sin placeholder
934/// (el label lo reemplaza). `wrap` envuelve los [`TextInputEvent`] como siempre.
935pub fn text_input_field_view<Msg, F>(
936    state: &TextInputState,
937    label: &str,
938    focused: bool,
939    float_t: f32,
940    palette: &TextInputPalette,
941    wrap: F,
942) -> View<Msg>
943where
944    Msg: Clone + 'static,
945    F: Fn(TextInputEvent) -> Msg + Send + Sync + 'static,
946{
947    let t = float_t.clamp(0.0, 1.0);
948    // Geometría (px). `LABEL_SPACE` es el aire arriba donde vive el label flotado
949    // (sobre la línea del borde superior del campo, que empieza en `LABEL_SPACE`).
950    const LABEL_SPACE: f32 = 14.0;
951    const FIELD_H: f32 = 34.0;
952    const LABEL_LEFT: f32 = 12.0;
953    const GAP_PAD: f32 = 4.0; // aire a los lados del label dentro de la muesca
954    // El campo, SIN borde propio (lo dibuja el notched outline de abajo) y sin
955    // placeholder (el label ocupa ese rol).
956    let field = build_input(state, "", focused, palette, ClickHandler::Full(Arc::new(wrap)), false);
957
958    // Label: interpola tamaño, color y posición vertical entre reposo (dentro,
959    // centrado en el campo) y flotado (sobre la línea del borde superior).
960    let size = lerp(13.0, 11.0, t);
961    let color = mix(palette.fg_placeholder, palette.border_focus, t);
962    let resting_y = LABEL_SPACE + (FIELD_H - 13.0) * 0.5; // centro vertical del campo
963    let floating_y = LABEL_SPACE - size * 0.62; // centrado sobre la línea del borde
964    let y = lerp(resting_y, floating_y, t);
965    let label_node = View::new(Style {
966        position: Position::Absolute,
967        inset: Rect {
968            left: length(LABEL_LEFT),
969            top: length(y),
970            right: auto(),
971            bottom: auto(),
972        },
973        ..Default::default()
974    })
975    .text_aligned(label.to_string(), size, color, Alignment::Start);
976
977    // Notched outline: borde del campo dibujado como TRAZO, con una muesca en el
978    // borde superior por donde asoma el label. El hueco crece con `t` y su ancho
979    // se ajusta al ancho medido del label (a su tamaño flotado). Va en un
980    // `paint_over` del contenedor (sobre el campo, bajo el label que queda en el
981    // hueco). Espeja el `OutlineInputBorder` de Material.
982    let (bcol, bw) = if focused {
983        (palette.border_focus, 2.0_f32)
984    } else {
985        (palette.border, 1.0_f32)
986    };
987    let lw_float = prefix_width(label, label.chars().count()) * (11.0 / 13.0);
988    let radius_f = palette.radius as f64; // themizable
989
990    // Contenedor: reserva el espacio del label arriba, campo debajo, label absoluto
991    // encima. El borde (trazo con muesca) se pinta al final con `paint_over`.
992    View::new(Style {
993        size: Size { width: percent(1.0_f32), height: length(LABEL_SPACE + FIELD_H) },
994        padding: Rect {
995            left: length(0.0_f32),
996            right: length(0.0_f32),
997            top: length(LABEL_SPACE),
998            bottom: length(0.0_f32),
999        },
1000        ..Default::default()
1001    })
1002    .children(vec![field, label_node])
1003    .paint_over(move |scene, _ts, rect| {
1004        use llimphi_compositor::peniko::kurbo::{Affine, BezPath, Stroke};
1005        if rect.w <= 0.0 {
1006            return;
1007        }
1008        let r = radius_f;
1009        let hw = bw as f64 * 0.5;
1010        let ox = rect.x as f64;
1011        let oy = rect.y as f64;
1012        let x0 = ox + hw;
1013        let x1 = ox + rect.w as f64 - hw;
1014        let y0 = oy + LABEL_SPACE as f64 + hw; // línea del borde superior
1015        let y1 = oy + (LABEL_SPACE + FIELD_H) as f64 - hw;
1016        // Muesca: centrada en el label; de un punto (t=0) al hueco completo (t=1).
1017        let g0_full = ox + (LABEL_LEFT - GAP_PAD) as f64;
1018        let g1_full = ox + (LABEL_LEFT + GAP_PAD) as f64 + lw_float as f64;
1019        let center = (g0_full + g1_full) * 0.5;
1020        let nx0 = (center - (center - g0_full) * t as f64).clamp(x0 + r, x1 - r);
1021        let nx1 = (center + (g1_full - center) * t as f64).clamp(x0 + r, x1 - r);
1022        // Trazo abierto en sentido horario, arrancando a la DERECHA de la muesca
1023        // y terminando a su IZQUIERDA → deja el hueco en el borde superior.
1024        let mut p = BezPath::new();
1025        p.move_to((nx1, y0));
1026        p.line_to((x1 - r, y0));
1027        p.quad_to((x1, y0), (x1, y0 + r)); // esquina sup-der
1028        p.line_to((x1, y1 - r));
1029        p.quad_to((x1, y1), (x1 - r, y1)); // inf-der
1030        p.line_to((x0 + r, y1));
1031        p.quad_to((x0, y1), (x0, y1 - r)); // inf-izq
1032        p.line_to((x0, y0 + r));
1033        p.quad_to((x0, y0), (x0 + r, y0)); // sup-izq
1034        p.line_to((nx0, y0)); // borde superior hasta el inicio de la muesca
1035        scene.stroke(&Stroke::new(bw as f64), Affine::IDENTITY, bcol, None, &p);
1036    })
1037}
1038
1039/// El `float_t` objetivo (sin animar) de un [`text_input_field_view`]: `1.0` si
1040/// el campo está focado o tiene texto (el label flota), `0.0` si no (descansa
1041/// adentro). Un host que quiera animar interpola hacia este valor con un tween.
1042pub fn float_target(focused: bool, state: &TextInputState) -> f32 {
1043    if focused || !state.is_empty() {
1044        1.0
1045    } else {
1046        0.0
1047    }
1048}
1049
1050fn build_input<Msg: Clone + 'static>(
1051    state: &TextInputState,
1052    placeholder: &str,
1053    focused: bool,
1054    palette: &TextInputPalette,
1055    click: ClickHandler<Msg>,
1056    // `false` = el campo NO dibuja su borde macizo (el ring queda del color del
1057    // fondo): lo usa [`text_input_field_view`], que pinta un **borde con muesca**
1058    // (notched outline de Material) alrededor, con hueco para el label flotado.
1059    bordered: bool,
1060) -> View<Msg> {
1061    let raw = state.text();
1062    let is_empty = raw.is_empty();
1063    let shown = if is_empty {
1064        placeholder.to_string()
1065    } else if state.masked {
1066        "•".repeat(raw.chars().count())
1067    } else {
1068        raw
1069    };
1070    let display = shown;
1071    // Prefijo del texto visible hasta el caret (cursor de inserción), para
1072    // medir su ancho y posicionar la barra del caret. La columna es índice de
1073    // carácter (single-line ⇒ `line == 0`); `take(col)` sobre el texto MOSTRADO
1074    // (placeholder/`•`/crudo) alinea el caret con lo que se ve. Cuando el input
1075    // está vacío el `col` es 0 ⇒ prefijo vacío ⇒ caret al inicio (no se mide el
1076    // placeholder).
1077    let caret_prefix: String = if focused {
1078        display.chars().take(state.editor().cursor.caret.col).collect()
1079    } else {
1080        String::new()
1081    };
1082    // Rango de selección en columnas (= char offsets, single-line). `None` si
1083    // no hay selección o el input no está focado. Se captura por valor para el
1084    // closure de pintado (no puede tomar prestado `state`).
1085    let sel_cols: Option<(usize, usize)> = if focused {
1086        let ed = state.editor();
1087        let (s, e) = ed.cursor.selection_range(&ed.buffer);
1088        (s != e).then_some((s, e))
1089    } else {
1090        None
1091    };
1092    let sel_color = palette.selection;
1093    let text_color = if is_empty {
1094        palette.fg_placeholder
1095    } else {
1096        palette.fg_text
1097    };
1098    let (bg, border) = if focused {
1099        (palette.bg_focus, palette.border_focus)
1100    } else {
1101        (palette.bg, palette.border)
1102    };
1103    // Decoración THEMIZABLE (de la paleta, que sale de los tokens del theme).
1104    let radius = palette.radius as f64;
1105    let relieve = palette.relieve;
1106    // Scroll horizontal: cuánto correr el texto a la izquierda para que el caret
1107    // quede a la vista cuando desborda. Sólo focado (sin foco no hay caret que
1108    // seguir; el texto va desde el inicio). `box_w` la escribe el `paint_over`.
1109    let scroll = if focused { state.scroll_x(&display) } else { 0.0 };
1110
1111    let inner = View::new(Style {
1112        size: Size {
1113            width: percent(1.0_f32),
1114            height: percent(1.0_f32),
1115        },
1116        padding: Rect {
1117            left: length(10.0_f32),
1118            right: length(10.0_f32),
1119            top: length(0.0_f32),
1120            bottom: length(0.0_f32),
1121        },
1122        align_items: Some(AlignItems::CENTER),
1123        ..Default::default()
1124    })
1125    .fill(bg)
1126    .radius(radius - 1.0)
1127    // Recorta el texto desplazado para que no se derrame fuera de la caja.
1128    .clip(true);
1129    // Sheen: gradiente vertical translúcido que le da dimensión al fondo (luz
1130    // arriba → neutro → sombra abajo), derivado de la luminancia del fondo para
1131    // leer igual en themes claros y oscuros. Va en `paint_with` (entre el fill y
1132    // el texto): no re-pinta texto (no reintroduce el "bold") y respeta el color.
1133    // Sólo si el theme pide relieve (un theme flat lo apaga).
1134    let inner = if !relieve {
1135        inner
1136    } else {
1137        let dark = 1.0 - luminance(bg);
1138        let radius_in = radius - 1.0;
1139        let top_a = (0.06 + 0.10 * dark).min(0.18);
1140        inner.paint_with(move |scene, _ts, rect| {
1141            use llimphi_compositor::peniko::kurbo::{Affine, Point, RoundedRect};
1142            use llimphi_compositor::peniko::{Color as PColor, Fill, Gradient};
1143            if rect.w <= 0.0 || rect.h <= 0.0 {
1144                return;
1145            }
1146            let x0 = rect.x as f64;
1147            let y0 = rect.y as f64;
1148            let x1 = (rect.x + rect.w) as f64;
1149            let y1 = (rect.y + rect.h) as f64;
1150            let rr = RoundedRect::new(x0, y0, x1, y1, radius_in);
1151            let top = PColor { components: [1.0, 1.0, 1.0, top_a], ..PColor::WHITE };
1152            let midc = PColor::from_rgba8(255, 255, 255, 0);
1153            let bot = PColor::from_rgba8(0, 0, 0, 20);
1154            let sheen = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y1))
1155                .with_stops([top, midc, bot].as_slice());
1156            scene.fill(Fill::NonZero, Affine::IDENTITY, &sheen, None, &rr);
1157        })
1158    };
1159    // El TEXTO va SIEMPRE como nodo hijo (mismo render del compositor, peso normal),
1160    // focado o no. Antes el camino focado lo re-pintaba con `draw_layout` en un
1161    // `paint_over` (dentro de un `push_layer`), y eso lo dejaba **más pesado/ilegible
1162    // al escribir** (la fuente del bundle es de un solo peso: no es `FontWeight`, es
1163    // el re-pintado en la capa). Ahora el `paint_over` sólo dibuja el **caret** y el
1164    // **resaltado de selección** (translúcido, encima del texto) — nunca el texto.
1165    // (Trade-off: se perdió el scroll horizontal del caret en campos que desbordan;
1166    // follow-up. Para campos cortos —lo típico en config— no hace falta.)
1167    let mut texto = View::new(Style {
1168        size: Size { width: percent(1.0_f32), height: auto() },
1169        ..Default::default()
1170    })
1171    .text_aligned(display.clone(), 13.0, text_color, Alignment::Start);
1172    // Traslada el texto por el scroll (mismo render del compositor, sin re-pintar
1173    // en capa → sin el bug del "bold"). El caret/selección abajo restan el mismo
1174    // offset, así todo queda alineado.
1175    if scroll > 0.0 {
1176        use llimphi_compositor::peniko::kurbo::{Affine, Vec2};
1177        texto = texto.transform(Affine::translate(Vec2::new(-scroll as f64, 0.0)));
1178    }
1179    let base = inner.children(vec![texto]);
1180    let inner = if focused {
1181        let caret_color = palette.caret;
1182        let caret_visible = palette.caret_visible;
1183        // Selección translúcida (deja ver el texto que está debajo).
1184        let sel_translucida = Color {
1185            components: [sel_color.components[0], sel_color.components[1], sel_color.components[2], 0.4],
1186            ..sel_color
1187        };
1188        let display_c = display;
1189        let caret_prefix_c = caret_prefix;
1190        let sel_cols_c = sel_cols;
1191        let scroll_c = scroll as f64;
1192        let box_w_cell = state.box_w.clone();
1193        base.paint_over(move |scene, ts, rect| {
1194            use llimphi_compositor::peniko::kurbo::{Affine, Rect as KRect};
1195            use llimphi_compositor::peniko::Fill;
1196            // Guarda el ancho pintado para el scroll del próximo frame.
1197            box_w_cell.store((rect.w).to_bits(), Ordering::Relaxed);
1198            let pad = 10.0_f64;
1199            let cx0 = rect.x as f64 + pad - scroll_c; // origen del texto, corrido por el scroll
1200            let x_min = rect.x as f64 + pad; // borde visible izq del área de texto
1201            let x_max = rect.x as f64 + rect.w as f64 - pad; // borde visible der
1202            let th = 16.0_f64; // alto aprox del resaltado/caret
1203            let oy = rect.y as f64 + (rect.h as f64 - th) * 0.5;
1204            // Ancho del prefijo hasta el caret = x del caret dentro del texto.
1205            let caret_w = if caret_prefix_c.is_empty() {
1206                0.0
1207            } else {
1208                ancho_con_ts(ts, &caret_prefix_c)
1209            };
1210            // Selección: rect translúcido encima del texto, recortado al área visible.
1211            if let Some((s, e)) = sel_cols_c {
1212                let mut prefix_w = |n: usize| -> f64 {
1213                    if n == 0 {
1214                        return 0.0;
1215                    }
1216                    let p: String = display_c.chars().take(n).collect();
1217                    ancho_con_ts(ts, &p)
1218                };
1219                let xs = (cx0 + prefix_w(s)).clamp(x_min, x_max);
1220                let xe = (cx0 + prefix_w(e)).clamp(x_min, x_max);
1221                if xe > xs {
1222                    let sel_rect = KRect::new(xs, oy, xe, oy + th);
1223                    scene.fill(Fill::NonZero, Affine::IDENTITY, sel_translucida, None, &sel_rect);
1224                }
1225            }
1226            // Caret: barra vertical (el scroll lo mantiene dentro del área visible).
1227            if caret_visible {
1228                let x = (cx0 + caret_w).clamp(x_min, x_max);
1229                let cy = rect.y as f64 + rect.h as f64 * 0.5;
1230                let bar = KRect::new(x, cy - 8.0, x + 1.5, cy + 8.0);
1231                scene.fill(Fill::NonZero, Affine::IDENTITY, caret_color, None, &bar);
1232            }
1233        })
1234    } else {
1235        base
1236    };
1237
1238    // Borde: 1 px en reposo, 2 px al focar (énfasis de foco, como Material). El
1239    // borde se pinta como padding coloreado del nodo exterior sobre el interior.
1240    let bw = if focused { 2.0_f32 } else { 1.0_f32 };
1241    // Sombra de elevación (themizable): `palette.elevation` sale de
1242    // `llimphi_theme::elevation::*`. Un pelo más marcada al focar (el campo activo
1243    // "sube"). Sólo se pinta si el theme pide relieve.
1244    let (elev_a, elev_blur, elev_dy) = palette.elevation;
1245    let shadow = llimphi_compositor::Shadow {
1246        color: Color::from_rgba8(0, 0, 0, if focused { elev_a.saturating_add(16) } else { elev_a }),
1247        blur: if focused { elev_blur * 1.5 } else { elev_blur },
1248        dx: 0.0,
1249        dy: elev_dy,
1250        spread: -1.0,
1251    };
1252    let mut node = View::new(Style {
1253        size: Size {
1254            width: percent(1.0_f32),
1255            height: length(palette.alto),
1256        },
1257        padding: Rect {
1258            left: length(bw),
1259            right: length(bw),
1260            top: length(bw),
1261            bottom: length(bw),
1262        },
1263        ..Default::default()
1264    })
1265    // Sin borde propio, el ring va del color del fondo (invisible) → lo dibuja
1266    // el notched outline del campo con label.
1267    .fill(if bordered { border } else { bg })
1268    .radius(radius);
1269    if relieve {
1270        node = node.shadow(shadow);
1271    }
1272    let node = node
1273    // Semántica: input de texto + el valor crudo como `value` (no el "•"
1274    // del modo masked — los lectores no deben dictar la contraseña en
1275    // voz alta; AccessKit ya marca el control como TextInput y el lector
1276    // sustituye por "punto" cuando el contexto lo requiere). El
1277    // placeholder va como `description` cuando el campo está vacío para
1278    // que el lector lo enuncie como pista. `value` queda vacío en masked.
1279    .role(llimphi_compositor::Role::TextInput)
1280    .aria_value(if state.masked { String::new() } else { state.text() })
1281    .aria_description(if is_empty { placeholder.to_string() } else { String::new() });
1282    // Click: plano (toma foco) o con posición (foco + caret/selección). El
1283    // `on_click_at` da `x` relativo al rect del box; el caller lo pasa a
1284    // `pointer_click`.
1285    let node = match click {
1286        ClickHandler::Focus(m) => node.on_click(m),
1287        ClickHandler::Full(cb) => {
1288            // `on_click_at` (press→caret, escala palabra/todo por timing) y
1289            // `draggable_at` (arrastre→selección) conviven en el mismo nodo
1290            // (patrón dominium). El click no dispara si el gesto deriva en
1291            // arrastre; el arrastre da deltas + la posición del press (`x0`).
1292            let press = cb.clone();
1293            let drag = cb.clone();
1294            let ctx = cb.clone();
1295            node.on_click_at(move |x, _y, _w, _h| Some(press(TextInputEvent::Press(x))))
1296                .draggable_at(move |phase, dx, _dy, x0, _y0| match phase {
1297                    DragPhase::Move => Some(drag(TextInputEvent::Drag { anchor_x: x0, dx })),
1298                    DragPhase::End => Some(drag(TextInputEvent::DragEnd)),
1299                })
1300                // Click derecho → abrir el menú de edición anclado al puntero.
1301                // `on_right_click_screen` da coords ABSOLUTAS de ventana (no
1302                // locales al nodo), que es lo que quiere el menú: aparecer donde
1303                // está el cursor. (Con `on_right_click_at` —local— el menú saltaba
1304                // a la esquina cuando el campo no estaba pegado al origen.)
1305                .on_right_click_screen(move |x, y, _w, _h| {
1306                    Some(ctx(TextInputEvent::Context { x, y }))
1307                })
1308        }
1309    };
1310    node.cursor(llimphi_compositor::Cursor::Text).children(vec![inner])
1311}
1312
1313#[cfg(test)]
1314mod tests {
1315    use super::*;
1316    use llimphi_compositor::{Key, KeyState, Modifiers, NamedKey};
1317
1318    fn key_press(key: Key, text: Option<&str>) -> KeyEvent {
1319        KeyEvent {
1320            key,
1321            state: KeyState::Pressed,
1322            text: text.map(|s| s.to_string()),
1323            modifiers: Default::default(),
1324            repeat: false,
1325        }
1326    }
1327
1328    #[test]
1329    fn palette_caret_default_sigue_al_texto() {
1330        // El caret por default sigue al color del texto (`caret-color: auto`):
1331        // `from_theme` y `Default` lo igualan a `fg_text`.
1332        let t = llimphi_theme::Theme::dark();
1333        let pal = TextInputPalette::from_theme(&t);
1334        assert_eq!(pal.caret, pal.fg_text);
1335        assert_eq!(pal.caret, t.fg_text);
1336        assert_eq!(TextInputPalette::default().caret, TextInputPalette::default().fg_text);
1337    }
1338
1339    #[test]
1340    fn caret_se_registra_como_over_painter_solo_focado() {
1341        // Caret v2 (Fase 7.1249): el caret se pinta con `paint_over` (pasada
1342        // FINAL sobre el glifo). Verificamos el wiring montando la vista: con
1343        // foco hay un over-painter registrado; sin foco no hay ninguno.
1344        use llimphi_compositor::llimphi_layout::LayoutTree;
1345        use llimphi_compositor::{has_over_painter, mount};
1346        let mut st = TextInputState::new();
1347        st.set_text("hola");
1348        let pal = TextInputPalette::default();
1349
1350        let mut lt = LayoutTree::new();
1351        let focado = mount(&mut lt, text_input_view(&st, "ph", true, &pal, ()));
1352        assert!(
1353            has_over_painter(&focado),
1354            "input focado debe registrar el caret como over-painter"
1355        );
1356
1357        let mut lt2 = LayoutTree::new();
1358        let sin_foco = mount(&mut lt2, text_input_view(&st, "ph", false, &pal, ()));
1359        assert!(
1360            !has_over_painter(&sin_foco),
1361            "input sin foco no pinta caret"
1362        );
1363    }
1364
1365    #[test]
1366    fn apply_key_inserts_printable_chars() {
1367        let mut s = TextInputState::new();
1368        let ev = key_press(Key::Character("a".into()), Some("a"));
1369        assert!(s.apply_key(&ev));
1370        assert_eq!(s.text(), "a");
1371    }
1372
1373    #[test]
1374    fn pointer_click_escalates_caret_word_all() {
1375        let mut s = TextInputState::new();
1376        s.set_text("hola mundo");
1377        // x cae dentro de "mundo" (col ~7 con char_w = 13*0.5 = 6.5; x = 11 + 7*6.5).
1378        let x = 11.0 + 7.0 * 6.5;
1379        // Instantes INYECTADOS, no el reloj de pared: la escalada a palabra/todo
1380        // depende de que los clicks caigan dentro de la ventana de doble-click, y
1381        // con `pointer_click` (que lee `Instant::now()`) el test se volvía flaky —
1382        // bastaba que otro test cargara la CPU en paralelo para que los tres
1383        // clicks se separaran de más y no escalara. Falla observada corriendo la
1384        // suite completa; pasaba solo. El reloj no es parte de lo que se prueba.
1385        let t0 = Instant::now();
1386        let paso = core::time::Duration::from_millis(50);
1387        // 1 click: caret simple, sin selección.
1388        s.pointer_click_at(x, 13.0, t0);
1389        assert!(!s.editor().has_selection(), "1 click no debe seleccionar");
1390        // 2 clicks: palabra bajo el cursor.
1391        s.pointer_click_at(x, 13.0, t0 + paso);
1392        assert!(s.editor().has_selection(), "2 clicks debe seleccionar palabra");
1393        assert_eq!(s.editor().selected_text().as_deref(), Some("mundo"));
1394        // 3 clicks: todo (párrafo = la línea entera en single-line).
1395        s.pointer_click_at(x, 13.0, t0 + paso * 2);
1396        assert_eq!(s.editor().selected_text().as_deref(), Some("hola mundo"));
1397    }
1398
1399    #[test]
1400    fn select_all_selecciona_todo() {
1401        let mut s = TextInputState::new();
1402        s.set_text("Frida Kahlo");
1403        s.select_all();
1404        assert_eq!(s.editor().selected_text().as_deref(), Some("Frida Kahlo"));
1405    }
1406
1407    #[test]
1408    fn apply_key_backspace_pops() {
1409        let mut s = TextInputState::new();
1410        s.set_text("hola");
1411        let ev = key_press(Key::Named(NamedKey::Backspace), None);
1412        assert!(s.apply_key(&ev));
1413        assert_eq!(s.text(), "hol");
1414    }
1415
1416    #[test]
1417    fn enter_ignorado_en_single_line() {
1418        let mut s = TextInputState::new();
1419        s.set_text("hola");
1420        let enter = key_press(Key::Named(NamedKey::Enter), None);
1421        assert!(!s.apply_key(&enter));
1422        assert_eq!(s.text(), "hola");
1423    }
1424
1425    #[test]
1426    fn masked_state_is_masked() {
1427        let s = TextInputState::masked();
1428        assert!(s.is_masked());
1429    }
1430
1431    #[test]
1432    fn flecha_izquierda_mueve_cursor() {
1433        // El refactor agrega esta capacidad — antes no había movimiento.
1434        let mut s = TextInputState::new();
1435        s.set_text("hola");
1436        let arr = key_press(Key::Named(NamedKey::ArrowLeft), None);
1437        assert!(s.apply_key(&arr));
1438        assert_eq!(s.editor().cursor.caret.col, 3);
1439    }
1440
1441    #[test]
1442    fn push_str_y_pop_funcionan() {
1443        let mut s = TextInputState::new();
1444        s.push_str("hola");
1445        assert_eq!(s.text(), "hola");
1446        assert_eq!(s.pop(), Some('a'));
1447        assert_eq!(s.text(), "hol");
1448    }
1449
1450    fn key_ctrl(ch: &str) -> KeyEvent {
1451        KeyEvent {
1452            key: Key::Character(ch.into()),
1453            state: KeyState::Pressed,
1454            text: Some(ch.to_string()),
1455            modifiers: Modifiers { ctrl: true, ..Default::default() },
1456            repeat: false,
1457        }
1458    }
1459
1460    #[test]
1461    fn copiar_y_pegar_por_clipboard() {
1462        // Ctrl+A + Ctrl+C copia; Ctrl+V al final pega. `apply_key` (sin
1463        // clipboard) NO copiaría — por eso el hueco que cierra F1.
1464        let mut s = TextInputState::new();
1465        s.set_text("hola");
1466        let mut clip = MemClipboard::new();
1467        s.select_all();
1468        assert!(s.apply_key_with_clipboard(&key_ctrl("c"), &mut clip));
1469        assert_eq!(clip.get().as_deref(), Some("hola"));
1470        // Caret al final (colapsa la selección a la derecha) y pega.
1471        let end = key_press(Key::Named(NamedKey::End), None);
1472        s.apply_key(&end);
1473        assert!(s.apply_key_with_clipboard(&key_ctrl("v"), &mut clip));
1474        assert_eq!(s.text(), "holahola");
1475    }
1476
1477    #[test]
1478    fn apply_key_sin_clipboard_no_pega() {
1479        // Contraste: el camino viejo (NullClipboard) deja Ctrl+V sin efecto.
1480        let mut s = TextInputState::new();
1481        s.set_text("hola");
1482        s.apply_key(&key_press(Key::Named(NamedKey::End), None));
1483        s.apply_key(&key_ctrl("v")); // NullClipboard.get() = None
1484        assert_eq!(s.text(), "hola");
1485    }
1486
1487    #[test]
1488    fn ime_compone_acento() {
1489        // Dead key: `´` en preedit y luego commit `á`.
1490        use llimphi_compositor::ImeEvent;
1491        let mut s = TextInputState::new();
1492        s.apply_ime(&ImeEvent::Preedit { text: "´".into(), cursor: None });
1493        assert!(s.apply_ime(&ImeEvent::Commit("á".into())));
1494        assert_eq!(s.text(), "á");
1495    }
1496
1497    #[test]
1498    fn handle_press_coloca_caret_y_drag_selecciona() {
1499        // El camino frictionless: Press ancla, Drag extiende — sin un Msg por
1500        // gesto. "hola mundo": press dentro de "hola" (col ~2), arrastre a la
1501        // derecha selecciona hacia "mundo".
1502        let mut s = TextInputState::new();
1503        s.set_text("hola mundo");
1504        let mut clip = NullClipboard;
1505        // Press cerca del inicio (col 0-ish).
1506        let x0 = 11.0 + 0.0 * 6.5;
1507        s.handle(TextInputEvent::Press(x0), &mut clip);
1508        assert!(!s.editor().has_selection());
1509        // Arrastre: primer Move ancla en x0, luego +delta hasta col ~10.
1510        s.handle(TextInputEvent::Drag { anchor_x: x0, dx: 10.0 * 6.5 }, &mut clip);
1511        assert!(s.editor().has_selection(), "el arrastre debe seleccionar");
1512        s.handle(TextInputEvent::DragEnd, &mut clip);
1513    }
1514
1515    #[test]
1516    fn hit_testing_usa_glifos_reales_no_ancho_fijo() {
1517        // La prueba de que el fix es real: con glifos de ancho MUY distinto
1518        // ("iii" angostas, "WWW" anchas), el borde entre ambos grupos NO cae
1519        // donde un ancho monoespaciado fijo lo pondría. `col_from_x` en el x del
1520        // borde real (medido con la fuente) debe devolver la columna 3 exacta.
1521        let display = "iiiWWW";
1522        // x del borde real entre la 3ª y 4ª columna (fin de "iii"), + el PAD.
1523        let borde = PAD_X + prefix_width(display, 3);
1524        assert_eq!(
1525            col_from_x(display, borde),
1526            3,
1527            "el click en el borde real de 'iii|WWW' debe dar columna 3"
1528        );
1529        // Y el viejo cálculo (ancho fijo = 13*0.5 = 6.5 px/char) habría dado otra
1530        // columna en ese mismo x — evidencia de que ahora medimos de verdad.
1531        let col_viejo = (((borde - PAD_X) / 6.5).round()) as usize;
1532        assert_ne!(
1533            col_viejo, 3,
1534            "el ancho fijo erraba la columna en ese x (por eso 'la selección agarraba mal')"
1535        );
1536        // Click bien dentro de las anchas 'W' cae en una columna > 3.
1537        let dentro_w = PAD_X + prefix_width(display, 5);
1538        assert!(col_from_x(display, dentro_w) >= 4);
1539    }
1540
1541    /// El escaneo carácter por carácter que tenía `col_from_x_pad_m` antes de la
1542    /// búsqueda binaria. Vive acá, en los tests, como **referencia**: lo que se
1543    /// prueba es que el camino rápido devuelve exactamente lo mismo.
1544    fn col_por_escaneo(display: &str, local_x: f32) -> usize {
1545        let m = Metricas::default();
1546        let target = (local_x - PAD_X).max(0.0);
1547        let n = display.chars().count();
1548        if n == 0 || target <= 0.0 {
1549            return 0;
1550        }
1551        let mut prev_w = 0.0_f32;
1552        for col in 1..=n {
1553            let w = prefix_width_m(display, col, &m);
1554            if target < w {
1555                let mid = (prev_w + w) * 0.5;
1556                return if target < mid { col - 1 } else { col };
1557            }
1558            prev_w = w;
1559        }
1560        n
1561    }
1562
1563    #[test]
1564    fn la_busqueda_binaria_del_click_da_lo_mismo_que_el_escaneo() {
1565        // Texto de anchos MUY dispares (i angosta, W ancha, espacios) para que
1566        // un error de borde se note: si la binaria y el escaneo difirieran en
1567        // un carácter, el caret caería al lado de donde se clickeó.
1568        let display = "iWiW mmm iii WWW lolo iWiW mmm iii WWW lolo";
1569        let total = prefix_width(display, display.chars().count());
1570        // Barrido denso de x, incluidos el 0, los bordes exactos de cada glifo
1571        // y un punto pasado el final del texto.
1572        let mut xs: Vec<f32> = (0..=200).map(|k| total * k as f32 / 200.0).collect();
1573        for col in 0..=display.chars().count() {
1574            xs.push(prefix_width(display, col));
1575        }
1576        xs.push(total + 50.0);
1577        for x in xs {
1578            let local_x = PAD_X + x;
1579            assert_eq!(
1580                col_from_x(display, local_x),
1581                col_por_escaneo(display, local_x),
1582                "x = {x:.2} px da distinta columna que el escaneo carácter a carácter"
1583            );
1584        }
1585    }
1586
1587    #[test]
1588    fn click_en_una_linea_larga_no_es_cuadratico() {
1589        // Hermano del `envolver_mucho_texto_no_es_cuadratico` del text-area,
1590        // para el camino de UNA línea: ahí el texto no llega partido en
1591        // renglones, llega entero. Con el caret al final (como queda tras
1592        // pegar) el campo está desplazado y el click cae al fondo del texto, así
1593        // que el escaneo viejo los medía TODOS: 2.000 caracteres tardaban 4,5 s
1594        // en release — el mismo cuelgue de T11, en el otro camino.
1595        let medir = |chars: usize| {
1596            let texto = "cargo ".repeat(chars / 6);
1597            let mut s = TextInputState::new();
1598            s.fijar_ancho_caja(900.0);
1599            s.set_text(&texto);
1600            s.editor_mut().set_caret_at(0, texto.chars().count());
1601            // Fuera del cronómetro: la primera medición carga la fuente.
1602            let _ = s.col_at_x(430.0);
1603            let t0 = std::time::Instant::now();
1604            let col = s.col_at_x(430.0);
1605            assert!(col > 0, "el click debería caer dentro del texto");
1606            t0.elapsed().as_secs_f64()
1607        };
1608        let corto = medir(1_200);
1609        let largo = medir(2_400);
1610        let factor = largo / corto.max(1e-6);
1611        assert!(
1612            factor < 3.0,
1613            "duplicar el texto multiplicó el click por {factor:.1}× \
1614             ({:.0} ms → {:.0} ms): el mapeo x→columna volvió a ser cuadrático",
1615            corto * 1000.0,
1616            largo * 1000.0
1617        );
1618    }
1619
1620    #[test]
1621    fn scroll_horizontal_mantiene_el_caret_a_la_vista() {
1622        // El caret nunca debe quedar fuera de la caja cuando el texto desborda.
1623        let mut s = TextInputState::new();
1624        s.set_text("abcdefghijklmnopqrstuvwxyz0123456789");
1625        let display = s.display_text();
1626        let total = prefix_width(&display, display.chars().count());
1627        // Sin ancho pintado todavía → sin scroll (no sabemos el tamaño).
1628        assert_eq!(s.scroll_x(&display), 0.0);
1629        // Simulá el ancho de caja que escribiría el paint_over: caja angosta.
1630        let box_w = 80.0_f32;
1631        s.box_w.store(box_w.to_bits(), Ordering::Relaxed);
1632        let area = box_w - 2.0 * PAD_X;
1633        assert!(total > area, "el texto debe desbordar para ejercitar el scroll");
1634        // Caret al final → el scroll muestra el final; el caret cae dentro del área.
1635        s.apply_key(&key_press(Key::Named(NamedKey::End), None));
1636        let scroll = s.scroll_x(&display);
1637        let caret_visible = prefix_width(&display, s.editor().cursor.caret.col) - scroll;
1638        assert!(
1639            caret_visible >= 0.0 && caret_visible <= area + 1.0,
1640            "caret a la vista con el offset: {caret_visible} debe caer en [0, {area}]"
1641        );
1642        // Caret al inicio → sin scroll (se ve el principio del texto).
1643        s.apply_key(&key_press(Key::Named(NamedKey::Home), None));
1644        assert_eq!(s.scroll_x(&display), 0.0, "con el caret al inicio no hay scroll");
1645    }
1646
1647    #[test]
1648    fn decoracion_sale_de_los_tokens_del_theme() {
1649        // Themizable: el radio y la elevación de la paleta salen de los tokens
1650        // canónicos del theme, no de números mágicos hardcodeados.
1651        let pal = TextInputPalette::from_theme(&llimphi_theme::Theme::dark());
1652        assert_eq!(pal.radius, llimphi_theme::radius::MD as f32);
1653        assert_eq!(pal.elevation, llimphi_theme::elevation::E1);
1654        assert!(pal.relieve, "por defecto el input lleva relieve");
1655        // Un theme/consumidor flat lo apaga sin tocar el widget.
1656        let flat = TextInputPalette { relieve: false, radius: 0.0, ..pal };
1657        assert!(!flat.relieve);
1658        assert_eq!(flat.radius, 0.0);
1659    }
1660
1661    #[test]
1662    fn multiline_acepta_enter_y_single_no() {
1663        // `multiline()` comparte el MISMO motor que el single-line; sólo cambia
1664        // `single_line`. Enter inserta salto en multi, se ignora en single.
1665        let mut m = TextInputState::multiline();
1666        assert!(m.is_multiline());
1667        m.set_text("a");
1668        m.apply_key(&key_press(Key::Named(NamedKey::Enter), None));
1669        // insertar 'b' tras el salto
1670        m.apply_key(&key_press(Key::Character("b".into()), Some("b")));
1671        assert_eq!(m.text(), "a\nb");
1672        let mut s = TextInputState::new();
1673        assert!(!s.is_multiline());
1674        s.set_text("a");
1675        s.apply_key(&key_press(Key::Named(NamedKey::Enter), None));
1676        assert_eq!(s.text(), "a", "single-line ignora Enter");
1677    }
1678
1679    #[test]
1680    fn float_target_sube_con_foco_o_texto() {
1681        let mut s = TextInputState::new();
1682        // Vacío + sin foco → descansa dentro.
1683        assert_eq!(float_target(false, &s), 0.0);
1684        // Con foco (aunque vacío) → flota.
1685        assert_eq!(float_target(true, &s), 1.0);
1686        // Con texto (aunque sin foco) → flota.
1687        s.set_text("hola");
1688        assert_eq!(float_target(false, &s), 1.0);
1689    }
1690
1691    #[test]
1692    fn context_no_toca_el_estado() {
1693        // El click derecho es un evento de overlay: `handle` no cambia el texto
1694        // ni el caret (devuelve false); el host lo intercepta para abrir el menú.
1695        let mut s = TextInputState::new();
1696        s.set_text("hola");
1697        let caret_antes = s.editor().cursor.caret.col;
1698        let mut clip = NullClipboard;
1699        assert!(!s.handle(TextInputEvent::Context { x: 5.0, y: 9.0 }, &mut clip));
1700        assert_eq!(s.text(), "hola");
1701        assert_eq!(s.editor().cursor.caret.col, caret_antes);
1702        assert!(!s.editor().has_selection());
1703    }
1704
1705    #[test]
1706    fn context_se_emite_en_la_vista_full() {
1707        // La vista full registra el handler de click derecho; la de reposo no
1708        // (solo toma foco). Verificamos el wiring montando ambas.
1709        use llimphi_compositor::llimphi_layout::LayoutTree;
1710        use llimphi_compositor::mount;
1711        let mut st = TextInputState::new();
1712        st.set_text("hola");
1713        let pal = TextInputPalette::default();
1714        let mut lt = LayoutTree::new();
1715        let full = mount(
1716            &mut lt,
1717            text_input_view_full(&st, "", true, &pal, |ev| ev),
1718        );
1719        assert!(
1720            llimphi_compositor::has_right_click_handler(&full),
1721            "la vista full debe emitir el click derecho (menú de edición)"
1722        );
1723    }
1724
1725    #[test]
1726    fn default_es_single_line() {
1727        // Regresión: `TextInputState::default()` debe ser single-line (Enter
1728        // ignorado), no heredar el multilínea de `EditorState::default()`.
1729        let mut s = TextInputState::default();
1730        s.set_text("hola");
1731        let enter = key_press(Key::Named(NamedKey::Enter), None);
1732        assert!(!s.apply_key(&enter));
1733        assert_eq!(s.text(), "hola");
1734    }
1735}