tawasuyu

tawasuyu/llimphi

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

Rust 484Markdown 258TOML 131RON 38118 Definiciones

⎇ 92dd0e5bfb2ca4456006da99437f4100e156d856 widgets/terminal/src/select.rs Rust · 694 líneas · 25.8 KB Historia Quién tocó qué crudo

refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel ccab39f14 · Sergio ·

1//! Modelo de selección del scrollback — base de la Fase 3 del SDD-TERMINAL.
2//!
3//! La selección se ancla por **(índice de línea en el store vigente, columna
4//! en bytes UTF-8 del texto de esa línea)** — no por id global ni por píxeles
5//! —, así sobrevive el append al fondo pero el caller debe descartarla si el
6//! frente del store se recortó (los índices se corren). El `Scrollback` ya
7//! expone `line_id`/`index_of_id` para que el caller traduzca antes/después
8//! del `drain` si quiere persistir la selección a través del recorte.
9//!
10//! Diseño:
11//!
12//! - `SelectionRange { anchor, head }`: dos puntos. `anchor` = donde empezó
13//!   (press), `head` = donde está ahora (drag). `head == anchor` => selección
14//!   vacía (cursor sin alcance).
15//! - `normalized()`: devuelve `(start, end)` con `start <= end`, **sin** mover
16//!   el modelo (la UI quiere saber dónde está el cursor "vivo" para el caret,
17//!   pero la extracción/painting necesita el rango ordenado).
18//! - `slice_text(store)`: extrae el texto seleccionado, una línea por
19//!   renglón del store, recortado por columnas en la primera/última (clampeado
20//!   a límites de char UTF-8).
21//!
22//! Sin dependencias de UI ni de wgpu — puro, testeable a mano. Las pintadas y
23//! el cableado de mouse vienen en commits siguientes (Fase 3 continúa).
24
25use crate::blocks::{Item, ItemGeo};
26use crate::store::Scrollback;
27use crate::view::TermMetrics;
28
29/// Un punto en el scrollback — un par `(idx_línea, col_byte)`. El índice es
30/// vigente en el store (post-recortes); la columna es offset **en bytes** del
31/// texto de esa línea. Se clampea al largo real al usar.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct Point {
34    /// Índice 0-based de la línea en el store vigente.
35    pub line: usize,
36    /// Offset en bytes dentro del texto de la línea (clampeado a límite UTF-8).
37    pub col: usize,
38}
39
40impl Point {
41    pub const fn new(line: usize, col: usize) -> Self {
42        Self { line, col }
43    }
44}
45
46/// Una selección viva — `anchor` (press) y `head` (drag actual). Convertir
47/// a `(start, end)` ordenado con [`Self::normalized`] antes de pintar o
48/// extraer texto.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct SelectionRange {
51    pub anchor: Point,
52    pub head: Point,
53}
54
55impl SelectionRange {
56    /// Selección colapsada (cursor sin alcance) en `p`.
57    pub const fn collapsed(p: Point) -> Self {
58        Self { anchor: p, head: p }
59    }
60
61    /// `true` si la selección no cubre ningún byte.
62    pub fn is_empty(&self) -> bool {
63        self.anchor == self.head
64    }
65
66    /// Devuelve `(start, end)` con `start <= end` en orden lexicográfico
67    /// `(line, col)`. **No** mueve el modelo — el caller decide si quiere
68    /// el ancla por separado del head (para el caret).
69    pub fn normalized(&self) -> (Point, Point) {
70        let a = self.anchor;
71        let b = self.head;
72        if (a.line, a.col) <= (b.line, b.col) {
73            (a, b)
74        } else {
75            (b, a)
76        }
77    }
78
79    /// `true` si la selección toca el renglón `line` (alguna parte del
80    /// rango está sobre esa línea). Útil para el painter de la ventana
81    /// visible: itera filas y pinta el highlight sólo donde aplica.
82    pub fn touches_line(&self, line: usize) -> bool {
83        let (s, e) = self.normalized();
84        line >= s.line && line <= e.line
85    }
86
87    /// Rango de columnas `(start_col, end_col_exclusive)` que la selección
88    /// cubre en la línea `line` cuyo texto tiene `text_len` bytes.
89    /// Para líneas intermedias: `(0, text_len)`. Para la primera/última:
90    /// recorta. Si la selección no toca esta línea: `None`.
91    pub fn col_range_on(&self, line: usize, text_len: usize) -> Option<(usize, usize)> {
92        let (s, e) = self.normalized();
93        if line < s.line || line > e.line {
94            return None;
95        }
96        let start = if line == s.line { s.col.min(text_len) } else { 0 };
97        let end = if line == e.line {
98            e.col.min(text_len)
99        } else {
100            text_len
101        };
102        // Si el rango es vacío (selección colapsada justo en límite) → None,
103        // para que el painter no dibuje un highlight de 0 bytes.
104        if start >= end {
105            return None;
106        }
107        Some((start, end))
108    }
109
110    /// Extrae el texto seleccionado del `store`. Multi-línea: las líneas
111    /// intermedias enteras, la primera/última recortadas por columna.
112    /// Columnas se clampean al límite de char UTF-8 más cercano hacia abajo
113    /// (no panic si caen a media codepoint). Líneas fuera del store
114    /// vigente se ignoran. Selección vacía → string vacío.
115    pub fn slice_text(&self, store: &Scrollback) -> String {
116        if self.is_empty() {
117            return String::new();
118        }
119        let (s, e) = self.normalized();
120        if store.len() == 0 || s.line >= store.len() {
121            return String::new();
122        }
123        let last_line = e.line.min(store.len().saturating_sub(1));
124        let mut out = String::new();
125        for line in s.line..=last_line {
126            let Some(text) = store.line(line) else {
127                continue;
128            };
129            let (a, b) = if line == s.line && line == e.line {
130                (clamp_char_boundary(text, s.col), clamp_char_boundary(text, e.col))
131            } else if line == s.line {
132                (clamp_char_boundary(text, s.col), text.len())
133            } else if line == last_line {
134                (0, clamp_char_boundary(text, e.col))
135            } else {
136                (0, text.len())
137            };
138            if a < b {
139                out.push_str(&text[a..b]);
140            }
141            if line != last_line {
142                out.push('\n');
143            }
144        }
145        out
146    }
147}
148
149/// Clampea `col` hacia abajo hasta el primer límite de char UTF-8 ≤ `col`.
150/// Si `col >= text.len()` devuelve `text.len()`. Garantiza que `text[..ret]`
151/// sea un slice válido.
152fn clamp_char_boundary(text: &str, col: usize) -> usize {
153    if col >= text.len() {
154        return text.len();
155    }
156    let mut c = col;
157    while c > 0 && !text.is_char_boundary(c) {
158        c -= 1;
159    }
160    c
161}
162
163/// Convierte coords `(lx, ly)` del viewport del `block_surface` a un
164/// [`Point`] del store (línea + columna en bytes UTF-8). **Puro**: replica
165/// la geometría del render (mismo `item_tops` + `visible_rows_in_item` que
166/// la pintada) para que el caret/anchor caigan exactamente donde el usuario
167/// hizo click. `(lx, ly)` son **relativas al viewport** (origen = esquina
168/// superior-izquierda del rect del widget). Devuelve `None` si `ly` cae en
169/// un item `Chrome` (los chrome no son seleccionables) o fuera del stream.
170///
171/// La conversión visual_col → byte_col cuenta chars del texto: para mono
172/// asume 1 cell por char (CJK doble queda fuera del MVP). Si el click cae
173/// más allá del fin del texto, snapea al fin.
174pub fn point_at<Msg>(
175    items: &[Item<Msg>],
176    scroll_y: f32,
177    viewport_h: f32,
178    metrics: TermMetrics,
179    gutter_w: f32,
180    store: &Scrollback,
181    lx: f32,
182    ly: f32,
183) -> Option<Point> {
184    // Wrapper sobre `point_at_geo` que extrae la geometría liviana de cada
185    // item. Práctico para callers que aún tienen el `Vec<Item>` a mano.
186    let geo: Vec<ItemGeo> = items.iter().map(|it| it.geo()).collect();
187    point_at_geo(&geo, scroll_y, viewport_h, metrics, gutter_w, store, lx, ly)
188}
189
190/// Como [`point_at`] pero contra `&[ItemGeo]` — lo que el caller puede
191/// stashear de un frame a otro (es `Copy`, no carga `View`s). Útil para que
192/// el `update` resuelva clicks contra el layout del render previo sin
193/// re-armar los items.
194pub fn point_at_geo(
195    items: &[ItemGeo],
196    scroll_y: f32,
197    viewport_h: f32,
198    metrics: TermMetrics,
199    gutter_w: f32,
200    store: &Scrollback,
201    lx: f32,
202    ly: f32,
203) -> Option<Point> {
204    if viewport_h <= 0.0 || metrics.line_height <= 0.0 {
205        return None;
206    }
207    let row_h = metrics.line_height;
208    let char_w = metrics.char_width.max(0.5);
209    let content_y = scroll_y + ly.max(0.0);
210
211    let mut item_top = 0.0_f32;
212    for it in items {
213        let item_h = it.height(row_h);
214        let item_bottom = item_top + item_h;
215        if content_y >= item_top && content_y < item_bottom {
216            match it {
217                ItemGeo::Chrome(_) => return None,
218                ItemGeo::Lines(start, end) => {
219                    let nrows = end.saturating_sub(*start);
220                    if nrows == 0 {
221                        return None;
222                    }
223                    let k = (((content_y - item_top) / row_h).floor() as usize).min(nrows - 1);
224                    let line = start + k;
225                    let text = store.line(line).unwrap_or("");
226                    // Mismo offset que usa `text_row` al pintar el texto
227                    // (gutter + 4 px de padding); sin esto el byte_col
228                    // copiado quedaba a ~½ char a la izquierda del click.
229                    let vis_x =
230                        (lx - gutter_w - crate::blocks::TEXT_LEFT_PADDING_PX).max(0.0);
231                    let vis_col = (vis_x / char_w).floor() as usize;
232                    let byte_col = visual_to_byte_col(text, vis_col);
233                    return Some(Point::new(line, byte_col));
234                }
235            }
236        }
237        item_top = item_bottom;
238    }
239    None
240}
241
242/// Convierte una columna visual (índice de char, 0-based) en una columna
243/// de bytes dentro de `text`. Si la visual cae más allá del último char,
244/// devuelve `text.len()`. Pensado para hit-test de mouse en mono.
245fn visual_to_byte_col(text: &str, vis_col: usize) -> usize {
246    let mut chars_seen = 0;
247    for (b, _c) in text.char_indices() {
248        if chars_seen == vis_col {
249            return b;
250        }
251        chars_seen += 1;
252    }
253    text.len()
254}
255
256/// Un rectángulo de highlight para pintar — coords **relativas al viewport**
257/// del `block_surface` (origen = esquina superior-izquierda del rect del
258/// widget, ya descontado `scroll_y`).
259#[derive(Debug, Clone, Copy, PartialEq)]
260pub struct HighlightRect {
261    pub x: f32,
262    pub y: f32,
263    pub w: f32,
264    pub h: f32,
265}
266
267/// Calcula los rectángulos de highlight que pinta una selección sobre la
268/// ventana visible de un `block_surface`. **Puro**: no depende de wgpu ni
269/// de Views — devuelve geometría que el painter del widget consume con
270/// `scene.fill`. El caller pasa `gutter_w` (típicamente vía
271/// [`crate::blocks::gutter_width`]) y las métricas de la superficie.
272///
273/// Sólo emite rects para líneas que (a) caen dentro de un `Item::Lines` del
274/// stream y (b) intersectan el viewport. Items `Chrome` no entran (el chrome
275/// es opaco y el caller decide su propio highlight si lo necesita).
276///
277/// Las columnas en `SelectionRange` son **bytes UTF-8**; el rect se calcula
278/// en **columnas visuales** (chars contados, mono = 1 cell por char). CJK
279/// ancho doble queda fuera del MVP — emite rects de 1 cell por char.
280pub fn selection_rects<Msg>(
281    items: &[Item<Msg>],
282    scroll_y: f32,
283    viewport_h: f32,
284    metrics: TermMetrics,
285    gutter_w: f32,
286    store: &Scrollback,
287    sel: &SelectionRange,
288) -> Vec<HighlightRect> {
289    if sel.is_empty() || viewport_h <= 0.0 || metrics.line_height <= 0.0 {
290        return Vec::new();
291    }
292    let row_h = metrics.line_height;
293    let char_w = metrics.char_width.max(0.5);
294    let mut out: Vec<HighlightRect> = Vec::new();
295
296    let mut item_top = 0.0_f32;
297    for it in items {
298        let item_h = it.height(row_h);
299        let item_bottom = item_top + item_h;
300        // Skip items totalmente fuera del viewport.
301        if item_bottom <= scroll_y || item_top >= scroll_y + viewport_h {
302            item_top = item_bottom;
303            continue;
304        }
305        if let Item::Lines { start, end } = it {
306            let nrows = end.saturating_sub(*start);
307            if nrows == 0 {
308                item_top = item_bottom;
309                continue;
310            }
311            // Sub-filas dentro del item que tocan el viewport (locales 0-based).
312            let off = scroll_y;
313            let k0 = (((off - item_top) / row_h).floor().max(0.0) as usize).min(nrows);
314            let k1 = (((off + viewport_h - item_top) / row_h).ceil().max(0.0) as usize).min(nrows);
315            for k in k0..k1 {
316                let idx = start + k;
317                if !sel.touches_line(idx) {
318                    continue;
319                }
320                let Some(text) = store.line(idx) else { continue };
321                let Some((a, b)) = sel.col_range_on(idx, text.len()) else { continue };
322                // Snap a límites UTF-8 (defensa; col_range_on ya clampa a len).
323                let a_safe = clamp_char_boundary(text, a);
324                let b_safe = clamp_char_boundary(text, b);
325                if a_safe >= b_safe {
326                    continue;
327                }
328                let vis_a = text[..a_safe].chars().count() as f32;
329                let vis_b = text[..b_safe].chars().count() as f32;
330                let row_y = item_top + k as f32 * row_h - scroll_y;
331                // El texto se pinta a `gutter + TEXT_LEFT_PADDING_PX` —
332                // el rect tiene que arrancar en el mismo offset.
333                let text_x0 = gutter_w + crate::blocks::TEXT_LEFT_PADDING_PX;
334                out.push(HighlightRect {
335                    x: text_x0 + vis_a * char_w,
336                    y: row_y,
337                    w: (vis_b - vis_a) * char_w,
338                    h: row_h,
339                });
340            }
341        }
342        item_top = item_bottom;
343    }
344    out
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    fn store_of(lines: &[&str]) -> Scrollback {
352        let mut s = Scrollback::new(0);
353        for l in lines {
354            s.push_line(l);
355        }
356        s
357    }
358
359    #[test]
360    fn collapsed_is_empty_and_yields_empty_slice() {
361        let sel = SelectionRange::collapsed(Point::new(0, 0));
362        assert!(sel.is_empty());
363        let store = store_of(&["hola", "mundo"]);
364        assert_eq!(sel.slice_text(&store), "");
365    }
366
367    #[test]
368    fn normalized_swaps_when_head_before_anchor() {
369        let sel = SelectionRange {
370            anchor: Point::new(3, 7),
371            head: Point::new(1, 2),
372        };
373        let (s, e) = sel.normalized();
374        assert_eq!(s, Point::new(1, 2));
375        assert_eq!(e, Point::new(3, 7));
376    }
377
378    #[test]
379    fn single_line_slice_recorta_por_columnas() {
380        let store = store_of(&["the quick brown fox"]);
381        let sel = SelectionRange {
382            anchor: Point::new(0, 4),
383            head: Point::new(0, 9),
384        };
385        assert_eq!(sel.slice_text(&store), "quick");
386    }
387
388    #[test]
389    fn multi_line_slice_incluye_lineas_intermedias_completas() {
390        let store = store_of(&["uno dos", "tres cuatro", "cinco seis"]);
391        let sel = SelectionRange {
392            anchor: Point::new(0, 4),
393            head: Point::new(2, 5),
394        };
395        // De "dos" en línea 0 (col 4..7), TODA línea 1, hasta "cinco" en línea 2.
396        assert_eq!(sel.slice_text(&store), "dos\ntres cuatro\ncinco");
397    }
398
399    #[test]
400    fn col_range_on_recorta_solo_primera_y_ultima() {
401        let sel = SelectionRange {
402            anchor: Point::new(0, 4),
403            head: Point::new(2, 5),
404        };
405        assert_eq!(sel.col_range_on(0, 7), Some((4, 7))); // primera: recorta start
406        assert_eq!(sel.col_range_on(1, 11), Some((0, 11))); // intermedia: línea entera
407        assert_eq!(sel.col_range_on(2, 10), Some((0, 5))); // última: recorta end
408        assert_eq!(sel.col_range_on(3, 10), None); // fuera
409    }
410
411    #[test]
412    fn col_range_on_descarta_rango_vacio() {
413        // Si la selección termina en col 0 de una línea, su contribución a esa
414        // línea es 0 bytes → no se debe pintar nada.
415        let sel = SelectionRange {
416            anchor: Point::new(0, 4),
417            head: Point::new(1, 0),
418        };
419        assert_eq!(sel.col_range_on(1, 5), None);
420    }
421
422    #[test]
423    fn touches_line_chequea_rango_inclusivo() {
424        let sel = SelectionRange {
425            anchor: Point::new(2, 0),
426            head: Point::new(4, 0),
427        };
428        assert!(!sel.touches_line(1));
429        assert!(sel.touches_line(2));
430        assert!(sel.touches_line(3));
431        assert!(sel.touches_line(4));
432        assert!(!sel.touches_line(5));
433    }
434
435    #[test]
436    fn slice_text_clampa_col_fuera_de_texto() {
437        // col más allá del largo del texto → recorta al fin del texto, sin panic.
438        let store = store_of(&["hi"]);
439        let sel = SelectionRange {
440            anchor: Point::new(0, 0),
441            head: Point::new(0, 999),
442        };
443        assert_eq!(sel.slice_text(&store), "hi");
444    }
445
446    #[test]
447    fn slice_text_respeta_limites_utf8() {
448        // "héllo" — la 'é' es 2 bytes (0xC3 0xA9). Col 2 cae a mitad de char;
449        // debe redondear hacia abajo a col 1 (después de 'h'), no panic.
450        let store = store_of(&["héllo"]);
451        let sel = SelectionRange {
452            anchor: Point::new(0, 0),
453            head: Point::new(0, 2),
454        };
455        // col 2 → boundary 1 (después de 'h'); slice "h".
456        assert_eq!(sel.slice_text(&store), "h");
457    }
458
459    #[test]
460    fn slice_text_clampa_lineas_fuera_del_store() {
461        // El store tiene 2 líneas; la selección termina en la 5 → recorta a la 1.
462        let store = store_of(&["uno", "dos"]);
463        let sel = SelectionRange {
464            anchor: Point::new(0, 0),
465            head: Point::new(5, 999),
466        };
467        assert_eq!(sel.slice_text(&store), "uno\ndos");
468    }
469
470    #[test]
471    fn slice_text_de_seleccion_vacia_es_vacio_aun_con_anchor_no_nulo() {
472        // anchor == head → vacío, aún si están en (3, 5).
473        let store = store_of(&["abcd", "efgh", "ijkl", "mnop"]);
474        let sel = SelectionRange::collapsed(Point::new(3, 2));
475        assert_eq!(sel.slice_text(&store), "");
476    }
477
478    #[test]
479    fn slice_text_sobre_store_vacio_es_vacio() {
480        let store = Scrollback::new(0);
481        let sel = SelectionRange {
482            anchor: Point::new(0, 0),
483            head: Point::new(2, 5),
484        };
485        assert_eq!(sel.slice_text(&store), "");
486    }
487
488    fn rects<Msg>(
489        items: &[Item<Msg>],
490        scroll_y: f32,
491        viewport_h: f32,
492        gutter_w: f32,
493        store: &Scrollback,
494        sel: &SelectionRange,
495    ) -> Vec<HighlightRect> {
496        let metrics = TermMetrics {
497            font_size: 12.0,
498            line_height: 16.0,
499            char_width: 8.0,
500        };
501        selection_rects(items, scroll_y, viewport_h, metrics, gutter_w, store, sel)
502    }
503
504    fn point(
505        items: &[Item<()>],
506        scroll_y: f32,
507        gutter_w: f32,
508        store: &Scrollback,
509        lx: f32,
510        ly: f32,
511    ) -> Option<Point> {
512        let metrics = TermMetrics {
513            font_size: 12.0,
514            line_height: 16.0,
515            char_width: 8.0,
516        };
517        point_at(items, scroll_y, 100.0, metrics, gutter_w, store, lx, ly)
518    }
519
520    #[test]
521    fn point_at_resuelve_linea_y_columna_para_un_click_simple() {
522        let store = store_of(&["abcdef", "ghijkl"]);
523        let items: Vec<Item<()>> = vec![Item::lines(0, 2)];
524        // Click en línea 1 (y = 20 → fila 1 con row_h=16), col visual = (50-30)/8 = 2.
525        let p = point(&items, 0.0, 30.0, &store, 50.0, 20.0).unwrap();
526        assert_eq!(p, Point::new(1, 2));
527    }
528
529    #[test]
530    fn point_at_clampea_click_fuera_del_texto_al_fin_de_linea() {
531        // Línea de 4 chars, click muy a la derecha → snap al fin (col 4).
532        let store = store_of(&["abcd"]);
533        let items: Vec<Item<()>> = vec![Item::lines(0, 1)];
534        let p = point(&items, 0.0, 30.0, &store, 1000.0, 5.0).unwrap();
535        assert_eq!(p, Point::new(0, 4));
536    }
537
538    #[test]
539    fn point_at_en_el_gutter_cae_a_col_0() {
540        // Click dentro del gutter (lx < gutter_w) → col visual = 0 → byte col = 0.
541        let store = store_of(&["xyz"]);
542        let items: Vec<Item<()>> = vec![Item::lines(0, 1)];
543        let p = point(&items, 0.0, 30.0, &store, 10.0, 5.0).unwrap();
544        assert_eq!(p, Point::new(0, 0));
545    }
546
547    #[test]
548    fn point_at_devuelve_none_para_chrome_o_fuera() {
549        // Item 0 = chrome (alto 24); item 1 = 2 líneas. Click en y=10 cae en chrome.
550        let store = store_of(&["aa", "bb"]);
551        let chrome_view: llimphi_ui::View<()> = llimphi_ui::View::new(Default::default());
552        let items: Vec<Item<()>> = vec![Item::chrome(24.0, chrome_view), Item::lines(0, 2)];
553        assert_eq!(point(&items, 0.0, 30.0, &store, 50.0, 10.0), None);
554        // y > total: fuera del stream → None.
555        assert_eq!(point(&items, 0.0, 30.0, &store, 50.0, 1000.0), None);
556    }
557
558    #[test]
559    fn point_at_respeta_scroll_y() {
560        // 100 líneas; con scroll_y = 800, el click en y=8 cae en la línea
561        // floor((800+8)/16) = 50.
562        let lines: Vec<&str> = (0..100).map(|_| "ab").collect();
563        let store = store_of(&lines);
564        let items: Vec<Item<()>> = vec![Item::lines(0, 100)];
565        let p = point(&items, 800.0, 30.0, &store, 30.0, 8.0).unwrap();
566        assert_eq!(p, Point::new(50, 0));
567    }
568
569    #[test]
570    fn point_at_convierte_visual_a_byte_para_utf8() {
571        // "héllo": vis 0='h', vis 1='é' (2 bytes), vis 2='l' (byte 3), vis 3='l' (byte 4).
572        let store = store_of(&["héllo"]);
573        let items: Vec<Item<()>> = vec![Item::lines(0, 1)];
574        // Click en vis col 2 (lx = 30 + 4 + 2*8 = 50) — el +4 es
575        // TEXT_LEFT_PADDING_PX → byte col 3.
576        let p = point(&items, 0.0, 30.0, &store, 50.0, 5.0).unwrap();
577        assert_eq!(p, Point::new(0, 3));
578    }
579
580    #[test]
581    fn rects_de_seleccion_vacia_son_vacio() {
582        let store = store_of(&["abc"]);
583        let items: Vec<Item<()>> = vec![Item::lines(0, 1)];
584        let sel = SelectionRange::collapsed(Point::new(0, 1));
585        assert_eq!(rects(&items, 0.0, 100.0, 30.0, &store, &sel), Vec::new());
586    }
587
588    #[test]
589    fn rect_single_line_ubica_x_y_w_correctos() {
590        // Línea 0 entera (3 chars). x = gutter + TEXT_LEFT_PADDING (4) + 0,
591        // w = 3 * char_w. El +4 es el padding interno del text_row.
592        let store = store_of(&["abc"]);
593        let items: Vec<Item<()>> = vec![Item::lines(0, 1)];
594        let sel = SelectionRange {
595            anchor: Point::new(0, 0),
596            head: Point::new(0, 3),
597        };
598        let r = rects(&items, 0.0, 100.0, 30.0, &store, &sel);
599        assert_eq!(r.len(), 1);
600        let h = r[0];
601        assert_eq!(h.x, 34.0); // 30 + 4
602        assert_eq!(h.y, 0.0);
603        assert_eq!(h.w, 24.0); // 3 * 8.0
604        assert_eq!(h.h, 16.0);
605    }
606
607    #[test]
608    fn rect_multi_line_emite_uno_por_renglon() {
609        // 3 líneas, selección abarca las 3 (primera/última recortadas).
610        let store = store_of(&["alpha", "beta", "gamma"]);
611        let items: Vec<Item<()>> = vec![Item::lines(0, 3)];
612        let sel = SelectionRange {
613            anchor: Point::new(0, 2), // "pha"
614            head: Point::new(2, 3),   // "gam"
615        };
616        let r = rects(&items, 0.0, 100.0, 30.0, &store, &sel);
617        assert_eq!(r.len(), 3);
618        // Línea 0: chars 2..5 → x = 30 + 4 + 2*8 = 50, w = 3*8 = 24
619        assert_eq!(r[0].x, 50.0);
620        assert_eq!(r[0].w, 24.0);
621        // Línea 1 entera: "beta" (4 chars).
622        assert_eq!(r[1].x, 34.0);
623        assert_eq!(r[1].w, 32.0);
624        // Línea 2: chars 0..3 → "gam".
625        assert_eq!(r[2].x, 34.0);
626        assert_eq!(r[2].w, 24.0);
627    }
628
629    #[test]
630    fn rects_descartan_lineas_fuera_del_viewport() {
631        // 100 líneas, viewport 32 px (=2 filas), scroll a la mitad → sólo 2-3 rects.
632        let lines: Vec<&str> = (0..100).map(|_| "row").collect();
633        let store = store_of(&lines);
634        let items: Vec<Item<()>> = vec![Item::lines(0, 100)];
635        // Selección sobre TODAS las líneas, pero sólo 2-3 entran al viewport.
636        let sel = SelectionRange {
637            anchor: Point::new(0, 0),
638            head: Point::new(99, 3),
639        };
640        // scroll a la fila 50 (50 * 16 = 800 px). Viewport de 32 px → filas
641        // 50, 51 (+ guarda).
642        let r = rects(&items, 800.0, 32.0, 30.0, &store, &sel);
643        assert!(r.len() <= 3 && !r.is_empty(),
644            "esperado ~2-3 rects, no {} (todas las líneas)", r.len());
645    }
646
647    #[test]
648    fn rects_saltan_items_chrome() {
649        // Item 0 = chrome (alto 20), item 1 = 2 líneas. Selección sobre las dos
650        // líneas. El chrome no debe aportar rects.
651        let store = store_of(&["aa", "bb"]);
652        let chrome_view: llimphi_ui::View<()> = llimphi_ui::View::new(Default::default());
653        let items: Vec<Item<()>> = vec![Item::chrome(20.0, chrome_view), Item::lines(0, 2)];
654        let sel = SelectionRange {
655            anchor: Point::new(0, 0),
656            head: Point::new(1, 2),
657        };
658        let r = rects(&items, 0.0, 100.0, 30.0, &store, &sel);
659        assert_eq!(r.len(), 2);
660        // El primer rect arranca DESPUÉS del chrome (y = 20).
661        assert_eq!(r[0].y, 20.0);
662        // El segundo está una fila más abajo (20 + 16 = 36).
663        assert_eq!(r[1].y, 36.0);
664    }
665
666    #[test]
667    fn rects_usan_visual_cols_no_bytes_para_utf8() {
668        // "héllo" — 'é' es 2 bytes, pero 1 char visual. Selección de col 0 a
669        // col 3 (byte) → snap a 3 (después de "hé"), 2 chars visuales.
670        let store = store_of(&["héllo"]);
671        let items: Vec<Item<()>> = vec![Item::lines(0, 1)];
672        let sel = SelectionRange {
673            anchor: Point::new(0, 0),
674            head: Point::new(0, 3),
675        };
676        let r = rects(&items, 0.0, 100.0, 30.0, &store, &sel);
677        assert_eq!(r.len(), 1);
678        // 2 chars visuales × 8 px = 16.
679        assert_eq!(r[0].w, 16.0);
680    }
681
682    #[test]
683    fn slice_text_a_linea_intermedia_omite_las_que_no_existen() {
684        // Si una línea intermedia desaparece (no debería pasar acá pero el
685        // store sólo expone `line()`), se omite — no se inserta `\n` extra.
686        // Acá lo cubrimos indirectamente con un store contiguo.
687        let store = store_of(&["aa", "bb", "cc"]);
688        let sel = SelectionRange {
689            anchor: Point::new(0, 1),
690            head: Point::new(2, 1),
691        };
692        assert_eq!(sel.slice_text(&store), "a\nbb\nc");
693    }
694}