feat: llimphi standalone — framework UI soberano extraído del monorepo
Motor gráfico Llimphi como workspace independiente: bucle Elm (input→update→view→layout→raster→present) sobre wgpu+vello+taffy+parley. Núcleo (hal/raster/layout/text/ui/theme/surface/motion/icons) + ~40 widgets + módulos, sin dependencias al resto del monorepo. cargo check --workspace pasa (64 crates). Puerta de entrada: cargo run -p llimphi-ui --example counter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
//! 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<Pos>,
|
||||
/// 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<Selection> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user