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