tawasuyu

tawasuyu/llimphi

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

Rust 238TOML 102Markdown 83Shell 24178 Definitions

⎇ main widgets/text-input/src/rico.rs Rust · 1060 lines · 43.5 KB History Who touched what raw

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

1//! Vista **rica** del text-area: el mismo motor de edición del resto del widget
2//! ([`TextInputState`] sobre `EditorState`), pero con los adornos que hasta hoy
3//! cada app se dibujaba por su cuenta con un pintor propio.
4//!
5//! Nació de una constatación concreta: shuma tenía en su barra un input hecho a
6//! mano —ajuste blando, coloreo por token, sugerencia fantasma, placeholder con
7//! ícono, caret dibujado a mano— que reimplementaba (peor, y sólo para shuma)
8//! lo que este widget ya hacía, y a cambio se perdía el salto de palabra con
9//! selección, el undo, el IME y el doble/triple click. Lo que le faltaba al
10//! widget para reemplazarlo era **presentación**, no edición:
11//!
12//! - **Métrica configurable** ([`Metricas`]): mono + zoom. Sin esto el widget no
13//!   puede ser el prompt de una terminal.
14//! - **Tramos de color** ([`Adornos::tramos`]): rangos de bytes → color, para
15//!   que el host coloree por sintaxis sin dejar de usar el widget.
16//! - **Sugerencia fantasma** ([`Adornos::ghost`]): el sufijo tenue tras el caret.
17//! - **Placeholder rico** ([`Placeholder`]): ícono con color propio, itálica y
18//!   fundido — el canal por el que shuma narra avisos en el hueco del input.
19//! - **Caret con estela** ([`CaretEstilo`]): el eyecandy en honor a kitty.
20//!
21//! Todo es opcional: [`Adornos::default`] + [`Metricas::default`] dan exactamente
22//! el text-area de siempre, y por eso [`super::text_area_view`] no es más que
23//! esta vista con los adornos vacíos — un solo renderizador, no dos.
24
25use alloc::format;
26use alloc::string::{String, ToString};
27use alloc::vec;
28use alloc::vec::Vec;
29
30use core::sync::atomic::Ordering;
31use alloc::sync::Arc;
32
33use llimphi_compositor::llimphi_layout::taffy::{
34    prelude::{length, percent, Size, Style},
35    Rect,
36};
37use llimphi_compositor::peniko::Color;
38use llimphi_compositor::{DragPhase, View};
39
40use super::area::{caret_vrow, measure_prefix, visual_layout, TextAreaEvent, VisualRow};
41use super::{Metricas, TextInputPalette, TextInputState};
42
43/// Placeholder **rico**: el texto que ocupa el input vacío, con un ícono de
44/// color propio delante, itálica opcional y un alpha para fundirlo.
45///
46/// El ícono va como bloque aparte (y no pegado al texto) porque lleva **su
47/// propio color**: es el punto donde el host codifica urgencia —un ⚠ ámbar, un
48/// ● calmo— sin que el widget sepa nada de urgencias.
49#[derive(Debug, Clone)]
50pub struct Placeholder {
51    pub texto: String,
52    pub color: Color,
53    /// Itálica — la convención para "esto es un murmullo, no tu texto".
54    pub italic: bool,
55    /// Glifo delante del texto, con color propio.
56    pub icono: Option<char>,
57    /// Color del ícono. `None` = el mismo del texto.
58    pub icono_color: Option<Color>,
59    /// Opacidad global `[0,1]` — el host la anima para fundir entradas/salidas.
60    pub alpha: f32,
61}
62
63impl Default for Placeholder {
64    fn default() -> Self {
65        Self {
66            texto: String::new(),
67            color: Color::from_rgba8(128, 128, 128, 255),
68            italic: false,
69            icono: None,
70            icono_color: None,
71            alpha: 1.0,
72        }
73    }
74}
75
76impl Placeholder {
77    /// Placeholder simple: sólo texto, sin ícono ni itálica, opaco.
78    pub fn texto(t: impl Into<String>, color: Color) -> Self {
79        Self { texto: t.into(), color, ..Self::default() }
80    }
81}
82
83/// Estilo del caret: grosor, parpadeo y **estela**.
84///
85/// El parpadeo no es un booleano que alterne el host: el widget lo deriva del
86/// reloj y de [`TextInputState::marcar_actividad`], porque un `bool` alternado
87/// por un timer externo se muestrea mal cuando el panel repinta lento — el caret
88/// se apaga por segundos justo mientras se escribe, que es cuando más se
89/// necesita. Con `solido_ms` el caret queda **fijo** mientras hay actividad y
90/// recién después titila.
91#[derive(Debug, Clone)]
92pub struct CaretEstilo {
93    /// Ancho de la barra (px).
94    pub ancho: f32,
95    /// Si titila (tras el período sólido). `false` = siempre visible.
96    pub parpadeo: bool,
97    /// Cuánto queda **sólido** tras la última actividad (ms).
98    pub solido_ms: u64,
99    /// Medio período del parpadeo (ms): tiempo encendido = tiempo apagado.
100    pub medio_periodo_ms: u64,
101    /// Si deja **estela** al moverse (el eyecandy en honor a kitty): la barra
102    /// se dibuja siempre en su posición real —donde va a caer la próxima
103    /// letra— y detrás queda un rastro que se encoge hasta desaparecer.
104    pub estela: bool,
105    /// Constante de tiempo (ms) del encogido de la estela: a mayor, más larga.
106    pub tau_ms: f32,
107    /// Salto horizontal (px) a partir del cual el movimiento cuenta como **viaje
108    /// entero** y usa `tau_ms` completo. Por debajo, el rastro se acorta de
109    /// forma continua hacia `tau_rapido` — así escribir deja apenas un destello
110    /// y un click lejano deja la estela larga, sin un umbral donde el efecto
111    /// aparezca de golpe. Del orden de dos caracteres.
112    pub salto_min: f32,
113    /// Constante de tiempo (ms) del rastro de los movimientos **chicos** (una
114    /// tecla). Muy corta a propósito: se intuye, no se lee.
115    pub tau_rapido: f32,
116}
117
118impl Default for CaretEstilo {
119    fn default() -> Self {
120        Self {
121            ancho: 1.5,
122            parpadeo: false,
123            solido_ms: 1_200,
124            medio_periodo_ms: 530,
125            estela: false,
126            tau_ms: 55.0,
127            salto_min: 16.0,
128            tau_rapido: 14.0,
129        }
130    }
131}
132
133impl CaretEstilo {
134    /// El caret de un input vivo: titila tras un segundo largo de quietud y
135    /// deja estela al moverse.
136    pub fn vivo() -> Self {
137        Self { parpadeo: true, estela: true, ancho: 2.0, ..Self::default() }
138    }
139
140    /// ¿Se pinta la barra en este instante? Sólida mientras hubo actividad
141    /// reciente; después, media onda cuadrada.
142    pub fn visible_en(&self, ahora_ms: u64, actividad_ms: u64) -> bool {
143        if !self.parpadeo {
144            return true;
145        }
146        let transcurrido = ahora_ms.saturating_sub(actividad_ms);
147        if transcurrido < self.solido_ms {
148            return true;
149        }
150        let periodo = self.medio_periodo_ms.max(1);
151        ((transcurrido - self.solido_ms) / periodo) % 2 == 0
152    }
153}
154
155/// `e^x`, con o sin sistema operativo: `exp` es un método de la stdlib, no una
156/// operación del lenguaje (misma razón que [`super::redondear`]).
157#[inline]
158fn exp_f32(x: f32) -> f32 {
159    #[cfg(feature = "std")]
160    {
161        x.exp()
162    }
163    #[cfg(not(feature = "std"))]
164    {
165        libm::expf(x)
166    }
167}
168
169/// Dónde queda el punto **rezagado** del rastro este cuadro, y si el viaje
170/// sigue en curso. Pura: es toda la política de la estela en un solo lugar.
171///
172/// `primer_cuadro` = todavía no hay muestreo previo (o el reloj retrocedió):
173/// el rastro se clava en el destino, si no un input recién montado estrenaría
174/// una estela desde el origen de coordenadas.
175///
176/// Devuelve `((x, y), persiguiendo)`.
177pub(crate) fn perseguir(
178    destino: (f64, f64),
179    rezagado: (f64, f64),
180    persiguiendo: bool,
181    dt_ms: f32,
182    estilo: &CaretEstilo,
183    primer_cuadro: bool,
184) -> ((f64, f64), bool) {
185    let (cx, cy) = destino;
186    let (mut nx, mut ny) = rezagado;
187    let salto_x = (cx - nx).abs();
188    let cambio_renglon = (cy - ny).abs() > 0.5;
189    if !estilo.estela || primer_cuadro || cambio_renglon {
190        return ((cx, cy), false);
191    }
192    if !persiguiendo && salto_x < 0.5 {
193        return ((cx, cy), false); // quieto: nada que perseguir
194    }
195    // Tau por TAMAÑO del movimiento. Cortar en seco los movimientos chicos
196    // dejaba el caret «muy normalito»: el rastro desaparecía justo en el gesto
197    // más frecuente, que es escribir. Un salto de un carácter deja un rastro
198    // brevísimo —se intuye más que se ve— y un viaje de verdad (click lejos,
199    // Home/End, salto por palabra) deja el rastro entero. La escala es continua:
200    // no hay un umbral donde el efecto aparezca de golpe.
201    let viaje = (salto_x / estilo.salto_min.max(1.0) as f64).clamp(0.0, 1.0) as f32;
202    let tau = estilo.tau_rapido + (estilo.tau_ms - estilo.tau_rapido) * viaje;
203    let k = (1.0 - exp_f32(-dt_ms / tau.max(1.0))) as f64;
204    // **Techo por CUADRO, no sólo por tiempo (T10).** Las constantes de arriba
205    // están en milisegundos y suponen que alguien dibuja seguido; el host real
206    // no lo hace. pata repinta con un cap adaptativo de 11–30 fps, o sea 33–90 ms
207    // por cuadro, y con `tau_rapido = 14 ms` eso da `k ≈ 0,91`: en UN cuadro el
208    // rastro ya recorrió el 91 % del camino y al siguiente está por debajo del
209    // umbral de medio píxel. La estela del tipeo —el gesto más frecuente, y el
210    // que sergio pedía «como kitty»— era matemáticamente invisible: existía en
211    // el código y no llegaba a dibujarse nunca.
212    //
213    // Acotar `k` hace que el rastro no cubra más de la mitad de lo que le falta
214    // por cuadro, así dura ~3–4 cuadros **sea cual sea** el framerate. Es la
215    // diferencia entre «el efecto dura 14 ms» y «el efecto dura unos cuadros»,
216    // que es lo que el ojo mide.
217    const K_MAX: f64 = 0.5;
218    let k = k.min(K_MAX);
219    nx += (cx - nx) * k;
220    ny += (cy - ny) * k;
221    // Cerca del destino, clavarlo: si no queda un rastro de sub-píxel eterno
222    // que obliga a repintar para siempre.
223    let llego = (cx - nx).abs() < 0.5 && (cy - ny).abs() < 0.5;
224    if llego {
225        return ((cx, cy), false);
226    }
227    ((nx, ny), true)
228}
229
230/// Los adornos de presentación de la vista rica. `default()` = el text-area de
231/// siempre (sin color por tramos, sin ghost, placeholder plano, caret simple).
232#[derive(Debug, Clone)]
233pub struct Adornos {
234    /// Tramos `(ini, fin, color)` en **bytes sobre el texto completo** (con los
235    /// `\n` contando como un byte, igual que el `String` que devuelve
236    /// [`TextInputState::text`]). Los que caen fuera se ignoran; los que cruzan
237    /// un renglón visual se recortan a cada tramo. Vacío = todo en `fg_text`.
238    pub tramos: Vec<(usize, usize, Color)>,
239    /// Sufijo tenue tras el caret (autocompletado inline). Sólo se pinta si el
240    /// caret está al **final** del texto: en medio, un fantasma miente sobre
241    /// dónde va a caer lo que se escriba.
242    pub ghost: Option<String>,
243    /// Placeholder del input vacío. `None` = sin placeholder.
244    pub placeholder: Option<Placeholder>,
245    /// Estilo del caret.
246    pub caret: CaretEstilo,
247    /// Unix-ms **de este cuadro** — el reloj del parpadeo y de la estela. El
248    /// widget no lo consulta por su cuenta a propósito (ver
249    /// [`TextInputState::marcar_actividad`]).
250    pub ahora_ms: u64,
251    /// Si el widget dibuja **su propia caja** (borde + fondo + relieve).
252    /// `true` = como siempre.
253    ///
254    /// `false` la necesita un host que ya tiene su marco y no quiere el del
255    /// widget encima. El caso real: la barra de shuma flota sobre el cristal
256    /// del compositor, y la caja del widget arma su borde con un *relleno
257    /// opaco* del tamaño del nodo (el truco clásico de marco de 1 px). Ese
258    /// relleno, aunque se vea como un borde fino, es una capa opaca de lado a
259    /// lado **debajo** del texto: tapa el backdrop y el input deja de glasear.
260    /// Sin marco, el widget aporta sólo texto, selección y caret, y el fondo lo
261    /// pone —o lo deja pasar— el host.
262    pub marco: bool,
263}
264
265impl Default for Adornos {
266    fn default() -> Self {
267        Self {
268            tramos: Vec::new(),
269            ghost: None,
270            placeholder: None,
271            caret: CaretEstilo::default(),
272            ahora_ms: 0,
273            marco: true,
274        }
275    }
276}
277
278/// TEMPORAL (diagnóstico, 2026-07-22). Deja rastro cuando el mismo input se
279/// pinta con **dos anchos distintos alternándose** — la firma de que hay dos
280/// vistas del mismo estado peleándose (p. ej. la barra y el cajón, que no miden
281/// igual). Con el ancho estable no escribe nada ni cuesta nada: es una
282/// comparación de `f32`.
283///
284/// Existe porque el síntoma que hay que cazar es exactamente ése: el texto se
285/// reparte una y otra vez entre dos anchos y salta sin que nadie escriba.
286/// Sin `std` no hay `/tmp` donde dejar la firma: el diagnóstico es del
287/// escritorio y en la jaula la función se disuelve en nada.
288#[cfg(not(feature = "std"))]
289fn avisar_ancho_inestable(_previo: f32, _ahora: f32) {}
290
291#[cfg(feature = "std")]
292fn avisar_ancho_inestable(previo: f32, ahora: f32) {
293    use core::sync::atomic::AtomicUsize;
294    if previo <= 1.0 || (previo - ahora).abs() < 1.0 {
295        return;
296    }
297    static VECES: AtomicUsize = AtomicUsize::new(0);
298    // Tope duro: interesa la firma, no llenar el disco si esto queda prendido.
299    let n = VECES.fetch_add(1, Ordering::Relaxed);
300    if n >= 200 {
301        return;
302    }
303    use std::io::Write;
304    if let Ok(mut f) = std::fs::OpenOptions::new()
305        .create(true)
306        .append(true)
307        .open("/tmp/llimphi-input-jitter.log")
308    {
309        let _ = writeln!(f, "#{n} ancho {previo:.1} -> {ahora:.1}");
310    }
311}
312
313/// Vista rica del text-area. Mismo cableado de mouse y mismo motor que
314/// [`super::text_area_view`]; lo que cambia es que el texto, la selección, el
315/// ghost, el placeholder y el caret los dibuja **un solo pintor** con la
316/// [`Metricas`] dada, en vez de un nodo de texto por renglón.
317///
318/// Un solo pintor no es capricho: cuando el texto vive en nodos y el caret en un
319/// `paint_over`, los dos miden por caminos distintos y se desalinean apenas la
320/// fuente deja de ser la default. Midiendo y dibujando en el mismo lugar, el
321/// caret cae siempre entre los glifos que se ven.
322/// ¿Este renglón visual se pinta, dado el rect del cuadro?
323///
324/// Un renglón entra sólo si entra **entero**. La versión laxa —«si empieza
325/// dentro, se pinta»— confiaba en el clip del nodo para tapar lo que asomara,
326/// y el clip de llimphi recorta **los hijos**, no el `paint_with` del propio
327/// nodo: el renglón que empezaba tres píxeles antes del borde se dibujaba
328/// completo *encima* de lo que viniera abajo. Se veía en el campo del magnet de
329/// media (2026-09-15): la caja crecía hasta su tope de renglones y el siguiente
330/// se escribía por fuera, sobre los botones.
331///
332/// Vale para los **dos** bordes, y el de arriba no es hipotético: apenas se
333/// arregló el de abajo, el mismo magnet con el caret al final desplazó el
334/// viewport un renglón y el de arriba salió a escribirse sobre el título del
335/// panel. Un borde arreglado solo mueve el defecto, no lo saca.
336///
337/// Media línea de texto cortada tampoco es lo que uno quiere ver: un área de
338/// `rows` renglones muestra `rows` renglones, y lo que sobra se alcanza con el
339/// caret (el pintor desplaza el viewport para seguirlo).
340pub(crate) fn renglon_entra(y: f64, line_h: f64, rect_y: f64, rect_h: f64) -> bool {
341    // Un pelo de tolerancia: el alto de la caja lo calcula el host con la misma
342    // métrica, pero pasa por taffy y vuelve como f32. Sin esto, un redondeo de
343    // 0,01 px borra el último renglón — que es el que uno está escribiendo.
344    const EPS: f64 = 0.5;
345    y >= rect_y - EPS && y + line_h <= rect_y + rect_h + EPS
346}
347
348pub fn text_area_view_rico<Msg, F>(
349    state: &TextInputState,
350    focused: bool,
351    rows: usize,
352    palette: &TextInputPalette,
353    metricas: &Metricas,
354    adornos: &Adornos,
355    wrap: F,
356) -> View<Msg>
357where
358    Msg: Clone + 'static,
359    F: Fn(TextAreaEvent) -> Msg + Send + Sync + 'static,
360{
361    // La métrica del cuadro queda publicada para el hit-testing del `update`.
362    state.set_metricas(metricas);
363
364    let m = metricas.clone();
365    let raw = state.text();
366    let is_empty = raw.is_empty();
367    let (bg, border) = if focused {
368        (palette.bg_focus, palette.border_focus)
369    } else {
370        (palette.bg, palette.border)
371    };
372    let visible = rows.max(1);
373    let body_h = visible as f32 * m.line_h + 2.0 * m.pad_y;
374
375    // El ajuste blando y el scroll se resuelven DENTRO del pintor, con el
376    // `rect.w` de ese mismo cuadro.
377    //
378    // Antes se calculaban acá, con el ancho que el pintor había guardado el
379    // cuadro anterior. Eso es un ancho **compartido**: si el mismo input se
380    // pinta en dos lados (la barra y el cajón, que no miden igual), cada vista
381    // envuelve con el ancho de la otra y el texto salta entre dos repartos, ida
382    // y vuelta, mientras las dos sigan repintando — aunque nadie escriba. Con el
383    // ancho tomado del propio rect no hay ningún cuadro en que el texto se
384    // reparta para un ancho que no es el suyo.
385    let caret = state.editor().cursor.caret;
386
387    // --- Datos que viajan al pintor (todo por valor: el closure es 'static) ---
388    let caret_anchor = state.editor().cursor.anchor;
389    let raw_lines: Vec<String> = raw.split('\n').map(str::to_string).collect();
390    // Offset de byte donde arranca cada línea lógica — el puente entre los
391    // `tramos` (bytes del texto entero) y los renglones visuales (columnas de
392    // carácter dentro de una línea).
393    let line_byte0: Vec<usize> = {
394        let mut acc = 0usize;
395        let mut v = Vec::with_capacity(raw_lines.len());
396        for l in &raw_lines {
397            v.push(acc);
398            acc += l.len() + 1; // +1 por el '\n'
399        }
400        v
401    };
402    let raw_paint = raw.clone();
403    let box_w_cell = state.box_w.clone();
404    let scroll_cell = state.area_scroll_y.clone();
405    let anim = state.caret_anim.clone();
406    let actividad_ms = state.actividad_ms();
407    let fg_text = palette.fg_text;
408    let sel_color = palette.selection;
409    let caret_color = palette.caret;
410    let caret_estilo = adornos.caret.clone();
411    let ahora_ms = adornos.ahora_ms;
412    let tramos = adornos.tramos.clone();
413    let ghost = adornos.ghost.clone();
414    let placeholder = adornos.placeholder.clone();
415    let caret_al_final = caret.line + 1 == raw_lines.len()
416        && caret.col == raw_lines.last().map(|l| l.chars().count()).unwrap_or(0);
417    let mp = m.clone();
418
419    let content = View::new(Style {
420        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
421        padding: Rect {
422            left: length(m.pad_x),
423            right: length(m.pad_r),
424            top: length(m.pad_y),
425            bottom: length(m.pad_y),
426        },
427        ..Default::default()
428    })
429    .fill(if adornos.marco { bg } else { bg.with_alpha(0.0) })
430    .radius((palette.radius - 1.0).max(0.0) as f64)
431    .clip(true)
432    .paint_with(move |scene, ts, rect| {
433        use llimphi_compositor::peniko::kurbo::{Affine, BezPath, Rect as KRect};
434        use llimphi_compositor::peniko::Fill;
435        use llimphi_compositor::llimphi_text::{
436            draw_layout, draw_layout_runs, layout_block, Alignment as TAlign, TextBlock,
437        };
438
439        // El ancho queda publicado para las consultas del host (cuántos
440        // renglones va a ocupar, dónde cae un click). Para **este** pintado no se
441        // usa: se usa el rect de acá.
442        let ancho_previo = f32::from_bits(box_w_cell.load(Ordering::Relaxed));
443        box_w_cell.store(rect.w.to_bits(), Ordering::Relaxed);
444        avisar_ancho_inestable(ancho_previo, rect.w);
445        if rect.w <= 0.0 || rect.h <= 0.0 {
446            return;
447        }
448        let line_h = mp.line_h as f64;
449        let ff = mp.font_family.as_deref();
450
451        // Ajuste blando con el ancho REAL de este cuadro.
452        let wrap_w = (rect.w - mp.pad_x - mp.pad_r).max(0.0);
453        let vrows_paint = visual_layout(&raw_paint, wrap_w, &mp);
454        let total = vrows_paint.len();
455
456        // Scroll vertical con histéresis: parte del scroll del cuadro previo y
457        // sólo se mueve si el renglón del caret sale del viewport. Persistido
458        // para que el hit-test del próximo cuadro reste el mismo offset.
459        let max_scroll_row = total.saturating_sub(visible);
460        let prev_px = f32::from_bits(scroll_cell.load(Ordering::Relaxed));
461        let mut sr = (super::redondear(prev_px / mp.line_h) as usize).min(max_scroll_row);
462        if focused {
463            let cv = caret_vrow(&vrows_paint, caret.line, caret.col);
464            if cv < sr {
465                sr = cv;
466            } else if cv >= sr + visible {
467                sr = cv + 1 - visible;
468            }
469        }
470        sr = sr.min(max_scroll_row);
471        let scroll_y = sr as f32 * mp.line_h;
472        scroll_cell.store(scroll_y.to_bits(), Ordering::Relaxed);
473
474        let ox = rect.x as f64 + mp.pad_x as f64;
475        let oy = rect.y as f64 + mp.pad_y as f64 - scroll_y as f64;
476
477        // Segmento (string) de un renglón visual.
478        let seg_of = |vr: &VisualRow| -> String {
479            raw_lines
480                .get(vr.line)
481                .map(|l| l.chars().skip(vr.start).take(vr.end - vr.start).collect())
482                .unwrap_or_default()
483        };
484        let line_len = |li: usize| raw_lines.get(li).map(|l| l.chars().count()).unwrap_or(0);
485
486        // --- Placeholder (input vacío) ---
487        if let Some(ph) = placeholder.as_ref().filter(|_| raw_lines.len() == 1 && raw_lines[0].is_empty())
488        {
489            let a = ph.alpha.clamp(0.0, 1.0);
490            if a > 0.01 {
491                let mut x = ox;
492                if let Some(g) = ph.icono {
493                    let icol = ph.icono_color.unwrap_or(ph.color).with_alpha(a);
494                    let gtxt = format!("{g} ");
495                    let gb = TextBlock {
496                        text: &gtxt,
497                        size_px: mp.font_size,
498                        color: icol,
499                        origin: (x, oy),
500                        max_width: None,
501                        alignment: TAlign::Start,
502                        line_height: 1.2,
503                        italic: false,
504                        font_family: mp.font_family.clone(),
505                    };
506                    let gl = layout_block(ts, &gb);
507                    draw_layout(scene, &gl, icol, (x, oy));
508                    x += llimphi_compositor::llimphi_text::measurement(&gl).width as f64;
509                }
510                let color = ph.color.with_alpha(ph.color.components[3] * a);
511                let b = TextBlock {
512                    text: &ph.texto,
513                    size_px: mp.font_size,
514                    color,
515                    origin: (x, oy),
516                    max_width: None,
517                    alignment: TAlign::Start,
518                    line_height: 1.2,
519                    italic: ph.italic,
520                    font_family: mp.font_family.clone(),
521                };
522                let l = layout_block(ts, &b);
523                draw_layout(scene, &l, color, (x, oy));
524            }
525        }
526
527        // Selección normalizada por (línea, columna).
528        let sel = caret_anchor.and_then(|a| {
529            let (s, e) = if (a.line, a.col) <= (caret.line, caret.col) {
530                (a, caret)
531            } else {
532                (caret, a)
533            };
534            ((s.line, s.col) != (e.line, e.col)).then_some((s, e))
535        });
536
537        // Posición del caret en coords de escena; se resuelve mientras se
538        // recorren los renglones (así se mide con los mismos glifos que se
539        // dibujan) y se usa después para la barra y la estela.
540        let mut caret_xy: Option<(f64, f64)> = None;
541        let mut fin_texto: Option<(f64, f64)> = None;
542
543        for (vi, vr) in vrows_paint.iter().enumerate() {
544            let y = oy + vi as f64 * line_h;
545            // Fuera del viewport: ni se mide ni se pinta. Medir texto que no se
546            // dibuja es el costo que se paga por cuadro en una conversación
547            // larga — y el que **asoma** por abajo hay que dejarlo afuera
548            // entero: ver [`renglon_entra`].
549            if !renglon_entra(y, line_h, rect.y as f64, rect.h as f64) {
550                continue;
551            }
552            let seg = seg_of(vr);
553
554            // --- Selección en este renglón ---
555            if let Some((s, e)) = sel {
556                if vr.line >= s.line && vr.line <= e.line {
557                    let sc = if vr.line == s.line { s.col.max(vr.start) } else { vr.start };
558                    let ec = if vr.line == e.line { e.col.min(vr.end) } else { vr.end };
559                    if ec >= sc {
560                        let x0 = measure_prefix(ts, &seg, sc.saturating_sub(vr.start), &mp);
561                        let is_line_end = vr.end == line_len(vr.line);
562                        let covers_nl = is_line_end && vr.line < e.line && ec == vr.end;
563                        let x1 = if covers_nl {
564                            measure_prefix(ts, &seg, seg.chars().count(), &mp) + 6.0
565                        } else {
566                            measure_prefix(ts, &seg, ec.saturating_sub(vr.start), &mp)
567                        };
568                        if x1 > x0 || covers_nl {
569                            let sc4 = sel_color.with_alpha(0.4);
570                            let r = KRect::new(ox + x0, y + 1.0, ox + x1.max(x0), y + line_h - 1.0);
571                            scene.fill(Fill::NonZero, Affine::IDENTITY, sc4, None, &r);
572                        }
573                    }
574                }
575            }
576
577            // --- Texto del renglón ---
578            if !seg.is_empty() {
579                // Rango de BYTES que cubre este renglón visual dentro del texto
580                // completo, para recortarle los tramos de color.
581                let linea = raw_lines.get(vr.line).map(String::as_str).unwrap_or("");
582                let b_ini = line_byte0.get(vr.line).copied().unwrap_or(0)
583                    + linea.chars().take(vr.start).map(char::len_utf8).sum::<usize>();
584                let b_fin = b_ini + seg.len();
585                let runs: Vec<(usize, usize, Color)> = tramos
586                    .iter()
587                    .filter_map(|&(a, b, c)| {
588                        let a = a.max(b_ini);
589                        let b = b.min(b_fin);
590                        (a < b).then(|| (a - b_ini, b - b_ini, c))
591                    })
592                    .collect();
593                if runs.is_empty() {
594                    let blk = TextBlock {
595                        text: &seg,
596                        size_px: mp.font_size,
597                        color: fg_text,
598                        origin: (ox, y),
599                        max_width: None,
600                        alignment: TAlign::Start,
601                        line_height: 1.2,
602                        italic: false,
603                        font_family: mp.font_family.clone(),
604                    };
605                    let l = layout_block(ts, &blk);
606                    draw_layout(scene, &l, fg_text, (ox, y));
607                } else {
608                    // Camino multicolor. La familia va explícita: sin ella este
609                    // camino shapea con la sans aunque el input sea mono, y las
610                    // columnas se desalinean en silencio.
611                    let l = ts.layout_runs(
612                        &seg,
613                        mp.font_size,
614                        fg_text,
615                        &runs,
616                        TAlign::Start,
617                        1.2,
618                        400.0,
619                        false,
620                        false,
621                        ff,
622                    );
623                    draw_layout_runs(scene, &l, (ox, y));
624                }
625            }
626
627            // --- ¿Cae el caret en este renglón? ---
628            if caret_vrow(&vrows_paint, caret.line, caret.col) == vi {
629                let off = caret.col.saturating_sub(vr.start);
630                caret_xy = Some((ox + measure_prefix(ts, &seg, off, &mp), y));
631            }
632            if vi + 1 == vrows_paint.len() {
633                fin_texto = Some((ox + measure_prefix(ts, &seg, seg.chars().count(), &mp), y));
634            }
635        }
636
637        // --- Sugerencia fantasma, pegada al final del texto ---
638        if let (Some(g), Some((gx, gy))) = (ghost.as_ref().filter(|g| !g.is_empty()), fin_texto) {
639            if caret_al_final {
640                let col = fg_text.with_alpha(0.38);
641                let blk = TextBlock {
642                    text: g,
643                    size_px: mp.font_size,
644                    color: col,
645                    origin: (gx, gy),
646                    max_width: None,
647                    alignment: TAlign::Start,
648                    line_height: 1.2,
649                    italic: false,
650                    font_family: mp.font_family.clone(),
651                };
652                let l = layout_block(ts, &blk);
653                draw_layout(scene, &l, col, (gx, gy));
654            }
655        }
656
657        // --- Caret + estela ---
658        if !focused {
659            return;
660        }
661        let Some((cx, cy)) = caret_xy else { return };
662        let alto = line_h - 3.0;
663        let visible_ahora = caret_estilo.visible_en(ahora_ms, actividad_ms);
664
665        // Persecución exponencial del punto rezagado. El primer cuadro (o con la
666        // estela apagada) se clava en el destino: sin esto, un input recién
667        // montado estrenaría una estela desde el origen de coordenadas.
668        let prev_t = anim.t_ms.load(Ordering::Relaxed);
669        let (mut nx, mut ny) = (
670            f32::from_bits(anim.x.load(Ordering::Relaxed)) as f64,
671            f32::from_bits(anim.y.load(Ordering::Relaxed)) as f64,
672        );
673        // La estela es un efecto de «el caret se fue de viaje». La política —qué
674        // cuenta como viaje— vive entera en `perseguir`, que es pura y testeable.
675        let primer_cuadro = prev_t == 0 || ahora_ms < prev_t;
676        let dt = ahora_ms.saturating_sub(prev_t) as f32;
677        let ((px, py), sigue) = perseguir(
678            (cx, cy),
679            (nx, ny),
680            anim.persiguiendo.load(Ordering::Relaxed),
681            dt,
682            &caret_estilo,
683            primer_cuadro,
684        );
685        nx = px;
686        ny = py;
687        anim.persiguiendo.store(sigue, Ordering::Relaxed);
688        anim.x.store((nx as f32).to_bits(), Ordering::Relaxed);
689        anim.y.store((ny as f32).to_bits(), Ordering::Relaxed);
690        anim.h.store((alto as f32).to_bits(), Ordering::Relaxed);
691        anim.t_ms.store(ahora_ms, Ordering::Relaxed);
692
693        if !visible_ahora {
694            return;
695        }
696        let w = caret_estilo.ancho as f64;
697
698        // La estela: el casco convexo de la barra en su posición rezagada y en
699        // la real. Sale un cuadrilátero que también funciona en diagonal (salto
700        // de renglón), no sólo horizontal.
701        if caret_estilo.estela && ((cx - nx).abs() > 0.5 || (cy - ny).abs() > 0.5) {
702            let mut p = BezPath::new();
703            p.move_to((nx, ny + 1.5));
704            p.line_to((cx + w, cy + 1.5));
705            p.line_to((cx + w, cy + 1.5 + alto));
706            p.line_to((nx, ny + 1.5 + alto));
707            p.close_path();
708            scene.fill(
709                Fill::NonZero,
710                Affine::IDENTITY,
711                caret_color.with_alpha(0.28),
712                None,
713                &p,
714            );
715        }
716
717        // La barra sólida va SIEMPRE en la posición real, nunca en la rezagada:
718        // el caret marca dónde cae la próxima letra, y un caret que llega tarde
719        // a su lugar es un caret que miente. Lo que se atrasa es el rastro.
720        let bar = KRect::new(cx, cy + 1.5, cx + w, cy + 1.5 + alto);
721        scene.fill(Fill::NonZero, Affine::IDENTITY, caret_color, None, &bar);
722    });
723
724    // --- Cableado de mouse (idéntico al text-area plano) ---
725    let press = Arc::new(wrap);
726    let drag = press.clone();
727    let ctx = press.clone();
728    let aria = if is_empty {
729        adornos.placeholder.as_ref().map(|p| p.texto.clone()).unwrap_or_default()
730    } else {
731        String::new()
732    };
733    let content = content
734        .on_click_at(move |x, y, _w, _h| Some(press(TextAreaEvent::Press(x, y))))
735        .draggable_at(move |phase, dx, dy, x0, y0| match phase {
736            DragPhase::Move => Some(drag(TextAreaEvent::Drag { anchor: (x0, y0), dx, dy })),
737            DragPhase::End => Some(drag(TextAreaEvent::DragEnd)),
738        })
739        .on_right_click_screen(move |x, y, _w, _h| Some(ctx(TextAreaEvent::Context { x, y })))
740        .cursor(llimphi_compositor::Cursor::Text)
741        .role(llimphi_compositor::Role::TextInput)
742        .aria_value(raw.clone())
743        .aria_description(aria);
744
745    // Sin marco propio, el contenido ES la vista: el host lo mete en su caja.
746    if !adornos.marco {
747        return content;
748    }
749
750    // Box exterior: borde + radius + relieve opcional.
751    let bw = if focused { 2.0_f32 } else { 1.0_f32 };
752    let mut node = View::new(Style {
753        size: Size { width: percent(1.0_f32), height: length(body_h + 2.0 * bw) },
754        padding: Rect {
755            left: length(bw),
756            right: length(bw),
757            top: length(bw),
758            bottom: length(bw),
759        },
760        ..Default::default()
761    })
762    .fill(border)
763    .radius(palette.radius as f64);
764    if palette.relieve {
765        let (elev_a, elev_blur, elev_dy) = palette.elevation;
766        node = node.shadow(llimphi_compositor::Shadow {
767            color: Color::from_rgba8(
768                0,
769                0,
770                0,
771                if focused { elev_a.saturating_add(16) } else { elev_a },
772            ),
773            blur: if focused { elev_blur * 1.5 } else { elev_blur },
774            dx: 0.0,
775            dy: elev_dy,
776            spread: -1.0,
777        });
778    }
779    node.children(vec![content])
780}
781
782#[cfg(test)]
783mod tests {
784    use super::*;
785
786    fn c(r: u8, g: u8, b: u8) -> Color {
787        Color::from_rgba8(r, g, b, 255)
788    }
789
790    #[test]
791    fn el_caret_queda_solido_mientras_se_escribe_y_titila_despues() {
792        let e = CaretEstilo::vivo();
793        // Recién tecleado: sólido, pase lo que pase con la fase.
794        assert!(e.visible_en(1_000, 1_000));
795        assert!(e.visible_en(1_000 + e.solido_ms - 1, 1_000));
796        // Pasado el período sólido arranca la onda cuadrada.
797        let t0 = 1_000 + e.solido_ms;
798        assert!(e.visible_en(t0, 1_000), "primer medio período: encendido");
799        assert!(
800            !e.visible_en(t0 + e.medio_periodo_ms, 1_000),
801            "segundo medio período: apagado"
802        );
803        assert!(e.visible_en(t0 + 2 * e.medio_periodo_ms, 1_000));
804    }
805
806    /// **T10** — la estela del tipeo tiene que sobrevivir varios CUADROS, no
807    /// varios milisegundos.
808    ///
809    /// Las constantes (`tau_rapido = 14 ms`) suponen que alguien dibuja seguido;
810    /// pata repinta a 11–30 fps, o sea 33–90 ms por cuadro. Sin techo, el rastro
811    /// del gesto más frecuente —escribir una letra— recorría el 91 % del camino
812    /// en el primer cuadro y moría en el segundo: el efecto existía en el código
813    /// y no se dibujaba nunca.
814    #[test]
815    fn la_estela_del_tipeo_sobrevive_varios_cuadros() {
816        let e = CaretEstilo::vivo();
817        // Un salto de una letra (~7 px) a la cadencia de pata.
818        for dt in [33.0_f32, 90.0] {
819            let destino = (7.0, 0.0);
820            let mut rezagado = (0.0, 0.0);
821            let mut persiguiendo = false;
822            let mut cuadros_visibles = 0;
823            for _ in 0..12 {
824                let (nuevo, sigue) =
825                    perseguir(destino, rezagado, persiguiendo, dt, &e, false);
826                rezagado = nuevo;
827                persiguiendo = sigue;
828                if !sigue {
829                    break;
830                }
831                cuadros_visibles += 1;
832            }
833            assert!(
834                cuadros_visibles >= 3,
835                "a {dt} ms por cuadro la estela duró {cuadros_visibles} cuadros: \
836                 no se llega a ver"
837            );
838        }
839    }
840
841    /// Y no se va al otro extremo: la estela **termina**. Un rastro que nunca
842    /// llega obliga a repintar para siempre (y en pata eso es CPU en la barra).
843    #[test]
844    fn la_estela_siempre_termina() {
845        let e = CaretEstilo::vivo();
846        let destino = (400.0, 0.0);
847        let mut rezagado = (0.0, 0.0);
848        let mut persiguiendo = false;
849        let mut cuadros = 0;
850        for _ in 0..200 {
851            let (nuevo, sigue) = perseguir(destino, rezagado, persiguiendo, 33.0, &e, false);
852            rezagado = nuevo;
853            persiguiendo = sigue;
854            cuadros += 1;
855            if !sigue {
856                break;
857            }
858        }
859        assert!(!persiguiendo, "la estela no terminó en {cuadros} cuadros");
860        assert!(cuadros < 40, "tardó {cuadros} cuadros: demasiado larga");
861    }
862
863    #[test]
864    fn sin_parpadeo_el_caret_siempre_se_ve() {
865        let e = CaretEstilo::default();
866        assert!(!e.parpadeo);
867        for t in [0u64, 5_000, 1_000_000] {
868            assert!(e.visible_en(t, 0), "t={t}");
869        }
870    }
871
872    #[test]
873    fn la_metrica_mono_da_ancho_fijo_y_escala_con_el_zoom() {
874        let m = Metricas::mono(13.0);
875        assert_eq!(m.font_family.as_deref(), Some(llimphi_compositor::llimphi_text::MONOSPACE));
876        let z = m.clone().con_zoom(2.0);
877        assert_eq!(z.font_size, 26.0);
878        assert_eq!(z.line_h, (m.line_h * 2.0).round());
879        // El clamp protege de un zoom absurdo (una métrica de 0 px colgaría el
880        // ajuste blando en un bucle sin avanzar).
881        assert_eq!(m.clone().con_zoom(0.01).font_size, 13.0 * 0.5);
882        assert_eq!(m.clone().con_zoom(99.0).font_size, 13.0 * 3.0);
883    }
884
885    #[test]
886    fn los_tramos_se_recortan_al_renglon() {
887        // Espeja el recorte que hace el pintor: un tramo que cruza el borde del
888        // renglón se corta, y el que queda afuera desaparece.
889        let (b_ini, b_fin) = (10usize, 20usize);
890        let tramos = vec![(0usize, 15usize, c(1, 0, 0)), (18, 30, c(0, 1, 0)), (40, 50, c(0, 0, 1))];
891        let runs: Vec<(usize, usize, Color)> = tramos
892            .iter()
893            .filter_map(|&(a, b, col)| {
894                let a = a.max(b_ini);
895                let b = b.min(b_fin);
896                (a < b).then(|| (a - b_ini, b - b_ini, col))
897            })
898            .collect();
899        assert_eq!(runs.len(), 2, "el tramo lejano no entra");
900        assert_eq!((runs[0].0, runs[0].1), (0, 5), "recortado por la izquierda");
901        assert_eq!((runs[1].0, runs[1].1), (8, 10), "recortado por la derecha");
902    }
903
904    #[test]
905    fn adornos_vacios_son_el_text_area_de_siempre() {
906        let a = Adornos::default();
907        assert!(a.tramos.is_empty());
908        assert!(a.ghost.is_none());
909        assert!(a.placeholder.is_none());
910        assert!(!a.caret.parpadeo, "sin parpadeo, como el caret_visible de antes");
911        assert!(!a.caret.estela);
912    }
913
914    // ── Política de la estela ────────────────────────────────────────
915
916    /// Un cuadro a 60 Hz con el caret vivo.
917    fn paso(destino: f64, rezagado: f64, persiguiendo: bool) -> ((f64, f64), bool) {
918        perseguir(
919            (destino, 0.0),
920            (rezagado, 0.0),
921            persiguiendo,
922            16.0,
923            &CaretEstilo::vivo(),
924            false,
925        )
926    }
927
928    #[test]
929    fn tipear_deja_apenas_un_destello() {
930        // Escribir corre el caret un carácter (~8 px): queda un rastro, pero
931        // brevísimo — a 16 ms ya recorrió la mayor parte del camino.
932        let ((x, _), _) = paso(108.0, 100.0, false);
933        let recorrido = (x - 100.0) / 8.0;
934        // A 16 ms recorre ~1/3: el rastro de una tecla vive unos tres cuadros.
935        // Se nota que respira sin dejar reguero — que es lo que se buscaba.
936        assert!(recorrido > 0.25, "avanza de verdad en un cuadro: {recorrido:.2}");
937        assert!(x < 108.0, "y todavía hay algo que dibujar: {x}");
938    }
939
940    #[test]
941    fn un_viaje_es_mucho_mas_lento_que_una_tecla() {
942        // La misma fracción de camino no se recorre igual: el rastro de un
943        // salto grande dura varios cuadros, el de una tecla casi ninguno.
944        let ((corto, _), _) = paso(108.0, 100.0, false);
945        let frac_tecla = (corto - 100.0) / 8.0;
946        let ((largo, _), _) = paso(300.0, 100.0, false);
947        let frac_viaje = (largo - 100.0) / 200.0;
948        assert!(
949            frac_viaje < frac_tecla,
950            "el viaje se rezaga más: {frac_viaje:.2} vs {frac_tecla:.2}"
951        );
952    }
953
954    #[test]
955    fn un_salto_deliberado_si_deja_estela() {
956        // Un click 200 px más allá (o Home/End, o salto de palabra).
957        let ((x, _), sigue) = paso(300.0, 100.0, false);
958        assert!(x > 100.0 && x < 300.0, "el rastro queda atrás: {x}");
959        assert!(sigue, "el viaje sigue en curso");
960    }
961
962    #[test]
963    fn el_viaje_no_se_corta_al_acercarse() {
964        // Ya persiguiendo, lo que falta baja del umbral de salto — y aun así
965        // debe terminar el recorrido en vez de teletransportarse.
966        let ((x, _), sigue) = paso(300.0, 295.0, true);
967        assert!(sigue || x == 300.0);
968        let ((x2, _), _) = paso(300.0, 240.0, true);
969        assert!(x2 > 240.0 && x2 < 300.0, "sigue persiguiendo: {x2}");
970    }
971
972    #[test]
973    fn cambiar_de_renglon_se_clava_sin_tajo_diagonal() {
974        // El caso que ensuciaba todo: con ajuste blando, escribir cerca del
975        // final reacomoda la última palabra al renglón de abajo y el caret salta
976        // al principio. Un casco convexo entre esos dos puntos es un tajo
977        // diagonal a lo ancho de la caja — y el usuario no navegó a ningún lado.
978        let ((x, y), sigue) = perseguir(
979            (4.0, 20.0),      // principio del renglón siguiente
980            (600.0, 0.0),     // final del anterior
981            false,
982            16.0,
983            &CaretEstilo::vivo(),
984            false,
985        );
986        assert_eq!((x, y), (4.0, 20.0), "se clava, no cruza en diagonal");
987        assert!(!sigue);
988    }
989
990    #[test]
991    fn el_primer_cuadro_no_estrena_estela_desde_el_origen() {
992        let ((x, y), sigue) = perseguir(
993            (300.0, 0.0),
994            (0.0, 0.0),
995            false,
996            16.0,
997            &CaretEstilo::vivo(),
998            true,
999        );
1000        assert_eq!((x, y), (300.0, 0.0));
1001        assert!(!sigue);
1002    }
1003
1004    #[test]
1005    fn sin_estela_el_rastro_no_existe() {
1006        let ((x, _), sigue) = perseguir(
1007            (300.0, 0.0),
1008            (100.0, 0.0),
1009            false,
1010            16.0,
1011            &CaretEstilo::default(), // estela: false
1012            false,
1013        );
1014        assert_eq!(x, 300.0);
1015        assert!(!sigue);
1016    }
1017
1018    /// El caso de media: caja de tres renglones (17 px de línea, 5 de aire) y
1019    /// un magnet que ocupa cuatro. El cuarto NO se pinta — antes se dibujaba
1020    /// entero sobre los botones de abajo.
1021    #[test]
1022    fn el_renglon_que_asoma_no_se_pinta() {
1023        let (line_h, pad_y) = (17.0_f64, 5.0_f64);
1024        let rect_y = 0.0;
1025        let rect_h = 3.0 * line_h + 2.0 * pad_y; // 61: lo que el host reserva
1026        let y_de = |vi: usize| rect_y + pad_y + vi as f64 * line_h;
1027        for vi in 0..3 {
1028            assert!(renglon_entra(y_de(vi), line_h, rect_y, rect_h), "el {vi} tiene que entrar");
1029        }
1030        assert!(
1031            !renglon_entra(y_de(3), line_h, rect_y, rect_h),
1032            "el cuarto empieza dentro (56 < 61) pero termina en 73: se sale"
1033        );
1034    }
1035
1036    /// El otro borde, que apareció apenas se tapó el primero: con el caret al
1037    /// final, el viewport baja un renglón y el anterior queda a caballo del
1038    /// borde de arriba — se escribía sobre el título del panel.
1039    #[test]
1040    fn el_que_asoma_por_arriba_tampoco_se_pinta() {
1041        let (line_h, pad_y, rect_y) = (17.0_f64, 5.0_f64, 0.0_f64);
1042        let rect_h = 4.0 * line_h + 2.0 * pad_y;
1043        // Viewport corrido un renglón: el primero visible arranca en `pad_y`.
1044        assert!(renglon_entra(pad_y, line_h, rect_y, rect_h), "el primero visible entra");
1045        // El de arriba arranca 17 px antes: pisa el borde y no se pinta.
1046        assert!(!renglon_entra(pad_y - line_h, line_h, rect_y, rect_h));
1047        assert!(!renglon_entra(-20.0, line_h, rect_y, rect_h), "y el que quedó muy arriba, menos");
1048    }
1049
1050    #[test]
1051    fn un_redondeo_no_borra_el_ultimo_renglon() {
1052        // El alto vuelve de taffy como f32: 61.0 puede llegar como 60.999996.
1053        let (line_h, pad_y) = (17.0_f64, 5.0_f64);
1054        let rect_h = (3.0_f32 * 17.0 + 2.0 * 5.0 - 0.004) as f64;
1055        assert!(
1056            renglon_entra(pad_y + 2.0 * line_h, line_h, 0.0, rect_h),
1057            "el tercero se pinta igual: la tolerancia existe para esto"
1058        );
1059    }
1060}