refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel

Re-sincroniza las fuentes desde el monorepo (estaba en vello 0.5/wgpu 24 y con la
estructura vieja de eventloop) y suma el 3D:

- bump del workspace a vello 0.7 / wgpu 27 / parley 0.6, + accesskit 0.24 /
  accesskit_winit 0.33 / vello_hybrid 0.0.9.
- nuevos crates: llimphi-3d (voxels ray-march + mallas en un depth compartido,
  montable dentro de un View 2D vía set_viewport+scissor) y llimphi-voxel
  (world-gen, personajes, director de escenas) + shared/foreign-vox (puente .vox).
- README: sección "Not just 2D — a 3D voxel engine" + GIF (docs/llimphi_voxel.gif).
- excluido modules/allichay (arrastra deps fuera del alcance del front-door).
- cargo check --workspace: verde.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-06-18 14:40:00 +00:00
parent e74800d9da
commit ccab39f140
202 changed files with 44034 additions and 1811 deletions
+208
View File
@@ -0,0 +1,208 @@
//! Búsqueda sobre el `Scrollback` — base de Ctrl+F del SDD-TERMINAL §Fase 3.
//!
//! Diseño: barata pero correcta. Recorre todas las líneas del store y
//! reporta los rangos `(line, start_byte, end_byte)` de cada ocurrencia.
//! Sin streaming, sin índice — para los típicos cientos de miles de líneas
//! del shell es suficiente (un `memmem` por línea, lineal en el contenido).
//!
//! Para infinitos masivos (millones de líneas), el `find` ya es O(N) en el
//! contenido, no en el render — el scroll y la pintada siguen siendo O(1).
//! Si en algún momento aprieta, se puede pre-indexar n-gramas; no hoy.
//!
//! Case-insensitive: lowercase ambos lados (sin Unicode-aware folding por
//! ahora — ASCII alcanza para el caso shell típico).
use crate::store::Scrollback;
/// Una coincidencia de búsqueda en el scrollback. `start`/`end` son offsets
/// **en bytes** UTF-8 del texto de la línea (slice-safe: el caller puede
/// hacer `&text[start..end]` sin clampear).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FindMatch {
pub line: usize,
pub start: usize,
pub end: usize,
}
/// Opciones de búsqueda. Defaults: case-sensitive, query literal (sin regex).
#[derive(Debug, Clone, Copy, Default)]
pub struct FindOpts {
/// `true` = lowercase ambos lados antes de comparar (ASCII fold).
pub case_insensitive: bool,
}
/// Busca todas las ocurrencias **no superpuestas** de `query` en el `store`,
/// línea por línea, en orden. Empty query → `Vec::new()` (paridad con la
/// barra de find de la mayoría de editores: vacío = "no hay nada que
/// resaltar"). El consumo es O(total_bytes) en el contenido del store.
///
/// Las coincidencias caen siempre en límites de char UTF-8 (vienen del
/// scanner de bytes y se snap-ean al borde más cercano hacia abajo si la
/// query atraviesa una codepoint, que con `find` literal no debería pasar).
pub fn find_matches(store: &Scrollback, query: &str, opts: FindOpts) -> Vec<FindMatch> {
if query.is_empty() {
return Vec::new();
}
let needle = if opts.case_insensitive {
query.to_ascii_lowercase()
} else {
query.to_string()
};
let mut out = Vec::new();
for line in 0..store.len() {
let Some(text) = store.line(line) else { continue };
let haystack_owned;
let haystack: &str = if opts.case_insensitive {
haystack_owned = text.to_ascii_lowercase();
&haystack_owned
} else {
text
};
let mut cursor = 0usize;
while cursor < haystack.len() {
let Some(rel) = haystack[cursor..].find(&needle) else {
break;
};
let start = cursor + rel;
let end = start + needle.len();
out.push(FindMatch { line, start, end });
// Avance no-superposición: una ocurrencia consume su rango, la
// siguiente arranca DESPUÉS. Si la query es vacía no llegamos
// acá (ya filtrado arriba), así que `end > start` siempre.
cursor = end;
}
}
out
}
/// Avanza al siguiente match desde `current` (envuelve al primero si está
/// al final). Si `matches` está vacío devuelve `None`. `None` en `current`
/// equivale a "no hay actual" → arranca por el primero.
pub fn next_match(matches: &[FindMatch], current: Option<usize>) -> Option<usize> {
if matches.is_empty() {
return None;
}
Some(match current {
None => 0,
Some(i) => (i + 1) % matches.len(),
})
}
/// Retrocede al match previo desde `current` (envuelve al último si está
/// al principio). Mismas semánticas que [`next_match`].
pub fn prev_match(matches: &[FindMatch], current: Option<usize>) -> Option<usize> {
if matches.is_empty() {
return None;
}
Some(match current {
None => matches.len() - 1,
Some(i) => (i + matches.len() - 1) % matches.len(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn store_of(lines: &[&str]) -> Scrollback {
let mut s = Scrollback::new(0);
for l in lines {
s.push_line(l);
}
s
}
#[test]
fn query_vacia_no_devuelve_nada() {
let s = store_of(&["foo", "bar"]);
assert_eq!(find_matches(&s, "", FindOpts::default()), Vec::new());
}
#[test]
fn una_ocurrencia_por_linea() {
let s = store_of(&["foo bar baz", "qux foo quux"]);
let m = find_matches(&s, "foo", FindOpts::default());
assert_eq!(
m,
vec![
FindMatch { line: 0, start: 0, end: 3 },
FindMatch { line: 1, start: 4, end: 7 },
]
);
}
#[test]
fn varias_ocurrencias_en_la_misma_linea_no_se_superponen() {
// "aaa" → en "aaaaa" hay 1 match en 0..3 y otro en 3..6 (no en 1..4).
let s = store_of(&["aaaaaa"]);
let m = find_matches(&s, "aaa", FindOpts::default());
assert_eq!(
m,
vec![
FindMatch { line: 0, start: 0, end: 3 },
FindMatch { line: 0, start: 3, end: 6 },
]
);
}
#[test]
fn case_sensitive_por_defecto() {
let s = store_of(&["Foo", "FOO", "foo"]);
let m = find_matches(&s, "foo", FindOpts::default());
assert_eq!(m, vec![FindMatch { line: 2, start: 0, end: 3 }]);
}
#[test]
fn case_insensitive_matchea_todas_las_variantes() {
let s = store_of(&["Foo", "FOO", "foo"]);
let m = find_matches(&s, "foo", FindOpts { case_insensitive: true });
assert_eq!(m.len(), 3);
assert_eq!(m[0].line, 0);
assert_eq!(m[1].line, 1);
assert_eq!(m[2].line, 2);
}
#[test]
fn no_match_devuelve_vec_vacio() {
let s = store_of(&["uno", "dos"]);
assert!(find_matches(&s, "xyz", FindOpts::default()).is_empty());
}
#[test]
fn match_utf8_funciona_al_ser_busqueda_literal_byte_a_byte() {
// "café" tiene 'é' = 2 bytes. Buscamos "afé" — match en bytes 1..5.
let s = store_of(&["café"]);
let m = find_matches(&s, "afé", FindOpts::default());
assert_eq!(m, vec![FindMatch { line: 0, start: 1, end: 5 }]);
}
#[test]
fn next_match_envuelve_al_primero() {
let m = vec![
FindMatch { line: 0, start: 0, end: 1 },
FindMatch { line: 1, start: 0, end: 1 },
];
assert_eq!(next_match(&m, None), Some(0));
assert_eq!(next_match(&m, Some(0)), Some(1));
assert_eq!(next_match(&m, Some(1)), Some(0));
}
#[test]
fn prev_match_envuelve_al_ultimo() {
let m = vec![
FindMatch { line: 0, start: 0, end: 1 },
FindMatch { line: 1, start: 0, end: 1 },
];
assert_eq!(prev_match(&m, None), Some(1));
assert_eq!(prev_match(&m, Some(0)), Some(1));
assert_eq!(prev_match(&m, Some(1)), Some(0));
}
#[test]
fn next_y_prev_en_lista_vacia_son_none() {
let m: Vec<FindMatch> = Vec::new();
assert_eq!(next_match(&m, None), None);
assert_eq!(next_match(&m, Some(0)), None);
assert_eq!(prev_match(&m, None), None);
}
}