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
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "llimphi-module-mini-map"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-module-mini-map — overlay minimap del buffer activo. Modulo Llimphi: el host le pasa un snapshot del buffer + viewport + caret, el modulo pinta un panel vertical con un slab por linea (ancho aprox chars), resalta el viewport visible y emite Jump(line) al click. Estilo VS Code/Sublime."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
[dev-dependencies]
+5
View File
@@ -0,0 +1,5 @@
# llimphi-module-mini-map
> Mini-mapa del editor de [llimphi](../../README.md).
Overview a la derecha del [`text-editor`](../../widgets/text-editor/README.md): renderizado escalado del archivo con highlight de la posición actual. Click salta a esa porción.
+5
View File
@@ -0,0 +1,5 @@
# llimphi-module-mini-map
> Editor mini-map of [llimphi](../../README.md).
Right-side overview of [`text-editor`](../../widgets/text-editor/README.md): scaled rendering of the file with highlight of current position. Click jumps to that section.
+274
View File
@@ -0,0 +1,274 @@
//! `llimphi-module-mini-map` — minimap del buffer activo.
//!
//! Equivalente al "Minimap" de VS Code / "thumbnail" de Sublime: un
//! panel angosto pegado al editor que pinta una linea horizontal por
//! cada linea del buffer (ancho ~= len_chars, cap a `usable_w`),
//! resalta el viewport visible como rect translucido y marca el caret.
//! Click sobre el minimap salta esa linea al editor.
//!
//! El modulo es agnostico del editor: el host pasa un slice con la
//! cantidad de chars por linea, el rango visible y la linea del
//! caret. No depende de `llimphi-widget-text-editor` — cualquier
//! buffer (rope, vec<String>, archivo memmaped) sirve.
//!
//! Sigue el contrato Llimphi de `docs/MODULES.md`:
//! `State + Msg + Action + apply/on_key/open_shortcut/view + Palette`.
#![forbid(unsafe_code)]
use std::sync::Arc;
use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
use llimphi_ui::llimphi_raster::kurbo::{Affine, Rect as KRect};
use llimphi_ui::llimphi_raster::peniko::{Color, Fill};
use llimphi_ui::{Key, KeyEvent, KeyState, View};
/// Capabilities que aporta este modulo al host.
pub const CAPABILITIES: &[&str] = &["editor.mini-map"];
/// Ancho del panel en pixeles (estilo VS Code).
pub const PANEL_W: f32 = 120.0;
/// Altura maxima por linea del buffer dentro del minimap (cap).
pub const LINE_PX: f32 = 2.0;
/// Escala chars->pixels para el ancho de cada slab. ~75 chars caben
/// completos en `PANEL_W - PAD * 2`; lo demas se trunca.
pub const CHAR_PX: f32 = 1.4;
/// Padding lateral del panel (los slabs no tocan los bordes).
pub const PAD: f32 = 6.0;
/// Estado interno. Hoy efectivamente vacio — la informacion del buffer
/// la pasa el host en cada frame via [`view`] — pero existe como
/// `Option<MiniMapState>` en el host para representar abierto/cerrado
/// y para futuras extensiones (scrubbing, fold-aware, syntax per slab).
#[derive(Debug, Default, Clone)]
pub struct MiniMapState {
/// Reservado para drag-scrub: la y inicial en pixeles dentro del
/// panel cuando el usuario empieza a arrastrar. `None` = sin drag
/// activo. Hoy no se consume (click es suficiente); declarado para
/// que el contrato del state no cambie cuando se agregue.
pub drag_anchor_y: Option<f32>,
}
impl MiniMapState {
pub fn new() -> Self {
Self::default()
}
}
/// Vocabulario interno. El host lo wrapea en su Msg.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MiniMapMsg {
/// Convencional: el host abre el panel guardando un `MiniMapState`
/// en el modelo. El modulo no construye state global.
Open,
Close,
/// El usuario clickeo o arrastro: salta a la linea indicada.
Jump(usize),
}
/// Efecto solicitado al host.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MiniMapAction {
None,
/// El host deberia remover el state del modelo.
Close,
/// El host deberia centrar el viewport en esta linea del buffer
/// activo. El modulo NO se cierra — el minimap es persistente.
JumpTo(usize),
}
/// Snapshot del buffer que el host pasa en cada frame. El modulo no
/// copia, solo lee. La cantidad de chars por linea es lo unico que
/// necesita para dibujar; viewport + caret se overlayean encima.
pub struct Snapshot<'a> {
/// `lines[i]` = numero de chars (no bytes) en la linea `i`.
pub lines: &'a [usize],
/// Rango visible en el editor: `[start, end)`.
pub viewport_start: usize,
pub viewport_end: usize,
/// Linea del caret (0-based). Se pinta como marker accent.
pub caret_line: usize,
}
/// Aplica un mensaje al estado.
pub fn apply(state: &mut MiniMapState, msg: MiniMapMsg) -> MiniMapAction {
match msg {
MiniMapMsg::Open => MiniMapAction::None,
MiniMapMsg::Close => MiniMapAction::Close,
MiniMapMsg::Jump(line) => {
state.drag_anchor_y = None;
MiniMapAction::JumpTo(line)
}
}
}
/// Routing de teclas. El minimap NO captura teclas (es un viewer
/// pasivo). Devolvemos `None`; el host sigue su routing normal.
pub fn on_key(_state: &MiniMapState, _event: &KeyEvent) -> Option<MiniMapMsg> {
None
}
/// Atajo recomendado: **Ctrl+Shift+M** (mnemonic M = Minimap).
pub fn open_shortcut(event: &KeyEvent) -> bool {
event.state == KeyState::Pressed
&& event.modifiers.ctrl
&& event.modifiers.shift
&& matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("m"))
}
/// Convierte una posicion-y dentro del panel a indice de linea. La
/// conversion es proporcional al total de lineas; clamping en ambos
/// extremos.
pub fn y_to_line(y: f32, panel_h: f32, total_lines: usize) -> usize {
if total_lines == 0 || panel_h <= 0.0 {
return 0;
}
let t = (y / panel_h).clamp(0.0, 1.0);
let line = (t * total_lines as f32) as usize;
line.min(total_lines.saturating_sub(1))
}
/// Paleta visual derivable del theme.
#[derive(Debug, Clone)]
pub struct MiniMapPalette {
/// Fondo del panel del minimap.
pub bg_panel: Color,
/// Color de los slabs (uno por linea de buffer).
pub fg_slab: Color,
/// Color del rect translucido que marca el viewport visible.
pub bg_viewport: Color,
/// Borde del rect del viewport.
pub border_viewport: Color,
/// Color del marker del caret.
pub fg_caret: Color,
}
impl MiniMapPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg_panel: t.bg_panel_alt,
fg_slab: t.fg_muted,
bg_viewport: with_alpha(t.bg_selected, 0.35),
border_viewport: t.border_focus,
fg_caret: t.accent,
}
}
}
fn with_alpha(c: Color, alpha: f32) -> Color {
let rgba = c.to_rgba8();
let a = (alpha.clamp(0.0, 1.0) * 255.0) as u8;
Color::from_rgba8(rgba.r, rgba.g, rgba.b, a)
}
/// Render del panel. `to_host` mapea cada `MiniMapMsg` al `Msg` de la app.
/// `snapshot` es la vista del buffer en este frame (sin copia).
///
/// Layout: columna fija de `PANEL_W` px que ocupa todo el alto del
/// contenedor padre. El host la mete en el `Row` del editor
/// (tipicamente al final, al estilo VS Code).
pub fn view<HostMsg, F>(
_state: &MiniMapState,
snapshot: &Snapshot,
palette: &MiniMapPalette,
to_host: F,
) -> View<HostMsg>
where
HostMsg: Clone + 'static,
F: Fn(MiniMapMsg) -> HostMsg + Copy + Send + Sync + 'static,
{
// Capturamos por valor porque el painter es Arc<dyn Fn>: 'static + Send + Sync.
let lines: Vec<usize> = snapshot.lines.to_vec();
let viewport_start = snapshot.viewport_start;
let viewport_end = snapshot.viewport_end;
let caret_line = snapshot.caret_line;
let pal = palette.clone();
let total_lines = lines.len();
let click_host = to_host;
let on_click: Arc<dyn Fn(f32, f32, f32, f32) -> Option<HostMsg> + Send + Sync> = Arc::new(move |_x: f32, y: f32, _w: f32, h: f32| {
let line = y_to_line(y, h, total_lines);
Some(click_host(MiniMapMsg::Jump(line)))
});
let mut view = View::new(Style {
size: Size { width: length(PANEL_W), height: percent(1.0_f32) },
flex_shrink: 0.0,
flex_direction: FlexDirection::Column,
..Default::default()
})
.fill(pal.bg_panel)
.clip(true)
.paint_with(move |scene, _ts, rect| {
if rect.w <= 0.0 || rect.h <= 0.0 || lines.is_empty() {
return;
}
let n = lines.len() as f32;
let line_h = (rect.h / n).min(LINE_PX);
let usable_w = (rect.w - PAD * 2.0).max(1.0);
// 1) Viewport overlay debajo de los slabs.
if viewport_end > viewport_start {
let y0 = rect.y + (viewport_start as f32 / n) * rect.h;
let y1 = rect.y + (viewport_end as f32 / n) * rect.h;
let vp = KRect::new(
rect.x as f64,
y0 as f64,
(rect.x + rect.w) as f64,
y1.max(y0 + 2.0) as f64,
);
scene.fill(Fill::NonZero, Affine::IDENTITY, pal.bg_viewport, None, &vp);
}
// 2) Slabs: uno por linea de buffer.
for (i, &chars) in lines.iter().enumerate() {
if chars == 0 {
continue;
}
let w = (chars as f32 * CHAR_PX).min(usable_w);
let y = rect.y + (i as f32 / n) * rect.h;
let slab_h = line_h.max(1.0);
let r = KRect::new(
(rect.x + PAD) as f64,
y as f64,
(rect.x + PAD + w) as f64,
(y + slab_h) as f64,
);
scene.fill(Fill::NonZero, Affine::IDENTITY, pal.fg_slab, None, &r);
}
// 3) Borde del viewport encima de los slabs.
if viewport_end > viewport_start {
let y0 = rect.y + (viewport_start as f32 / n) * rect.h;
let y1 = (rect.y + (viewport_end as f32 / n) * rect.h).max(y0 + 2.0);
let top = KRect::new(
rect.x as f64,
y0 as f64,
(rect.x + rect.w) as f64,
(y0 + 1.0) as f64,
);
let bot = KRect::new(
rect.x as f64,
(y1 - 1.0) as f64,
(rect.x + rect.w) as f64,
y1 as f64,
);
scene.fill(Fill::NonZero, Affine::IDENTITY, pal.border_viewport, None, &top);
scene.fill(Fill::NonZero, Affine::IDENTITY, pal.border_viewport, None, &bot);
}
// 4) Marker del caret: barra horizontal accent.
if caret_line < lines.len() {
let y = rect.y + (caret_line as f32 / n) * rect.h;
let r = KRect::new(
rect.x as f64,
y as f64,
(rect.x + rect.w) as f64,
(y + 2.0) as f64,
);
scene.fill(Fill::NonZero, Affine::IDENTITY, pal.fg_caret, None, &r);
}
});
view.on_click_at = Some(on_click);
view
}
+63
View File
@@ -0,0 +1,63 @@
//! Smoke tests del minimap. Sin backend grafico — solo `apply`,
//! `on_key`, `open_shortcut` y la conversion y->line.
use llimphi_module_mini_map::{
self as minimap, MiniMapAction, MiniMapMsg, MiniMapState,
};
use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers};
fn key_with(ctrl: bool, shift: bool, ch: &str) -> KeyEvent {
KeyEvent {
key: Key::Character(ch.into()),
state: KeyState::Pressed,
text: Some(ch.into()),
modifiers: Modifiers { ctrl, shift, ..Modifiers::default() },
repeat: false,
}
}
#[test]
fn open_shortcut_es_ctrl_shift_m() {
assert!(minimap::open_shortcut(&key_with(true, true, "m")));
assert!(minimap::open_shortcut(&key_with(true, true, "M")));
assert!(!minimap::open_shortcut(&key_with(true, false, "m")));
assert!(!minimap::open_shortcut(&key_with(false, true, "m")));
}
#[test]
fn jump_emite_jumpto() {
let mut s = MiniMapState::new();
let action = minimap::apply(&mut s, MiniMapMsg::Jump(42));
assert_eq!(action, MiniMapAction::JumpTo(42));
}
#[test]
fn close_emite_close() {
let mut s = MiniMapState::new();
let action = minimap::apply(&mut s, MiniMapMsg::Close);
assert_eq!(action, MiniMapAction::Close);
}
#[test]
fn y_to_line_proporcional() {
// 100 lineas, panel de 200 px → cada linea ocupa 2 px.
assert_eq!(minimap::y_to_line(0.0, 200.0, 100), 0);
assert_eq!(minimap::y_to_line(100.0, 200.0, 100), 50);
assert_eq!(minimap::y_to_line(200.0, 200.0, 100), 99);
// Clamping fuera de rango.
assert_eq!(minimap::y_to_line(-50.0, 200.0, 100), 0);
assert_eq!(minimap::y_to_line(500.0, 200.0, 100), 99);
}
#[test]
fn y_to_line_buffer_vacio_no_paniquea() {
assert_eq!(minimap::y_to_line(0.0, 100.0, 0), 0);
assert_eq!(minimap::y_to_line(50.0, 100.0, 0), 0);
}
#[test]
fn on_key_es_pasivo() {
let s = MiniMapState::new();
let ev = key_with(false, false, "a");
assert!(minimap::on_key(&s, &ev).is_none());
}