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:
2026-06-04 04:23:42 +00:00
commit e65e9cc623
286 changed files with 46136 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "llimphi-widget-text-editor-core"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-text-editor-core — núcleo agnóstico del editor de código: rope buffer (ropey), cursor + selección, undo/redo, bracket matching, find, diagnostics y syntax highlighting (tree-sitter). Sin dependencias de render — reutilizable en TUI/web/headless. La capa Llimphi (state + view) vive en `llimphi-widget-text-editor`."
[dependencies]
ropey = { workspace = true }
tree-sitter = { workspace = true }
tree-sitter-rust = { workspace = true }
tree-sitter-python = { workspace = true }
# peniko sólo aporta el tipo de color (peniko::Color) para SyntaxPalette;
# es un crate de tipos sin GPU — no arrastra wgpu/vello. Versión alineada
# con la que expone vello 0.5 (ver workspace root).
peniko = "0.4"
+165
View File
@@ -0,0 +1,165 @@
//! Matching de paréntesis/corchetes/llaves bajo el cursor.
//!
//! Si el carácter inmediatamente *antes* o *en* el caret es un bracket
//! abridor o cerrador, busca su par contando profundidad y devuelve las
//! dos posiciones. Útil para el visor (resaltar ambas).
//!
//! Restricciones del PMV: no diferencia brackets dentro de strings ni
//! comentarios — el tokenizer del bloque de highlight (tree-sitter) lo
//! resolverá mejor en una pasada futura. Para WAT/JSON/Lisp esto basta.
use crate::buffer::Buffer;
use crate::cursor::{Cursor, Pos};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
Forward,
Backward,
}
/// Pares reconocidos.
const PAIRS: &[(char, char)] = &[('(', ')'), ('[', ']'), ('{', '}')];
fn pair_of(c: char) -> Option<(char, char, Direction)> {
for &(o, cl) in PAIRS {
if c == o {
return Some((o, cl, Direction::Forward));
}
if c == cl {
return Some((o, cl, Direction::Backward));
}
}
None
}
/// Si el caret toca un bracket, devuelve `(pos_del_bracket, pos_del_par)`.
pub fn find_bracket_pair(buf: &Buffer, cursor: &Cursor) -> Option<(Pos, Pos)> {
let caret_off = buf.pos_to_offset(cursor.caret.line, cursor.caret.col);
// Probamos en `caret` y `caret-1` — un caret "entre" dos chars puede
// tocar al de la izquierda visualmente.
let candidates: [Option<usize>; 2] = [
Some(caret_off).filter(|&o| o < buf.len_chars()),
caret_off.checked_sub(1),
];
for opt in candidates {
let Some(off) = opt else { continue };
let Some(ch) = buf.char_at(off) else { continue };
let Some((open, close, dir)) = pair_of(ch) else { continue };
let mate = match dir {
Direction::Forward => find_forward(buf, off + 1, open, close),
Direction::Backward => find_backward(buf, off, open, close),
};
if let Some(mate_off) = mate {
let a = buf.offset_to_pos(off);
let b = buf.offset_to_pos(mate_off);
return Some((Pos::new(a.0, a.1), Pos::new(b.0, b.1)));
}
}
None
}
fn find_forward(buf: &Buffer, from: usize, open: char, close: char) -> Option<usize> {
let mut depth = 1usize;
let mut off = from;
let len = buf.len_chars();
while off < len {
match buf.char_at(off) {
Some(c) if c == open => depth += 1,
Some(c) if c == close => {
depth -= 1;
if depth == 0 {
return Some(off);
}
}
_ => {}
}
off += 1;
}
None
}
fn find_backward(buf: &Buffer, before: usize, open: char, close: char) -> Option<usize> {
if before == 0 {
return None;
}
let mut depth = 1usize;
let mut off = before;
while off > 0 {
off -= 1;
match buf.char_at(off) {
Some(c) if c == close => depth += 1,
Some(c) if c == open => {
depth -= 1;
if depth == 0 {
return Some(off);
}
}
_ => {}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empareja_paren_simple() {
let b = Buffer::from_str("(a)");
let c = Cursor::at(0, 0); // caret antes del '('
let (a, m) = find_bracket_pair(&b, &c).unwrap();
assert_eq!(a, Pos::new(0, 0));
assert_eq!(m, Pos::new(0, 2));
}
#[test]
fn empareja_desde_el_lado_derecho() {
let b = Buffer::from_str("(a)");
let c = Cursor::at(0, 3); // caret después del ')'
let (a, m) = find_bracket_pair(&b, &c).unwrap();
assert_eq!(a, Pos::new(0, 2)); // ')'
assert_eq!(m, Pos::new(0, 0)); // '('
}
#[test]
fn anidados_respeta_profundidad() {
let b = Buffer::from_str("((a))");
let c = Cursor::at(0, 0); // primer '('
let (_, m) = find_bracket_pair(&b, &c).unwrap();
assert_eq!(m, Pos::new(0, 4)); // último ')'
}
#[test]
fn empareja_brackets_y_llaves() {
let b = Buffer::from_str("[a]");
assert!(find_bracket_pair(&b, &Cursor::at(0, 0)).is_some());
let b2 = Buffer::from_str("{a}");
assert!(find_bracket_pair(&b2, &Cursor::at(0, 0)).is_some());
}
#[test]
fn caret_lejos_de_bracket_devuelve_none() {
let b = Buffer::from_str("hola");
let c = Cursor::at(0, 2);
assert!(find_bracket_pair(&b, &c).is_none());
}
#[test]
fn bracket_sin_par_devuelve_none() {
let b = Buffer::from_str("(a");
let c = Cursor::at(0, 0);
assert!(find_bracket_pair(&b, &c).is_none());
}
#[test]
fn multilinea_pasa_saltos() {
let b = Buffer::from_str("(\n a\n)");
let c = Cursor::at(0, 0);
let (_, m) = find_bracket_pair(&b, &c).unwrap();
assert_eq!(m, Pos::new(2, 0));
}
}
+255
View File
@@ -0,0 +1,255 @@
//! Buffer del editor — wrapper fino sobre [`ropey::Rope`] con las
//! conversiones de coordenadas que el resto del crate usa.
//!
//! Coordenadas:
//! - `char_offset`: índice de carácter (no byte) en el buffer entero.
//! - `(line, col)`: línea (0-based) + columna en chars dentro de esa línea.
//!
//! Convenciones:
//! - Las líneas son las que define `Rope::lines()` — un `\n` separa
//! líneas; la última línea puede o no terminar en `\n` (en cuyo caso
//! hay una línea vacía extra después).
//! - `col` cuenta chars, no graphemes ni bytes. Para CJK ancho doble
//! el render decidirá el ancho visual; el cursor avanza en chars.
use ropey::Rope;
#[derive(Debug, Clone)]
pub struct Buffer {
rope: Rope,
}
impl Default for Buffer {
fn default() -> Self {
Self::new()
}
}
impl Buffer {
pub fn new() -> Self {
Self { rope: Rope::new() }
}
pub fn from_str(s: &str) -> Self {
Self { rope: Rope::from_str(s) }
}
pub fn text(&self) -> String {
self.rope.to_string()
}
pub fn len_chars(&self) -> usize {
self.rope.len_chars()
}
pub fn len_lines(&self) -> usize {
self.rope.len_lines().max(1)
}
pub fn is_empty(&self) -> bool {
self.rope.len_chars() == 0
}
/// Devuelve la línea `n` como `String` (incluye su trailing `\n` si
/// no es la última). Si `n` está fuera de rango devuelve `""`.
pub fn line(&self, n: usize) -> String {
if n >= self.rope.len_lines() {
return String::new();
}
self.rope.line(n).to_string()
}
/// Cantidad de chars en la línea `n` **sin contar** el `\n` terminal.
pub fn line_len_chars(&self, n: usize) -> usize {
if n >= self.rope.len_lines() {
return 0;
}
let line = self.rope.line(n);
let mut len = line.len_chars();
// Quitamos el `\n` final si lo hay.
if len > 0 && line.char(len - 1) == '\n' {
len -= 1;
}
len
}
/// Convierte `char_offset` global a `(line, col)`.
pub fn offset_to_pos(&self, offset: usize) -> (usize, usize) {
let off = offset.min(self.rope.len_chars());
let line = self.rope.char_to_line(off);
let line_start = self.rope.line_to_char(line);
(line, off - line_start)
}
/// Convierte `(line, col)` a `char_offset`. Clampea `line` y `col`
/// para no panicear con coordenadas fuera de rango.
pub fn pos_to_offset(&self, line: usize, col: usize) -> usize {
let line = line.min(self.rope.len_lines().saturating_sub(1));
let line_start = self.rope.line_to_char(line);
let line_chars = self.line_len_chars(line);
let col = col.min(line_chars);
line_start + col
}
/// Carácter en `char_offset`. `None` si está fuera de rango.
pub fn char_at(&self, offset: usize) -> Option<char> {
if offset >= self.rope.len_chars() {
return None;
}
Some(self.rope.char(offset))
}
/// Slice `[start..end)` como `String`. Clampea para no panicear.
pub fn slice(&self, start: usize, end: usize) -> String {
let len = self.rope.len_chars();
let s = start.min(len);
let e = end.min(len).max(s);
self.rope.slice(s..e).to_string()
}
/// Inserta `s` en `offset`. Clampea `offset`.
pub fn insert(&mut self, offset: usize, s: &str) {
let off = offset.min(self.rope.len_chars());
self.rope.insert(off, s);
}
/// Borra `[start..end)`. Clampea ambos.
pub fn delete(&mut self, start: usize, end: usize) {
let len = self.rope.len_chars();
let s = start.min(len);
let e = end.min(len).max(s);
if s == e {
return;
}
self.rope.remove(s..e);
}
pub fn set_text(&mut self, s: &str) {
self.rope = Rope::from_str(s);
}
pub fn replace_all(&mut self, s: &str) {
self.set_text(s);
}
/// Convierte char_offset → byte_offset. tree-sitter trabaja en bytes
/// (UTF-8); el editor en chars. Esto las conecta.
pub fn char_to_byte(&self, char_offset: usize) -> usize {
let off = char_offset.min(self.rope.len_chars());
self.rope.char_to_byte(off)
}
/// Línea (0-based) que contiene el char_offset dado.
pub fn char_to_line(&self, char_offset: usize) -> usize {
let off = char_offset.min(self.rope.len_chars());
self.rope.char_to_line(off)
}
/// Byte_offset del primer char de la línea `n`.
pub fn line_to_byte(&self, line: usize) -> usize {
let line = line.min(self.rope.len_lines());
self.rope.line_to_byte(line)
}
/// Devuelve el rango `[start_col..col)` que contiene el "word" actual
/// — desde el último carácter no-de-palabra hasta `col`, en la línea
/// `line`. Útil para autocompletion (smart-replace del prefijo).
pub fn current_word_prefix(&self, line: usize, col: usize) -> (usize, String) {
let line_text = self.line(line);
let chars: Vec<char> = line_text
.chars()
.filter(|c| *c != '\n')
.collect();
let end = col.min(chars.len());
let mut start = end;
while start > 0 && is_word_char(chars[start - 1]) {
start -= 1;
}
let prefix: String = chars[start..end].iter().collect();
(start, prefix)
}
}
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_buffer_has_one_line() {
let b = Buffer::new();
assert_eq!(b.len_lines(), 1);
assert_eq!(b.line_len_chars(0), 0);
}
#[test]
fn pos_offset_roundtrip() {
let b = Buffer::from_str("hola\nmundo\nfin");
let cases = [(0usize, 0usize), (0, 4), (1, 0), (1, 5), (2, 3)];
for (line, col) in cases {
let off = b.pos_to_offset(line, col);
assert_eq!(b.offset_to_pos(off), (line, col));
}
}
#[test]
fn line_len_excludes_trailing_newline() {
let b = Buffer::from_str("hola\nfin");
assert_eq!(b.line_len_chars(0), 4); // "hola" sin \n
assert_eq!(b.line_len_chars(1), 3); // "fin"
}
#[test]
fn insert_and_delete_modify_text() {
let mut b = Buffer::from_str("ab");
b.insert(1, "X");
assert_eq!(b.text(), "aXb");
b.delete(1, 2);
assert_eq!(b.text(), "ab");
}
#[test]
fn slice_clampea() {
let b = Buffer::from_str("hola");
assert_eq!(b.slice(0, 100), "hola");
assert_eq!(b.slice(50, 100), "");
assert_eq!(b.slice(2, 1), ""); // end < start clampea
}
#[test]
fn current_word_prefix_basic() {
let b = Buffer::from_str("let hola_mundo = 1;");
// Caret en col 14 (después de la 'o' de "hola_mundo").
let (start, p) = b.current_word_prefix(0, 14);
assert_eq!(start, 4);
assert_eq!(p, "hola_mundo");
}
#[test]
fn current_word_prefix_en_inicio_es_vacio() {
let b = Buffer::from_str("hola");
let (start, p) = b.current_word_prefix(0, 0);
assert_eq!(start, 0);
assert!(p.is_empty());
}
#[test]
fn current_word_prefix_caret_despues_de_no_word() {
let b = Buffer::from_str("foo.bar");
let (start, p) = b.current_word_prefix(0, 4);
// El '.' no es word; el prefijo empieza ahí.
assert_eq!(start, 4);
assert!(p.is_empty());
}
#[test]
fn pos_to_offset_clampea_col() {
let b = Buffer::from_str("ab\ncd");
// col fuera de rango → fin de línea
assert_eq!(b.pos_to_offset(0, 99), 2);
assert_eq!(b.pos_to_offset(1, 99), 5);
}
}
+50
View File
@@ -0,0 +1,50 @@
//! Clipboard abstracto. El editor no quiere acoplarse a un backend de
//! SO concreto (X11 / Wayland / macOS / Windows), así que define el
//! trait y entrega un mock para tests. La impl real (vía `arboard`)
//! vive del lado del caller — típicamente la app embebida en
//! `nada` o el visor del notebook.
/// Backend de clipboard. `set` mete texto; `get` lo lee. Cualquiera de
/// los dos puede fallar (sin display, headless CI, race con otro
/// programa) — `None` / no-op silencioso es válido.
pub trait Clipboard: Send {
fn get(&mut self) -> Option<String>;
fn set(&mut self, s: &str);
}
/// Clipboard de memoria — útil para tests y como fallback cuando el
/// sistema no expone uno.
#[derive(Debug, Default, Clone)]
pub struct MemClipboard {
content: Option<String>,
}
impl MemClipboard {
pub fn new() -> Self {
Self::default()
}
pub fn with(s: impl Into<String>) -> Self {
Self { content: Some(s.into()) }
}
}
impl Clipboard for MemClipboard {
fn get(&mut self) -> Option<String> {
self.content.clone()
}
fn set(&mut self, s: &str) {
self.content = Some(s.to_owned());
}
}
/// "No clipboard" — `set` descarta, `get` devuelve `None`. Útil cuando
/// el caller quiere desactivar copy/paste explícitamente.
#[derive(Debug, Default, Clone, Copy)]
pub struct NullClipboard;
impl Clipboard for NullClipboard {
fn get(&mut self) -> Option<String> {
None
}
fn set(&mut self, _: &str) {}
}
+325
View File
@@ -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);
}
}
@@ -0,0 +1,78 @@
//! Diagnósticos del editor — espejo minimal del shape de `lsp-types`
//! sin depender del crate. Pensado para que un client LSP (rust-analyzer,
//! pylsp, etc.) lo poble desde fuera; el render del editor los pinta
//! como subrayado bajo el rango.
//!
//! El client real vive aparte (proceso + JSON-RPC) — este módulo sólo
//! define el shape de los datos y el helper para renderizarlos.
use crate::cursor::Pos;
/// Severidad — mismos valores y orden que en LSP (1 = Error es el más alto).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
Information,
Hint,
}
/// Rango cerrado de un diagnostic. `end` exclusivo en `col`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DiagnosticRange {
pub start: Pos,
pub end: Pos,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Diagnostic {
pub range: DiagnosticRange,
pub severity: Severity,
/// Mensaje humano corto — el render lo trunca para mostrar al hover/
/// en una mini popup futura. En esta versión solo se usa el rango.
pub message: String,
/// Source del diagnostic — "rust-analyzer", "pylsp", "clippy", etc.
/// `None` si no se conoce.
pub source: Option<String>,
}
impl Diagnostic {
pub fn error(line_start: usize, col_start: usize, line_end: usize, col_end: usize, message: impl Into<String>) -> Self {
Self {
range: DiagnosticRange {
start: Pos::new(line_start, col_start),
end: Pos::new(line_end, col_end),
},
severity: Severity::Error,
message: message.into(),
source: None,
}
}
pub fn warning(line_start: usize, col_start: usize, line_end: usize, col_end: usize, message: impl Into<String>) -> Self {
Self {
range: DiagnosticRange {
start: Pos::new(line_start, col_start),
end: Pos::new(line_end, col_end),
},
severity: Severity::Warning,
message: message.into(),
source: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constructors_funcionan() {
let e = Diagnostic::error(1, 2, 1, 5, "boom");
assert_eq!(e.severity, Severity::Error);
assert_eq!(e.range.start, Pos::new(1, 2));
assert_eq!(e.range.end, Pos::new(1, 5));
let w = Diagnostic::warning(0, 0, 0, 10, "ojo");
assert_eq!(w.severity, Severity::Warning);
}
}
+168
View File
@@ -0,0 +1,168 @@
//! Búsqueda en el buffer. PMV: case-insensitive opcional, sin regex,
//! sin replace. La UI del prompt vive en el caller (típicamente una
//! barra arriba del editor); este módulo sólo provee:
//!
//! - [`FindState`] con el query actual + dirección + flag case-sensitive.
//! - [`find_next`] / [`find_prev`] que devuelven la próxima/anterior
//! match desde el caret del editor.
//! - [`all_matches`] para que el render resalte cada ocurrencia.
use crate::buffer::Buffer;
use crate::cursor::{Cursor, Pos};
/// Configuración de búsqueda del editor.
#[derive(Debug, Clone, Default)]
pub struct FindState {
pub query: String,
pub case_sensitive: bool,
}
impl FindState {
pub fn new() -> Self {
Self::default()
}
pub fn with_query(query: impl Into<String>) -> Self {
Self { query: query.into(), case_sensitive: false }
}
pub fn is_active(&self) -> bool {
!self.query.is_empty()
}
}
/// Devuelve todas las ocurrencias del query en el buffer como
/// `(start_offset, end_offset)` en char offsets. Vacío si query vacío.
pub fn all_matches(buf: &Buffer, find: &FindState) -> Vec<(usize, usize)> {
if find.query.is_empty() {
return Vec::new();
}
let hay = buf.text();
let (hay_search, needle_search) = if find.case_sensitive {
(hay.clone(), find.query.clone())
} else {
(hay.to_lowercase(), find.query.to_lowercase())
};
// Buscamos en bytes; convertimos a char_offsets al devolver.
let mut out: Vec<(usize, usize)> = Vec::new();
let mut byte_start = 0;
while let Some(pos) = hay_search[byte_start..].find(&needle_search) {
let byte_match = byte_start + pos;
let char_start = hay[..byte_match].chars().count();
let char_end = char_start + find.query.chars().count();
out.push((char_start, char_end));
byte_start = byte_match + needle_search.len().max(1);
}
out
}
/// Encuentra la próxima ocurrencia con `start >= caret_off` (la match
/// **en** el caret cuenta, no la saltea). Para avanzar a la siguiente
/// real, el caller mueve el caret al `end` de la match anterior y
/// vuelve a llamar. Wrap-around al fin del buffer → primera match.
pub fn find_next(buf: &Buffer, find: &FindState, cursor: &Cursor) -> Option<(Pos, Pos)> {
let matches = all_matches(buf, find);
if matches.is_empty() {
return None;
}
let caret_off = buf.pos_to_offset(cursor.caret.line, cursor.caret.col);
let next = matches
.iter()
.find(|(s, _)| *s >= caret_off)
.copied()
.or_else(|| matches.first().copied())?;
Some(positions_of(buf, next))
}
/// Como [`find_next`] pero en reverso.
pub fn find_prev(buf: &Buffer, find: &FindState, cursor: &Cursor) -> Option<(Pos, Pos)> {
let matches = all_matches(buf, find);
if matches.is_empty() {
return None;
}
let caret_off = buf.pos_to_offset(cursor.caret.line, cursor.caret.col);
let prev = matches
.iter()
.rev()
.find(|(_, e)| *e < caret_off)
.copied()
.or_else(|| matches.last().copied())?;
Some(positions_of(buf, prev))
}
fn positions_of(buf: &Buffer, (start, end): (usize, usize)) -> (Pos, Pos) {
let (sl, sc) = buf.offset_to_pos(start);
let (el, ec) = buf.offset_to_pos(end);
(Pos::new(sl, sc), Pos::new(el, ec))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_matches_vacio_devuelve_vacio() {
let b = Buffer::from_str("hola hola");
let f = FindState::new();
assert!(all_matches(&b, &f).is_empty());
}
#[test]
fn all_matches_encuentra_todas() {
let b = Buffer::from_str("ab cd ab ef ab");
let f = FindState::with_query("ab");
let m = all_matches(&b, &f);
assert_eq!(m, vec![(0, 2), (6, 8), (12, 14)]);
}
#[test]
fn case_insensitive_por_default() {
let b = Buffer::from_str("Hola HOLA hola");
let f = FindState::with_query("hola");
assert_eq!(all_matches(&b, &f).len(), 3);
}
#[test]
fn case_sensitive_filtra() {
let b = Buffer::from_str("Hola HOLA hola");
let f = FindState { query: "hola".into(), case_sensitive: true };
assert_eq!(all_matches(&b, &f).len(), 1);
}
#[test]
fn find_next_wrap_al_final() {
let b = Buffer::from_str("ab cd ab");
let f = FindState::with_query("ab");
let c = Cursor::at(0, 8); // al final
let (a, _) = find_next(&b, &f, &c).unwrap();
assert_eq!(a, Pos::new(0, 0)); // wrap al primero
}
#[test]
fn find_prev_wrap_al_principio() {
let b = Buffer::from_str("ab cd ab");
let f = FindState::with_query("ab");
let c = Cursor::at(0, 0);
let (a, _) = find_prev(&b, &f, &c).unwrap();
assert_eq!(a, Pos::new(0, 6)); // wrap al último
}
#[test]
fn find_next_devuelve_match_en_el_caret() {
let b = Buffer::from_str("ab ab ab");
let f = FindState::with_query("ab");
let c = Cursor::at(0, 0);
let (a, _) = find_next(&b, &f, &c).unwrap();
assert_eq!(a, Pos::new(0, 0));
}
#[test]
fn find_next_avanza_si_caret_va_al_fin_de_match_anterior() {
let b = Buffer::from_str("ab ab ab");
let f = FindState::with_query("ab");
let mut c = Cursor::at(0, 0);
let (_, end1) = find_next(&b, &f, &c).unwrap();
c.caret = end1; // (0, 2) — fin de la primera
let (a2, _) = find_next(&b, &f, &c).unwrap();
assert_eq!(a2, Pos::new(0, 3));
}
}
+590
View File
@@ -0,0 +1,590 @@
//! Syntax highlighting. Cada `Language` produce una `Vec<LineSpans>`:
//! por línea, una secuencia ordenada de `(start_col, end_col, TokenKind)`
//! que cubre toda la línea. El renderer pinta cada span con el color
//! que el [`SyntaxPalette`] mapea desde el `TokenKind`.
//!
//! - **Rust / Python**: tree-sitter parseando el buffer entero (ineficiente
//! pero adecuado para celdas de notebook ≤ ~1k LOC). Las queries se
//! compilan una vez por `Language`.
//! - **WAT**: tokenizer en Rust puro (LISP-like: paren, `$`-prefijo,
//! strings, números, keywords típicos del subset MVP).
//! - **Plain**: un solo span por línea con `TokenKind::Other`.
use peniko::Color;
/// Lenguajes soportados — la matriz se extiende sumando un variant +
/// una rama en [`Highlighter::tokenize_line`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Language {
Plain,
Rust,
Python,
Wat,
}
impl Language {
/// Heurística: derivar el `Language` del `language` del `CellKind`.
pub fn from_cell_language(s: &str) -> Self {
match s.to_ascii_lowercase().as_str() {
"rust" | "rs" => Language::Rust,
"python" | "py" => Language::Python,
"wasm" | "wat" => Language::Wat,
_ => Language::Plain,
}
}
}
/// Categorías de token — lo suficientemente granular para colores
/// distintos sin saturar el theme.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenKind {
Keyword,
Type,
Function,
String,
Number,
Comment,
Operator,
Punctuation,
Identifier,
Other,
}
/// Un span dentro de una línea: `[start_col..end_col)` de la línea,
/// más su categoría.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Span {
pub start_col: usize,
pub end_col: usize,
pub kind: TokenKind,
}
/// Paleta de colores por categoría — el theme la deriva.
#[derive(Debug, Clone, Copy)]
pub struct SyntaxPalette {
pub keyword: Color,
pub typ: Color,
pub function: Color,
pub string: Color,
pub number: Color,
pub comment: Color,
pub operator: Color,
pub punctuation: Color,
pub identifier: Color,
pub other: Color,
}
impl SyntaxPalette {
pub fn color(&self, k: TokenKind) -> Color {
match k {
TokenKind::Keyword => self.keyword,
TokenKind::Type => self.typ,
TokenKind::Function => self.function,
TokenKind::String => self.string,
TokenKind::Number => self.number,
TokenKind::Comment => self.comment,
TokenKind::Operator => self.operator,
TokenKind::Punctuation => self.punctuation,
TokenKind::Identifier => self.identifier,
TokenKind::Other => self.other,
}
}
}
// El constructor `dark_default(theme)` — única pieza que dependía de
// `llimphi_theme` — vive ahora en `llimphi-widget-text-editor`
// (`syntax_palette_dark`), para que este núcleo no arrastre el stack de
// render. Aquí queda sólo el modelo de color puro (peniko::Color).
// Pool thread-local de parsers tree-sitter. Reconstruir el parser
// (con `set_language`) es caro; reusarlo entre highlights del mismo
// lenguaje es un ahorro grande. `tree_sitter::Parser` no es Send/
// Sync ni Clone, así que vive en thread-local — un parser por
// lenguaje por thread.
thread_local! {
static PARSER_POOL: std::cell::RefCell<std::collections::HashMap<Language, tree_sitter::Parser>>
= std::cell::RefCell::new(std::collections::HashMap::new());
/// Cache del último árbol parseado por lenguaje. Se pasa como hint
/// al siguiente `parse(source, Some(&old_tree))`. El "verdadero
/// incremental" (aplicar `InputEdit`s al tree antes de reparsear)
/// ya está cableado: `EditorState` acumula los edits por delta y
/// llama a [`apply_pending_edits`] antes de cada highlight, de modo
/// que tree-sitter sólo reconstruye los subtrees afectados.
static TREE_CACHE: std::cell::RefCell<std::collections::HashMap<Language, tree_sitter::Tree>>
= std::cell::RefCell::new(std::collections::HashMap::new());
}
/// Invalida el árbol cached para `language` — el caller lo invoca al
/// hacer `set_text` o cambios masivos donde el hint puede confundir
/// más que ayudar. No es estrictamente necesario, pero es defensivo.
pub fn invalidate_tree_cache(language: Language) {
TREE_CACHE.with(|c| {
c.borrow_mut().remove(&language);
});
}
/// Aplica una lista de `InputEdit` al tree cached del `language`.
/// Llamarlo ANTES de `parse(source, Some(&old_tree))` activa el modo
/// incremental real de tree-sitter — solo reconstruye los subtrees
/// afectados por las edits.
pub fn apply_pending_edits(language: Language, edits: &[tree_sitter::InputEdit]) {
if edits.is_empty() {
return;
}
TREE_CACHE.with(|c| {
let mut c = c.borrow_mut();
if let Some(tree) = c.get_mut(&language) {
for e in edits {
tree.edit(e);
}
}
});
}
/// Highlighter — fina capa sin estado mutable propio. La parser real
/// vive en el pool thread-local.
pub struct Highlighter {
language: Language,
}
impl Highlighter {
pub fn new(language: Language) -> Self {
Self { language }
}
pub fn language(&self) -> Language {
self.language
}
pub fn set_language(&mut self, language: Language) {
self.language = language;
}
/// Tokeniza el `source` entero y devuelve los spans por línea.
/// `result.len() == source.lines().count().max(1)`.
pub fn highlight(&mut self, source: &str) -> Vec<Vec<Span>> {
match self.language {
Language::Plain => plain_lines(source),
Language::Wat => highlight_wat(source),
Language::Rust => self.highlight_treesitter(source, rust_kind),
Language::Python => self.highlight_treesitter(source, python_kind),
}
}
fn highlight_treesitter(
&mut self,
source: &str,
kind_of: fn(&str) -> Option<TokenKind>,
) -> Vec<Vec<Span>> {
let language = self.language;
// Parsea con hint del tree previo si lo hay. tree-sitter puede
// reusar subtrees por hash incluso sin InputEdits aplicados.
let tree_opt = PARSER_POOL.with(|pool| {
let mut pool = pool.borrow_mut();
let parser = pool.entry(language).or_insert_with(|| {
make_ts_parser(language).unwrap_or_else(tree_sitter::Parser::new)
});
let old = TREE_CACHE.with(|c| c.borrow().get(&language).cloned());
parser.parse(source, old.as_ref())
});
let Some(tree) = tree_opt else {
return plain_lines(source);
};
// Guarda el nuevo árbol para la próxima invocación.
TREE_CACHE.with(|c| {
c.borrow_mut().insert(language, tree.clone());
});
// Por línea: recopilamos spans de los nodos *named* tipados que
// matchean kind_of. Luego rellenamos los huecos con `Other`.
let line_count = source.lines().count().max(1)
+ (if source.ends_with('\n') { 1 } else { 0 });
let mut per_line: Vec<Vec<Span>> = vec![Vec::new(); line_count.max(1)];
let mut stack: Vec<tree_sitter::Node> = vec![tree.root_node()];
while let Some(node) = stack.pop() {
if node.child_count() == 0 {
// hoja: tomamos el tipo del nodo (token).
let kind = node.kind();
if let Some(tk) = kind_of(kind) {
let start = node.start_position();
let end = node.end_position();
// Sólo manejamos tokens single-line (los multi-line
// como block strings se splitean por línea).
if start.row == end.row {
if let Some(line) = per_line.get_mut(start.row) {
line.push(Span {
start_col: start.column,
end_col: end.column,
kind: tk,
});
}
} else {
// Multi-line: marca cada línea entera como ese kind.
// Aproximación; suficiente para strings multi-línea.
for row in start.row..=end.row {
if let Some(line) = per_line.get_mut(row) {
let line_text =
source.lines().nth(row).unwrap_or("");
let s = if row == start.row { start.column } else { 0 };
let e =
if row == end.row { end.column } else { line_text.chars().count() };
line.push(Span { start_col: s, end_col: e, kind: tk });
}
}
}
}
} else {
for i in (0..node.child_count()).rev() {
if let Some(c) = node.child(i) {
stack.push(c);
}
}
}
}
// Por cada línea: ordena, fusiona overlapping, rellena huecos.
let mut result: Vec<Vec<Span>> = Vec::with_capacity(per_line.len());
for (row, mut spans) in per_line.into_iter().enumerate() {
let line_text = source.lines().nth(row).unwrap_or("");
spans.sort_by_key(|s| s.start_col);
result.push(fill_gaps(spans, line_text.chars().count()));
}
result
}
}
fn make_ts_parser(language: Language) -> Option<tree_sitter::Parser> {
let mut parser = tree_sitter::Parser::new();
let lang: tree_sitter::Language = match language {
Language::Rust => tree_sitter_rust::LANGUAGE.into(),
Language::Python => tree_sitter_python::LANGUAGE.into(),
_ => return None,
};
parser.set_language(&lang).ok()?;
Some(parser)
}
/// Mapeo de tree-sitter node `kind` → TokenKind para Rust.
fn rust_kind(kind: &str) -> Option<TokenKind> {
// Lista deliberadamente acotada al subset común; nodos no listados
// caen como Identifier/Other vía fill_gaps.
match kind {
// Keywords
"fn" | "let" | "mut" | "const" | "static" | "if" | "else" | "match"
| "for" | "while" | "loop" | "break" | "continue" | "return" | "use"
| "mod" | "pub" | "impl" | "trait" | "struct" | "enum" | "type"
| "where" | "as" | "in" | "ref" | "move" | "self" | "Self" | "crate"
| "super" | "async" | "await" | "dyn" | "unsafe" | "extern" => {
Some(TokenKind::Keyword)
}
// Tipos primitivos
"primitive_type" => Some(TokenKind::Type),
// Literales
"string_literal" | "raw_string_literal" | "char_literal" | "string_content" => {
Some(TokenKind::String)
}
"integer_literal" | "float_literal" | "boolean_literal" => Some(TokenKind::Number),
// Comentarios
"line_comment" | "block_comment" => Some(TokenKind::Comment),
_ => None,
}
}
/// Mapeo para Python.
fn python_kind(kind: &str) -> Option<TokenKind> {
match kind {
"def" | "class" | "if" | "elif" | "else" | "for" | "while" | "return"
| "import" | "from" | "as" | "in" | "is" | "not" | "and" | "or"
| "with" | "try" | "except" | "finally" | "raise" | "yield" | "pass"
| "break" | "continue" | "global" | "nonlocal" | "lambda" | "True"
| "False" | "None" | "async" | "await" => Some(TokenKind::Keyword),
"string" | "string_start" | "string_content" | "string_end" => Some(TokenKind::String),
"integer" | "float" | "true" | "false" | "none" => Some(TokenKind::Number),
"comment" => Some(TokenKind::Comment),
_ => None,
}
}
// ---------------------------------------------------------------------
// WAT — tokenizer en Rust puro (sin tree-sitter).
// ---------------------------------------------------------------------
fn highlight_wat(source: &str) -> Vec<Vec<Span>> {
let mut out: Vec<Vec<Span>> = Vec::new();
for line in iterate_lines(source) {
out.push(tokenize_wat_line(line));
}
out
}
fn iterate_lines(source: &str) -> Vec<&str> {
let mut out: Vec<&str> = source.lines().collect();
if source.ends_with('\n') || source.is_empty() {
out.push("");
}
if out.is_empty() {
out.push("");
}
out
}
fn tokenize_wat_line(line: &str) -> Vec<Span> {
let mut out: Vec<Span> = Vec::new();
let chars: Vec<char> = line.chars().collect();
let len = chars.len();
let mut i = 0usize;
while i < len {
let c = chars[i];
if c.is_whitespace() {
let start = i;
while i < len && chars[i].is_whitespace() {
i += 1;
}
out.push(Span { start_col: start, end_col: i, kind: TokenKind::Other });
continue;
}
// Comentario línea `;; ...`
if c == ';' && i + 1 < len && chars[i + 1] == ';' {
out.push(Span { start_col: i, end_col: len, kind: TokenKind::Comment });
break;
}
// Paren
if c == '(' || c == ')' {
out.push(Span { start_col: i, end_col: i + 1, kind: TokenKind::Punctuation });
i += 1;
continue;
}
// String "..."
if c == '"' {
let start = i;
i += 1;
while i < len {
let cc = chars[i];
if cc == '\\' && i + 1 < len {
i += 2;
continue;
}
if cc == '"' {
i += 1;
break;
}
i += 1;
}
out.push(Span { start_col: start, end_col: i, kind: TokenKind::String });
continue;
}
// Identificador `$nombre`
if c == '$' {
let start = i;
i += 1;
while i < len && is_wat_ident_char(chars[i]) {
i += 1;
}
out.push(Span { start_col: start, end_col: i, kind: TokenKind::Identifier });
continue;
}
// Número (entero/hex/float — simplificado: empieza con dígito o -dígito).
if c.is_ascii_digit() || (c == '-' && i + 1 < len && chars[i + 1].is_ascii_digit()) {
let start = i;
if c == '-' {
i += 1;
}
while i < len {
let cc = chars[i];
if cc.is_ascii_digit() || cc == '.' || cc == 'x' || cc.is_ascii_hexdigit() {
i += 1;
} else {
break;
}
}
out.push(Span { start_col: start, end_col: i, kind: TokenKind::Number });
continue;
}
// Word: keyword o identificador
if is_wat_word_start(c) {
let start = i;
while i < len && is_wat_ident_char(chars[i]) {
i += 1;
}
let word: String = chars[start..i].iter().collect();
let kind = wat_word_kind(&word);
out.push(Span { start_col: start, end_col: i, kind });
continue;
}
// Otros (operadores como `.`)
out.push(Span { start_col: i, end_col: i + 1, kind: TokenKind::Operator });
i += 1;
}
fill_gaps(out, len)
}
fn is_wat_word_start(c: char) -> bool {
c.is_ascii_alphabetic() || c == '_'
}
fn is_wat_ident_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '!' | '#' | '$' | '%' | '&' | '\'' | '*' | '+' | '-' | '/' | ':' | '<' | '=' | '>' | '?' | '@' | '\\' | '^' | '`' | '|' | '~')
}
fn wat_word_kind(w: &str) -> TokenKind {
const KEYWORDS: &[&str] = &[
"module", "func", "param", "result", "local", "import", "export",
"memory", "data", "table", "elem", "type", "global", "start", "block",
"loop", "if", "then", "else", "end", "br", "br_if", "br_table",
"return", "call", "call_indirect",
];
const TYPES: &[&str] = &["i32", "i64", "f32", "f64", "v128", "funcref", "externref", "anyref"];
if KEYWORDS.contains(&w) {
TokenKind::Keyword
} else if TYPES.contains(&w) {
TokenKind::Type
} else if w.contains('.') {
// Instrucciones tipo `i32.const`, `local.get`, etc.
TokenKind::Function
} else {
TokenKind::Identifier
}
}
// ---------------------------------------------------------------------
// Plain + utilities
// ---------------------------------------------------------------------
fn plain_lines(source: &str) -> Vec<Vec<Span>> {
let mut out: Vec<Vec<Span>> = Vec::new();
for line in iterate_lines(source) {
let len = line.chars().count();
out.push(vec![Span { start_col: 0, end_col: len, kind: TokenKind::Other }]);
}
out
}
/// Rellena los huecos entre spans con `Other` para cubrir `[0..line_len)`.
fn fill_gaps(spans: Vec<Span>, line_len: usize) -> Vec<Span> {
if spans.is_empty() {
return vec![Span { start_col: 0, end_col: line_len, kind: TokenKind::Other }];
}
let mut out: Vec<Span> = Vec::with_capacity(spans.len() * 2);
let mut cursor = 0usize;
for s in spans {
if s.start_col > cursor {
out.push(Span { start_col: cursor, end_col: s.start_col, kind: TokenKind::Other });
}
// Clampea overlaps con el anterior.
if s.end_col > cursor {
let start_col = s.start_col.max(cursor);
out.push(Span { start_col, end_col: s.end_col, kind: s.kind });
cursor = s.end_col;
}
}
if cursor < line_len {
out.push(Span { start_col: cursor, end_col: line_len, kind: TokenKind::Other });
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_devuelve_un_span_por_linea() {
let mut h = Highlighter::new(Language::Plain);
let r = h.highlight("hola\nmundo");
assert_eq!(r.len(), 2);
assert_eq!(r[0].len(), 1);
assert_eq!(r[0][0].kind, TokenKind::Other);
}
#[test]
fn wat_paren_es_punctuation() {
let mut h = Highlighter::new(Language::Wat);
let r = h.highlight("(module)");
let line = &r[0];
let paren = line.iter().find(|s| s.kind == TokenKind::Punctuation).unwrap();
assert_eq!(paren.start_col, 0);
assert_eq!(paren.end_col, 1);
}
#[test]
fn wat_keyword_module_clasifica_como_keyword() {
let mut h = Highlighter::new(Language::Wat);
let r = h.highlight("(module)");
let kw = r[0].iter().find(|s| s.kind == TokenKind::Keyword).unwrap();
assert_eq!(kw.start_col, 1);
assert_eq!(kw.end_col, 7);
}
#[test]
fn wat_tipo_i32_es_type() {
let mut h = Highlighter::new(Language::Wat);
let r = h.highlight("(result i32)");
assert!(r[0].iter().any(|s| s.kind == TokenKind::Type));
}
#[test]
fn wat_string_y_comment() {
let mut h = Highlighter::new(Language::Wat);
let r = h.highlight(r#"(data "hola") ;; comentario"#);
assert!(r[0].iter().any(|s| s.kind == TokenKind::String));
assert!(r[0].iter().any(|s| s.kind == TokenKind::Comment));
}
#[test]
fn wat_instruction_dotted_es_function() {
let mut h = Highlighter::new(Language::Wat);
let r = h.highlight("i32.const 42");
assert!(r[0].iter().any(|s| s.kind == TokenKind::Function));
assert!(r[0].iter().any(|s| s.kind == TokenKind::Number));
}
#[test]
fn rust_keyword_fn() {
let mut h = Highlighter::new(Language::Rust);
let r = h.highlight("fn main() {}");
// El span de "fn" debe estar marcado como keyword.
assert!(r[0].iter().any(|s| s.kind == TokenKind::Keyword));
}
#[test]
fn python_keyword_def() {
let mut h = Highlighter::new(Language::Python);
let r = h.highlight("def f():\n return 1");
assert!(r[0].iter().any(|s| s.kind == TokenKind::Keyword));
// "return" en la línea 2.
assert!(r[1].iter().any(|s| s.kind == TokenKind::Keyword));
}
#[test]
fn fill_gaps_rellena_y_clampea() {
let spans = vec![
Span { start_col: 2, end_col: 4, kind: TokenKind::Keyword },
Span { start_col: 6, end_col: 9, kind: TokenKind::String },
];
let filled = fill_gaps(spans, 10);
// [Other 0..2] [Keyword 2..4] [Other 4..6] [String 6..9] [Other 9..10]
assert_eq!(filled.len(), 5);
assert_eq!(filled[0].kind, TokenKind::Other);
assert_eq!(filled[4].kind, TokenKind::Other);
}
#[test]
fn from_cell_language_mapea_aliases() {
assert_eq!(Language::from_cell_language("rust"), Language::Rust);
assert_eq!(Language::from_cell_language("rs"), Language::Rust);
assert_eq!(Language::from_cell_language("py"), Language::Python);
assert_eq!(Language::from_cell_language("wat"), Language::Wat);
assert_eq!(Language::from_cell_language("desconocido"), Language::Plain);
}
}
+40
View File
@@ -0,0 +1,40 @@
//! `llimphi-widget-text-editor-core` — núcleo agnóstico del editor de código.
//!
//! Capas finas y **puras** (sin IO, sin Llimphi, sin GPU) sobre [`ropey`]:
//!
//! - [`buffer`] — wrapper de `Rope` con conversiones (línea, col) ↔ char_offset.
//! - [`cursor`] — `Cursor` + `Selection`; movimiento por char/word/line/page.
//! - [`ops`] — operaciones puras de edición sobre `(Buffer, Cursor) → (Buffer, Cursor)`.
//! - [`undo`] — pila reversible: cada operación se registra como `EditDelta`.
//! - [`bracket`] — matching de paréntesis/llaves/corchetes.
//! - [`find`] — búsqueda incremental sobre el buffer.
//! - [`diagnostics`] — modelo de diagnósticos (errores/warnings) por rango.
//! - [`clipboard`] — abstracción de portapapeles (mem/null) sin tocar el SO.
//! - [`highlight`] — syntax highlighting con tree-sitter (Rust/Python/WAT/Plain).
//!
//! Único acoplamiento externo: [`peniko::Color`] en [`highlight::SyntaxPalette`]
//! — un tipo de color, no el stack de render. Eso deja el núcleo reutilizable
//! desde un TUI, una mini-REPL, un text-input single-line, un backend web, etc.
//! La capa visual (state + view sobre Llimphi) vive en
//! `llimphi-widget-text-editor`, que re-exporta todo este núcleo.
#![forbid(unsafe_code)]
pub mod bracket;
pub mod buffer;
pub mod clipboard;
pub mod cursor;
pub mod diagnostics;
pub mod find;
pub mod highlight;
pub mod ops;
pub mod undo;
pub use buffer::Buffer;
pub use clipboard::{Clipboard, MemClipboard, NullClipboard};
pub use cursor::{Cursor, Pos, Selection};
pub use diagnostics::{Diagnostic, DiagnosticRange, Severity};
pub use find::{all_matches, find_next, find_prev, FindState};
pub use highlight::{Highlighter, Language, Span, SyntaxPalette, TokenKind};
pub use ops::{indent_str, EditDelta};
pub use undo::UndoStack;
+384
View File
@@ -0,0 +1,384 @@
//! Operaciones de edición. Cada una toma `&mut Buffer + &mut Cursor` y
//! devuelve un [`EditDelta`] reversible que la pila de undo guarda.
//!
//! El delta es minimal: el rango `[start..end)` que se reemplazó + el
//! texto que estaba antes + el texto nuevo. Aplicado en reversa,
//! restaura el estado anterior exactamente.
use crate::buffer::Buffer;
use crate::cursor::{Cursor, Pos};
/// Delta atómico de edición — útil para undo/redo y log de cambios.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EditDelta {
pub start: usize,
pub removed: String,
pub inserted: String,
/// Caret antes de la operación (para restaurarlo en undo).
pub cursor_before: Cursor,
/// Caret después de la operación.
pub cursor_after: Cursor,
}
impl EditDelta {
/// Aplica el delta a `(buf, cursor)`.
pub fn apply(&self, buf: &mut Buffer, cursor: &mut Cursor) {
let end = self.start + self.removed.chars().count();
buf.delete(self.start, end);
if !self.inserted.is_empty() {
buf.insert(self.start, &self.inserted);
}
*cursor = self.cursor_after;
}
/// Aplica el inverso (undo).
pub fn undo(&self, buf: &mut Buffer, cursor: &mut Cursor) {
let end = self.start + self.inserted.chars().count();
buf.delete(self.start, end);
if !self.removed.is_empty() {
buf.insert(self.start, &self.removed);
}
*cursor = self.cursor_before;
}
}
/// Genera la string de indentación según la config.
pub fn indent_str(tab_to_spaces: bool, indent_size: usize) -> String {
if tab_to_spaces {
" ".repeat(indent_size)
} else {
"\t".to_string()
}
}
/// Reemplaza la selección activa por `text`. Si no hay selección,
/// inserta `text` en el caret. Devuelve el delta resultante.
pub fn replace_selection(
buf: &mut Buffer,
cursor: &mut Cursor,
text: &str,
) -> EditDelta {
let before = *cursor;
let (start, end) = cursor.selection_range(buf);
let removed = buf.slice(start, end);
if start != end {
buf.delete(start, end);
}
if !text.is_empty() {
buf.insert(start, text);
}
let new_off = start + text.chars().count();
let (line, col) = buf.offset_to_pos(new_off);
cursor.caret = Pos::new(line, col);
cursor.desired_col = col;
cursor.anchor = None;
EditDelta {
start,
removed,
inserted: text.to_string(),
cursor_before: before,
cursor_after: *cursor,
}
}
/// Borra hacia atrás (Backspace). Si hay selección, la borra; si no,
/// borra el char antes del caret. Devuelve `None` si no había nada que
/// borrar (cursor al inicio + sin selección).
pub fn delete_backward(buf: &mut Buffer, cursor: &mut Cursor) -> Option<EditDelta> {
if cursor.has_selection() {
return Some(replace_selection(buf, cursor, ""));
}
let before = *cursor;
let caret_off = buf.pos_to_offset(cursor.caret.line, cursor.caret.col);
if caret_off == 0 {
return None;
}
let removed = buf.slice(caret_off - 1, caret_off);
buf.delete(caret_off - 1, caret_off);
let (line, col) = buf.offset_to_pos(caret_off - 1);
cursor.caret = Pos::new(line, col);
cursor.desired_col = col;
Some(EditDelta {
start: caret_off - 1,
removed,
inserted: String::new(),
cursor_before: before,
cursor_after: *cursor,
})
}
/// Borra hacia adelante (Delete).
pub fn delete_forward(buf: &mut Buffer, cursor: &mut Cursor) -> Option<EditDelta> {
if cursor.has_selection() {
return Some(replace_selection(buf, cursor, ""));
}
let before = *cursor;
let caret_off = buf.pos_to_offset(cursor.caret.line, cursor.caret.col);
if caret_off >= buf.len_chars() {
return None;
}
let removed = buf.slice(caret_off, caret_off + 1);
buf.delete(caret_off, caret_off + 1);
Some(EditDelta {
start: caret_off,
removed,
inserted: String::new(),
cursor_before: before,
cursor_after: *cursor,
})
}
/// Inserta un salto de línea con **indentación automática**: copia los
/// whitespace iniciales del renglón actual al renglón nuevo.
pub fn insert_newline_auto_indent(buf: &mut Buffer, cursor: &mut Cursor) -> EditDelta {
let current_line = buf.line(cursor.caret.line);
let indent: String = current_line
.chars()
.take_while(|c| *c == ' ' || *c == '\t')
.collect();
let text = format!("\n{indent}");
replace_selection(buf, cursor, &text)
}
/// Inserta un tab (o `indent_size` spaces según config). Si hay
/// selección **multilínea**, indenta cada línea de la selección.
pub fn indent_or_insert_tab(
buf: &mut Buffer,
cursor: &mut Cursor,
tab_to_spaces: bool,
indent_size: usize,
) -> EditDelta {
let indent = indent_str(tab_to_spaces, indent_size);
// Sin selección o selección en una sola línea → inserta indent.
let multi_line = match cursor.selection() {
Some(sel) => sel.anchor.line != sel.caret.line,
None => false,
};
if !multi_line {
return replace_selection(buf, cursor, &indent);
}
// Selección multilínea: indenta cada línea afectada por el rango.
let before = *cursor;
let sel = cursor.selection().expect("multi_line implica selección");
let first = sel.anchor.line.min(sel.caret.line);
let last = sel.anchor.line.max(sel.caret.line);
let mut start_global = buf.pos_to_offset(first, 0);
let removed = String::new();
let mut inserted = String::new();
for line in first..=last {
let line_start = buf.pos_to_offset(line, 0);
buf.insert(line_start, &indent);
inserted.push_str(&indent);
let _ = start_global; // (sin uso; se mantiene por simetría)
start_global = buf.pos_to_offset(first, 0);
}
// Mantenemos la selección extendida sobre las líneas indentadas.
let n_added = indent.chars().count();
let new_anchor = Pos::new(sel.anchor.line, sel.anchor.col + n_added);
let new_caret = Pos::new(sel.caret.line, sel.caret.col + n_added);
cursor.anchor = Some(new_anchor);
cursor.caret = new_caret;
cursor.desired_col = new_caret.col;
EditDelta {
start: start_global,
removed,
inserted,
cursor_before: before,
cursor_after: *cursor,
}
}
/// Quita un nivel de indent del renglón actual (o de cada línea si hay
/// selección multilínea). Devuelve `None` si nada cambió.
pub fn dedent(
buf: &mut Buffer,
cursor: &mut Cursor,
tab_to_spaces: bool,
indent_size: usize,
) -> Option<EditDelta> {
let before = *cursor;
let (first, last) = match cursor.selection() {
Some(sel) => (
sel.anchor.line.min(sel.caret.line),
sel.anchor.line.max(sel.caret.line),
),
None => (cursor.caret.line, cursor.caret.line),
};
let mut total_removed = 0usize;
let mut removed_text = String::new();
let start_offset = buf.pos_to_offset(first, 0);
for line in first..=last {
let line_str = buf.line(line);
let mut n = 0usize;
let mut chars = line_str.chars();
if tab_to_spaces {
for _ in 0..indent_size {
if chars.next() == Some(' ') {
n += 1;
} else {
break;
}
}
} else if chars.next() == Some('\t') {
n = 1;
}
if n == 0 {
continue;
}
let line_start = buf.pos_to_offset(line, 0);
removed_text.push_str(&buf.slice(line_start, line_start + n));
buf.delete(line_start, line_start + n);
total_removed += n;
}
if total_removed == 0 {
return None;
}
// Cursor: clampea col al nuevo line_len.
let caret_line = cursor.caret.line;
let caret_col = cursor
.caret
.col
.saturating_sub(if caret_line >= first && caret_line <= last {
// Cuánto se removió de esta línea (varía); aproximamos al
// common case de mismo n por línea. Si fuera distinto el
// visual queda OK porque clampea.
removed_text.chars().count() / (last - first + 1).max(1)
} else {
0
});
cursor.caret.col = caret_col.min(buf.line_len_chars(caret_line));
cursor.desired_col = cursor.caret.col;
if let Some(anchor) = cursor.anchor.as_mut() {
if anchor.line >= first && anchor.line <= last {
anchor.col = anchor
.col
.saturating_sub(removed_text.chars().count() / (last - first + 1).max(1))
.min(buf.line_len_chars(anchor.line));
}
}
Some(EditDelta {
start: start_offset,
removed: removed_text,
inserted: String::new(),
cursor_before: before,
cursor_after: *cursor,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn replace_selection_sin_seleccion_inserta() {
let mut b = Buffer::from_str("ab");
let mut c = Cursor::at(0, 1);
let d = replace_selection(&mut b, &mut c, "X");
assert_eq!(b.text(), "aXb");
assert_eq!(c.caret, Pos::new(0, 2));
assert_eq!(d.removed, "");
assert_eq!(d.inserted, "X");
}
#[test]
fn replace_selection_con_seleccion_reemplaza() {
let mut b = Buffer::from_str("hola mundo");
let mut c = Cursor { caret: Pos::new(0, 9), anchor: Some(Pos::new(0, 5)), desired_col: 9 };
replace_selection(&mut b, &mut c, "luna");
assert_eq!(b.text(), "hola lunao");
}
#[test]
fn backspace_borra_char() {
let mut b = Buffer::from_str("hola");
let mut c = Cursor::at(0, 4);
delete_backward(&mut b, &mut c);
assert_eq!(b.text(), "hol");
assert_eq!(c.caret, Pos::new(0, 3));
}
#[test]
fn backspace_en_inicio_no_hace_nada() {
let mut b = Buffer::from_str("a");
let mut c = Cursor::at(0, 0);
assert!(delete_backward(&mut b, &mut c).is_none());
}
#[test]
fn delete_forward_borra_char() {
let mut b = Buffer::from_str("ab");
let mut c = Cursor::at(0, 0);
delete_forward(&mut b, &mut c);
assert_eq!(b.text(), "b");
}
#[test]
fn newline_copia_indent_del_renglon_anterior() {
let mut b = Buffer::from_str(" hola");
let mut c = Cursor::at(0, 8);
insert_newline_auto_indent(&mut b, &mut c);
assert_eq!(b.text(), " hola\n ");
assert_eq!(c.caret, Pos::new(1, 4));
}
#[test]
fn tab_inserta_spaces() {
let mut b = Buffer::from_str("ab");
let mut c = Cursor::at(0, 1);
indent_or_insert_tab(&mut b, &mut c, true, 4);
assert_eq!(b.text(), "a b");
assert_eq!(c.caret, Pos::new(0, 5));
}
#[test]
fn tab_con_seleccion_multilinea_indenta_cada_linea() {
let mut b = Buffer::from_str("a\nb\nc");
let mut c = Cursor {
anchor: Some(Pos::new(0, 0)),
caret: Pos::new(2, 1),
desired_col: 1,
};
indent_or_insert_tab(&mut b, &mut c, true, 2);
assert_eq!(b.text(), " a\n b\n c");
}
#[test]
fn dedent_quita_indent_del_renglon() {
let mut b = Buffer::from_str(" hola");
let mut c = Cursor::at(0, 8);
dedent(&mut b, &mut c, true, 4);
assert_eq!(b.text(), "hola");
}
#[test]
fn dedent_sin_indent_devuelve_none() {
let mut b = Buffer::from_str("hola");
let mut c = Cursor::at(0, 0);
assert!(dedent(&mut b, &mut c, true, 4).is_none());
}
#[test]
fn delta_undo_restaura_estado() {
let mut b = Buffer::from_str("hola");
let mut c = Cursor::at(0, 4);
let d = replace_selection(&mut b, &mut c, "!");
assert_eq!(b.text(), "hola!");
d.undo(&mut b, &mut c);
assert_eq!(b.text(), "hola");
assert_eq!(c.caret, Pos::new(0, 4));
}
}
+133
View File
@@ -0,0 +1,133 @@
//! Pila de undo/redo basada en [`EditDelta`].
//!
//! API simple: `push(delta)` añade al historial y limpia el stack de
//! redo; `undo`/`redo` aplican o reaplican deltas existentes. No
//! coalesce inserciones consecutivas — cada keystroke es un delta;
//! para una UX más fina, el llamador puede agrupar deltas relacionados
//! (ej. cada secuencia de chars imprimibles hasta whitespace).
use crate::buffer::Buffer;
use crate::cursor::Cursor;
use crate::ops::EditDelta;
const DEFAULT_CAPACITY: usize = 256;
#[derive(Debug, Clone, Default)]
pub struct UndoStack {
/// Deltas aplicados, en orden cronológico. El `Vec::last` es el
/// próximo candidato a deshacer.
done: Vec<EditDelta>,
/// Deltas deshechos disponibles para redo (en orden inverso del
/// `undo`: el último deshecho es el primero a rehacer).
undone: Vec<EditDelta>,
capacity: usize,
}
impl UndoStack {
pub fn new() -> Self {
Self::with_capacity(DEFAULT_CAPACITY)
}
pub fn with_capacity(capacity: usize) -> Self {
Self {
done: Vec::with_capacity(capacity.min(64)),
undone: Vec::new(),
capacity,
}
}
/// Registra un delta. Limpia el stack de redo (la rama alternativa
/// se pierde, como en todo editor estándar).
pub fn push(&mut self, delta: EditDelta) {
self.done.push(delta);
self.undone.clear();
if self.done.len() > self.capacity {
// Truncamos por el extremo viejo.
let drop = self.done.len() - self.capacity;
self.done.drain(0..drop);
}
}
pub fn can_undo(&self) -> bool {
!self.done.is_empty()
}
pub fn can_redo(&self) -> bool {
!self.undone.is_empty()
}
pub fn undo(&mut self, buf: &mut Buffer, cursor: &mut Cursor) -> bool {
let Some(delta) = self.done.pop() else {
return false;
};
delta.undo(buf, cursor);
self.undone.push(delta);
true
}
pub fn redo(&mut self, buf: &mut Buffer, cursor: &mut Cursor) -> bool {
let Some(delta) = self.undone.pop() else {
return false;
};
delta.apply(buf, cursor);
self.done.push(delta);
true
}
pub fn clear(&mut self) {
self.done.clear();
self.undone.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ops::replace_selection;
#[test]
fn undo_y_redo_son_simetricos() {
let mut b = Buffer::from_str("a");
let mut c = Cursor::at(0, 1);
let mut st = UndoStack::new();
st.push(replace_selection(&mut b, &mut c, "b"));
st.push(replace_selection(&mut b, &mut c, "c"));
assert_eq!(b.text(), "abc");
assert!(st.undo(&mut b, &mut c));
assert_eq!(b.text(), "ab");
assert!(st.undo(&mut b, &mut c));
assert_eq!(b.text(), "a");
assert!(st.redo(&mut b, &mut c));
assert_eq!(b.text(), "ab");
assert!(st.redo(&mut b, &mut c));
assert_eq!(b.text(), "abc");
}
#[test]
fn push_limpia_redo() {
let mut b = Buffer::from_str("a");
let mut c = Cursor::at(0, 1);
let mut st = UndoStack::new();
st.push(replace_selection(&mut b, &mut c, "b"));
st.undo(&mut b, &mut c);
assert!(st.can_redo());
st.push(replace_selection(&mut b, &mut c, "X"));
assert!(!st.can_redo());
}
#[test]
fn capacity_descartan_viejos() {
let mut b = Buffer::from_str("");
let mut c = Cursor::at(0, 0);
let mut st = UndoStack::with_capacity(2);
for ch in ["a", "b", "c"] {
st.push(replace_selection(&mut b, &mut c, ch));
}
// Sólo deberían quedar los últimos 2 deltas; el undo del primero
// (cuando ya no está) no debería hacer nada.
st.undo(&mut b, &mut c);
st.undo(&mut b, &mut c);
assert!(!st.undo(&mut b, &mut c));
}
}