tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ 92dd0e5bfb2ca4456006da99437f4100e156d856
widgets/terminal/src/select.rs
Historia
Quién tocó qué
crudo
refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel
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}