tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8
widgets/text-input/src/lib.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
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}