//! `llimphi-widget-text-input` — input de texto single-line para Llimphi. //! //! Después del refactor 2026-05-25, [`TextInputState`] es un wrapper fino //! sobre [`llimphi_widget_text_editor::EditorState`] con //! `options.single_line = true` + un flag `masked` para passwords. La //! API pública (`new`, `masked`, `text`, `set_text`, `clear`, `apply_key`, //! `is_empty`, `push_str`, `pop`, `is_masked`) se mantiene salvo que //! `text()` ahora devuelve `String` (antes `&str`) — los callers que //! hacían `.text().trim().to_string()` siguen funcionando idénticos. //! //! Beneficios heredados del editor: selección con Shift+arrows, undo/ //! redo con Ctrl+Z/Y, salto de palabra con Ctrl+arrows, Home/End, //! Delete (además de Backspace). Tab/Enter siguen ignorados (single_line). //! //! `no_std` + alloc sin la feature `std` (default-on): con `wire` + `libm` //! el campo entero —y el text-area de [`area`]— entra a la jaula WASM. #![cfg_attr(not(any(feature = "std", test)), no_std)] #![forbid(unsafe_code)] extern crate alloc; use alloc::format; use alloc::string::{String, ToString}; use alloc::vec; #[cfg(feature = "std")] use core::cell::RefCell; use core::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use alloc::sync::Arc; use spin::Mutex; use llimphi_compositor::llimphi_layout::taffy::{ prelude::{auto, length, percent, Size, Style}, AlignItems, Position, Rect, }; use llimphi_compositor::peniko::Color; use llimphi_compositor::llimphi_text::Alignment; use llimphi_compositor::{Instant, DragPhase, ImeEvent, KeyEvent, View}; use llimphi_widget_text_editor::{EditorOptions, EditorState}; // Backends de clipboard re-exportados para que los callers puedan sostener // uno en su Model (`MemClipboard` intra-app; `NullClipboard` si no quieren // copiar/pegar) y pasarlo a [`TextInputState::handle`] / [`TextInputState:: // apply_key_with_clipboard`]. Para portapapeles del SO, el caller puentea su // propio backend (p. ej. `arboard`) implementando el trait `Clipboard`. pub use llimphi_widget_text_editor::{Clipboard, MemClipboard, NullClipboard}; /// Variante **multilínea** (text-area): mismo motor `EditorState`, hit-testing /// 2D real (renglón + columna con glifos medidos), caret y selección pintados /// a través de líneas. Reemplaza al viejo `llimphi-widget-text-area` de `String` /// plano. Ver [`area::text_area_view`] y [`area::TextAreaEvent`]. mod area; pub use area::{TextAreaEvent, AREA_LINE_H}; pub use area::text_area_view; /// Vista **rica** del text-area: la misma edición, más los adornos de /// presentación que hacían que una app se dibujara su propio input a mano /// (métrica mono/zoom, color por tramos, sugerencia fantasma, placeholder con /// ícono, caret con estela). Ver [`rico::text_area_view_rico`]. mod rico; pub use rico::{text_area_view_rico, Adornos, CaretEstilo, Placeholder}; /// Paleta del input. Defaults son una variante dark con borde tenue que /// se enciende al focar, equivalente conceptual al `nahual-theme` dark. #[derive(Debug, Clone, Copy)] pub struct TextInputPalette { pub bg: Color, pub bg_focus: Color, pub border: Color, pub border_focus: Color, pub fg_text: Color, pub fg_placeholder: Color, /// Color del caret (cursor de inserción) que se pinta cuando el input /// está focado. Default = `fg_text` (sigue al texto, como `caret-color: /// auto` en CSS). pub caret: Color, /// Fase del parpadeo del caret: `true` = visible, `false` = apagado. El /// caret sólo se pinta cuando el input está focado **y** esta fase está en /// `true`. Default `true` (visible sin parpadeo — un consumidor que quiera /// blink alterna este flag con un timer). Fase 7.1267. pub caret_visible: bool, /// Fondo del texto seleccionado (con Shift+flechas o doble/triple click). pub selection: Color, // --- Decoración (themizable: sale de los tokens del theme, no hardcodeada) --- /// Radio de las esquinas (px). Sale de `llimphi_theme::radius::*`. Un theme /// con inputs cuadrados lo baja a `XS`; uno redondeado, a `MD`/`LG`. pub radius: f32, /// Sombra de elevación `(alpha 0–255, blur px, dy px)` — de /// `llimphi_theme::elevation::*`. Sólo se pinta si [`Self::relieve`]. pub elevation: (u8, f64, f64), /// Si el input lleva **relieve** (sombra + sheen). `false` = plano. Es el /// interruptor de carácter: un theme flat lo apaga, uno con dimensión lo deja. pub relieve: bool, /// **Alto de la caja en px.** 34 es la medida de la casa (un campo de /// formulario); lo baja quien lo monte en un chrome más fino — el buscador del /// `rag-sidebar` vive en una fila de 40 y en una de 28. Antes era un literal /// adentro de `build_input`, y un campo que no entra en su fila no se recorta: /// desborda sobre lo de abajo. pub alto: f32, } impl Default for TextInputPalette { fn default() -> Self { Self::from_theme(&llimphi_theme::Theme::dark()) } } impl TextInputPalette { /// Construye la paleta desde un `Theme` semántico. pub fn from_theme(t: &llimphi_theme::Theme) -> Self { Self { bg: t.bg_input, bg_focus: t.bg_input_focus, border: t.border, border_focus: t.border_focus, fg_text: t.fg_text, fg_placeholder: t.fg_placeholder, caret: t.fg_text, caret_visible: true, selection: t.bg_selected, // Decoración desde los tokens canónicos del theme. radius: llimphi_theme::radius::MD as f32, elevation: llimphi_theme::elevation::E1, relieve: true, alto: 34.0, } } } /// **Métrica del texto**: tamaño, alto de renglón, familia y paddings. Existe /// porque el widget tenía estos cuatro valores clavados en constantes, y con /// ellos clavados no puede ser un input *monoespaciado* (una terminal, un prompt /// de shell) ni acompañar un **zoom de fuente**: el ajuste blando, el caret, la /// selección y el mapeo click→columna se calculan todos con esta métrica, así /// que o cambian los cuatro juntos o se desalinean entre sí. /// /// [`Metricas::default`] reproduce **exactamente** las constantes históricas, de /// modo que las vistas que no la mencionan se comportan igual que siempre. #[derive(Debug, Clone, PartialEq)] pub struct Metricas { /// Tamaño de fuente (px) del texto, el caret y las mediciones. pub font_size: f32, /// Alto (px) de cada renglón visual. Fijo a propósito (no derivado de las /// métricas internas de parley): el render pinta un nodo por renglón con /// esta altura, así el hit-testing `y → renglón` coincide con lo dibujado. pub line_h: f32, /// Familia CSS-like de la fuente. `None` = la sans de la UI. Para un input /// de terminal, `Some(llimphi_text::MONOSPACE.into())`. /// /// **No es cosmético:** sin familia, el camino de texto multicolor shapea /// con la sans aunque el resto del input sea mono, y las columnas se /// desalinean en silencio (la misma trampa que torcía las tablas). pub font_family: Option, /// Padding izquierdo del texto dentro del renglón (px). pub pad_x: f32, /// Padding derecho del bloque (px) — aire antes del borde. pub pad_r: f32, /// Padding superior/inferior del bloque de renglones (px). pub pad_y: f32, } impl Default for Metricas { fn default() -> Self { Self { font_size: FONT_SIZE, line_h: 19.0, font_family: None, pad_x: 8.0, pad_r: 6.0, pad_y: 5.0, } } } impl Metricas { /// Métrica **monoespaciada** a `font_size` px, con el alto de renglón en la /// proporción clásica de terminal (1.4×) y la familia mono embebida. pub fn mono(font_size: f32) -> Self { Self { font_size, line_h: redondear(font_size * 1.4), font_family: Some(llimphi_compositor::llimphi_text::MONOSPACE.to_string()), ..Self::default() } } /// La misma métrica escalada por un factor de zoom (clampeado a 0.5–3×). /// Escala tamaño y alto de renglón; los paddings quedan (son aire del marco, /// no del texto). pub fn con_zoom(mut self, zoom: f32) -> Self { let z = zoom.clamp(0.5, 3.0); self.font_size *= z; self.line_h = redondear(self.line_h * z); self } } /// Tamaño de fuente (px) con el que el widget renderiza el texto. El caret, la /// selección y el hit-testing del mouse miden con ESTE tamaño para coincidir al /// píxel — el render (`build_input`) y el `paint_over` usan el mismo valor. const FONT_SIZE: f32 = 13.0; /// Padding izquierdo del texto dentro del box: borde de 1 px + padding interno /// de 10 px. `col_from_x` lo resta antes de mapear a columna. const PAD_X: f32 = 11.0; #[cfg(feature = "std")] thread_local! { /// Typesetter dedicado a medir texto en el hilo de UI para el hit-testing /// del mouse (x→columna). `Typesetter::new` instala la MISMA fuente UI que /// usa el compositor, así el click cae exactamente donde se dibuja el glifo. /// El costo de instalar la fuente se paga una sola vez por hilo. static MEASURE_TS: RefCell = RefCell::new(llimphi_compositor::llimphi_text::Typesetter::new()); } /// Ancho (px) del prefijo de `text` hasta la columna `cols` (nº de chars), /// medido con la fuente real — el MISMO cálculo que hace el `paint_over` para /// posicionar el caret. `cols == 0` → 0.0. Es la fuente de verdad del mapeo /// x↔columna: sustituye el viejo `font_size * 0.5` (ancho monoespaciado falso /// sobre una fuente proporcional), que era la causa de que la selección /// "agarrara" texto equivocado y derivara al arrastrar. fn prefix_width(text: &str, cols: usize) -> f32 { prefix_width_m(text, cols, &Metricas::default()) } /// [`prefix_width`] con **métrica explícita** — mide con el tamaño y la familia /// que realmente se van a pintar. Es el que usa el camino multilínea/rico: medir /// mono con la métrica de la sans (o al revés) desplaza el caret y el ajuste /// blando respecto de los glifos dibujados. fn prefix_width_m(text: &str, cols: usize, m: &Metricas) -> f32 { if cols == 0 { return 0.0; } let prefix: String = text.chars().take(cols).collect(); if !prefix.ends_with(char::is_whitespace) { return medir_crudo(&prefix, m); } // parley —como CSS— le da ancho **cero** al whitespace que queda al final de // lo que mide. Medido: en mono de 13 px, `"ab"` y `"ab "` dan los dos 15.603. // // Para envolver eso es lo correcto (el espacio cuelga en el margen). Para // **posicionar** es un error de un carácter entero: el caret parado al // principio de una palabra se dibujaba antes del espacio que la precede, y // entonces Delete parecía borrar la letra equivocada — el caret estaba bien, // mentía el dibujo. Se mide con un ancla detrás y se le resta el ancla. const ANCLA: &str = "0"; medir_crudo(&format!("{prefix}{ANCLA}"), m) - medir_crudo(ANCLA, m) } /// Mide un texto tal cual lo shapea parley — con el **colapso del espacio /// final**, que es justo lo que quiere el ajuste blando: una línea que termina /// en un espacio no debe abrir un renglón por ese espacio (colgaría un renglón /// vacío al tipear en el borde). /// /// (Antes había un `prefix_width_colapsando` que envolvía esto midiendo desde /// el carácter 0. Lo usaba el ajuste blando del text-area, y era el origen del /// costo cuadrático de T11 — ver `area::seg_width`, que ahora mide el tramo.) #[cfg(feature = "std")] fn medir_crudo(prefix: &str, m: &Metricas) -> f32 { MEASURE_TS.with(|ts| { let mut ts = ts.borrow_mut(); let lp = ts.layout( prefix, m.font_size, None, Alignment::Start, 1.2, false, m.font_family.as_deref(), 400.0, false, false, 0.0, 0.0, ); llimphi_compositor::llimphi_text::measurement(&lp).width as f32 }) } /// Sin `std` no hay shaper de este lado (doctrina W4.1): `text_width` bajo /// `wire` es la estimación documentada en W4.6 — el caret y el click quedan /// aproximados; la medida buena la da el kernel vía el medidor de la jaula. /// Si la aproximación molesta, el camino es pasar el `Typesetter` /// (`text_width_with`), no inventar un global. #[cfg(not(feature = "std"))] fn medir_crudo(prefix: &str, m: &Metricas) -> f32 { llimphi_compositor::llimphi_text::text_width(prefix, m.font_size) } /// Mapea `local_x` (px desde el borde izquierdo del box) a la columna de /// carácter cuyo **borde real** cae más cerca del punto de inserción, midiendo /// los glifos con la fuente proporcional. `display` es el texto MOSTRADO (crudo, /// o `•` en masked) — se mide lo que se ve, así el click coincide con el caret /// dibujado. Escaneo lineal (los inputs son cortos): avanza hasta pasarse de /// `local_x` y elige, entre la columna previa y la actual, el borde más próximo /// (punto medio del glifo). Devuelve la columna del punto de inserción. fn col_from_x(display: &str, local_x: f32) -> usize { col_from_x_pad(display, local_x, PAD_X) } /// Como [`col_from_x`] pero con el padding izquierdo del texto explícito — lo /// usa el text-area (multilínea), cuyos renglones tienen su propio pad interno /// distinto del box single-line. Mide glifos reales por línea (misma fuente). fn col_from_x_pad(display: &str, local_x: f32, pad: f32) -> usize { col_from_x_pad_m(display, local_x, pad, &Metricas::default()) } /// [`col_from_x_pad`] con métrica explícita — ver [`prefix_width_m`]. fn col_from_x_pad_m(display: &str, local_x: f32, pad: f32, m: &Metricas) -> usize { let target = (local_x - pad).max(0.0); let n = display.chars().count(); if n == 0 || target <= 0.0 { return 0; } // `prefix_width_m(·, col)` es monótona no decreciente en `col`, así que // `target < w(col)` es un predicado monótono: falso hasta el primer borde // que se pasa del punto, verdadero desde ahí. Sale por **búsqueda binaria**, // en `log n` mediciones. // // El escaneo lineal de antes medía una vez por carácter, y cada medición // shapea el prefijo ENTERO desde el carácter 0: el mismo O(n²) de shaping // que colgaba el ajuste blando (T11 de COLA-SHUMA). Ahí el texto largo // llegaba partido en renglones cortos; acá, en el camino de **una sola // línea**, llega entero — y con el caret al final (como queda tras pegar) // el punto clickeado cae al fondo del texto, así que el escaneo los // recorría todos. El resultado es idéntico carácter a carácter. let mut lo = 1usize; let mut hi = n; let mut primero: Option = None; while lo <= hi { let mid = lo + (hi - lo) / 2; if target < prefix_width_m(display, mid, m) { primero = Some(mid); hi = mid - 1; // `mid >= lo >= 1`, no se pasa de vuelta } else { lo = mid + 1; } } let Some(col) = primero else { return n; // el punto cae más allá del último glifo }; // Entre la columna previa y la que se pasó, la del borde más cercano // (punto medio del glifo) — misma regla que el escaneo viejo. let prev_w = prefix_width_m(display, col - 1, m); let w = prefix_width_m(display, col, m); if target < (prev_w + w) * 0.5 { col - 1 } else { col } } /// Luminancia perceptual [0,1] de un color (para modular el relieve según el /// theme sin mirar su nombre, igual que el `llimphi-button`). fn luminance(c: Color) -> f32 { let [r, g, b, _] = c.components; 0.2126 * r + 0.7152 * g + 0.0722 * b } /// Interpolación lineal escalar. fn lerp(a: f32, b: f32, t: f32) -> f32 { a + (b - a) * t } /// Redondeo half-away-from-zero sin `std` — `f32::round` es un método de la /// stdlib, no una operación del lenguaje (misma razón que el `ceil_f32` del /// compositor; NUNCA `FloatFuncs` de kurbo, es una trampa de cfg). /// Ancho de `texto` medido con el `Typesetter` del pintado, a 13 px (el /// tamaño fijo del single-line). Con `std` shapea con parley (exacto); sin /// `std` delega en `text_width_with` — que bajo `wire` le pregunta al /// medidor de la jaula, o sea al kernel: también exacto. fn ancho_con_ts(ts: &mut llimphi_compositor::llimphi_text::Typesetter, texto: &str) -> f64 { #[cfg(feature = "std")] { let lp = ts.layout( texto, 13.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0, ); llimphi_compositor::llimphi_text::measurement(&lp).width as f64 } #[cfg(not(feature = "std"))] { llimphi_compositor::llimphi_text::text_width_with(ts, texto, 13.0) as f64 } } #[inline] fn redondear(x: f32) -> f32 { if x >= 0.0 { (x + 0.5) as i64 as f32 } else { (x - 0.5) as i64 as f32 } } /// Mezcla lineal `a`→`b` por `t∈[0,1]`, conservando el alpha de `a`. fn mix(a: Color, b: Color, t: f32) -> Color { let [ar, ag, ab, aa] = a.components; let [br, bg, bb, _] = b.components; Color { components: [ ar + (br - ar) * t, ag + (bg - ag) * t, ab + (bb - ab) * t, aa, ], ..a } } /// Estado del input. Wrappea un `EditorState` single-line. #[derive(Debug, Clone)] pub struct TextInputState { inner: EditorState, masked: bool, /// Ancho (px) de la caja tal como se pintó el último frame — lo escribe el /// `paint_over` y lo lee el render/hit-testing para el **scroll horizontal** /// (mantener el caret a la vista cuando el texto desborda). `Arc` /// (bits de un `f32`) para que el estado siga `Clone + Send + Sync` y el /// closure de pintado pueda escribirlo. 0 = todavía sin pintar (sin scroll). box_w: Arc, /// Estado del arrastre de selección con mouse. `drag_x` es la x acumulada /// (px desde el borde) del extremo vivo; `drag_active` marca si ya se /// ancló el otro extremo en el press. Ver [`Self::handle`]. drag_x: f32, /// Componente vertical acumulada del arrastre — sólo la usa el text-area /// (multilínea) para extender la selección en 2D. Ver [`area`]. drag_y: f32, drag_active: bool, /// Offset vertical (px, bits de `f32`) del scroll del text-area multilínea — /// lo escribe la vista (para mantener el caret a la vista cuando hay más /// renglones visuales que `rows`) y lo lee el hit-testing 2D del próximo /// frame. Sólo lo usa [`area`]; en single-line queda en 0. area_scroll_y: Arc, /// Columna "meta" en **px** (offset horizontal desde el inicio del renglón /// visual) que preserva la posición al navegar ↑/↓ por renglones visuales en /// el text-area — el análogo de `desired_col` pero en píxeles, así no deriva /// sobre fuente proporcional / wrap. `None` = recalcular del caret. La resetea /// cualquier movimiento no-vertical. Sólo lo usa [`area`]. area_goal_x: Option, /// La [`Metricas`] con la que la vista pintó el último frame. El /// hit-testing (`x → columna`, `y → renglón`) corre en el `update`, donde no /// hay vista a mano: sin esto mediría con la métrica default aunque el input /// se esté pintando en mono a 3× de zoom, y el click caería en otra letra. /// La escribe la vista, la lee el `update`. metricas: Arc>, /// Unix-ms de la última actividad del usuario (tecla o click), que la /// escribe el host con [`Self::marcar_actividad`]. El caret la usa para /// quedarse **sólido** mientras se escribe y recién después titilar, y para /// arrancar la estela. `0` = todavía sin actividad. actividad_ms: Arc, /// Posición animada del caret `(x, y)` en px del bloque de texto, más el /// unix-ms en que se muestreó — el estado de la **estela**. Lo escribe el /// pintor cada cuadro (persigue al caret real) y lo lee el cuadro siguiente. /// Empaquetado en atómicos para que el estado siga `Clone + Send + Sync`. caret_anim: Arc, } /// Posición animada del caret — el estado que hace posible la **estela**: sin /// recordar dónde estaba y cuándo, cada cuadro dibujaría el caret ya en su /// destino y no habría nada que arrastrar. #[derive(Debug)] struct CaretAnim { /// x actual (bits de `f32`), px desde el inicio del bloque de texto. x: AtomicU32, /// y actual (bits de `f32`), px desde el tope del bloque de texto. y: AtomicU32, /// Alto del caret (bits de `f32`) — se anima junto con la posición para que /// un cambio de zoom no lo haga saltar. h: AtomicU32, /// Unix-ms del último muestreo. `0` = sin muestrear (el primer cuadro se /// clava en el destino, sin estela de entrada). t_ms: AtomicU64, /// `true` mientras el rastro va en camino al caret. Distingue «arrancó un /// viaje» de «el caret se corrió un poco»: sin esto, el umbral de salto /// cortaría la estela a mitad de camino, justo cuando lo que falta ya es /// menos que el umbral. persiguiendo: AtomicBool, } impl Default for CaretAnim { fn default() -> Self { Self { x: AtomicU32::new(0), y: AtomicU32::new(0), h: AtomicU32::new(0), t_ms: AtomicU64::new(0), persiguiendo: AtomicBool::new(false), } } } impl Default for TextInputState { /// **Single-line** como [`Self::new`] — importa porque `EditorState:: /// default()` es multilínea; derivar `Default` dejaba el input aceptando /// `Enter`/saltos de línea. Un campo default es un campo vacío single-line. fn default() -> Self { Self::new() } } impl TextInputState { /// Input vacío visible (texto plano). pub fn new() -> Self { Self { inner: EditorState::with_options(EditorOptions { single_line: true, ..EditorOptions::default() }), masked: false, box_w: Arc::new(AtomicU32::new(0)), drag_x: 0.0, drag_y: 0.0, drag_active: false, area_scroll_y: Arc::new(AtomicU32::new(0)), area_goal_x: None, metricas: Arc::new(Mutex::new(Metricas::default())), actividad_ms: Arc::new(AtomicU64::new(0)), caret_anim: Arc::new(CaretAnim::default()), } } /// Estampa "el usuario acaba de tocar el input" en unix-ms. El host la llama /// en cada tecla/click aceptado; el caret la usa para quedarse **sólido** /// mientras se escribe y titilar recién después. /// /// Va por afuera de [`Self::handle`] a propósito: el widget no tiene reloj /// (ni debe tenerlo — sería un `SystemTime::now()` escondido dentro de una /// función pura y sin forma de testearlo), así que la hora la trae el host, /// que ya la tiene. pub fn marcar_actividad(&self, ahora_ms: u64) { self.actividad_ms.store(ahora_ms, Ordering::Relaxed); } /// Unix-ms de la última actividad (0 = ninguna todavía). pub fn actividad_ms(&self) -> u64 { self.actividad_ms.load(Ordering::Relaxed) } /// La [`Metricas`] con la que se pintó el último cuadro. El `update` la lee /// para hit-testear con la misma métrica que se dibujó. pub fn metricas(&self) -> Metricas { self.metricas.lock().clone() } /// Fija la métrica **antes** de pintar. La vista la publica sola en cada /// cuadro; esto es para el host que necesita hit-testear (o preguntar /// cuántos renglones ocupa el texto) *antes* del primer pintado — si no, el /// primer click de la sesión mediría con la métrica default. pub fn aplicar_metricas(&self, m: &Metricas) { self.set_metricas(m); } /// Inserta texto en el caret, reemplazando la selección viva si la hay, y /// registrándolo en el **undo**. Es el equivalente programático de tipear: /// lo usa el host para pegar, aplicar un autocompletado o insertar un salto /// de línea sin fabricar un `KeyEvent` falso. /// Ojo: insertar la cadena **vacía** es un no-op, NO borra la selección — /// para eso está [`Self::borrar_seleccion`]. Un host que quiera cortar tiene /// que pedir borrar explícitamente; `insertar("")` no alcanza. pub fn insertar(&mut self, s: &str) { if s.is_empty() { return; } let ed = &mut self.inner; let d = llimphi_widget_text_editor::ops::replace_selection(&mut ed.buffer, &mut ed.cursor, s); ed.undo.push(d); ed.bump_edit_seq(); } /// Borra la selección viva (una sola entrada de undo) y devuelve `true` si /// había algo que borrar. Es la mitad que le faltaba a «cortar»: copiar lo /// hace el host, borrar lo hace el motor. /// /// Existe porque `insertar("")` **no** borra: sale temprano con la cadena /// vacía. Quien cortaba con `insertar("")` copiaba y dejaba el texto puesto. pub fn borrar_seleccion(&mut self) -> bool { let ed = &mut self.inner; let (start, end) = ed.cursor.selection_range(&ed.buffer); if start == end { return false; } let d = llimphi_widget_text_editor::ops::replace_selection( &mut ed.buffer, &mut ed.cursor, "", ); ed.undo.push(d); ed.bump_edit_seq(); true } /// Guarda la métrica del cuadro (la llama la vista). fn set_metricas(&self, m: &Metricas) { let mut g = self.metricas.lock(); if *g != *m { *g = m.clone(); } } /// Input enmascarado — para campos de contraseña. pub fn masked() -> Self { Self { masked: true, ..Self::new() } } /// Input **multilínea** — acepta `Enter` como salto de línea (no "submit"). /// Es el estado que usa `llimphi-widget-text-area`: hereda del mismo motor /// [`EditorState`] toda la edición (selección, undo/redo, word-jump, Home/End, /// copiar/pegar, IME) — la misma `handle`/`apply_key` que el single-line, sin /// una implementación paralela. El caret/selección con mouse se posicionan en /// 2D con [`Self::place_caret_2d`] / [`Self::extend_sel_2d`]. pub fn multiline() -> Self { Self { inner: EditorState::with_options(EditorOptions { single_line: false, ..EditorOptions::default() }), ..Self::new() } } /// `true` si el estado acepta saltos de línea (creado con [`Self::multiline`]). pub fn is_multiline(&self) -> bool { !self.inner.options.single_line } /// Texto actual. Devuelve `String` (antes `&str` — el rope no expone /// slice borrowed sin clone). Para evitar copias innecesarias, los /// callers que sólo necesitan derivar `.trim()` o `.is_empty()` /// pueden hacerlo directo sobre el `String` devuelto. pub fn text(&self) -> String { self.inner.text() } pub fn is_empty(&self) -> bool { self.inner.is_empty() } /// Enciende o apaga el enmascarado sobre un estado ya existente. /// /// Existe para que un HOST genérico —uno que arma sus campos desde un /// esquema, como `allichay`— pueda garantizar el enmascarado en el momento /// de PINTAR, sin depender de que quien creó el estado se acordara. Un /// panel que se olvida de pedir `masked()` al enfocar deja la contraseña a /// la vista, y eso no se nota hasta que alguien mira la pantalla. pub fn set_masked(&mut self, masked: bool) { self.masked = masked; } pub fn is_masked(&self) -> bool { self.masked } pub fn clear(&mut self) { self.inner.set_text(""); } pub fn set_text(&mut self, s: impl Into) { let s = s.into(); self.inner.set_text(&s); } pub fn push_str(&mut self, s: &str) { let combined = format!("{}{}", self.inner.text(), s); self.inner.set_text(&combined); } pub fn pop(&mut self) -> Option { let mut t = self.inner.text(); let ch = t.pop()?; self.inner.set_text(&t); Some(ch) } /// Aplica una tecla al estado. Devuelve `true` si cambió el contenido /// **o** sólo se movió el cursor (cualquier cosa que requiera repintar). pub fn apply_key(&mut self, event: &KeyEvent) -> bool { self.inner.apply_key(event).touched() } /// Acceso de bajo nivel al editor interno — útil si el caller /// quiere consultar cursor/selección o aplicar ops avanzadas. pub fn editor(&self) -> &EditorState { &self.inner } pub fn editor_mut(&mut self) -> &mut EditorState { &mut self.inner } /// El texto tal como se MUESTRA: `•` por carácter en masked, o el crudo. Es /// lo que se mide para el hit-testing del mouse (así el click coincide con lo /// que se ve). Vacío ⇒ cadena vacía (no se hit-testea el placeholder). fn display_text(&self) -> String { let raw = self.inner.text(); if self.masked { "\u{2022}".repeat(raw.chars().count()) } else { raw } } /// Offset horizontal (px) del texto para mantener el **caret a la vista** /// cuando el contenido desborda la caja. `0` si el texto entra o si la caja /// aún no se pintó (no sabemos su ancho). Stateless: lo deriva del ancho del /// último frame ([`Self::box_w`]) + la posición real del caret. Lo comparten /// el render (traslada el nodo de texto), el `paint_over` (caret/selección) y /// el hit-testing (x↔columna) para que todo quede alineado. fn scroll_x(&self, display: &str) -> f32 { let box_w = f32::from_bits(self.box_w.load(Ordering::Relaxed)); if box_w <= 1.0 { return 0.0; } let area = box_w - 2.0 * PAD_X; let total = prefix_width(display, display.chars().count()); if area <= 1.0 || total <= area { return 0.0; } let n = display.chars().count(); let caret_x = prefix_width(display, self.inner.cursor.caret.col.min(n)); const MARGIN: f32 = 8.0; (caret_x - area + MARGIN).clamp(0.0, total - area) } /// Registra un click del mouse a `local_x` px del borde izquierdo del box /// (coords del nodo, incluido el borde de 1 px + el padding de 10 px). /// Mapea x → columna midiendo los **glifos reales** ([`col_from_x`]) y delega /// en [`EditorState::register_click`], que escala **1 click = caret**, /// **2 = palabra**, **3 = todo** por ventana de tiempo. El parámetro /// `_font_size` quedó por compatibilidad de firma: el render es a /// [`FONT_SIZE`] fijo y la medición usa la fuente real, no un ancho fijo. #[cfg(feature = "std")] pub fn pointer_click(&mut self, local_x: f32, _font_size: f32) { self.pointer_click_at(local_x, _font_size, Instant::now()); } /// Como [`Self::pointer_click`] pero con el instante inyectado — el perfil /// sin `std` (la jaula) no tiene reloj propio; entra el del fotograma. pub fn pointer_click_at(&mut self, local_x: f32, _font_size: f32, now: Instant) { let col = col_from_x(&self.display_text(), local_x); // Single-line ⇒ línea 0; register_click clampa la columna al texto. self.inner.register_click_at(0, col, now); } /// Selecciona todo el texto (equivalente a triple-click / Ctrl+A). pub fn select_all(&mut self) { self.inner.select_all(); } /// Mapea `local_x` (px desde el borde izquierdo del box) a columna de /// carácter midiendo los **glifos reales** de la fuente ([`col_from_x`]) — no /// un ancho fijo. Suma el [`Self::scroll_x`] para que el click acierte aun /// con el texto desplazado (caret a la vista). Coincide con la posición donde /// el `paint_over` dibuja el caret. Usado por el press y el arrastre. fn col_at_x(&self, local_x: f32) -> usize { let display = self.display_text(); col_from_x(&display, local_x + self.scroll_x(&display)) } /// Aplica una tecla con **clipboard activo**: Ctrl+C copia la selección, /// Ctrl+X la corta, Ctrl+V pega. El resto es idéntico a [`Self::apply_key`] /// (que pasa un `NullClipboard`, por eso ahí copiar/pegar no hacen nada). /// Devuelve `true` si hay que repintar. El caller sostiene el `Clipboard` /// (típico: un [`MemClipboard`] en su Model, o un puente al portapapeles /// del SO). pub fn apply_key_with_clipboard( &mut self, event: &KeyEvent, clipboard: &mut dyn Clipboard, ) -> bool { self.inner.apply_key_with_clipboard(event, clipboard).touched() } /// Aplica un evento de IME — el camino de los acentos compuestos (dead /// keys: `´` + `a` → `á`), CJK y el emoji picker cuando la app habilita /// `App::ime_allowed`. Devuelve `true` si cambió algo visible. pub fn apply_ime(&mut self, event: &ImeEvent) -> bool { self.inner.apply_ime_event(event).touched() } /// Extiende la selección hasta `local_x` (px desde el borde) — el extremo /// vivo del arrastre. El otro extremo quedó anclado por el press. Pensado /// para el arrastre con mouse; ver [`Self::handle`] con /// [`TextInputEvent::Drag`], que lo maneja acumulando el delta. pub fn pointer_drag(&mut self, local_x: f32) { let col = self.col_at_x(local_x); self.inner.extend_selection_to(0, col); } /// Procesa un [`TextInputEvent`] (teclado, IME o mouse) y devuelve `true` /// si hay que repintar. Es el **camino sin fricción**: el caller envuelve /// este enum en su propio `Msg` y en `update` hace /// `state.handle(ev, &mut clipboard)` — un solo `Msg` cubre escribir, /// mover, seleccionar, copiar/pegar, acentos, click, doble/triple-click y /// arrastre. Combinar con [`text_input_view_full`], que emite los eventos /// de mouse ya envueltos. #[cfg(feature = "std")] pub fn handle(&mut self, ev: TextInputEvent, clipboard: &mut dyn Clipboard) -> bool { self.handle_at(ev, clipboard, Instant::now()) } /// Como [`Self::handle`] pero con el instante inyectado (la ventana del /// doble/triple click se mide contra él) — el perfil sin `std` no tiene /// reloj propio; el runtime de la jaula pasa el del fotograma. pub fn handle_at(&mut self, ev: TextInputEvent, clipboard: &mut dyn Clipboard, now: Instant) -> bool { match ev { TextInputEvent::Key(e) => self.apply_key_with_clipboard(&e, clipboard), TextInputEvent::Ime(e) => self.apply_ime(&e), TextInputEvent::Press(x) => { // Click plano: coloca el caret y escala a palabra/todo por la // ventana de tiempo de `register_click` (2 = palabra, 3 = todo). self.drag_active = false; self.drag_x = x; self.inner.register_click_at(0, self.col_at_x(x), now); true } TextInputEvent::Drag { anchor_x, dx } => { // Primer `Move` del arrastre: ancla el caret en la posición del // press (`anchor_x`) antes de extender. Los siguientes acumulan // el delta sobre `drag_x` (el runtime da deltas, no absolutos). if !self.drag_active { self.inner.set_caret_at(0, self.col_at_x(anchor_x)); self.drag_x = anchor_x; self.drag_active = true; } self.drag_x += dx; self.pointer_drag(self.drag_x); true } TextInputEvent::DragEnd => { self.drag_active = false; false } // Click derecho: no toca el estado del input. El host lo intercepta // (antes de llamar a `handle`) para abrir el menú de edición en la // posición. Si llega hasta aquí, es no-op — nada que repintar. TextInputEvent::Context { .. } => false, } } } /// Evento de entrada dirigido a un text-input: teclado, IME o mouse. El caller /// lo envuelve en su propio `Msg` (típicamente `Msg::Field(id, TextInputEvent)`) /// y lo reenvía a [`TextInputState::handle`] — así **un campo se cablea con un /// solo `Msg`** en vez de uno por gesto. Los eventos de mouse ([`Self::Press`] /// y [`Self::Drag`]/[`Self::DragEnd`]) los produce [`text_input_view_full`]; los /// de teclado/IME los inyecta el handler global de la app. /// Deriva `Debug` (sus campos `KeyEvent`/`ImeEvent`/`f32` lo son) para que los /// `enum Msg` de las apps que lo envuelvan puedan seguir derivando `Debug`. #[derive(Debug, Clone)] pub enum TextInputEvent { /// Tecla (con modificadores). Ctrl+C/X/V operan sobre el clipboard pasado. Key(KeyEvent), /// Evento de IME (acentos compuestos, CJK, emoji). Ime(ImeEvent), /// Mouse-down a `local_x` px del borde: coloca el caret y escala a /// palabra/todo por doble/triple-click. Press(f32), /// Arrastre: `anchor_x` = posición del press (px), `dx` = delta desde el /// evento anterior. Extiende la selección. Drag { anchor_x: f32, dx: f32 }, /// Fin del arrastre (soltar el botón). DragEnd, /// Click **derecho** a `(x, y)` (coords **absolutas de ventana**, vía /// `on_right_click_screen`): pide abrir el menú de edición /// (cortar/copiar/pegar/…) anclado al puntero. El estado del input no cambia — es un /// evento de *overlay* que el host resuelve montando /// [`llimphi_widget_edit_menu`] en su `view_overlay` con `(x, y)` de ancla. /// Por eso [`TextInputState::handle`] lo trata como no-op (devuelve `false`): /// el host lo intercepta antes de delegar en `handle`. Context { x: f32, y: f32 }, } /// Compone el input box: borde de 1 px (rect padre coloreado), relleno /// interno, texto o placeholder, y el caret (cursor de inserción) sobre el /// texto si está focado. Caret v3 (Fase 7.1255): cuando está focado la hoja /// pinta texto+caret en un `paint_over` con **scroll horizontal** — el texto /// se desplaza para mantener el caret a la vista cuando desborda la caja, y se /// recorta al área de contenido. Sin foco usa un nodo-hijo de texto (sin caret). /// Click sobre el box emite `on_focus` (típicamente `Msg::Focus(Field)`). pub fn text_input_view( state: &TextInputState, placeholder: &str, focused: bool, palette: &TextInputPalette, on_focus: Msg, ) -> View { build_input(state, placeholder, focused, palette, ClickHandler::Focus(on_focus), true) } /// Cómo responde el box al click. enum ClickHandler { /// Click plano → un solo `Msg` (toma foco). Sin selección por mouse: es el /// render en **reposo** (campo sin foco). Para el campo focado se usa /// [`ClickHandler::Full`], que da caret/selección/arrastre. Focus(Msg), /// Camino completo: el box emite [`TextInputEvent`]s de mouse /// (`Press`/`Drag`/`DragEnd`) ya envueltos por la closure. Habilita /// click→caret, doble/triple-click→palabra/todo y **arrastre→selección** /// sin que el caller cablee cada gesto. Ver [`text_input_view_full`]. Full(Arc Msg + Send + Sync>), } /// Input con **paridad de comportamiento** tipo `TextField`: el box emite /// [`TextInputEvent`]s de mouse (click→caret, doble/triple-click→palabra/todo, /// **arrastre→selección**) ya envueltos por `wrap`, y el caller sólo hace /// `state.handle(ev, &mut clipboard)` en `update`. Combinado con reenviar /// `TextInputEvent::Key`/`Ime` desde el handler global, un campo queda con /// selección, copiar/pegar, undo/redo, salto de palabra y acentos — todo con /// **un solo `Msg`** (`Msg::Field(id, TextInputEvent)`), sin un mensaje por /// gesto. Es la vista de un campo **focado** (editable con paridad TextField); /// para el render en reposo (sin foco, click→foco) está [`text_input_view`]. pub fn text_input_view_full( state: &TextInputState, placeholder: &str, focused: bool, palette: &TextInputPalette, wrap: F, ) -> View where Msg: Clone + 'static, F: Fn(TextInputEvent) -> Msg + Send + Sync + 'static, { build_input(state, placeholder, focused, palette, ClickHandler::Full(Arc::new(wrap)), true) } /// Campo de texto **con label flotante** — el look característico de Material / /// Flutter. El `label` descansa dentro del campo (como un placeholder) cuando /// está vacío y sin foco, y **flota hacia arriba** (más chico, en el color de /// foco) al focar o cuando hay texto. `float_t ∈ [0,1]` controla la posición: /// `0` = descansando, `1` = flotado; el host puede animarlo con un tween (o /// pasar `if focused || !state.is_empty() { 1.0 } else { 0.0 }` para el salto /// sin animar — [`float_target`] lo calcula). El campo en sí es el mismo /// [`text_input_view_full`] (scroll, selección, menú, relieve), sin placeholder /// (el label lo reemplaza). `wrap` envuelve los [`TextInputEvent`] como siempre. pub fn text_input_field_view( state: &TextInputState, label: &str, focused: bool, float_t: f32, palette: &TextInputPalette, wrap: F, ) -> View where Msg: Clone + 'static, F: Fn(TextInputEvent) -> Msg + Send + Sync + 'static, { let t = float_t.clamp(0.0, 1.0); // Geometría (px). `LABEL_SPACE` es el aire arriba donde vive el label flotado // (sobre la línea del borde superior del campo, que empieza en `LABEL_SPACE`). const LABEL_SPACE: f32 = 14.0; const FIELD_H: f32 = 34.0; const LABEL_LEFT: f32 = 12.0; const GAP_PAD: f32 = 4.0; // aire a los lados del label dentro de la muesca // El campo, SIN borde propio (lo dibuja el notched outline de abajo) y sin // placeholder (el label ocupa ese rol). let field = build_input(state, "", focused, palette, ClickHandler::Full(Arc::new(wrap)), false); // Label: interpola tamaño, color y posición vertical entre reposo (dentro, // centrado en el campo) y flotado (sobre la línea del borde superior). let size = lerp(13.0, 11.0, t); let color = mix(palette.fg_placeholder, palette.border_focus, t); let resting_y = LABEL_SPACE + (FIELD_H - 13.0) * 0.5; // centro vertical del campo let floating_y = LABEL_SPACE - size * 0.62; // centrado sobre la línea del borde let y = lerp(resting_y, floating_y, t); let label_node = View::new(Style { position: Position::Absolute, inset: Rect { left: length(LABEL_LEFT), top: length(y), right: auto(), bottom: auto(), }, ..Default::default() }) .text_aligned(label.to_string(), size, color, Alignment::Start); // Notched outline: borde del campo dibujado como TRAZO, con una muesca en el // borde superior por donde asoma el label. El hueco crece con `t` y su ancho // se ajusta al ancho medido del label (a su tamaño flotado). Va en un // `paint_over` del contenedor (sobre el campo, bajo el label que queda en el // hueco). Espeja el `OutlineInputBorder` de Material. let (bcol, bw) = if focused { (palette.border_focus, 2.0_f32) } else { (palette.border, 1.0_f32) }; let lw_float = prefix_width(label, label.chars().count()) * (11.0 / 13.0); let radius_f = palette.radius as f64; // themizable // Contenedor: reserva el espacio del label arriba, campo debajo, label absoluto // encima. El borde (trazo con muesca) se pinta al final con `paint_over`. View::new(Style { size: Size { width: percent(1.0_f32), height: length(LABEL_SPACE + FIELD_H) }, padding: Rect { left: length(0.0_f32), right: length(0.0_f32), top: length(LABEL_SPACE), bottom: length(0.0_f32), }, ..Default::default() }) .children(vec![field, label_node]) .paint_over(move |scene, _ts, rect| { use llimphi_compositor::peniko::kurbo::{Affine, BezPath, Stroke}; if rect.w <= 0.0 { return; } let r = radius_f; let hw = bw as f64 * 0.5; let ox = rect.x as f64; let oy = rect.y as f64; let x0 = ox + hw; let x1 = ox + rect.w as f64 - hw; let y0 = oy + LABEL_SPACE as f64 + hw; // línea del borde superior let y1 = oy + (LABEL_SPACE + FIELD_H) as f64 - hw; // Muesca: centrada en el label; de un punto (t=0) al hueco completo (t=1). let g0_full = ox + (LABEL_LEFT - GAP_PAD) as f64; let g1_full = ox + (LABEL_LEFT + GAP_PAD) as f64 + lw_float as f64; let center = (g0_full + g1_full) * 0.5; let nx0 = (center - (center - g0_full) * t as f64).clamp(x0 + r, x1 - r); let nx1 = (center + (g1_full - center) * t as f64).clamp(x0 + r, x1 - r); // Trazo abierto en sentido horario, arrancando a la DERECHA de la muesca // y terminando a su IZQUIERDA → deja el hueco en el borde superior. let mut p = BezPath::new(); p.move_to((nx1, y0)); p.line_to((x1 - r, y0)); p.quad_to((x1, y0), (x1, y0 + r)); // esquina sup-der p.line_to((x1, y1 - r)); p.quad_to((x1, y1), (x1 - r, y1)); // inf-der p.line_to((x0 + r, y1)); p.quad_to((x0, y1), (x0, y1 - r)); // inf-izq p.line_to((x0, y0 + r)); p.quad_to((x0, y0), (x0 + r, y0)); // sup-izq p.line_to((nx0, y0)); // borde superior hasta el inicio de la muesca scene.stroke(&Stroke::new(bw as f64), Affine::IDENTITY, bcol, None, &p); }) } /// El `float_t` objetivo (sin animar) de un [`text_input_field_view`]: `1.0` si /// el campo está focado o tiene texto (el label flota), `0.0` si no (descansa /// adentro). Un host que quiera animar interpola hacia este valor con un tween. pub fn float_target(focused: bool, state: &TextInputState) -> f32 { if focused || !state.is_empty() { 1.0 } else { 0.0 } } fn build_input( state: &TextInputState, placeholder: &str, focused: bool, palette: &TextInputPalette, click: ClickHandler, // `false` = el campo NO dibuja su borde macizo (el ring queda del color del // fondo): lo usa [`text_input_field_view`], que pinta un **borde con muesca** // (notched outline de Material) alrededor, con hueco para el label flotado. bordered: bool, ) -> View { let raw = state.text(); let is_empty = raw.is_empty(); let shown = if is_empty { placeholder.to_string() } else if state.masked { "•".repeat(raw.chars().count()) } else { raw }; let display = shown; // Prefijo del texto visible hasta el caret (cursor de inserción), para // medir su ancho y posicionar la barra del caret. La columna es índice de // carácter (single-line ⇒ `line == 0`); `take(col)` sobre el texto MOSTRADO // (placeholder/`•`/crudo) alinea el caret con lo que se ve. Cuando el input // está vacío el `col` es 0 ⇒ prefijo vacío ⇒ caret al inicio (no se mide el // placeholder). let caret_prefix: String = if focused { display.chars().take(state.editor().cursor.caret.col).collect() } else { String::new() }; // Rango de selección en columnas (= char offsets, single-line). `None` si // no hay selección o el input no está focado. Se captura por valor para el // closure de pintado (no puede tomar prestado `state`). let sel_cols: Option<(usize, usize)> = if focused { let ed = state.editor(); let (s, e) = ed.cursor.selection_range(&ed.buffer); (s != e).then_some((s, e)) } else { None }; let sel_color = palette.selection; let text_color = if is_empty { palette.fg_placeholder } else { palette.fg_text }; let (bg, border) = if focused { (palette.bg_focus, palette.border_focus) } else { (palette.bg, palette.border) }; // Decoración THEMIZABLE (de la paleta, que sale de los tokens del theme). let radius = palette.radius as f64; let relieve = palette.relieve; // Scroll horizontal: cuánto correr el texto a la izquierda para que el caret // quede a la vista cuando desborda. Sólo focado (sin foco no hay caret que // seguir; el texto va desde el inicio). `box_w` la escribe el `paint_over`. let scroll = if focused { state.scroll_x(&display) } else { 0.0 }; let inner = View::new(Style { size: Size { width: percent(1.0_f32), height: percent(1.0_f32), }, padding: Rect { left: length(10.0_f32), right: length(10.0_f32), top: length(0.0_f32), bottom: length(0.0_f32), }, align_items: Some(AlignItems::CENTER), ..Default::default() }) .fill(bg) .radius(radius - 1.0) // Recorta el texto desplazado para que no se derrame fuera de la caja. .clip(true); // Sheen: gradiente vertical translúcido que le da dimensión al fondo (luz // arriba → neutro → sombra abajo), derivado de la luminancia del fondo para // leer igual en themes claros y oscuros. Va en `paint_with` (entre el fill y // el texto): no re-pinta texto (no reintroduce el "bold") y respeta el color. // Sólo si el theme pide relieve (un theme flat lo apaga). let inner = if !relieve { inner } else { let dark = 1.0 - luminance(bg); let radius_in = radius - 1.0; let top_a = (0.06 + 0.10 * dark).min(0.18); inner.paint_with(move |scene, _ts, rect| { use llimphi_compositor::peniko::kurbo::{Affine, Point, RoundedRect}; use llimphi_compositor::peniko::{Color as PColor, Fill, Gradient}; if rect.w <= 0.0 || rect.h <= 0.0 { return; } let x0 = rect.x as f64; let y0 = rect.y as f64; let x1 = (rect.x + rect.w) as f64; let y1 = (rect.y + rect.h) as f64; let rr = RoundedRect::new(x0, y0, x1, y1, radius_in); let top = PColor { components: [1.0, 1.0, 1.0, top_a], ..PColor::WHITE }; let midc = PColor::from_rgba8(255, 255, 255, 0); let bot = PColor::from_rgba8(0, 0, 0, 20); let sheen = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y1)) .with_stops([top, midc, bot].as_slice()); scene.fill(Fill::NonZero, Affine::IDENTITY, &sheen, None, &rr); }) }; // El TEXTO va SIEMPRE como nodo hijo (mismo render del compositor, peso normal), // focado o no. Antes el camino focado lo re-pintaba con `draw_layout` en un // `paint_over` (dentro de un `push_layer`), y eso lo dejaba **más pesado/ilegible // al escribir** (la fuente del bundle es de un solo peso: no es `FontWeight`, es // el re-pintado en la capa). Ahora el `paint_over` sólo dibuja el **caret** y el // **resaltado de selección** (translúcido, encima del texto) — nunca el texto. // (Trade-off: se perdió el scroll horizontal del caret en campos que desbordan; // follow-up. Para campos cortos —lo típico en config— no hace falta.) let mut texto = View::new(Style { size: Size { width: percent(1.0_f32), height: auto() }, ..Default::default() }) .text_aligned(display.clone(), 13.0, text_color, Alignment::Start); // Traslada el texto por el scroll (mismo render del compositor, sin re-pintar // en capa → sin el bug del "bold"). El caret/selección abajo restan el mismo // offset, así todo queda alineado. if scroll > 0.0 { use llimphi_compositor::peniko::kurbo::{Affine, Vec2}; texto = texto.transform(Affine::translate(Vec2::new(-scroll as f64, 0.0))); } let base = inner.children(vec![texto]); let inner = if focused { let caret_color = palette.caret; let caret_visible = palette.caret_visible; // Selección translúcida (deja ver el texto que está debajo). let sel_translucida = Color { components: [sel_color.components[0], sel_color.components[1], sel_color.components[2], 0.4], ..sel_color }; let display_c = display; let caret_prefix_c = caret_prefix; let sel_cols_c = sel_cols; let scroll_c = scroll as f64; let box_w_cell = state.box_w.clone(); base.paint_over(move |scene, ts, rect| { use llimphi_compositor::peniko::kurbo::{Affine, Rect as KRect}; use llimphi_compositor::peniko::Fill; // Guarda el ancho pintado para el scroll del próximo frame. box_w_cell.store((rect.w).to_bits(), Ordering::Relaxed); let pad = 10.0_f64; let cx0 = rect.x as f64 + pad - scroll_c; // origen del texto, corrido por el scroll let x_min = rect.x as f64 + pad; // borde visible izq del área de texto let x_max = rect.x as f64 + rect.w as f64 - pad; // borde visible der let th = 16.0_f64; // alto aprox del resaltado/caret let oy = rect.y as f64 + (rect.h as f64 - th) * 0.5; // Ancho del prefijo hasta el caret = x del caret dentro del texto. let caret_w = if caret_prefix_c.is_empty() { 0.0 } else { ancho_con_ts(ts, &caret_prefix_c) }; // Selección: rect translúcido encima del texto, recortado al área visible. if let Some((s, e)) = sel_cols_c { let mut prefix_w = |n: usize| -> f64 { if n == 0 { return 0.0; } let p: String = display_c.chars().take(n).collect(); ancho_con_ts(ts, &p) }; let xs = (cx0 + prefix_w(s)).clamp(x_min, x_max); let xe = (cx0 + prefix_w(e)).clamp(x_min, x_max); if xe > xs { let sel_rect = KRect::new(xs, oy, xe, oy + th); scene.fill(Fill::NonZero, Affine::IDENTITY, sel_translucida, None, &sel_rect); } } // Caret: barra vertical (el scroll lo mantiene dentro del área visible). if caret_visible { let x = (cx0 + caret_w).clamp(x_min, x_max); let cy = rect.y as f64 + rect.h as f64 * 0.5; let bar = KRect::new(x, cy - 8.0, x + 1.5, cy + 8.0); scene.fill(Fill::NonZero, Affine::IDENTITY, caret_color, None, &bar); } }) } else { base }; // Borde: 1 px en reposo, 2 px al focar (énfasis de foco, como Material). El // borde se pinta como padding coloreado del nodo exterior sobre el interior. let bw = if focused { 2.0_f32 } else { 1.0_f32 }; // Sombra de elevación (themizable): `palette.elevation` sale de // `llimphi_theme::elevation::*`. Un pelo más marcada al focar (el campo activo // "sube"). Sólo se pinta si el theme pide relieve. let (elev_a, elev_blur, elev_dy) = palette.elevation; let shadow = llimphi_compositor::Shadow { color: Color::from_rgba8(0, 0, 0, if focused { elev_a.saturating_add(16) } else { elev_a }), blur: if focused { elev_blur * 1.5 } else { elev_blur }, dx: 0.0, dy: elev_dy, spread: -1.0, }; let mut node = View::new(Style { size: Size { width: percent(1.0_f32), height: length(palette.alto), }, padding: Rect { left: length(bw), right: length(bw), top: length(bw), bottom: length(bw), }, ..Default::default() }) // Sin borde propio, el ring va del color del fondo (invisible) → lo dibuja // el notched outline del campo con label. .fill(if bordered { border } else { bg }) .radius(radius); if relieve { node = node.shadow(shadow); } let node = node // Semántica: input de texto + el valor crudo como `value` (no el "•" // del modo masked — los lectores no deben dictar la contraseña en // voz alta; AccessKit ya marca el control como TextInput y el lector // sustituye por "punto" cuando el contexto lo requiere). El // placeholder va como `description` cuando el campo está vacío para // que el lector lo enuncie como pista. `value` queda vacío en masked. .role(llimphi_compositor::Role::TextInput) .aria_value(if state.masked { String::new() } else { state.text() }) .aria_description(if is_empty { placeholder.to_string() } else { String::new() }); // Click: plano (toma foco) o con posición (foco + caret/selección). El // `on_click_at` da `x` relativo al rect del box; el caller lo pasa a // `pointer_click`. let node = match click { ClickHandler::Focus(m) => node.on_click(m), ClickHandler::Full(cb) => { // `on_click_at` (press→caret, escala palabra/todo por timing) y // `draggable_at` (arrastre→selección) conviven en el mismo nodo // (patrón dominium). El click no dispara si el gesto deriva en // arrastre; el arrastre da deltas + la posición del press (`x0`). let press = cb.clone(); let drag = cb.clone(); let ctx = cb.clone(); node.on_click_at(move |x, _y, _w, _h| Some(press(TextInputEvent::Press(x)))) .draggable_at(move |phase, dx, _dy, x0, _y0| match phase { DragPhase::Move => Some(drag(TextInputEvent::Drag { anchor_x: x0, dx })), DragPhase::End => Some(drag(TextInputEvent::DragEnd)), }) // Click derecho → abrir el menú de edición anclado al puntero. // `on_right_click_screen` da coords ABSOLUTAS de ventana (no // locales al nodo), que es lo que quiere el menú: aparecer donde // está el cursor. (Con `on_right_click_at` —local— el menú saltaba // a la esquina cuando el campo no estaba pegado al origen.) .on_right_click_screen(move |x, y, _w, _h| { Some(ctx(TextInputEvent::Context { x, y })) }) } }; node.cursor(llimphi_compositor::Cursor::Text).children(vec![inner]) } #[cfg(test)] mod tests { use super::*; use llimphi_compositor::{Key, KeyState, Modifiers, NamedKey}; fn key_press(key: Key, text: Option<&str>) -> KeyEvent { KeyEvent { key, state: KeyState::Pressed, text: text.map(|s| s.to_string()), modifiers: Default::default(), repeat: false, } } #[test] fn palette_caret_default_sigue_al_texto() { // El caret por default sigue al color del texto (`caret-color: auto`): // `from_theme` y `Default` lo igualan a `fg_text`. let t = llimphi_theme::Theme::dark(); let pal = TextInputPalette::from_theme(&t); assert_eq!(pal.caret, pal.fg_text); assert_eq!(pal.caret, t.fg_text); assert_eq!(TextInputPalette::default().caret, TextInputPalette::default().fg_text); } #[test] fn caret_se_registra_como_over_painter_solo_focado() { // Caret v2 (Fase 7.1249): el caret se pinta con `paint_over` (pasada // FINAL sobre el glifo). Verificamos el wiring montando la vista: con // foco hay un over-painter registrado; sin foco no hay ninguno. use llimphi_compositor::llimphi_layout::LayoutTree; use llimphi_compositor::{has_over_painter, mount}; let mut st = TextInputState::new(); st.set_text("hola"); let pal = TextInputPalette::default(); let mut lt = LayoutTree::new(); let focado = mount(&mut lt, text_input_view(&st, "ph", true, &pal, ())); assert!( has_over_painter(&focado), "input focado debe registrar el caret como over-painter" ); let mut lt2 = LayoutTree::new(); let sin_foco = mount(&mut lt2, text_input_view(&st, "ph", false, &pal, ())); assert!( !has_over_painter(&sin_foco), "input sin foco no pinta caret" ); } #[test] fn apply_key_inserts_printable_chars() { let mut s = TextInputState::new(); let ev = key_press(Key::Character("a".into()), Some("a")); assert!(s.apply_key(&ev)); assert_eq!(s.text(), "a"); } #[test] fn pointer_click_escalates_caret_word_all() { let mut s = TextInputState::new(); s.set_text("hola mundo"); // x cae dentro de "mundo" (col ~7 con char_w = 13*0.5 = 6.5; x = 11 + 7*6.5). let x = 11.0 + 7.0 * 6.5; // Instantes INYECTADOS, no el reloj de pared: la escalada a palabra/todo // depende de que los clicks caigan dentro de la ventana de doble-click, y // con `pointer_click` (que lee `Instant::now()`) el test se volvía flaky — // bastaba que otro test cargara la CPU en paralelo para que los tres // clicks se separaran de más y no escalara. Falla observada corriendo la // suite completa; pasaba solo. El reloj no es parte de lo que se prueba. let t0 = Instant::now(); let paso = core::time::Duration::from_millis(50); // 1 click: caret simple, sin selección. s.pointer_click_at(x, 13.0, t0); assert!(!s.editor().has_selection(), "1 click no debe seleccionar"); // 2 clicks: palabra bajo el cursor. s.pointer_click_at(x, 13.0, t0 + paso); assert!(s.editor().has_selection(), "2 clicks debe seleccionar palabra"); assert_eq!(s.editor().selected_text().as_deref(), Some("mundo")); // 3 clicks: todo (párrafo = la línea entera en single-line). s.pointer_click_at(x, 13.0, t0 + paso * 2); assert_eq!(s.editor().selected_text().as_deref(), Some("hola mundo")); } #[test] fn select_all_selecciona_todo() { let mut s = TextInputState::new(); s.set_text("Frida Kahlo"); s.select_all(); assert_eq!(s.editor().selected_text().as_deref(), Some("Frida Kahlo")); } #[test] fn apply_key_backspace_pops() { let mut s = TextInputState::new(); s.set_text("hola"); let ev = key_press(Key::Named(NamedKey::Backspace), None); assert!(s.apply_key(&ev)); assert_eq!(s.text(), "hol"); } #[test] fn enter_ignorado_en_single_line() { let mut s = TextInputState::new(); s.set_text("hola"); let enter = key_press(Key::Named(NamedKey::Enter), None); assert!(!s.apply_key(&enter)); assert_eq!(s.text(), "hola"); } #[test] fn masked_state_is_masked() { let s = TextInputState::masked(); assert!(s.is_masked()); } #[test] fn flecha_izquierda_mueve_cursor() { // El refactor agrega esta capacidad — antes no había movimiento. let mut s = TextInputState::new(); s.set_text("hola"); let arr = key_press(Key::Named(NamedKey::ArrowLeft), None); assert!(s.apply_key(&arr)); assert_eq!(s.editor().cursor.caret.col, 3); } #[test] fn push_str_y_pop_funcionan() { let mut s = TextInputState::new(); s.push_str("hola"); assert_eq!(s.text(), "hola"); assert_eq!(s.pop(), Some('a')); assert_eq!(s.text(), "hol"); } fn key_ctrl(ch: &str) -> KeyEvent { KeyEvent { key: Key::Character(ch.into()), state: KeyState::Pressed, text: Some(ch.to_string()), modifiers: Modifiers { ctrl: true, ..Default::default() }, repeat: false, } } #[test] fn copiar_y_pegar_por_clipboard() { // Ctrl+A + Ctrl+C copia; Ctrl+V al final pega. `apply_key` (sin // clipboard) NO copiaría — por eso el hueco que cierra F1. let mut s = TextInputState::new(); s.set_text("hola"); let mut clip = MemClipboard::new(); s.select_all(); assert!(s.apply_key_with_clipboard(&key_ctrl("c"), &mut clip)); assert_eq!(clip.get().as_deref(), Some("hola")); // Caret al final (colapsa la selección a la derecha) y pega. let end = key_press(Key::Named(NamedKey::End), None); s.apply_key(&end); assert!(s.apply_key_with_clipboard(&key_ctrl("v"), &mut clip)); assert_eq!(s.text(), "holahola"); } #[test] fn apply_key_sin_clipboard_no_pega() { // Contraste: el camino viejo (NullClipboard) deja Ctrl+V sin efecto. let mut s = TextInputState::new(); s.set_text("hola"); s.apply_key(&key_press(Key::Named(NamedKey::End), None)); s.apply_key(&key_ctrl("v")); // NullClipboard.get() = None assert_eq!(s.text(), "hola"); } #[test] fn ime_compone_acento() { // Dead key: `´` en preedit y luego commit `á`. use llimphi_compositor::ImeEvent; let mut s = TextInputState::new(); s.apply_ime(&ImeEvent::Preedit { text: "´".into(), cursor: None }); assert!(s.apply_ime(&ImeEvent::Commit("á".into()))); assert_eq!(s.text(), "á"); } #[test] fn handle_press_coloca_caret_y_drag_selecciona() { // El camino frictionless: Press ancla, Drag extiende — sin un Msg por // gesto. "hola mundo": press dentro de "hola" (col ~2), arrastre a la // derecha selecciona hacia "mundo". let mut s = TextInputState::new(); s.set_text("hola mundo"); let mut clip = NullClipboard; // Press cerca del inicio (col 0-ish). let x0 = 11.0 + 0.0 * 6.5; s.handle(TextInputEvent::Press(x0), &mut clip); assert!(!s.editor().has_selection()); // Arrastre: primer Move ancla en x0, luego +delta hasta col ~10. s.handle(TextInputEvent::Drag { anchor_x: x0, dx: 10.0 * 6.5 }, &mut clip); assert!(s.editor().has_selection(), "el arrastre debe seleccionar"); s.handle(TextInputEvent::DragEnd, &mut clip); } #[test] fn hit_testing_usa_glifos_reales_no_ancho_fijo() { // La prueba de que el fix es real: con glifos de ancho MUY distinto // ("iii" angostas, "WWW" anchas), el borde entre ambos grupos NO cae // donde un ancho monoespaciado fijo lo pondría. `col_from_x` en el x del // borde real (medido con la fuente) debe devolver la columna 3 exacta. let display = "iiiWWW"; // x del borde real entre la 3ª y 4ª columna (fin de "iii"), + el PAD. let borde = PAD_X + prefix_width(display, 3); assert_eq!( col_from_x(display, borde), 3, "el click en el borde real de 'iii|WWW' debe dar columna 3" ); // Y el viejo cálculo (ancho fijo = 13*0.5 = 6.5 px/char) habría dado otra // columna en ese mismo x — evidencia de que ahora medimos de verdad. let col_viejo = (((borde - PAD_X) / 6.5).round()) as usize; assert_ne!( col_viejo, 3, "el ancho fijo erraba la columna en ese x (por eso 'la selección agarraba mal')" ); // Click bien dentro de las anchas 'W' cae en una columna > 3. let dentro_w = PAD_X + prefix_width(display, 5); assert!(col_from_x(display, dentro_w) >= 4); } /// El escaneo carácter por carácter que tenía `col_from_x_pad_m` antes de la /// búsqueda binaria. Vive acá, en los tests, como **referencia**: lo que se /// prueba es que el camino rápido devuelve exactamente lo mismo. fn col_por_escaneo(display: &str, local_x: f32) -> usize { let m = Metricas::default(); let target = (local_x - PAD_X).max(0.0); let n = display.chars().count(); if n == 0 || target <= 0.0 { return 0; } let mut prev_w = 0.0_f32; for col in 1..=n { let w = prefix_width_m(display, col, &m); if target < w { let mid = (prev_w + w) * 0.5; return if target < mid { col - 1 } else { col }; } prev_w = w; } n } #[test] fn la_busqueda_binaria_del_click_da_lo_mismo_que_el_escaneo() { // Texto de anchos MUY dispares (i angosta, W ancha, espacios) para que // un error de borde se note: si la binaria y el escaneo difirieran en // un carácter, el caret caería al lado de donde se clickeó. let display = "iWiW mmm iii WWW lolo iWiW mmm iii WWW lolo"; let total = prefix_width(display, display.chars().count()); // Barrido denso de x, incluidos el 0, los bordes exactos de cada glifo // y un punto pasado el final del texto. let mut xs: Vec = (0..=200).map(|k| total * k as f32 / 200.0).collect(); for col in 0..=display.chars().count() { xs.push(prefix_width(display, col)); } xs.push(total + 50.0); for x in xs { let local_x = PAD_X + x; assert_eq!( col_from_x(display, local_x), col_por_escaneo(display, local_x), "x = {x:.2} px da distinta columna que el escaneo carácter a carácter" ); } } #[test] fn click_en_una_linea_larga_no_es_cuadratico() { // Hermano del `envolver_mucho_texto_no_es_cuadratico` del text-area, // para el camino de UNA línea: ahí el texto no llega partido en // renglones, llega entero. Con el caret al final (como queda tras // pegar) el campo está desplazado y el click cae al fondo del texto, así // que el escaneo viejo los medía TODOS: 2.000 caracteres tardaban 4,5 s // en release — el mismo cuelgue de T11, en el otro camino. let medir = |chars: usize| { let texto = "cargo ".repeat(chars / 6); let mut s = TextInputState::new(); s.fijar_ancho_caja(900.0); s.set_text(&texto); s.editor_mut().set_caret_at(0, texto.chars().count()); // Fuera del cronómetro: la primera medición carga la fuente. let _ = s.col_at_x(430.0); let t0 = std::time::Instant::now(); let col = s.col_at_x(430.0); assert!(col > 0, "el click debería caer dentro del texto"); t0.elapsed().as_secs_f64() }; let corto = medir(1_200); let largo = medir(2_400); let factor = largo / corto.max(1e-6); assert!( factor < 3.0, "duplicar el texto multiplicó el click por {factor:.1}× \ ({:.0} ms → {:.0} ms): el mapeo x→columna volvió a ser cuadrático", corto * 1000.0, largo * 1000.0 ); } #[test] fn scroll_horizontal_mantiene_el_caret_a_la_vista() { // El caret nunca debe quedar fuera de la caja cuando el texto desborda. let mut s = TextInputState::new(); s.set_text("abcdefghijklmnopqrstuvwxyz0123456789"); let display = s.display_text(); let total = prefix_width(&display, display.chars().count()); // Sin ancho pintado todavía → sin scroll (no sabemos el tamaño). assert_eq!(s.scroll_x(&display), 0.0); // Simulá el ancho de caja que escribiría el paint_over: caja angosta. let box_w = 80.0_f32; s.box_w.store(box_w.to_bits(), Ordering::Relaxed); let area = box_w - 2.0 * PAD_X; assert!(total > area, "el texto debe desbordar para ejercitar el scroll"); // Caret al final → el scroll muestra el final; el caret cae dentro del área. s.apply_key(&key_press(Key::Named(NamedKey::End), None)); let scroll = s.scroll_x(&display); let caret_visible = prefix_width(&display, s.editor().cursor.caret.col) - scroll; assert!( caret_visible >= 0.0 && caret_visible <= area + 1.0, "caret a la vista con el offset: {caret_visible} debe caer en [0, {area}]" ); // Caret al inicio → sin scroll (se ve el principio del texto). s.apply_key(&key_press(Key::Named(NamedKey::Home), None)); assert_eq!(s.scroll_x(&display), 0.0, "con el caret al inicio no hay scroll"); } #[test] fn decoracion_sale_de_los_tokens_del_theme() { // Themizable: el radio y la elevación de la paleta salen de los tokens // canónicos del theme, no de números mágicos hardcodeados. let pal = TextInputPalette::from_theme(&llimphi_theme::Theme::dark()); assert_eq!(pal.radius, llimphi_theme::radius::MD as f32); assert_eq!(pal.elevation, llimphi_theme::elevation::E1); assert!(pal.relieve, "por defecto el input lleva relieve"); // Un theme/consumidor flat lo apaga sin tocar el widget. let flat = TextInputPalette { relieve: false, radius: 0.0, ..pal }; assert!(!flat.relieve); assert_eq!(flat.radius, 0.0); } #[test] fn multiline_acepta_enter_y_single_no() { // `multiline()` comparte el MISMO motor que el single-line; sólo cambia // `single_line`. Enter inserta salto en multi, se ignora en single. let mut m = TextInputState::multiline(); assert!(m.is_multiline()); m.set_text("a"); m.apply_key(&key_press(Key::Named(NamedKey::Enter), None)); // insertar 'b' tras el salto m.apply_key(&key_press(Key::Character("b".into()), Some("b"))); assert_eq!(m.text(), "a\nb"); let mut s = TextInputState::new(); assert!(!s.is_multiline()); s.set_text("a"); s.apply_key(&key_press(Key::Named(NamedKey::Enter), None)); assert_eq!(s.text(), "a", "single-line ignora Enter"); } #[test] fn float_target_sube_con_foco_o_texto() { let mut s = TextInputState::new(); // Vacío + sin foco → descansa dentro. assert_eq!(float_target(false, &s), 0.0); // Con foco (aunque vacío) → flota. assert_eq!(float_target(true, &s), 1.0); // Con texto (aunque sin foco) → flota. s.set_text("hola"); assert_eq!(float_target(false, &s), 1.0); } #[test] fn context_no_toca_el_estado() { // El click derecho es un evento de overlay: `handle` no cambia el texto // ni el caret (devuelve false); el host lo intercepta para abrir el menú. let mut s = TextInputState::new(); s.set_text("hola"); let caret_antes = s.editor().cursor.caret.col; let mut clip = NullClipboard; assert!(!s.handle(TextInputEvent::Context { x: 5.0, y: 9.0 }, &mut clip)); assert_eq!(s.text(), "hola"); assert_eq!(s.editor().cursor.caret.col, caret_antes); assert!(!s.editor().has_selection()); } #[test] fn context_se_emite_en_la_vista_full() { // La vista full registra el handler de click derecho; la de reposo no // (solo toma foco). Verificamos el wiring montando ambas. use llimphi_compositor::llimphi_layout::LayoutTree; use llimphi_compositor::mount; let mut st = TextInputState::new(); st.set_text("hola"); let pal = TextInputPalette::default(); let mut lt = LayoutTree::new(); let full = mount( &mut lt, text_input_view_full(&st, "", true, &pal, |ev| ev), ); assert!( llimphi_compositor::has_right_click_handler(&full), "la vista full debe emitir el click derecho (menú de edición)" ); } #[test] fn default_es_single_line() { // Regresión: `TextInputState::default()` debe ser single-line (Enter // ignorado), no heredar el multilínea de `EditorState::default()`. let mut s = TextInputState::default(); s.set_text("hola"); let enter = key_press(Key::Named(NamedKey::Enter), None); assert!(!s.apply_key(&enter)); assert_eq!(s.text(), "hola"); } }