//! Cursor + selección. Coordenadas en `(line, col)` (col en chars). //! //! Un [`Cursor`] tiene siempre una posición `caret` y opcionalmente un //! `anchor`: si están en distintos puntos, hay una **selección**. //! Movimiento sin `shift` colapsa la selección al caret nuevo; //! movimiento con `shift` extiende desde el `anchor`. use crate::buffer::Buffer; fn is_word(c: char) -> bool { c.is_alphanumeric() || c == '_' } fn is_ws(c: char) -> bool { c.is_whitespace() && c != '\n' } /// Posición lógica del cursor — (línea, columna en chars). #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub struct Pos { pub line: usize, pub col: usize, } impl Pos { pub const fn new(line: usize, col: usize) -> Self { Self { line, col } } pub const ORIGIN: Pos = Pos { line: 0, col: 0 }; } /// Selección activa (anchor + caret). El rango efectivo es /// `(min(anchor,caret), max(anchor,caret))` en orden de offset. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Selection { pub anchor: Pos, pub caret: Pos, } impl Selection { pub fn new(anchor: Pos, caret: Pos) -> Self { Self { anchor, caret } } pub fn is_empty(&self) -> bool { self.anchor == self.caret } } /// Cursor: caret + (opcional) anchor cuando hay selección. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Cursor { pub caret: Pos, pub anchor: Option, /// Columna "deseada" — preserva la posición horizontal al saltar /// entre líneas de distinto largo. Se setea al mover horizontal /// y se respeta al mover vertical. pub desired_col: usize, } impl Default for Cursor { fn default() -> Self { Self::new() } } impl Cursor { pub fn new() -> Self { Self { caret: Pos::ORIGIN, anchor: None, desired_col: 0 } } pub fn at(line: usize, col: usize) -> Self { Self { caret: Pos::new(line, col), anchor: None, desired_col: col } } pub fn selection(&self) -> Option { self.anchor.map(|a| Selection::new(a, self.caret)) } pub fn has_selection(&self) -> bool { self.anchor.map_or(false, |a| a != self.caret) } /// Rango efectivo `(start, end)` en `char_offset` global. Si no hay /// selección, ambos son el caret. pub fn selection_range(&self, buf: &Buffer) -> (usize, usize) { let caret_off = buf.pos_to_offset(self.caret.line, self.caret.col); match self.anchor { None => (caret_off, caret_off), Some(a) => { let anchor_off = buf.pos_to_offset(a.line, a.col); if anchor_off <= caret_off { (anchor_off, caret_off) } else { (caret_off, anchor_off) } } } } /// Colapsa la selección dejando el caret donde está. pub fn collapse(&mut self) { self.anchor = None; } /// Asegura que `anchor = caret` si `extending` es true y no había /// anchor; si es false, colapsa. pub fn set_extending(&mut self, extending: bool) { match (extending, self.anchor) { (true, None) => self.anchor = Some(self.caret), (true, Some(_)) => {} (false, _) => self.anchor = None, } } // ----- Movimiento por chars ----- pub fn move_left(&mut self, buf: &Buffer, extending: bool) { self.set_extending(extending); if self.caret.col > 0 { self.caret.col -= 1; } else if self.caret.line > 0 { self.caret.line -= 1; self.caret.col = buf.line_len_chars(self.caret.line); } self.desired_col = self.caret.col; } pub fn move_right(&mut self, buf: &Buffer, extending: bool) { self.set_extending(extending); let line_len = buf.line_len_chars(self.caret.line); if self.caret.col < line_len { self.caret.col += 1; } else if self.caret.line + 1 < buf.len_lines() { self.caret.line += 1; self.caret.col = 0; } self.desired_col = self.caret.col; } pub fn move_up(&mut self, buf: &Buffer, extending: bool) { self.set_extending(extending); if self.caret.line == 0 { self.caret.col = 0; } else { self.caret.line -= 1; self.caret.col = self.desired_col.min(buf.line_len_chars(self.caret.line)); } } pub fn move_down(&mut self, buf: &Buffer, extending: bool) { self.set_extending(extending); if self.caret.line + 1 >= buf.len_lines() { self.caret.col = buf.line_len_chars(self.caret.line); } else { self.caret.line += 1; self.caret.col = self.desired_col.min(buf.line_len_chars(self.caret.line)); } } pub fn move_home(&mut self, _buf: &Buffer, extending: bool) { self.set_extending(extending); // Atajo: ir al inicio del primer non-whitespace; segundo Home // iría al 0 — por ahora siempre al 0. self.caret.col = 0; self.desired_col = 0; } pub fn move_end(&mut self, buf: &Buffer, extending: bool) { self.set_extending(extending); self.caret.col = buf.line_len_chars(self.caret.line); self.desired_col = self.caret.col; } pub fn move_page_up(&mut self, buf: &Buffer, extending: bool, page: usize) { self.set_extending(extending); self.caret.line = self.caret.line.saturating_sub(page); self.caret.col = self.desired_col.min(buf.line_len_chars(self.caret.line)); } pub fn move_page_down(&mut self, buf: &Buffer, extending: bool, page: usize) { self.set_extending(extending); self.caret.line = (self.caret.line + page).min(buf.len_lines().saturating_sub(1)); self.caret.col = self.desired_col.min(buf.line_len_chars(self.caret.line)); } pub fn move_doc_start(&mut self, _buf: &Buffer, extending: bool) { self.set_extending(extending); self.caret = Pos::ORIGIN; self.desired_col = 0; } pub fn move_doc_end(&mut self, buf: &Buffer, extending: bool) { self.set_extending(extending); let last_line = buf.len_lines().saturating_sub(1); self.caret = Pos::new(last_line, buf.line_len_chars(last_line)); self.desired_col = self.caret.col; } // ----- Word movement ----- /// Movimiento por palabra a la izquierda — salta whitespace, después /// caracteres de palabra (alfanumérico + `_`). pub fn move_word_left(&mut self, buf: &Buffer, extending: bool) { self.set_extending(extending); let mut off = buf.pos_to_offset(self.caret.line, self.caret.col); while off > 0 && buf.char_at(off - 1).map_or(false, is_ws) { off -= 1; } while off > 0 && buf.char_at(off - 1).map_or(false, is_word) { off -= 1; } let (l, c) = buf.offset_to_pos(off); self.caret = Pos::new(l, c); self.desired_col = c; } pub fn move_word_right(&mut self, buf: &Buffer, extending: bool) { self.set_extending(extending); let len = buf.len_chars(); let mut off = buf.pos_to_offset(self.caret.line, self.caret.col); while off < len && buf.char_at(off).map_or(false, is_word) { off += 1; } while off < len && buf.char_at(off).map_or(false, is_ws) { off += 1; } let (l, c) = buf.offset_to_pos(off); self.caret = Pos::new(l, c); self.desired_col = c; } // ----- Setters ----- pub fn set_caret(&mut self, buf: &Buffer, pos: Pos) { let line = pos.line.min(buf.len_lines().saturating_sub(1)); let col = pos.col.min(buf.line_len_chars(line)); self.caret = Pos::new(line, col); self.desired_col = col; self.anchor = None; } } #[cfg(test)] mod tests { use super::*; fn buf() -> Buffer { Buffer::from_str("hola\nmundo\nfin") } #[test] fn cursor_new_is_origin() { let c = Cursor::new(); assert_eq!(c.caret, Pos::ORIGIN); assert!(!c.has_selection()); } #[test] fn move_right_atraviesa_lineas() { let b = buf(); let mut c = Cursor::at(0, 4); // fin de "hola" c.move_right(&b, false); assert_eq!(c.caret, Pos::new(1, 0)); // inicio de "mundo" } #[test] fn move_left_retrocede_a_linea_anterior() { let b = buf(); let mut c = Cursor::at(1, 0); c.move_left(&b, false); assert_eq!(c.caret, Pos::new(0, 4)); } #[test] fn move_up_preserva_desired_col() { let b = Buffer::from_str("abcdefgh\nxy\nlmnop"); let mut c = Cursor::at(0, 7); c.move_down(&b, false); // "xy" sólo tiene 2 chars; el cursor se pega a col=2 assert_eq!(c.caret, Pos::new(1, 2)); // pero al bajar de nuevo, el desired (7) reanima. c.move_down(&b, false); assert_eq!(c.caret, Pos::new(2, 5)); // "lmnop" tiene 5 } #[test] fn shift_arrow_inicia_seleccion() { let b = buf(); let mut c = Cursor::at(0, 0); c.move_right(&b, true); c.move_right(&b, true); assert!(c.has_selection()); let (s, e) = c.selection_range(&b); assert_eq!((s, e), (0, 2)); } #[test] fn arrow_sin_shift_colapsa() { let b = buf(); let mut c = Cursor::at(0, 0); c.move_right(&b, true); c.move_right(&b, true); c.move_right(&b, false); assert!(!c.has_selection()); } #[test] fn home_end_son_locales_a_la_linea() { let b = buf(); let mut c = Cursor::at(1, 2); c.move_home(&b, false); assert_eq!(c.caret, Pos::new(1, 0)); c.move_end(&b, false); assert_eq!(c.caret, Pos::new(1, 5)); } #[test] fn doc_start_y_end() { let b = buf(); let mut c = Cursor::at(1, 2); c.move_doc_end(&b, false); assert_eq!(c.caret, Pos::new(2, 3)); c.move_doc_start(&b, false); assert_eq!(c.caret, Pos::ORIGIN); } }