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
+3 -3
View File
@@ -34,11 +34,11 @@ 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 state::{ApplyResult, EditorOptions, EditorState};
pub use state::{ApplyResult, EditorOptions, EditorState, Preedit};
pub use undo::UndoStack;
pub use view::{
text_editor_view, text_editor_view_full, text_editor_view_highlighted, EditorMetrics,
EditorPalette, GutterStyle, PointerEvent,
text_editor_view, text_editor_view_colored, text_editor_view_full,
text_editor_view_highlighted, EditorMetrics, EditorPalette, GutterStyle, PointerEvent,
};
use llimphi_ui::llimphi_raster::peniko::Color;
+133 -1
View File
@@ -5,7 +5,7 @@
use std::cell::RefCell;
use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey};
use llimphi_ui::{ImeEvent, Key, KeyEvent, KeyState, NamedKey};
use crate::buffer::Buffer;
use crate::clipboard::{Clipboard, NullClipboard};
@@ -75,6 +75,12 @@ pub struct EditorState {
/// ~40/255 sobre el bg).
pub line_tints: Vec<Option<llimphi_ui::llimphi_raster::peniko::Color>>,
pub undo: UndoStack,
/// Texto en composición del IME (dead keys de acentos, CJK, emoji
/// picker). No vive en el buffer hasta el `Commit`: el view lo pinta
/// subrayado en el caret y el caret se corre detrás. `None` = sin
/// composición activa (el caso normal). Lo administra
/// [`Self::apply_ime_event`].
pub preedit: Option<Preedit>,
/// Línea inicial visible — el viewport renderiza
/// `[scroll_offset, scroll_offset + visible)`. El caller llama a
/// [`Self::ensure_caret_visible`] tras movimientos para auto-scrollear.
@@ -95,6 +101,22 @@ pub struct EditorState {
pub highlight_cache: RefCell<Option<HighlightCache>>,
}
/// Texto en composición del IME — el que el método de entrada está
/// armando antes de confirmarlo (un acento muerto a medio componer, una
/// sílaba CJK con su ventana de candidatos, un emoji del picker). No
/// pertenece al buffer todavía; el view lo dibuja en el caret con
/// subrayado para que el usuario vea lo que está tecleando.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Preedit {
/// Texto provisional a pintar en el caret.
pub text: String,
/// Rango `(inicio, fin)` en **bytes** dentro de `text` que el IME
/// quiere resaltar (la "clause" activa), si lo reporta. Hoy el view
/// subraya todo el preedit por igual; el campo se conserva para un
/// resaltado más fino cuando haga falta.
pub cursor: Option<(usize, usize)>,
}
/// Entrada del cache: spans por línea + clave que la generó.
#[derive(Debug, Clone)]
pub struct HighlightCache {
@@ -120,6 +142,7 @@ impl EditorState {
guard_lines: Vec::new(),
line_tints: Vec::new(),
undo: UndoStack::new(),
preedit: None,
scroll_offset: 0,
edit_seq: 0,
pending_input_edits: RefCell::new(Vec::new()),
@@ -377,6 +400,60 @@ impl EditorState {
self.apply_key_with_clipboard(event, &mut NullClipboard)
}
/// Aplica un evento de IME al editor — el camino por el que llegan los
/// acentos compuestos (dead keys), CJK y el emoji picker cuando la app
/// habilita `App::ime_allowed`. El flujo del IME es
/// `Enabled → Preedit* → Commit | Disabled`:
///
/// - `Enabled`: el IME tomó el foco; no hay nada que insertar aún.
/// - `Preedit{text,..}`: texto en composición. Lo guardamos en
/// [`Self::preedit`] para que el view lo pinte subrayado en el caret
/// **sin** tocar el buffer. Un `Preedit` con `text` vacío cierra la
/// composición sin confirmar.
/// - `Commit(text)`: texto confirmado. Limpiamos el preedit e
/// insertamos `text` en todos los cursores, exactamente como si se
/// hubiera tecleado (mismo camino de undo + parseo incremental).
/// - `Disabled`: el IME soltó el foco; descartamos cualquier
/// composición pendiente.
///
/// Devuelve [`ApplyResult::Changed`] sólo en el `Commit` no-vacío (lo
/// único que persiste); el resto es `CursorMoved` (hubo que
/// redibujar el preedit) o `Ignored`.
pub fn apply_ime_event(&mut self, event: &ImeEvent) -> ApplyResult {
match event {
ImeEvent::Enabled => ApplyResult::Ignored,
ImeEvent::Preedit { text, cursor } => {
let had = self.preedit.is_some();
if text.is_empty() {
self.preedit = None;
if had { ApplyResult::CursorMoved } else { ApplyResult::Ignored }
} else {
self.preedit = Some(Preedit { text: text.clone(), cursor: *cursor });
ApplyResult::CursorMoved
}
}
ImeEvent::Commit(text) => {
let had = self.preedit.take().is_some();
if text.is_empty() {
return if had { ApplyResult::CursorMoved } else { ApplyResult::Ignored };
}
let text = text.clone();
let changed =
self.apply_edit_all(|b, c, _opts| Some(replace_selection(b, c, &text)));
if changed {
self.bump_edit_seq();
ApplyResult::Changed
} else {
ApplyResult::Ignored
}
}
ImeEvent::Disabled => {
let had = self.preedit.take().is_some();
if had { ApplyResult::CursorMoved } else { ApplyResult::Ignored }
}
}
}
/// Como [`Self::apply_key`] pero con backend de clipboard activo:
/// Ctrl+C copia la selección, Ctrl+X la corta, Ctrl+V pega lo que
/// haya en el clipboard.
@@ -1244,6 +1321,61 @@ mod tests {
assert!(s.cursor.caret.line != 1);
}
#[test]
fn ime_commit_inserta_como_tecleo() {
// El caso español: el dead-key compone "á" y llega como Commit.
let mut s = EditorState::new();
s.apply_key(&evtext("c", false, false));
let r = s.apply_ime_event(&ImeEvent::Commit("á".into()));
assert_eq!(r, ApplyResult::Changed);
assert_eq!(s.text(), "");
assert!(s.preedit.is_none());
}
#[test]
fn ime_preedit_no_toca_el_buffer() {
let mut s = EditorState::new();
s.set_text("ab");
s.cursor = Cursor::at(0, 2);
let r = s.apply_ime_event(&ImeEvent::Preedit { text: "´".into(), cursor: None });
assert_eq!(r, ApplyResult::CursorMoved);
// El buffer sigue intacto; el preedit vive aparte.
assert_eq!(s.text(), "ab");
assert_eq!(s.preedit.as_ref().map(|p| p.text.as_str()), Some("´"));
// El Commit reemplaza la composición e inserta el char final.
s.apply_ime_event(&ImeEvent::Commit("é".into()));
assert_eq!(s.text(), "abé");
assert!(s.preedit.is_none());
}
#[test]
fn ime_preedit_vacio_y_disabled_cierran_la_composicion() {
let mut s = EditorState::new();
s.apply_ime_event(&ImeEvent::Preedit { text: "".into(), cursor: None });
assert!(s.preedit.is_some());
// Un Preedit vacío cancela sin confirmar.
let r = s.apply_ime_event(&ImeEvent::Preedit { text: String::new(), cursor: None });
assert_eq!(r, ApplyResult::CursorMoved);
assert!(s.preedit.is_none());
// Disabled sobre algo en composición también limpia.
s.apply_ime_event(&ImeEvent::Preedit { text: "".into(), cursor: None });
s.apply_ime_event(&ImeEvent::Disabled);
assert!(s.preedit.is_none());
}
#[test]
fn ime_commit_reemplaza_seleccion() {
let mut s = EditorState::new();
s.set_text("abc");
s.cursor = Cursor {
anchor: Some(Pos::new(0, 0)),
caret: Pos::new(0, 2),
desired_col: 2,
};
s.apply_ime_event(&ImeEvent::Commit("ñ".into()));
assert_eq!(s.text(), "ñc");
}
#[test]
fn ctrl_c_sin_seleccion_es_ignorado() {
use crate::clipboard::MemClipboard;
+168 -4
View File
@@ -26,6 +26,16 @@ use crate::diagnostics::{Diagnostic, Severity};
use crate::highlight::{Language, Span, SyntaxPalette, TokenKind};
use crate::state::EditorState;
/// Tope de líneas que la variante embebida (`text_editor_view_colored`)
/// renderiza de una. La virtualización del editor-de-archivos capa a 200 para
/// no generar miles de Views (wgpu rechaza el bind group); pero la variante
/// embebida deja el scroll al contenedor de afuera y necesita pintar TODAS sus
/// líneas (si no, la mitad de abajo queda sin pintar = negro al anclar el panel
/// al fondo). Este tope es sólo la red de seguridad de wgpu — el caller acota el
/// total real (el shell, por su `MAX_VISIBLE = 400`). Probado: ~400 líneas
/// renderizan sin que wgpu rechace nada (el render plano viejo ya lo hacía).
pub const EMBEDDED_LINE_CAP: usize = 512;
/// Paleta del editor. Defaults dark.
#[derive(Debug, Clone, Copy)]
pub struct EditorPalette {
@@ -278,6 +288,7 @@ pub fn text_editor_view_full<Msg: Clone + 'static>(
spans,
&syntax,
match_ranges,
None,
on_pointer,
);
@@ -291,6 +302,53 @@ pub fn text_editor_view_full<Msg: Clone + 'static>(
.children(vec![gutter, content])
}
/// Como [`text_editor_view`] pero el caller provee el color de cada tramo de
/// cada línea (`line_color_runs[n]` = `(byte_start, byte_end, Color)` de la
/// línea `n`), en vez de derivarlo de un `Language`. Para outputs con coloreo
/// semántico propio (un shell que tinta `ls`, paths, urls, números…) sobre el
/// mismo editor read-only (numeración + selección + copiar).
pub fn text_editor_view_colored<Msg: Clone + 'static>(
state: &EditorState,
palette: &EditorPalette,
metrics: EditorMetrics,
visible_lines: usize,
line_color_runs: &[Vec<(usize, usize, Color)>],
on_pointer: impl Fn(PointerEvent) -> Option<Msg> + Send + Sync + Clone + 'static,
) -> View<Msg> {
let caret = state.cursor.caret;
let syntax = crate::syntax_palette_dark(&llimphi_theme::Theme::dark());
// Variante embebida: el contenedor de afuera (el panel de output del shell)
// hace el scroll y reserva alto para TODAS las líneas, así que las pintamos
// completas (cap alto = red de seguridad de wgpu, ver `EMBEDDED_LINE_CAP`).
let visible = visible_lines.max(1).min(EMBEDDED_LINE_CAP);
let line_count = state.line_count();
let scroll = state.scroll_offset.min(line_count.saturating_sub(1));
let end_line = (scroll + visible).min(line_count);
let height = (end_line - scroll) as f32 * metrics.line_height;
let gutter = build_gutter(state, scroll, end_line, caret.line, metrics, palette);
let content = build_content(
state,
palette,
metrics,
height,
scroll,
end_line,
Vec::new(),
&syntax,
&[],
Some(line_color_runs),
on_pointer,
);
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(height) },
..Default::default()
})
.fill(palette.bg)
.clip(true)
.children(vec![gutter, content])
}
fn build_gutter<Msg: Clone + 'static>(
state: &EditorState,
scroll: usize,
@@ -336,7 +394,8 @@ fn build_gutter<Msg: Clone + 'static>(
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(label, metrics.font_size * 0.85, color, Alignment::End),
.text_aligned(label, metrics.font_size * 0.85, color, Alignment::End)
.mono(),
);
}
GutterStyle::Phantom => {
@@ -394,6 +453,7 @@ fn with_alpha(c: Color, alpha: f32) -> Color {
Color::from_rgba8(rgba.r, rgba.g, rgba.b, a)
}
#[allow(clippy::too_many_arguments)]
fn build_content<Msg: Clone + 'static>(
state: &EditorState,
palette: &EditorPalette,
@@ -404,6 +464,11 @@ fn build_content<Msg: Clone + 'static>(
spans_per_line: Vec<Vec<Span>>,
syntax: &SyntaxPalette,
match_ranges: &[(usize, usize)],
// Override de color por línea: `color_runs[n]` son `(byte_start, byte_end,
// Color)` para la línea `n` del buffer (índice absoluto). Cuando es `Some`,
// gana sobre el syntax highlight — para callers que colorean por semántica
// propia (un shell que tinta `ls`, paths, urls…). `None` = highlight normal.
color_runs: Option<&[Vec<(usize, usize, Color)>]>,
on_pointer: impl Fn(PointerEvent) -> Option<Msg> + Send + Sync + Clone + 'static,
) -> View<Msg> {
let caret = state.cursor.caret;
@@ -461,7 +526,9 @@ fn build_content<Msg: Clone + 'static>(
children.push(phantom_guard_divider(local_line, metrics, palette));
continue;
}
if let Some(line_spans) = spans_per_line.get(n) {
if let Some(runs) = color_runs.and_then(|cr| cr.get(n)) {
children.push(line_text_color_runs(local_line, &text, runs, metrics, palette));
} else if let Some(line_spans) = spans_per_line.get(n) {
children.push(line_text_tokens(local_line, &text, line_spans, metrics, palette, syntax));
} else {
children.push(line_text_plain(local_line, text, metrics, palette));
@@ -473,15 +540,33 @@ fn build_content<Msg: Clone + 'static>(
children.extend(diagnostic_underline(d, scroll, end_line, metrics, palette));
}
// 4) Caret — uno por cursor, sólo si visible.
// 4) Caret — uno por cursor, sólo si visible. El caret del cursor
// primario se corre detrás del preedit del IME en composición (el
// texto compuesto se pinta desde `p.col`), para que quede al final
// de lo que el usuario está tecleando.
let preedit_cols = state.preedit.as_ref().map_or(0, |p| p.text.chars().count());
for c in state.all_cursors() {
let p = c.caret;
if p.line >= scroll && p.line < end_line {
let local = crate::cursor::Pos::new(p.line - scroll, p.col);
let is_primary = std::ptr::eq(c, &state.cursor);
let col = if is_primary { p.col + preedit_cols } else { p.col };
let local = crate::cursor::Pos::new(p.line - scroll, col);
children.push(caret_rect(local, metrics, palette));
}
}
// 5) Preedit del IME — texto en composición pintado en el caret con
// subrayado, todavía fuera del buffer. Sólo en el cursor primario y
// si su línea está en viewport. (En mono el ancho es exacto; el
// texto que sigue al caret puede solaparse mientras se compone —
// transitorio y, en el caso típico de acentos, de un solo char.)
if let Some(pre) = state.preedit.as_ref() {
let p = state.cursor.caret;
if p.line >= scroll && p.line < end_line {
children.extend(preedit_views(p.line - scroll, p.col, &pre.text, metrics, palette));
}
}
let click_cb = on_pointer.clone();
let drag_cb = on_pointer;
View::new(Style {
@@ -603,6 +688,7 @@ fn line_text_plain<Msg: Clone + 'static>(
..Default::default()
})
.text_aligned(text, metrics.font_size, palette.fg_text, Alignment::Start)
.mono()
}
/// Renderiza una línea como secuencia de Views absolutos posicionados,
@@ -660,6 +746,38 @@ fn line_text_tokens<Msg: Clone + 'static>(
runs,
Alignment::Start,
)
.mono()
}
/// Como [`line_text_tokens`] pero con `(byte_start, byte_end, Color)`
/// explícitos provistos por el caller (coloreo semántico propio, p. ej. un
/// shell que tinta `ls`/paths/urls). El resto del texto va en `fg_text`.
fn line_text_color_runs<Msg: Clone + 'static>(
line: usize,
text: &str,
runs: &[(usize, usize, Color)],
metrics: EditorMetrics,
palette: &EditorPalette,
) -> View<Msg> {
View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(4.0_f32),
top: length(line as f32 * metrics.line_height),
right: auto(),
bottom: auto(),
},
size: Size { width: length(2000.0_f32), height: length(metrics.line_height) },
..Default::default()
})
.text_runs(
text.to_string(),
metrics.font_size,
palette.fg_text,
runs.to_vec(),
Alignment::Start,
)
.mono()
}
fn caret_rect<Msg: Clone + 'static>(
@@ -683,6 +801,52 @@ fn caret_rect<Msg: Clone + 'static>(
.fill(palette.caret)
}
/// Pinta el texto en composición del IME en `(local_line, col)`: el texto
/// provisional + un subrayado debajo que lo marca como no-confirmado.
/// Devuelve los dos Views (texto y subrayado). Posición en coords del
/// área de contenido (mismo origen que [`line_text_plain`]).
fn preedit_views<Msg: Clone + 'static>(
local_line: usize,
col: usize,
text: &str,
metrics: EditorMetrics,
palette: &EditorPalette,
) -> Vec<View<Msg>> {
let x = 4.0 + col as f32 * metrics.char_width;
let y = local_line as f32 * metrics.line_height;
let w = (text.chars().count() as f32 * metrics.char_width).max(metrics.char_width);
vec![
// Texto provisional, en el color de texto normal.
View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(x),
top: length(y),
right: auto(),
bottom: auto(),
},
size: Size { width: length(w), height: length(metrics.line_height) },
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(text.to_string(), metrics.font_size, palette.fg_text, Alignment::Start)
.mono(),
// Subrayado: una línea fina en el color del caret bajo el texto.
View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(x),
top: length(y + metrics.line_height - 2.0),
right: auto(),
bottom: auto(),
},
size: Size { width: length(w), height: length(1.5_f32) },
..Default::default()
})
.fill(palette.caret),
]
}
fn bracket_highlight<Msg: Clone + 'static>(
pos: Pos,
metrics: EditorMetrics,