tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
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}