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//! Text-area (input de texto **multilínea**) sobre el mismo motor que el
2//! single-line: [`super::TextInputState`] con un `EditorState` construido con
3//! [`super::TextInputState::multiline`]. No hay estado paralelo ni un `String`
4//! plano: hereda selección, undo/redo, salto de palabra, Home/End, copiar/pegar
5//! e IME del motor, y agrega lo que faltaba para multilínea:
6//!
7//! - **Layout visual** ([`visual_layout`]): parte cada línea lógica en
8//! **renglones visuales** según el ancho disponible (soft-wrap por palabra, con
9//! corte duro para palabras larguísimas). Es la **fuente única** que comparten
10//! el render, el hit-testing y el pintado del caret — así los tres coinciden.
11//! - **Hit-testing 2D real**: cada renglón visual se pinta como un nodo de alto
12//! fijo [`AREA_LINE_H`] → `y → renglón visual` exacto; dentro, `x → columna`
13//! midiendo los **glifos reales** del segmento ([`super::col_from_x_pad`]).
14//! - **Scroll vertical**: si hay más renglones visuales que `rows`, el bloque se
15//! desplaza para mantener el caret a la vista (mínimo, con histéresis: sólo
16//! scrollea al salir del viewport). Espeja el scroll horizontal del single-line.
17//! - **Caret y selección a través de líneas/wraps**: un `paint_over` los dibuja en
18//! coordenadas de renglón visual, restando el scroll.
19//!
20//! Limitación conocida (follow-up, anotada — no fingida): **↑/↓ navegan por línea
21//! lógica**, no por renglón visual (el motor `EditorState` razona en líneas
22//! lógicas). El click 2D sí cae en el renglón visual correcto de una línea
23//! envuelta; sólo la navegación vertical por teclado salta la línea entera.
24
25use alloc::format;
26use alloc::string::String;
27use alloc::vec;
28use alloc::vec::Vec;
29
30use core::sync::atomic::Ordering;
31
32use llimphi_compositor::llimphi_text::Alignment;
33use llimphi_compositor::View;
34use llimphi_compositor::{ImeEvent, Instant, Key, KeyEvent, NamedKey};
35
36use super::TextInputPalette;
37use super::{
38 col_from_x_pad_m, prefix_width_m, Clipboard, Metricas, TextInputState,
39};
40
41/// Alto (px) de cada renglón visual del text-area con la métrica default.
42/// Se mantiene como constante pública porque varios hosts dimensionan su caja
43/// con ella; la métrica real de un cuadro es [`Metricas::line_h`].
44pub const AREA_LINE_H: f32 = 19.0;
45
46/// Un **renglón visual**: un tramo `[start, end)` (en columnas de carácter) de la
47/// línea lógica `line`. Una línea que entra en el ancho es un solo renglón; una
48/// que se envuelve son varios contiguos que cubren toda la línea.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub(crate) struct VisualRow {
51 pub(crate) line: usize,
52 pub(crate) start: usize,
53 pub(crate) end: usize,
54}
55
56/// Ancho (px) del subtramo `[a, b)` de `line`, medido con la fuente real (la
57/// misma que el render).
58///
59/// Se mide el **tramo**, no la diferencia de dos prefijos desde el carácter 0.
60/// La resta de prefijos era exacta pero cobraba el shaping del prefijo entero en
61/// cada llamada: envolver una línea larga terminaba re-shapeando el texto
62/// completo una vez por renglón (bug T11 de COLA-SHUMA — el input se colgaba al
63/// clickear con mucho texto). Midiendo el tramo, el costo es proporcional al
64/// renglón y no a dónde caiga dentro del párrafo.
65///
66/// El colapso del espacio final —lo que hace que una línea terminada en espacio
67/// no abra un renglón vacío— lo sigue dando parley solo, porque mide igual que
68/// `medir_crudo`: whitespace al final vale cero.
69fn seg_width(line: &str, a: usize, b: usize, m: &Metricas) -> f32 {
70 if b <= a {
71 return 0.0;
72 }
73 super::medir_crudo(&line[byte_de_col(line, a)..byte_de_col(line, b)], m)
74}
75
76/// Offset en bytes de la columna `col` (en caracteres); `line.len()` si la
77/// columna cae al final o más allá.
78fn byte_de_col(line: &str, col: usize) -> usize {
79 line.char_indices().nth(col).map_or(line.len(), |(i, _)| i)
80}
81
82/// [`seg_width`] con los offsets de byte **ya calculados** (`offs[i]` = byte de
83/// la columna `i`, con `offs[n] = line.len()`). Es el que usa el bucle de
84/// envoltura: ahí `seg_width` se llama muchas veces sobre la misma línea, y
85/// recorrerla desde el carácter 0 en cada medición —o peor, recolectar el tramo
86/// en un `String` nuevo— agrega un término cuadrático propio, aparte del
87/// shaping.
88fn seg_width_offs(line: &str, offs: &[usize], a: usize, b: usize, m: &Metricas) -> f32 {
89 if b <= a {
90 return 0.0;
91 }
92 super::medir_crudo(&line[offs[a]..offs[b]], m)
93}
94
95/// Parte `text` en renglones visuales para el `wrap_w` dado (ancho del área de
96/// texto, px). `wrap_w <= 1` (aún sin medir la caja) ⇒ sin envolver (un renglón
97/// por línea lógica). Word-wrap: corta en el último espacio que entra; si una
98/// palabra sola no entra, corte duro por carácter (siempre progresa).
99pub(crate) fn visual_layout(text: &str, wrap_w: f32, m: &Metricas) -> Vec<VisualRow> {
100 let logical: Vec<&str> = if text.is_empty() {
101 vec![""]
102 } else {
103 text.split('\n').collect()
104 };
105 let mut rows = Vec::new();
106 for (li, line) in logical.iter().enumerate() {
107 let n = line.chars().count();
108 if n == 0 {
109 rows.push(VisualRow { line: li, start: 0, end: 0 });
110 continue;
111 }
112 if wrap_w <= 1.0 || seg_width(line, 0, n, m) <= wrap_w {
113 rows.push(VisualRow { line: li, start: 0, end: n });
114 continue;
115 }
116 let chars: Vec<char> = line.chars().collect();
117 // Offsets de byte por columna (`n + 1` entradas), calculados una vez
118 // por línea lógica: con ellos cada medición corta el tramo con un
119 // slice, sin recorrer ni copiar.
120 let mut offs: Vec<usize> = line.char_indices().map(|(i, _)| i).collect();
121 offs.push(line.len());
122 let mut start = 0usize;
123 while start < n {
124 // Cuántos caracteres entran desde `start`. El escaneo lineal de
125 // antes llamaba a `seg_width` **una vez por carácter**, y cada
126 // llamada re-shapea el prefijo entero con parley: costo cuadrático
127 // en shaping, medido en 4.5 s para 4.000 caracteres (bug T11 de
128 // COLA-SHUMA: el input se colgaba al clickear con mucho texto).
129 //
130 // `seg_width(start, ·)` es monótona no decreciente (el ancho de un
131 // prefijo nunca baja al agregarle un carácter; el colapso del
132 // espacio final sólo lo deja igual), así que el mismo corte sale
133 // por **búsqueda binaria**, con `log(k)` mediciones por renglón en
134 // vez de `k`. El resultado es idéntico carácter a carácter — lo
135 // único que cambia es cuántas veces se mide.
136 //
137 // Pero el binario a secas arranca en `hi = n`, o sea que su primera
138 // medición shapea **medio párrafo** para decidir un renglón de cien
139 // caracteres: el costo por renglón queda atado al largo del texto y
140 // el total vuelve a crecer cuadráticamente (medido: 8.000 chars =
141 // 158 ms, casi 5× lo de 4.000). Por eso primero se **galopa** —
142 // duplicar el tramo desde `start` hasta que deje de entrar — y el
143 // binario corre dentro de ese corchete. Así toda medición es del
144 // orden del renglón, no del párrafo.
145 let mut lo = start; // entra seguro
146 let mut hi = n; // puede no entrar
147 let mut paso = 1usize;
148 loop {
149 let cand = start.saturating_add(paso);
150 if cand >= n {
151 break; // el resto de la línea es el corchete; `hi = n`
152 }
153 if seg_width_offs(line, &offs, start, cand, m) <= wrap_w {
154 lo = cand;
155 paso = paso.saturating_mul(2);
156 } else {
157 hi = cand - 1; // `cand` no entra ⇒ el corte está antes
158 break;
159 }
160 }
161 while lo < hi {
162 let mid = hi - (hi - lo) / 2; // techo: `mid > lo` siempre
163 if seg_width_offs(line, &offs, start, mid, m) <= wrap_w {
164 lo = mid;
165 } else {
166 hi = mid - 1;
167 }
168 }
169 // Al menos un carácter por renglón, aunque solo no entre (corte
170 // duro de la palabra larguísima) — igual que el escaneo viejo.
171 let end = lo.max(start + 1).min(n);
172 // Último corte tras un espacio dentro de lo que entró. Sin medir:
173 // es un barrido de caracteres sobre el tramo ya elegido.
174 let last_space = chars[start..end]
175 .iter()
176 .rposition(|&c| c == ' ')
177 .map(|i| start + i + 1);
178 let brk = if end >= n {
179 n
180 } else {
181 last_space.filter(|&b| b > start).unwrap_or(end)
182 };
183 let brk = brk.clamp(start + 1, n);
184 rows.push(VisualRow { line: li, start, end: brk });
185 start = brk;
186 }
187 }
188 if rows.is_empty() {
189 rows.push(VisualRow { line: 0, start: 0, end: 0 });
190 }
191 rows
192}
193
194/// n-ésima línea lógica de `text` (o `""`).
195pub(crate) fn nth_line(text: &str, i: usize) -> &str {
196 if text.is_empty() {
197 return "";
198 }
199 text.split('\n').nth(i).unwrap_or("")
200}
201
202/// Índice del renglón visual que contiene el caret. En un borde de wrap
203/// (`col == end`) prefiere el renglón de continuación (inicio del siguiente).
204pub(crate) fn caret_vrow(rows: &[VisualRow], caret_line: usize, caret_col: usize) -> usize {
205 let mut idx = 0;
206 for (i, r) in rows.iter().enumerate() {
207 if r.line == caret_line && caret_col >= r.start && caret_col <= r.end {
208 idx = i;
209 if caret_col < r.end {
210 break;
211 }
212 }
213 }
214 idx
215}
216
217/// Eventos de entrada del text-area — ver el enum en el crate. Idéntico patrón
218/// que [`super::TextInputEvent`] pero los gestos de mouse llevan `(x, y)`.
219#[derive(Debug, Clone)]
220pub enum TextAreaEvent {
221 /// Tecla (con modificadores). Ctrl+C/X/V sobre el clipboard. Enter = `\n`.
222 Key(KeyEvent),
223 /// Evento de IME (acentos compuestos, CJK, emoji).
224 Ime(ImeEvent),
225 /// Mouse-down a `(x, y)` px locales al bloque de texto: coloca el caret y
226 /// escala a palabra/todo por doble/triple-click.
227 Press(f32, f32),
228 /// Arrastre: `anchor` = press (px), `dx`/`dy` = delta. Extiende selección 2D.
229 Drag { anchor: (f32, f32), dx: f32, dy: f32 },
230 /// Fin del arrastre.
231 DragEnd,
232 /// Click derecho a `(x, y)` (coords absolutas de ventana): pide el menú de
233 /// edición. No cambia el estado (lo intercepta el host).
234 Context { x: f32, y: f32 },
235}
236
237impl TextInputState {
238 /// Ancho (px) del área de texto según el último frame pintado. `0` si la caja
239 /// aún no se midió (primer frame) ⇒ sin soft-wrap ese frame.
240 fn area_wrap_w(&self) -> f32 {
241 let m = self.metricas();
242 let bw = f32::from_bits(self.box_w.load(Ordering::Relaxed));
243 if bw <= 1.0 {
244 return 0.0;
245 }
246 (bw - m.pad_x - m.pad_r).max(0.0)
247 }
248
249 /// Cuántos **renglones visuales** ocupa hoy el texto con el ancho y la
250 /// métrica del último cuadro pintado (1 si todavía no se midió la caja).
251 ///
252 /// Lo necesita un host que **crece con el contenido**: en un layer-shell la
253 /// vista no puede desbordar su surface, así que la altura la decide el host,
254 /// y para decidirla tiene que poder preguntar cuánto va a ocupar el texto
255 /// *antes* de pintarlo. Sin esto, cada host se reimplementa el ajuste blando
256 /// —y el suyo y el del widget se desfasan en el primer caso raro.
257 pub fn renglones_visuales(&self) -> usize {
258 let m = self.metricas();
259 visual_layout(&self.text(), self.area_wrap_w(), &m).len()
260 }
261
262 /// Como [`Self::renglones_visuales`] pero sobre un texto arbitrario — para
263 /// preguntar "¿cuánto ocuparía *esto*?" sin tocar el estado (p. ej. decidir
264 /// si conviene crecer antes de insertar).
265 pub fn renglones_visuales_de(&self, texto: &str) -> usize {
266 let m = self.metricas();
267 visual_layout(texto, self.area_wrap_w(), &m).len()
268 }
269
270 /// Largo (en caracteres) del último renglón visual y cuántos caracteres
271 /// entran en un renglón lleno. Es lo que necesita un overlay pegado a la
272 /// derecha del input para saber si el texto lo está por alcanzar.
273 ///
274 /// Con más de un renglón devuelve `(cols, cols)`: los de arriba **ya**
275 /// llegaron al borde derecho y pasan por detrás del overlay, por corta que
276 /// sea la última fila.
277 pub fn avance_en_renglon(&self) -> (usize, usize) {
278 let m = self.metricas();
279 let texto = self.text();
280 let rows = visual_layout(&texto, self.area_wrap_w(), &m);
281 // Ancho útil en caracteres, medido con un dígito (en mono es exacto; en
282 // proporcional es una estimación honesta, que es todo lo que un overlay
283 // necesita para decidir si esquivar).
284 let char_w = prefix_width_m("0", 1, &m).max(1.0);
285 let cols = ((self.area_wrap_w() / char_w) as usize).max(1);
286 if rows.len() > 1 {
287 return (cols, cols);
288 }
289 let ultima = rows.last().map(|r| r.end - r.start).unwrap_or(0);
290 (ultima, cols)
291 }
292
293 /// Fija a mano el ancho (px) de la caja de texto, como si ya se hubiera
294 /// pintado un cuadro.
295 ///
296 /// Sirve para dos cosas concretas: que un host que ya sabe su ancho no se
297 /// coma el primer cuadro **sin** ajuste blando (el texto entra de una tira y
298 /// salta al reflujo en el segundo), y que se pueda testear el wrap sin
299 /// levantar una GPU.
300 pub fn fijar_ancho_caja(&self, ancho_px: f32) {
301 self.box_w.store(ancho_px.max(0.0).to_bits(), Ordering::Relaxed);
302 }
303
304 /// Ancho (px) de un carácter con la métrica del último cuadro. Exacto en
305 /// mono; en proporcional es el ancho del `0`, que es lo que un host puede
306 /// usar para traducir un ancho suyo a "cuántos caracteres entran".
307 pub fn ancho_caracter(&self) -> f32 {
308 prefix_width_m("0", 1, &self.metricas())
309 }
310
311 /// Mapea `(x, y)` locales al bloque (viewport) → `(línea lógica, columna)`,
312 /// atravesando el layout visual (wrap) y el scroll vertical del último frame.
313 fn line_col_at(&self, local_x: f32, local_y: f32) -> (usize, usize) {
314 let m = self.metricas();
315 let text = self.text();
316 let rows = visual_layout(&text, self.area_wrap_w(), &m);
317 let scroll_y = f32::from_bits(self.area_scroll_y.load(Ordering::Relaxed));
318 let content_y = local_y + scroll_y; // deshacer la traslación del render
319 let vi = (((content_y - m.pad_y).max(0.0)) / m.line_h) as usize;
320 let vi = vi.min(rows.len().saturating_sub(1));
321 let vr = rows[vi];
322 let line_str = nth_line(&text, vr.line);
323 let seg: String = line_str
324 .chars()
325 .skip(vr.start)
326 .take(vr.end - vr.start)
327 .collect();
328 let col_in_seg = col_from_x_pad_m(&seg, local_x, m.pad_x, &m);
329 let col = (vr.start + col_in_seg).min(vr.end);
330 (vr.line, col)
331 }
332
333 /// Coloca el caret (o escala a palabra/todo por doble/triple-click) bajo
334 /// `(x, y)`. Multilínea análogo de [`TextInputState::pointer_click`].
335 #[cfg(feature = "std")]
336 pub fn place_caret_2d(&mut self, local_x: f32, local_y: f32) {
337 self.place_caret_2d_at(local_x, local_y, Instant::now());
338 }
339
340 /// Como [`Self::place_caret_2d`] pero con el instante inyectado — el
341 /// perfil sin `std` (la jaula) pasa el del fotograma.
342 pub fn place_caret_2d_at(&mut self, local_x: f32, local_y: f32, now: Instant) {
343 self.area_goal_x = None;
344 let (line, col) = self.line_col_at(local_x, local_y);
345 self.inner.register_click_at(line, col, now);
346 }
347
348 /// Ancla el caret (sin escalar) en `(x, y)` — inicio de un arrastre.
349 pub fn set_caret_2d(&mut self, local_x: f32, local_y: f32) {
350 self.area_goal_x = None;
351 let (line, col) = self.line_col_at(local_x, local_y);
352 self.inner.set_caret_at(line, col);
353 }
354
355 /// Extiende la selección hasta `(x, y)` — extremo vivo del arrastre.
356 pub fn extend_sel_2d(&mut self, local_x: f32, local_y: f32) {
357 self.area_goal_x = None;
358 let (line, col) = self.line_col_at(local_x, local_y);
359 self.inner.extend_selection_to(line, col);
360 }
361
362 /// Mueve el caret **un renglón visual** hacia arriba (`dir=-1`) o abajo
363 /// (`dir=+1`), preservando la posición horizontal en px (columna meta) para no
364 /// derivar sobre fuente proporcional / wrap. `extend=true` (Shift) arrastra la
365 /// selección. Es lo que hace ↑/↓ un renglón envuelto correcto (a diferencia
366 /// del motor, que salta la línea lógica entera). Devuelve `true` (repintar).
367 /// ¿El caret está en el **primer renglón visual** del área?
368 ///
369 /// Existe para que el host pueda ceder la flecha: [`Self::move_vertical_visual`]
370 /// nunca contesta «no pude» —en el borde mueve el caret al principio (o al
371 /// final) del documento y devuelve `true`—, así que preguntándole a él no se
372 /// distingue «subí un renglón» de «ya estaba arriba». Quien tiene algo
373 /// **afuera** del área a donde ir con la misma tecla —el historial de una
374 /// shell, la pestaña de al lado— necesita saberlo **antes** de delegar.
375 ///
376 /// Es por renglón **visual** y no por línea lógica, igual que el movimiento:
377 /// en texto envuelto, un párrafo largo son varios renglones y ceder en el
378 /// primero de la línea sería ceder en medio de la pantalla.
379 pub fn en_primer_renglon_visual(&self) -> bool {
380 self.vrow_del_caret() == 0
381 }
382
383 /// ¿El caret está en el **último renglón visual**? El espejo de
384 /// [`Self::en_primer_renglon_visual`].
385 pub fn en_ultimo_renglon_visual(&self) -> bool {
386 let m = self.metricas();
387 let text = self.text();
388 let rows = visual_layout(&text, self.area_wrap_w(), &m);
389 self.vrow_del_caret() + 1 >= rows.len().max(1)
390 }
391
392 /// Índice del renglón visual donde está el caret.
393 fn vrow_del_caret(&self) -> usize {
394 let m = self.metricas();
395 let text = self.text();
396 let rows = visual_layout(&text, self.area_wrap_w(), &m);
397 let caret = self.inner.cursor.caret;
398 caret_vrow(&rows, caret.line, caret.col)
399 }
400
401 pub fn move_vertical_visual(&mut self, dir: i32, extend: bool) -> bool {
402 let m = self.metricas();
403 let text = self.text();
404 let rows = visual_layout(&text, self.area_wrap_w(), &m);
405 let caret = self.inner.cursor.caret;
406 let cv = caret_vrow(&rows, caret.line, caret.col);
407 let cur = rows[cv];
408 // Columna meta (px desde el inicio del renglón): la guardada, o la actual.
409 let goal_x = self.area_goal_x.unwrap_or_else(|| {
410 let line = nth_line(&text, cur.line);
411 seg_width(line, cur.start, caret.col, &m)
412 });
413
414 let target = cv as i32 + dir;
415 let apply = |st: &mut TextInputState, line: usize, col: usize| {
416 if extend {
417 st.inner.extend_selection_to(line, col);
418 } else {
419 st.inner.set_caret_at(line, col);
420 }
421 };
422
423 if target < 0 {
424 // ↑ en el primer renglón → inicio del documento.
425 apply(self, 0, 0);
426 self.area_goal_x = None;
427 return true;
428 }
429 if target as usize >= rows.len() {
430 // ↓ en el último renglón → fin del documento.
431 let last = rows[rows.len() - 1];
432 apply(self, last.line, last.end);
433 self.area_goal_x = None;
434 return true;
435 }
436
437 let tv = rows[target as usize];
438 let tline = nth_line(&text, tv.line);
439 let seg: String = tline.chars().skip(tv.start).take(tv.end - tv.start).collect();
440 // La columna del renglón destino cuyo x se acerca más a la meta.
441 let col_in_seg = col_from_x_pad_m(&seg, goal_x + m.pad_x, m.pad_x, &m);
442 let tcol = (tv.start + col_in_seg).min(tv.end);
443 apply(self, tv.line, tcol);
444 self.area_goal_x = Some(goal_x);
445 true
446 }
447
448 /// Lleva el caret al **inicio o fin del renglón visual** donde está.
449 ///
450 /// El motor mueve al inicio/fin de la línea *lógica*, que en texto envuelto
451 /// es todo el párrafo: con tres renglones de ajuste blando, End saltaba al
452 /// final del último y Home al principio del primero — o sea, Home/End se
453 /// portaban como Ctrl+Home/Ctrl+End. Un editor de verdad los mueve renglón a
454 /// renglón, que es donde está el ojo. `Ctrl+Home`/`Ctrl+End` (documento
455 /// entero) siguen yendo al motor, sin tocar.
456 ///
457 /// `extend=true` (Shift) arrastra la selección. Devuelve `true` (repintar).
458 pub fn move_horizontal_visual(&mut self, al_final: bool, extend: bool) -> bool {
459 let m = self.metricas();
460 let text = self.text();
461 let rows = visual_layout(&text, self.area_wrap_w(), &m);
462 let caret = self.inner.cursor.caret;
463 let vr = rows[caret_vrow(&rows, caret.line, caret.col)];
464 // El fin de un renglón CORTADO POR AJUSTE cae sobre el espacio que lo
465 // separa del siguiente; parar antes de él es lo que espera la mano (si
466 // no, un End seguido de escribir mete texto del otro lado del corte).
467 let col = if al_final {
468 let linea = nth_line(&text, vr.line);
469 let corte_blando = vr.end < linea.chars().count();
470 let termina_en_espacio = linea
471 .chars()
472 .nth(vr.end.saturating_sub(1))
473 .is_some_and(|c| c == ' ');
474 if corte_blando && termina_en_espacio && vr.end > vr.start {
475 vr.end - 1
476 } else {
477 vr.end
478 }
479 } else {
480 vr.start
481 };
482 if extend {
483 self.inner.extend_selection_to(vr.line, col);
484 } else {
485 self.inner.set_caret_at(vr.line, col);
486 }
487 self.area_goal_x = None;
488 true
489 }
490
491 /// Procesa un [`TextAreaEvent`] y devuelve `true` si hay que repintar. Espeja
492 /// [`TextInputState::handle`] en 2D: un solo `Msg` cubre escribir, mover
493 /// (incl. ↑/↓), seleccionar, copiar/pegar, acentos, click, doble/triple-click
494 /// y arrastre.
495 #[cfg(feature = "std")]
496 pub fn handle_area(&mut self, ev: TextAreaEvent, clipboard: &mut dyn Clipboard) -> bool {
497 self.handle_area_at(ev, clipboard, Instant::now())
498 }
499
500 /// Como [`Self::handle_area`] pero con el instante inyectado — el perfil
501 /// sin `std` (la jaula) pasa el del fotograma.
502 pub fn handle_area_at(&mut self, ev: TextAreaEvent, clipboard: &mut dyn Clipboard, now: Instant) -> bool {
503 match ev {
504 TextAreaEvent::Key(e) => {
505 // ↑/↓ (sin Ctrl/Alt) navegan por **renglón visual**, no por línea
506 // lógica: interceptamos antes de delegar en el motor (que salta la
507 // línea entera en texto envuelto). Shift arrastra la selección.
508 if matches!(e.key, Key::Named(NamedKey::ArrowUp | NamedKey::ArrowDown))
509 && !e.modifiers.ctrl
510 && !e.modifiers.alt
511 && e.state == llimphi_compositor::KeyState::Pressed
512 {
513 let dir = if matches!(e.key, Key::Named(NamedKey::ArrowUp)) { -1 } else { 1 };
514 return self.move_vertical_visual(dir, e.modifiers.shift);
515 }
516 // Home/End PELADOS: por renglón visual, igual que ↑/↓ y por la
517 // misma razón. Con Ctrl siguen yendo al motor (documento entero).
518 if matches!(e.key, Key::Named(NamedKey::Home | NamedKey::End))
519 && !e.modifiers.ctrl
520 && !e.modifiers.alt
521 && e.state == llimphi_compositor::KeyState::Pressed
522 {
523 let al_final = matches!(e.key, Key::Named(NamedKey::End));
524 return self.move_horizontal_visual(al_final, e.modifiers.shift);
525 }
526 // Cualquier otra tecla resetea la columna meta y delega en el motor.
527 self.area_goal_x = None;
528 self.apply_key_with_clipboard(&e, clipboard)
529 }
530 TextAreaEvent::Ime(e) => {
531 self.area_goal_x = None;
532 self.apply_ime(&e)
533 }
534 TextAreaEvent::Press(x, y) => {
535 self.drag_active = false;
536 self.drag_x = x;
537 self.drag_y = y;
538 self.place_caret_2d_at(x, y, now);
539 true
540 }
541 TextAreaEvent::Drag { anchor, dx, dy } => {
542 if !self.drag_active {
543 self.set_caret_2d(anchor.0, anchor.1);
544 self.drag_x = anchor.0;
545 self.drag_y = anchor.1;
546 self.drag_active = true;
547 }
548 self.drag_x += dx;
549 self.drag_y += dy;
550 self.extend_sel_2d(self.drag_x, self.drag_y);
551 true
552 }
553 TextAreaEvent::DragEnd => {
554 self.drag_active = false;
555 false
556 }
557 TextAreaEvent::Context { .. } => false,
558 }
559 }
560}
561
562/// Ancho (px) del prefijo de `line` hasta `cols` chars, medido con el
563/// `Typesetter` del pintado (misma fuente/tamaño que el render → exacto).
564pub(crate) fn measure_prefix(
565 ts: &mut llimphi_compositor::llimphi_text::Typesetter,
566 line: &str,
567 cols: usize,
568 m: &Metricas,
569) -> f64 {
570 if cols == 0 {
571 return 0.0;
572 }
573 let p: String = line.chars().take(cols).collect();
574 // Ancla contra el colapso del espacio final — ver `prefix_width_m`. Sin
575 // esto el caret parado tras un espacio se dibuja un carácter a la izquierda.
576 if p.ends_with(char::is_whitespace) {
577 const ANCLA: &str = "0";
578 return medir_con(ts, &format!("{p}{ANCLA}"), m) - medir_con(ts, ANCLA, m);
579 }
580 medir_con(ts, &p, m)
581}
582
583/// Mide un texto con el `Typesetter` del pintado (misma fuente/tamaño que el
584/// render → exacto), tal cual lo shapea parley.
585fn medir_con(ts: &mut llimphi_compositor::llimphi_text::Typesetter, p: &str, m: &Metricas) -> f64 {
586 let lp = ts.layout(
587 p,
588 m.font_size,
589 None,
590 Alignment::Start,
591 1.2,
592 false,
593 m.font_family.as_deref(),
594 400.0,
595 false,
596 false,
597 0.0,
598 0.0,
599 );
600 llimphi_compositor::llimphi_text::measurement(&lp).width as f64
601}
602
603/// Vista del text-area: bloque multilínea con **soft-wrap**, **scroll
604/// vertical**, caret 2D, selección a través de líneas, teclado completo,
605/// copiar/pegar y menú contextual. `rows` = renglones **visibles**.
606///
607/// Es [`super::rico::text_area_view_rico`] con los adornos vacíos y la métrica
608/// default. No hay dos renderizadores: cuando había un pintor "simple" y otro
609/// "rico" se desincronizaban en cuanto uno de los dos tocaba una medida.
610pub fn text_area_view<Msg, F>(
611 state: &TextInputState,
612 placeholder: &str,
613 focused: bool,
614 rows: usize,
615 palette: &TextInputPalette,
616 wrap: F,
617) -> View<Msg>
618where
619 Msg: Clone + 'static,
620 F: Fn(TextAreaEvent) -> Msg + Send + Sync + 'static,
621{
622 let adornos = super::rico::Adornos {
623 placeholder: (!placeholder.is_empty())
624 .then(|| super::rico::Placeholder::texto(placeholder, palette.fg_placeholder)),
625 caret: super::rico::CaretEstilo {
626 parpadeo: false,
627 ..super::rico::CaretEstilo::default()
628 },
629 ..Default::default()
630 };
631 // `caret_visible == false` (la fase que alternaba el host) se respeta
632 // dejando el caret sin pintar: el ancho a cero es el equivalente exacto.
633 let adornos = if palette.caret_visible {
634 adornos
635 } else {
636 super::rico::Adornos { caret: super::rico::CaretEstilo { ancho: 0.0, ..adornos.caret }, ..adornos }
637 };
638 super::rico::text_area_view_rico(
639 state,
640 focused,
641 rows,
642 palette,
643 &Metricas::default(),
644 &adornos,
645 wrap,
646 )
647}
648
649#[cfg(test)]
650mod tests {
651 use super::*;
652
653 /// Métrica mono de prueba y el ancho (px) que dan exactamente `cols`
654 /// caracteres de texto útil. Con mono el ancho de carácter es constante, así
655 /// que la cuenta es exacta y los asserts se leen en columnas.
656 fn mono_cols(cols: usize) -> (Metricas, f32) {
657 let m = Metricas::mono(13.0);
658 let cw = prefix_width_m("0", 1, &m);
659 (m, cw * cols as f32)
660 }
661
662 /// **Regresión de T11** (COLA-SHUMA): el input se colgaba al clickear con
663 /// mucho texto. El ajuste blando era cuadrático en shaping —una medición por
664 /// carácter, cada una re-shapeando el prefijo entero—: 4.000 caracteres
665 /// tardaban **4,5 s en release**, y un click dispara el layout de nuevo.
666 /// Con el corte por búsqueda binaria y la medición por tramo son ~20 ms.
667 ///
668 /// El assert es **relativo**, no un umbral en milisegundos: se mide `N` y
669 /// `2N` y se exige que el costo no más que se duplique con holgura. Así el
670 /// test no depende de lo rápida que sea la máquina ni de debug/release —
671 /// sólo de la **forma** de la curva. Cuadrático daría ~4×; el arreglo da ~2×.
672 #[test]
673 fn envolver_mucho_texto_no_es_cuadratico() {
674 let (m, w) = mono_cols(80);
675 let medir = |chars: usize| {
676 let texto = "cargo ".repeat(chars / 6);
677 // Fuera del cronómetro: la primera medición carga la fuente.
678 let _ = visual_layout(&texto[..64], w, &m);
679 let t0 = std::time::Instant::now();
680 let filas = visual_layout(&texto, w, &m);
681 assert!(filas.len() > 10, "debería envolver en varios renglones");
682 t0.elapsed().as_secs_f64()
683 };
684 // Tallas grandes a propósito: con 2.400 → 4.800 el factor daba 2,8× y
685 // el guarda pasaba **con el costo cuadrático adentro**. El binario que
686 // cerró T11 arrancaba en `hi = n`, así que su primera medición shapeaba
687 // medio párrafo y el costo por renglón seguía atado al largo del texto;
688 // recién se delataba de 4.800 en adelante (4.800 → 9.600 daba 4,9×).
689 let corto = medir(4_800);
690 let largo = medir(9_600);
691 let factor = largo / corto.max(1e-6);
692 assert!(
693 factor < 3.0,
694 "duplicar el texto multiplicó el ajuste blando por {factor:.1}× \
695 ({:.0} ms → {:.0} ms): volvió el costo cuadrático de T11",
696 corto * 1000.0,
697 largo * 1000.0
698 );
699 }
700
701 /// Los textos de cada renglón visual, para leer los asserts sin contar
702 /// columnas.
703 fn textos(t: &str, cols: usize) -> Vec<String> {
704 let (m, w) = mono_cols(cols);
705 visual_layout(t, w, &m)
706 .into_iter()
707 .map(|vr| {
708 nth_line(t, vr.line)
709 .chars()
710 .skip(vr.start)
711 .take(vr.end - vr.start)
712 .collect()
713 })
714 .collect()
715 }
716
717 #[test]
718 fn una_linea_corta_es_un_solo_renglon() {
719 assert_eq!(textos("hola", 20), vec!["hola"]);
720 // Vacío: un renglón vacío, no cero (el caret tiene que caer en algún lado).
721 assert_eq!(textos("", 20), vec![""]);
722 }
723
724 #[test]
725 fn los_saltos_duros_siguen_cortando() {
726 assert_eq!(textos("uno\ndos", 20), vec!["uno", "dos"]);
727 // Línea vacía en el medio: se conserva.
728 assert_eq!(textos("a\n\nb", 20), vec!["a", "", "b"]);
729 }
730
731 #[test]
732 fn corta_por_palabra_no_por_letra() {
733 // 8 columnas: la palabra viaja entera al renglón siguiente, nunca se
734 // parte por la mitad si hay dónde cortar.
735 assert_eq!(textos("hola mundo cruel", 8), vec!["hola ", "mundo ", "cruel"]);
736 }
737
738 #[test]
739 fn el_espacio_final_cuelga_en_el_margen() {
740 // "hola mundo " son 11 caracteres, pero entra en 10 columnas: el espacio
741 // que queda al final del renglón **no ocupa ancho** (parley colapsa el
742 // whitespace final, como hace cualquier editor — si no, escribir un
743 // espacio al llegar al borde abriría un renglón vacío de golpe).
744 assert_eq!(textos("hola mundo cruel", 10), vec!["hola mundo ", "cruel"]);
745 }
746
747 #[test]
748 fn una_palabra_mas_larga_que_el_ancho_se_parte_a_lo_bruto() {
749 // Sin espacio donde cortar, el corte es duro — pero nunca deja renglón
750 // vacío: eso colgaría el bucle sin avanzar nunca.
751 assert_eq!(textos("aaaaaaaa", 3), vec!["aaa", "aaa", "aa"]);
752 }
753
754 #[test]
755 fn reconstruye_el_texto_exacto() {
756 // Ni un carácter de más ni de menos: los renglones son una partición del
757 // texto salvo los `\n`, que se los comen los cortes duros.
758 let t = "un texto largo con varias palabras\ny un salto duro adentro";
759 assert_eq!(textos(t, 11).concat(), t.replace('\n', ""));
760 }
761
762 #[test]
763 fn sin_ancho_medido_no_hay_ajuste() {
764 // Antes del primer cuadro (o con `fijar_ancho_caja(0)`) el texto no se
765 // parte por ancho, sólo por `\n`.
766 let t = "una linea larguisima que no deberia partirse todavia";
767 let m = Metricas::mono(13.0);
768 assert_eq!(visual_layout(t, 0.0, &m).len(), 1);
769 }
770
771 #[test]
772 fn el_caret_al_final_de_un_corte_blando_baja_al_renglon_siguiente() {
773 let (m, w) = mono_cols(8);
774 let t = "hola mundo cruel"; // ["hola ", "mundo ", "cruel"]
775 let rows = visual_layout(t, w, &m);
776 assert_eq!(rows.len(), 3);
777 // Columna 5 = final del renglón 0 y principio del 1: se muestra abajo,
778 // como en cualquier editor con ajuste blando.
779 assert_eq!(caret_vrow(&rows, 0, 5), 1);
780 assert_eq!(caret_vrow(&rows, 0, 0), 0);
781 assert_eq!(caret_vrow(&rows, 0, t.chars().count()), 2);
782 }
783
784 #[test]
785 fn el_caret_al_final_de_una_linea_con_salto_duro_se_queda_arriba() {
786 let (m, w) = mono_cols(20);
787 let rows = visual_layout("uno\ndos", w, &m);
788 // Fin de "uno" (línea 0, col 3): NO baja — la de abajo es otra línea lógica.
789 assert_eq!(caret_vrow(&rows, 0, 3), 0);
790 assert_eq!(caret_vrow(&rows, 1, 0), 1);
791 }
792
793 #[test]
794 fn renglones_visuales_usa_el_ancho_fijado() {
795 let mut st = TextInputState::multiline();
796 let (m, w) = mono_cols(8);
797 st.aplicar_metricas(&m);
798 // El ancho de CAJA incluye los paddings; `area_wrap_w` se los resta.
799 st.fijar_ancho_caja(w + m.pad_x + m.pad_r);
800 st.set_text("hola mundo cruel");
801 assert_eq!(st.renglones_visuales(), 3);
802 // Y se puede preguntar por un texto hipotético sin tocar el estado.
803 assert_eq!(st.renglones_visuales_de("hola"), 1);
804 assert_eq!(st.text(), "hola mundo cruel", "preguntar no muta");
805 }
806}
807
808#[cfg(test)]
809mod tests_espacio {
810 use super::*;
811
812 /// El caret parado **después de un espacio** tiene que dibujarse después del
813 /// espacio, no antes.
814 ///
815 /// parley —como CSS— le da ancho cero al whitespace que queda al final de lo
816 /// que se mide, así que medir el prefijo `"ab "` daba lo mismo que `"ab"` y
817 /// el caret al principio de cada palabra aparecía un carácter corrido a la
818 /// izquierda. Como el caret lógico sí estaba bien, Delete borraba de la
819 /// palabra y no el espacio que el usuario veía a su derecha: el dibujo
820 /// mentía, no la edición.
821 #[test]
822 fn el_caret_tras_un_espacio_no_se_corre_al_espacio() {
823 let m = Metricas::mono(13.0);
824 let cw = prefix_width_m("0", 1, &m);
825 let t = "ab cd";
826 // Columna 3 = justo antes de la `c`, o sea después del espacio.
827 let x = prefix_width_m(t, 3, &m);
828 assert!(
829 (x - 3.0 * cw).abs() < 0.5,
830 "el prefijo \"ab \" tiene que medir 3 caracteres, midió {x:.2} (cw={cw:.2})"
831 );
832 // Y sigue siendo estrictamente mayor que el prefijo sin el espacio.
833 assert!(x > prefix_width_m(t, 2, &m) + cw * 0.5);
834 }
835
836 /// Pero el ajuste blando sigue **colgando** el espacio final en el margen:
837 /// si contara, escribir un espacio al llegar al borde abriría un renglón
838 /// vacío de golpe.
839 #[test]
840 fn el_wrap_sigue_colgando_el_espacio_final() {
841 let m = Metricas::mono(13.0);
842 let cw = prefix_width_m("0", 1, &m);
843 assert!(
844 seg_width("ab ", 0, 3, &m) <= 2.0 * cw + 0.5,
845 "para envolver, el espacio final no ocupa"
846 );
847 }
848}
849
850
851#[cfg(test)]
852mod tests_home_end {
853 use super::*;
854 use llimphi_compositor::{Key, KeyEvent, KeyState, Modifiers, NamedKey};
855
856 pub(super) fn tecla(key: Key, ctrl: bool, shift: bool) -> KeyEvent {
857 KeyEvent {
858 key,
859 state: KeyState::Pressed,
860 text: None,
861 modifiers: Modifiers { ctrl, shift, ..Default::default() },
862 repeat: false,
863 }
864 }
865
866 /// Un área multilínea con el texto ya envuelto en varios renglones visuales.
867 pub(super) fn area_envuelta() -> (TextInputState, usize) {
868 let mut st = TextInputState::multiline();
869 // ~48 px de caja con la métrica default: corta cada pocas palabras.
870 st.fijar_ancho_caja(260.0);
871 st.set_text(
872 "uno dos tres cuatro cinco seis siete ocho nueve diez once doce trece \
873 catorce quince dieciseis diecisiete dieciocho diecinueve veinte",
874 );
875 let m = st.metricas();
876 let filas = visual_layout(&st.text(), st.area_wrap_w(), &m).len();
877 (st, filas)
878 }
879
880 #[test]
881 fn home_y_end_se_mueven_por_renglon_visual_no_por_parrafo() {
882 let (mut st, filas) = area_envuelta();
883 assert!(filas >= 3, "hacen falta 3 renglones: el 2º tiene que ser del MEDIO");
884
885 // Bajamos al segundo renglón y pedimos End.
886 let mut clip = crate::NullClipboard;
887 st.handle_area(TextAreaEvent::Key(tecla(Key::Named(NamedKey::Home), true, false)), &mut clip);
888 st.handle_area(TextAreaEvent::Key(tecla(Key::Named(NamedKey::ArrowDown), false, false)), &mut clip);
889 st.handle_area(TextAreaEvent::Key(tecla(Key::Named(NamedKey::End), false, false)), &mut clip);
890
891 let texto = st.text();
892 let m = st.metricas();
893 let rows = visual_layout(&texto, st.area_wrap_w(), &m);
894 let caret = st.editor().cursor.caret;
895 let vr = caret_vrow(&rows, caret.line, caret.col);
896 assert_eq!(vr, 1, "el caret sigue en el SEGUNDO renglón, no saltó al final");
897 assert!(caret.col < texto.chars().count(), "no se fue al fin del párrafo");
898
899 // Y Home lo lleva al inicio de ESE renglón, no del párrafo.
900 st.handle_area(TextAreaEvent::Key(tecla(Key::Named(NamedKey::Home), false, false)), &mut clip);
901 let caret = st.editor().cursor.caret;
902 let rows = visual_layout(&st.text(), st.area_wrap_w(), &m);
903 assert_eq!(caret.col, rows[1].start, "inicio del renglón visual");
904 assert_ne!(caret.col, 0, "y NO el inicio del párrafo");
905 }
906
907 #[test]
908 fn ctrl_home_y_ctrl_end_siguen_siendo_el_documento() {
909 let (mut st, _) = area_envuelta();
910 let mut clip = crate::NullClipboard;
911 st.handle_area(TextAreaEvent::Key(tecla(Key::Named(NamedKey::End), true, false)), &mut clip);
912 let fin = st.editor().cursor.caret;
913 st.handle_area(TextAreaEvent::Key(tecla(Key::Named(NamedKey::Home), true, false)), &mut clip);
914 let ini = st.editor().cursor.caret;
915 assert_eq!((ini.line, ini.col), (0, 0), "Ctrl+Home = principio del documento");
916 assert!(fin.col > ini.col, "Ctrl+End se fue lejos: {fin:?}");
917 }
918}
919
920#[cfg(test)]
921mod pruebas_borde_visual {
922 use super::*;
923 use crate::{NullClipboard, TextAreaEvent, TextInputState};
924 use llimphi_compositor::{Key, NamedKey};
925
926 /// **Por qué existen estas dos preguntas**, en un test: `move_vertical_visual`
927 /// en el borde mueve al principio del documento y contesta `true`, así que un
928 /// host que quiera ceder la flecha no puede usarla como compuerta — «subí» y
929 /// «ya estaba arriba» le llegan iguales.
930 #[test]
931 fn moverse_en_el_borde_contesta_true_y_por_eso_hace_falta_preguntar_aparte() {
932 let (mut st, filas) = super::tests_home_end::area_envuelta();
933 assert!(filas >= 3);
934 let mut clip = NullClipboard;
935 // Al principio del documento.
936 st.handle_area(
937 TextAreaEvent::Key(super::tests_home_end::tecla(Key::Named(NamedKey::Home), true, false)),
938 &mut clip,
939 );
940 assert!(st.en_primer_renglon_visual(), "arriba de todo");
941 assert!(!st.en_ultimo_renglon_visual(), "con 3 renglones, arriba no es abajo");
942 // Subir desde arriba: contesta `true` igual. Ésa es la trampa.
943 assert!(st.move_vertical_visual(-1, false), "el widget dice que sí se movió");
944 assert!(st.en_primer_renglon_visual(), "y sigue arriba");
945 }
946
947 /// El otro extremo, y que en el medio no se contesta ninguno de los dos —
948 /// brazo de control: sin esto, dos funciones que devolvieran siempre `true`
949 /// pasarían el test de arriba.
950 #[test]
951 fn en_el_medio_no_esta_ni_arriba_ni_abajo() {
952 let (mut st, filas) = super::tests_home_end::area_envuelta();
953 assert!(filas >= 3);
954 let mut clip = NullClipboard;
955 st.handle_area(
956 TextAreaEvent::Key(super::tests_home_end::tecla(Key::Named(NamedKey::Home), true, false)),
957 &mut clip,
958 );
959 st.handle_area(
960 TextAreaEvent::Key(super::tests_home_end::tecla(Key::Named(NamedKey::ArrowDown), false, false)),
961 &mut clip,
962 );
963 assert!(!st.en_primer_renglon_visual() && !st.en_ultimo_renglon_visual(), "renglón 2 de 3+");
964
965 st.handle_area(
966 TextAreaEvent::Key(super::tests_home_end::tecla(Key::Named(NamedKey::End), true, false)),
967 &mut clip,
968 );
969 assert!(st.en_ultimo_renglon_visual(), "fin del documento");
970 }
971
972 /// Un área **vacía** está en el primero y en el último a la vez. Es el caso
973 /// más frecuente de todos (el input en reposo) y el que más fácil se rompe
974 /// con un `rows.len() - 1` sobre un vector vacío.
975 #[test]
976 fn el_area_vacia_esta_en_los_dos_bordes_y_no_desborda() {
977 let st = TextInputState::multiline();
978 assert!(st.en_primer_renglon_visual());
979 assert!(st.en_ultimo_renglon_visual());
980 }
981}