gioser

tawasuyu/llimphi

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

Rust 124Markdown 82TOML 78Shell 12149 Sutichaykuna

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

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