tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo (pin tawasuyu.git v0.3.0)
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_with_offset` 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/// Índice del item del stream que contiene `content_y` (coords de contenido:
243/// `scroll_y + ly`, ya sin descontar scroll). **Puro** — misma acumulación de
244/// alturas que [`point_at_geo`] y que la pintada.
245///
246/// Existe porque `point_at_geo` devuelve `None` en dos casos que el caller
247/// necesita distinguir: el click cayó sobre un `Chrome` (un header, una tabla,
248/// una imagen — hay item, no hay línea) o cayó fuera del stream (no hay nada).
249/// Con el índice del item, el caller resuelve a qué desplegable pertenece ese
250/// chrome consultando su propia tabla.
251pub fn item_at_y(items: &[ItemGeo], row_h: f32, content_y: f32) -> Option<usize> {
252 if row_h <= 0.0 || content_y < 0.0 {
253 return None;
254 }
255 let mut item_top = 0.0_f32;
256 for (i, it) in items.iter().enumerate() {
257 let item_bottom = item_top + it.height(row_h);
258 if content_y >= item_top && content_y < item_bottom {
259 return Some(i);
260 }
261 item_top = item_bottom;
262 }
263 None
264}
265
266/// Convierte una columna visual (índice de char, 0-based) en una columna
267/// de bytes dentro de `text`. Si la visual cae más allá del último char,
268/// devuelve `text.len()`. Pensado para hit-test de mouse en mono.
269fn visual_to_byte_col(text: &str, vis_col: usize) -> usize {
270 let mut chars_seen = 0;
271 for (b, _c) in text.char_indices() {
272 if chars_seen == vis_col {
273 return b;
274 }
275 chars_seen += 1;
276 }
277 text.len()
278}
279
280/// Un rectángulo de highlight para pintar — coords **relativas al viewport**
281/// del `block_surface` (origen = esquina superior-izquierda del rect del
282/// widget, ya descontado `scroll_y`).
283#[derive(Debug, Clone, Copy, PartialEq)]
284pub struct HighlightRect {
285 pub x: f32,
286 pub y: f32,
287 pub w: f32,
288 pub h: f32,
289}
290
291/// Calcula los rectángulos de highlight que pinta una selección sobre la
292/// ventana visible de un `block_surface`. **Puro**: no depende de wgpu ni
293/// de Views — devuelve geometría que el painter del widget consume con
294/// `scene.fill`. El caller pasa `gutter_w` (típicamente vía
295/// [`crate::blocks::gutter_width`]) y las métricas de la superficie.
296///
297/// Sólo emite rects para líneas que (a) caen dentro de un `Item::Lines` del
298/// stream y (b) intersectan el viewport. Items `Chrome` no entran (el chrome
299/// es opaco y el caller decide su propio highlight si lo necesita).
300///
301/// Las columnas en `SelectionRange` son **bytes UTF-8**; el rect se calcula
302/// en **columnas visuales** (chars contados, mono = 1 cell por char). CJK
303/// ancho doble queda fuera del MVP — emite rects de 1 cell por char.
304pub fn selection_rects<Msg>(
305 items: &[Item<'_, Msg>],
306 scroll_y: f32,
307 viewport_h: f32,
308 metrics: TermMetrics,
309 gutter_w: f32,
310 store: &Scrollback,
311 sel: &SelectionRange,
312) -> Vec<HighlightRect> {
313 let geo: Vec<ItemGeo> = items.iter().map(|it| it.geo()).collect();
314 selection_rects_geo(&geo, scroll_y, viewport_h, metrics, gutter_w, store, sel)
315}
316
317/// Como [`selection_rects`] pero contra la geometría sola. Es el núcleo: pintar
318/// un highlight no necesita saber construir ningún chrome, sólo dónde caen las
319/// filas. Lo usa el camino de [`crate::blocks::block_surface_specs`], que no
320/// tiene `Item`s que recorrer.
321#[allow(clippy::too_many_arguments)]
322pub fn selection_rects_geo(
323 items: &[ItemGeo],
324 scroll_y: f32,
325 viewport_h: f32,
326 metrics: TermMetrics,
327 gutter_w: f32,
328 store: &Scrollback,
329 sel: &SelectionRange,
330) -> Vec<HighlightRect> {
331 if sel.is_empty() || viewport_h <= 0.0 || metrics.line_height <= 0.0 {
332 return Vec::new();
333 }
334 let row_h = metrics.line_height;
335 let char_w = metrics.char_width.max(0.5);
336 let mut out: Vec<HighlightRect> = Vec::new();
337
338 let mut item_top = 0.0_f32;
339 for it in items {
340 let item_h = it.height(row_h);
341 let item_bottom = item_top + item_h;
342 // Skip items totalmente fuera del viewport.
343 if item_bottom <= scroll_y || item_top >= scroll_y + viewport_h {
344 item_top = item_bottom;
345 continue;
346 }
347 if let ItemGeo::Lines(start, end) = it {
348 let nrows = end.saturating_sub(*start);
349 if nrows == 0 {
350 item_top = item_bottom;
351 continue;
352 }
353 // Sub-filas dentro del item que tocan el viewport (locales 0-based).
354 let off = scroll_y;
355 let k0 = (((off - item_top) / row_h).floor().max(0.0) as usize).min(nrows);
356 let k1 = (((off + viewport_h - item_top) / row_h).ceil().max(0.0) as usize).min(nrows);
357 for k in k0..k1 {
358 let idx = start + k;
359 if !sel.touches_line(idx) {
360 continue;
361 }
362 let Some(text) = store.line(idx) else { continue };
363 let Some((a, b)) = sel.col_range_on(idx, text.len()) else { continue };
364 // Snap a límites UTF-8 (defensa; col_range_on ya clampa a len).
365 let a_safe = clamp_char_boundary(text, a);
366 let b_safe = clamp_char_boundary(text, b);
367 if a_safe >= b_safe {
368 continue;
369 }
370 let vis_a = text[..a_safe].chars().count() as f32;
371 let vis_b = text[..b_safe].chars().count() as f32;
372 let row_y = item_top + k as f32 * row_h - scroll_y;
373 // El texto se pinta a `gutter + TEXT_LEFT_PADDING_PX` —
374 // el rect tiene que arrancar en el mismo offset.
375 let text_x0 = gutter_w + crate::blocks::TEXT_LEFT_PADDING_PX;
376 out.push(HighlightRect {
377 x: text_x0 + vis_a * char_w,
378 y: row_y,
379 w: (vis_b - vis_a) * char_w,
380 h: row_h,
381 });
382 }
383 }
384 item_top = item_bottom;
385 }
386 out
387}
388
389/// Ancho en px del caret readonly (barra vertical sólida). No parpadea: el
390/// panel es de sólo lectura, así que el caret marca la posición sin titilar.
391pub const CARET_W: f32 = 2.0;
392
393/// Un movimiento del caret readonly (copy-mode del panel de output). El caret
394/// **es** el `head` de la selección: extenderlo con Shift mantiene el `anchor`,
395/// moverlo sin Shift lo colapsa.
396#[derive(Debug, Clone, Copy, PartialEq, Eq)]
397pub enum Motion {
398 CharLeft,
399 CharRight,
400 WordLeft,
401 WordRight,
402 LineHome,
403 LineEnd,
404 LineUp,
405 LineDown,
406 /// Sube `n` filas (medio/una página).
407 PageUp(usize),
408 /// Baja `n` filas.
409 PageDown(usize),
410 DocTop,
411 DocBottom,
412}
413
414impl SelectionRange {
415 /// Mueve el caret (`head`) según `motion` sobre `store`. Con `extend` = true
416 /// (Shift) mantiene el `anchor` y la selección crece/encoge; con `extend` =
417 /// false colapsa (`anchor = head`). Todo clampeado a los límites del store y
418 /// a boundaries UTF-8 — nunca deja `col` a media codepoint ni `line` fuera
419 /// de rango.
420 pub fn move_caret(&mut self, store: &Scrollback, motion: Motion, extend: bool) {
421 let h = move_point(store, self.head, motion);
422 self.head = h;
423 if !extend {
424 self.anchor = h;
425 }
426 }
427}
428
429/// Resuelve la nueva posición del caret aplicando `motion` a `p`. **Puro**.
430fn move_point(store: &Scrollback, p: Point, motion: Motion) -> Point {
431 let n = store.len();
432 if n == 0 {
433 return Point::new(0, 0);
434 }
435 let last = n - 1;
436 let line = p.line.min(last);
437 let text = store.line(line).unwrap_or("");
438 let col = clamp_char_boundary(text, p.col);
439 let line_len = |l: usize| store.line(l).map(|t| t.len()).unwrap_or(0);
440 match motion {
441 Motion::CharLeft => {
442 if col > 0 {
443 Point::new(line, prev_boundary(text, col))
444 } else if line > 0 {
445 Point::new(line - 1, line_len(line - 1))
446 } else {
447 Point::new(0, 0)
448 }
449 }
450 Motion::CharRight => {
451 if col < text.len() {
452 Point::new(line, next_boundary(text, col))
453 } else if line < last {
454 Point::new(line + 1, 0)
455 } else {
456 Point::new(line, text.len())
457 }
458 }
459 Motion::WordLeft => {
460 if col > 0 {
461 Point::new(line, prev_word(text, col))
462 } else if line > 0 {
463 Point::new(line - 1, line_len(line - 1))
464 } else {
465 Point::new(0, 0)
466 }
467 }
468 Motion::WordRight => {
469 if col < text.len() {
470 Point::new(line, next_word(text, col))
471 } else if line < last {
472 Point::new(line + 1, 0)
473 } else {
474 Point::new(line, text.len())
475 }
476 }
477 Motion::LineHome => Point::new(line, 0),
478 Motion::LineEnd => Point::new(line, text.len()),
479 Motion::LineUp => {
480 if line > 0 {
481 snap_col(store, line - 1, col_visual(text, col))
482 } else {
483 Point::new(0, 0)
484 }
485 }
486 Motion::LineDown => {
487 if line < last {
488 snap_col(store, line + 1, col_visual(text, col))
489 } else {
490 Point::new(line, text.len())
491 }
492 }
493 Motion::PageUp(k) => snap_col(store, line.saturating_sub(k), col_visual(text, col)),
494 Motion::PageDown(k) => snap_col(store, (line + k).min(last), col_visual(text, col)),
495 Motion::DocTop => Point::new(0, 0),
496 Motion::DocBottom => Point::new(last, line_len(last)),
497 }
498}
499
500/// Boundary UTF-8 inmediatamente a la izquierda de `col` (`col` ya clampeada).
501fn prev_boundary(text: &str, col: usize) -> usize {
502 let mut c = col.min(text.len());
503 if c == 0 {
504 return 0;
505 }
506 c -= 1;
507 while c > 0 && !text.is_char_boundary(c) {
508 c -= 1;
509 }
510 c
511}
512
513/// Boundary UTF-8 inmediatamente a la derecha de `col`.
514fn next_boundary(text: &str, col: usize) -> usize {
515 let mut c = (col + 1).min(text.len());
516 while c < text.len() && !text.is_char_boundary(c) {
517 c += 1;
518 }
519 c
520}
521
522/// Columna **visual** (nº de chars antes de `col`) — para preservar la columna
523/// al subir/bajar de línea (mono = 1 celda por char).
524fn col_visual(text: &str, col: usize) -> usize {
525 let c = clamp_char_boundary(text, col);
526 text[..c].chars().count()
527}
528
529/// Convierte una columna visual a byte-col en la línea `line`, clampeada al fin.
530fn snap_col(store: &Scrollback, line: usize, vis: usize) -> Point {
531 let text = store.line(line).unwrap_or("");
532 let mut chars = 0;
533 for (b, _) in text.char_indices() {
534 if chars == vis {
535 return Point::new(line, b);
536 }
537 chars += 1;
538 }
539 Point::new(line, text.len())
540}
541
542/// Char de palabra: alfanumérico o `_`. Puntuación y espacio son separadores
543/// (semántica "WORD" de vim — apta para paths y símbolos del terminal).
544fn is_word(c: char) -> bool {
545 c.is_alphanumeric() || c == '_'
546}
547
548/// Byte-col del inicio de la palabra siguiente a `col` dentro de `text`.
549fn next_word(text: &str, col: usize) -> usize {
550 let cols: Vec<(usize, char)> = text.char_indices().collect();
551 let start_ci = cols.iter().position(|(b, _)| *b >= col).unwrap_or(cols.len());
552 if start_ci >= cols.len() {
553 return text.len();
554 }
555 let mut i = start_ci;
556 let cur = is_word(cols[i].1);
557 while i < cols.len() && is_word(cols[i].1) == cur {
558 i += 1;
559 }
560 while i < cols.len() && !is_word(cols[i].1) {
561 i += 1;
562 }
563 if i >= cols.len() {
564 text.len()
565 } else {
566 cols[i].0
567 }
568}
569
570/// Byte-col del inicio de la palabra anterior a `col` dentro de `text`.
571fn prev_word(text: &str, col: usize) -> usize {
572 let cols: Vec<(usize, char)> = text.char_indices().collect();
573 let col = clamp_char_boundary(text, col);
574 let mut i = cols.iter().take_while(|(b, _)| *b < col).count();
575 if i == 0 {
576 return 0;
577 }
578 i -= 1;
579 while i > 0 && !is_word(cols[i].1) {
580 i -= 1;
581 }
582 while i > 0 && is_word(cols[i - 1].1) {
583 i -= 1;
584 }
585 cols[i].0
586}
587
588/// Rectángulo del caret readonly — barra vertical sólida de [`CARET_W`] px en
589/// la posición `caret` (línea + col en bytes). Coords **relativas al viewport**
590/// (scroll descontado). `None` si la línea del caret no cae en un `Item::Lines`
591/// visible. **Puro**, hermano de [`selection_rects`].
592pub fn caret_rect<Msg>(
593 items: &[Item<'_, Msg>],
594 scroll_y: f32,
595 viewport_h: f32,
596 metrics: TermMetrics,
597 gutter_w: f32,
598 store: &Scrollback,
599 caret: Point,
600) -> Option<HighlightRect> {
601 let geo: Vec<ItemGeo> = items.iter().map(|it| it.geo()).collect();
602 caret_rect_geo(&geo, scroll_y, viewport_h, metrics, gutter_w, store, caret)
603}
604
605/// Como [`caret_rect`] pero contra la geometría sola — ver
606/// [`selection_rects_geo`].
607#[allow(clippy::too_many_arguments)]
608pub fn caret_rect_geo(
609 items: &[ItemGeo],
610 scroll_y: f32,
611 viewport_h: f32,
612 metrics: TermMetrics,
613 gutter_w: f32,
614 store: &Scrollback,
615 caret: Point,
616) -> Option<HighlightRect> {
617 if viewport_h <= 0.0 || metrics.line_height <= 0.0 {
618 return None;
619 }
620 let row_h = metrics.line_height;
621 let char_w = metrics.char_width.max(0.5);
622 let mut item_top = 0.0_f32;
623 for it in items {
624 let item_h = it.height(row_h);
625 if let ItemGeo::Lines(start, end) = it {
626 if caret.line >= *start && caret.line < *end {
627 let k = caret.line - start;
628 let row_y = item_top + k as f32 * row_h - scroll_y;
629 if row_y + row_h < 0.0 || row_y > viewport_h {
630 return None;
631 }
632 let text = store.line(caret.line).unwrap_or("");
633 let cb = clamp_char_boundary(text, caret.col);
634 let vis = text[..cb].chars().count() as f32;
635 let text_x0 = gutter_w + crate::blocks::TEXT_LEFT_PADDING_PX;
636 return Some(HighlightRect {
637 x: text_x0 + vis * char_w,
638 y: row_y,
639 w: CARET_W,
640 h: row_h,
641 });
642 }
643 }
644 item_top += item_h;
645 }
646 None
647}
648
649/// Dado el layout (`items_geo`), el `scroll_y` actual y la línea del caret,
650/// devuelve `(scroll_y_nuevo, overflow)`: el desplazamiento mínimo que deja la
651/// línea del caret **visible** dentro del viewport (sube o baja lo justo), y el
652/// overflow total (para que el caller lo reconvierta a su métrica de scroll).
653/// **Puro** — no pinta ni toca estado. Si el caret no cae en ninguna línea,
654/// devuelve el `scroll_y` clampeado sin cambios.
655pub fn caret_reveal_scroll(
656 items: &[ItemGeo],
657 scroll_y: f32,
658 viewport_h: f32,
659 metrics: TermMetrics,
660 caret_line: usize,
661) -> (f32, f32) {
662 let row_h = metrics.line_height.max(1.0);
663 let mut item_top = 0.0_f32;
664 let mut caret_y = None;
665 for it in items {
666 let h = it.height(row_h);
667 if let ItemGeo::Lines(start, end) = it {
668 if caret_line >= *start && caret_line < *end {
669 caret_y = Some(item_top + (caret_line - *start) as f32 * row_h);
670 }
671 }
672 item_top += h;
673 }
674 let overflow = (item_top - viewport_h).max(0.0);
675 let Some(cy) = caret_y else {
676 return (scroll_y.clamp(0.0, overflow), overflow);
677 };
678 let mut sy = scroll_y.clamp(0.0, overflow);
679 if cy < sy {
680 sy = cy;
681 }
682 if cy + row_h > sy + viewport_h {
683 sy = cy + row_h - viewport_h;
684 }
685 (sy.clamp(0.0, overflow), overflow)
686}
687
688#[cfg(test)]
689mod tests {
690 use super::*;
691
692 fn store_of(lines: &[&str]) -> Scrollback {
693 let mut s = Scrollback::new(0);
694 for l in lines {
695 s.push_line(l);
696 }
697 s
698 }
699
700 #[test]
701 fn collapsed_is_empty_and_yields_empty_slice() {
702 let sel = SelectionRange::collapsed(Point::new(0, 0));
703 assert!(sel.is_empty());
704 let store = store_of(&["hola", "mundo"]);
705 assert_eq!(sel.slice_text(&store), "");
706 }
707
708 #[test]
709 fn normalized_swaps_when_head_before_anchor() {
710 let sel = SelectionRange {
711 anchor: Point::new(3, 7),
712 head: Point::new(1, 2),
713 };
714 let (s, e) = sel.normalized();
715 assert_eq!(s, Point::new(1, 2));
716 assert_eq!(e, Point::new(3, 7));
717 }
718
719 #[test]
720 fn single_line_slice_recorta_por_columnas() {
721 let store = store_of(&["the quick brown fox"]);
722 let sel = SelectionRange {
723 anchor: Point::new(0, 4),
724 head: Point::new(0, 9),
725 };
726 assert_eq!(sel.slice_text(&store), "quick");
727 }
728
729 #[test]
730 fn multi_line_slice_incluye_lineas_intermedias_completas() {
731 let store = store_of(&["uno dos", "tres cuatro", "cinco seis"]);
732 let sel = SelectionRange {
733 anchor: Point::new(0, 4),
734 head: Point::new(2, 5),
735 };
736 // De "dos" en línea 0 (col 4..7), TODA línea 1, hasta "cinco" en línea 2.
737 assert_eq!(sel.slice_text(&store), "dos\ntres cuatro\ncinco");
738 }
739
740 #[test]
741 fn col_range_on_recorta_solo_primera_y_ultima() {
742 let sel = SelectionRange {
743 anchor: Point::new(0, 4),
744 head: Point::new(2, 5),
745 };
746 assert_eq!(sel.col_range_on(0, 7), Some((4, 7))); // primera: recorta start
747 assert_eq!(sel.col_range_on(1, 11), Some((0, 11))); // intermedia: línea entera
748 assert_eq!(sel.col_range_on(2, 10), Some((0, 5))); // última: recorta end
749 assert_eq!(sel.col_range_on(3, 10), None); // fuera
750 }
751
752 #[test]
753 fn col_range_on_descarta_rango_vacio() {
754 // Si la selección termina en col 0 de una línea, su contribución a esa
755 // línea es 0 bytes → no se debe pintar nada.
756 let sel = SelectionRange {
757 anchor: Point::new(0, 4),
758 head: Point::new(1, 0),
759 };
760 assert_eq!(sel.col_range_on(1, 5), None);
761 }
762
763 #[test]
764 fn touches_line_chequea_rango_inclusivo() {
765 let sel = SelectionRange {
766 anchor: Point::new(2, 0),
767 head: Point::new(4, 0),
768 };
769 assert!(!sel.touches_line(1));
770 assert!(sel.touches_line(2));
771 assert!(sel.touches_line(3));
772 assert!(sel.touches_line(4));
773 assert!(!sel.touches_line(5));
774 }
775
776 #[test]
777 fn slice_text_clampa_col_fuera_de_texto() {
778 // col más allá del largo del texto → recorta al fin del texto, sin panic.
779 let store = store_of(&["hi"]);
780 let sel = SelectionRange {
781 anchor: Point::new(0, 0),
782 head: Point::new(0, 999),
783 };
784 assert_eq!(sel.slice_text(&store), "hi");
785 }
786
787 #[test]
788 fn slice_text_respeta_limites_utf8() {
789 // "héllo" — la 'é' es 2 bytes (0xC3 0xA9). Col 2 cae a mitad de char;
790 // debe redondear hacia abajo a col 1 (después de 'h'), no panic.
791 let store = store_of(&["héllo"]);
792 let sel = SelectionRange {
793 anchor: Point::new(0, 0),
794 head: Point::new(0, 2),
795 };
796 // col 2 → boundary 1 (después de 'h'); slice "h".
797 assert_eq!(sel.slice_text(&store), "h");
798 }
799
800 #[test]
801 fn slice_text_clampa_lineas_fuera_del_store() {
802 // El store tiene 2 líneas; la selección termina en la 5 → recorta a la 1.
803 let store = store_of(&["uno", "dos"]);
804 let sel = SelectionRange {
805 anchor: Point::new(0, 0),
806 head: Point::new(5, 999),
807 };
808 assert_eq!(sel.slice_text(&store), "uno\ndos");
809 }
810
811 #[test]
812 fn slice_text_de_seleccion_vacia_es_vacio_aun_con_anchor_no_nulo() {
813 // anchor == head → vacío, aún si están en (3, 5).
814 let store = store_of(&["abcd", "efgh", "ijkl", "mnop"]);
815 let sel = SelectionRange::collapsed(Point::new(3, 2));
816 assert_eq!(sel.slice_text(&store), "");
817 }
818
819 #[test]
820 fn slice_text_sobre_store_vacio_es_vacio() {
821 let store = Scrollback::new(0);
822 let sel = SelectionRange {
823 anchor: Point::new(0, 0),
824 head: Point::new(2, 5),
825 };
826 assert_eq!(sel.slice_text(&store), "");
827 }
828
829 fn rects<Msg>(
830 items: &[Item<'_, Msg>],
831 scroll_y: f32,
832 viewport_h: f32,
833 gutter_w: f32,
834 store: &Scrollback,
835 sel: &SelectionRange,
836 ) -> Vec<HighlightRect> {
837 let metrics = TermMetrics {
838 font_size: 12.0,
839 line_height: 16.0,
840 char_width: 8.0,
841 };
842 selection_rects(items, scroll_y, viewport_h, metrics, gutter_w, store, sel)
843 }
844
845 fn point(
846 items: &[Item<'_, ()>],
847 scroll_y: f32,
848 gutter_w: f32,
849 store: &Scrollback,
850 lx: f32,
851 ly: f32,
852 ) -> Option<Point> {
853 let metrics = TermMetrics {
854 font_size: 12.0,
855 line_height: 16.0,
856 char_width: 8.0,
857 };
858 point_at(items, scroll_y, 100.0, metrics, gutter_w, store, lx, ly)
859 }
860
861 #[test]
862 fn point_at_resuelve_linea_y_columna_para_un_click_simple() {
863 let store = store_of(&["abcdef", "ghijkl"]);
864 let items: Vec<Item<'_, ()>> = vec![Item::lines(0, 2)];
865 // Click en línea 1 (y = 20 → fila 1 con row_h=16), col visual = (50-30)/8 = 2.
866 let p = point(&items, 0.0, 30.0, &store, 50.0, 20.0).unwrap();
867 assert_eq!(p, Point::new(1, 2));
868 }
869
870 #[test]
871 fn point_at_clampea_click_fuera_del_texto_al_fin_de_linea() {
872 // Línea de 4 chars, click muy a la derecha → snap al fin (col 4).
873 let store = store_of(&["abcd"]);
874 let items: Vec<Item<'_, ()>> = vec![Item::lines(0, 1)];
875 let p = point(&items, 0.0, 30.0, &store, 1000.0, 5.0).unwrap();
876 assert_eq!(p, Point::new(0, 4));
877 }
878
879 #[test]
880 fn point_at_en_el_gutter_cae_a_col_0() {
881 // Click dentro del gutter (lx < gutter_w) → col visual = 0 → byte col = 0.
882 let store = store_of(&["xyz"]);
883 let items: Vec<Item<'_, ()>> = vec![Item::lines(0, 1)];
884 let p = point(&items, 0.0, 30.0, &store, 10.0, 5.0).unwrap();
885 assert_eq!(p, Point::new(0, 0));
886 }
887
888 #[test]
889 fn point_at_devuelve_none_para_chrome_o_fuera() {
890 // Item 0 = chrome (alto 24); item 1 = 2 líneas. Click en y=10 cae en chrome.
891 let store = store_of(&["aa", "bb"]);
892 let chrome_view: llimphi_compositor::View<()> = llimphi_compositor::View::new(Default::default());
893 let items: Vec<Item<'_, ()>> = vec![Item::chrome(24.0, chrome_view), Item::lines(0, 2)];
894 assert_eq!(point(&items, 0.0, 30.0, &store, 50.0, 10.0), None);
895 // y > total: fuera del stream → None.
896 assert_eq!(point(&items, 0.0, 30.0, &store, 50.0, 1000.0), None);
897 }
898
899 #[test]
900 fn point_at_respeta_scroll_y() {
901 // 100 líneas; con scroll_y = 800, el click en y=8 cae en la línea
902 // floor((800+8)/16) = 50.
903 let lines: Vec<&str> = (0..100).map(|_| "ab").collect();
904 let store = store_of(&lines);
905 let items: Vec<Item<'_, ()>> = vec![Item::lines(0, 100)];
906 let p = point(&items, 800.0, 30.0, &store, 30.0, 8.0).unwrap();
907 assert_eq!(p, Point::new(50, 0));
908 }
909
910 #[test]
911 fn point_at_convierte_visual_a_byte_para_utf8() {
912 // "héllo": vis 0='h', vis 1='é' (2 bytes), vis 2='l' (byte 3), vis 3='l' (byte 4).
913 let store = store_of(&["héllo"]);
914 let items: Vec<Item<'_, ()>> = vec![Item::lines(0, 1)];
915 // Click en vis col 2 (lx = 30 + 4 + 2*8 = 50) — el +4 es
916 // TEXT_LEFT_PADDING_PX → byte col 3.
917 let p = point(&items, 0.0, 30.0, &store, 50.0, 5.0).unwrap();
918 assert_eq!(p, Point::new(0, 3));
919 }
920
921 #[test]
922 fn item_at_y_distingue_chrome_de_fuera_del_stream() {
923 // Chrome de 24 px, luego 2 líneas de 16. `point_at_geo` devuelve None
924 // en ambos casos; `item_at_y` los separa — que es su razón de existir.
925 let geo = vec![ItemGeo::Chrome(24.0), ItemGeo::Lines(0, 2)];
926 assert_eq!(item_at_y(&geo, 16.0, 10.0), Some(0)); // dentro del chrome
927 assert_eq!(item_at_y(&geo, 16.0, 30.0), Some(1)); // primera línea
928 assert_eq!(item_at_y(&geo, 16.0, 55.0), Some(1)); // segunda línea
929 assert_eq!(item_at_y(&geo, 16.0, 56.0), None); // pasado el fin: fuera
930 }
931
932 #[test]
933 fn item_at_y_es_semiabierto_en_los_bordes() {
934 // El borde exacto entre dos items pertenece al de ABAJO — así dos items
935 // contiguos nunca reclaman el mismo píxel.
936 let geo = vec![ItemGeo::Chrome(20.0), ItemGeo::Chrome(20.0)];
937 assert_eq!(item_at_y(&geo, 16.0, 19.99), Some(0));
938 assert_eq!(item_at_y(&geo, 16.0, 20.0), Some(1));
939 }
940
941 #[test]
942 fn item_at_y_rechaza_y_negativa_y_row_h_nula() {
943 let geo = vec![ItemGeo::Lines(0, 2)];
944 assert_eq!(item_at_y(&geo, 16.0, -1.0), None);
945 // row_h = 0 haría que los `Lines` midan 0 y el barrido no avance.
946 assert_eq!(item_at_y(&geo, 0.0, 5.0), None);
947 }
948
949 #[test]
950 fn rects_de_seleccion_vacia_son_vacio() {
951 let store = store_of(&["abc"]);
952 let items: Vec<Item<'_, ()>> = vec![Item::lines(0, 1)];
953 let sel = SelectionRange::collapsed(Point::new(0, 1));
954 assert_eq!(rects(&items, 0.0, 100.0, 30.0, &store, &sel), Vec::new());
955 }
956
957 #[test]
958 fn rect_single_line_ubica_x_y_w_correctos() {
959 // Línea 0 entera (3 chars). x = gutter + TEXT_LEFT_PADDING (4) + 0,
960 // w = 3 * char_w. El +4 es el padding interno del renglón.
961 let store = store_of(&["abc"]);
962 let items: Vec<Item<'_, ()>> = vec![Item::lines(0, 1)];
963 let sel = SelectionRange {
964 anchor: Point::new(0, 0),
965 head: Point::new(0, 3),
966 };
967 let r = rects(&items, 0.0, 100.0, 30.0, &store, &sel);
968 assert_eq!(r.len(), 1);
969 let h = r[0];
970 assert_eq!(h.x, 34.0); // 30 + 4
971 assert_eq!(h.y, 0.0);
972 assert_eq!(h.w, 24.0); // 3 * 8.0
973 assert_eq!(h.h, 16.0);
974 }
975
976 #[test]
977 fn rect_multi_line_emite_uno_por_renglon() {
978 // 3 líneas, selección abarca las 3 (primera/última recortadas).
979 let store = store_of(&["alpha", "beta", "gamma"]);
980 let items: Vec<Item<'_, ()>> = vec![Item::lines(0, 3)];
981 let sel = SelectionRange {
982 anchor: Point::new(0, 2), // "pha"
983 head: Point::new(2, 3), // "gam"
984 };
985 let r = rects(&items, 0.0, 100.0, 30.0, &store, &sel);
986 assert_eq!(r.len(), 3);
987 // Línea 0: chars 2..5 → x = 30 + 4 + 2*8 = 50, w = 3*8 = 24
988 assert_eq!(r[0].x, 50.0);
989 assert_eq!(r[0].w, 24.0);
990 // Línea 1 entera: "beta" (4 chars).
991 assert_eq!(r[1].x, 34.0);
992 assert_eq!(r[1].w, 32.0);
993 // Línea 2: chars 0..3 → "gam".
994 assert_eq!(r[2].x, 34.0);
995 assert_eq!(r[2].w, 24.0);
996 }
997
998 #[test]
999 fn rects_descartan_lineas_fuera_del_viewport() {
1000 // 100 líneas, viewport 32 px (=2 filas), scroll a la mitad → sólo 2-3 rects.
1001 let lines: Vec<&str> = (0..100).map(|_| "row").collect();
1002 let store = store_of(&lines);
1003 let items: Vec<Item<'_, ()>> = vec![Item::lines(0, 100)];
1004 // Selección sobre TODAS las líneas, pero sólo 2-3 entran al viewport.
1005 let sel = SelectionRange {
1006 anchor: Point::new(0, 0),
1007 head: Point::new(99, 3),
1008 };
1009 // scroll a la fila 50 (50 * 16 = 800 px). Viewport de 32 px → filas
1010 // 50, 51 (+ guarda).
1011 let r = rects(&items, 800.0, 32.0, 30.0, &store, &sel);
1012 assert!(r.len() <= 3 && !r.is_empty(),
1013 "esperado ~2-3 rects, no {} (todas las líneas)", r.len());
1014 }
1015
1016 #[test]
1017 fn rects_saltan_items_chrome() {
1018 // Item 0 = chrome (alto 20), item 1 = 2 líneas. Selección sobre las dos
1019 // líneas. El chrome no debe aportar rects.
1020 let store = store_of(&["aa", "bb"]);
1021 let chrome_view: llimphi_compositor::View<()> = llimphi_compositor::View::new(Default::default());
1022 let items: Vec<Item<'_, ()>> = vec![Item::chrome(20.0, chrome_view), Item::lines(0, 2)];
1023 let sel = SelectionRange {
1024 anchor: Point::new(0, 0),
1025 head: Point::new(1, 2),
1026 };
1027 let r = rects(&items, 0.0, 100.0, 30.0, &store, &sel);
1028 assert_eq!(r.len(), 2);
1029 // El primer rect arranca DESPUÉS del chrome (y = 20).
1030 assert_eq!(r[0].y, 20.0);
1031 // El segundo está una fila más abajo (20 + 16 = 36).
1032 assert_eq!(r[1].y, 36.0);
1033 }
1034
1035 #[test]
1036 fn rects_usan_visual_cols_no_bytes_para_utf8() {
1037 // "héllo" — 'é' es 2 bytes, pero 1 char visual. Selección de col 0 a
1038 // col 3 (byte) → snap a 3 (después de "hé"), 2 chars visuales.
1039 let store = store_of(&["héllo"]);
1040 let items: Vec<Item<'_, ()>> = vec![Item::lines(0, 1)];
1041 let sel = SelectionRange {
1042 anchor: Point::new(0, 0),
1043 head: Point::new(0, 3),
1044 };
1045 let r = rects(&items, 0.0, 100.0, 30.0, &store, &sel);
1046 assert_eq!(r.len(), 1);
1047 // 2 chars visuales × 8 px = 16.
1048 assert_eq!(r[0].w, 16.0);
1049 }
1050
1051 #[test]
1052 fn move_caret_char_cruza_lineas_y_colapsa_sin_extend() {
1053 let store = store_of(&["ab", "cd"]);
1054 let mut sel = SelectionRange::collapsed(Point::new(0, 2)); // fin de "ab"
1055 sel.move_caret(&store, Motion::CharRight, false);
1056 assert_eq!(sel.head, Point::new(1, 0)); // baja a inicio de "cd"
1057 assert!(sel.is_empty()); // colapsó (anchor == head)
1058 sel.move_caret(&store, Motion::CharLeft, false);
1059 assert_eq!(sel.head, Point::new(0, 2)); // vuelve al fin de "ab"
1060 }
1061
1062 #[test]
1063 fn move_caret_extend_mantiene_anchor() {
1064 let store = store_of(&["hola mundo"]);
1065 let mut sel = SelectionRange::collapsed(Point::new(0, 0));
1066 sel.move_caret(&store, Motion::WordRight, true); // extiende hasta "mundo"
1067 assert_eq!(sel.anchor, Point::new(0, 0));
1068 assert_eq!(sel.head, Point::new(0, 5)); // inicio de "mundo"
1069 assert_eq!(sel.slice_text(&store), "hola ");
1070 }
1071
1072 #[test]
1073 fn word_motions_saltan_palabra_y_separadores() {
1074 let store = store_of(&["the quick brown"]);
1075 assert_eq!(next_word("the quick brown", 0), 4); // → "quick"
1076 assert_eq!(next_word("the quick brown", 4), 10); // → "brown"
1077 assert_eq!(prev_word("the quick brown", 15), 10); // ← "brown"
1078 assert_eq!(prev_word("the quick brown", 10), 4); // ← "quick"
1079 let _ = store;
1080 }
1081
1082 #[test]
1083 fn move_caret_vertical_preserva_columna_visual() {
1084 let store = store_of(&["abcdef", "xy", "ghijkl"]);
1085 let mut sel = SelectionRange::collapsed(Point::new(0, 4)); // col visual 4
1086 sel.move_caret(&store, Motion::LineDown, false);
1087 assert_eq!(sel.head, Point::new(1, 2)); // "xy" sólo tiene 2 → clampa al fin
1088 sel.move_caret(&store, Motion::LineDown, false);
1089 assert_eq!(sel.head, Point::new(2, 2)); // baja preservando la col 2
1090 }
1091
1092 #[test]
1093 fn move_caret_doc_edges() {
1094 let store = store_of(&["uno", "dos", "tres"]);
1095 let mut sel = SelectionRange::collapsed(Point::new(1, 1));
1096 sel.move_caret(&store, Motion::DocBottom, false);
1097 assert_eq!(sel.head, Point::new(2, 4)); // fin de "tres"
1098 sel.move_caret(&store, Motion::DocTop, false);
1099 assert_eq!(sel.head, Point::new(0, 0));
1100 }
1101
1102 #[test]
1103 fn caret_rect_ubica_la_barra_en_head() {
1104 let store = store_of(&["abcdef"]);
1105 let items: Vec<Item<'_, ()>> = vec![Item::lines(0, 1)];
1106 let metrics = TermMetrics {
1107 font_size: 12.0,
1108 line_height: 16.0,
1109 char_width: 8.0,
1110 };
1111 // Caret en col 3 → x = 30(gutter) + 4(padding) + 3*8 = 58, w = CARET_W.
1112 let r = caret_rect(&items, 0.0, 100.0, metrics, 30.0, &store, Point::new(0, 3)).unwrap();
1113 assert_eq!(r.x, 58.0);
1114 assert_eq!(r.w, CARET_W);
1115 assert_eq!(r.h, 16.0);
1116 }
1117
1118 #[test]
1119 fn slice_text_a_linea_intermedia_omite_las_que_no_existen() {
1120 // Si una línea intermedia desaparece (no debería pasar aquí pero el
1121 // store sólo expone `line()`), se omite — no se inserta `\n` extra.
1122 // Aquí lo cubrimos indirectamente con un store contiguo.
1123 let store = store_of(&["aa", "bb", "cc"]);
1124 let sel = SelectionRange {
1125 anchor: Point::new(0, 1),
1126 head: Point::new(2, 1),
1127 };
1128 assert_eq!(sel.slice_text(&store), "a\nbb\nc");
1129 }
1130}