tawasuyu

tawasuyu/llimphi

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

Rust 614Markdown 274TOML 149RON 310606 Definiciones

⎇ main widgets/text-input/src/area.rs Rust · 981 líneas · 42.1 KB Historia Quién tocó qué crudo

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

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}