tawasuyu

tawasuyu/llimphi

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

Rust 124Markdown 82TOML 78Shell 12149 Definitions

⎇ main widgets/terminal/src/select.rs Rust · 1130 lines · 41.3 KB History Who touched what raw

chore: refresco desde el monorepo (pin tawasuyu.git v0.3.0) b575c63f1 · 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_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}