tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ 168cd187519c0ad575d3ce63baee2f164c38d9d1
modules/mini-map/src/lib.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos
1//! `llimphi-module-mini-map` — minimap del buffer activo.
2//!
3//! Equivalente al "Minimap" de VS Code / "thumbnail" de Sublime: un
4//! panel angosto pegado al editor que pinta una linea horizontal por
5//! cada linea del buffer (ancho ~= len_chars, cap a `usable_w`),
6//! resalta el viewport visible como rect translucido y marca el caret.
7//! Click sobre el minimap salta esa linea al editor.
8//!
9//! El modulo es agnostico del editor: el host pasa un slice con la
10//! cantidad de chars por linea, el rango visible y la linea del
11//! caret. No depende de `llimphi-widget-text-editor` — cualquier
12//! buffer (rope, vec<String>, archivo memmaped) sirve.
13//!
14//! Sigue el contrato Llimphi de `docs/MODULES.md`:
15//! `State + Msg + Action + apply/on_key/open_shortcut/view + Palette`.
16
17#![forbid(unsafe_code)]
18
19use std::sync::Arc;
20
21use llimphi_compositor::llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
22use llimphi_compositor::peniko::kurbo::{Affine, Rect as KRect};
23use llimphi_compositor::peniko::{Color, Fill};
24use llimphi_compositor::{Key, KeyEvent, KeyState, View};
25
26/// Capabilities que aporta este modulo al host.
27pub const CAPABILITIES: &[&str] = &["editor.mini-map"];
28
29/// Ancho del panel en pixeles (estilo VS Code).
30pub const PANEL_W: f32 = 120.0;
31/// Altura maxima por linea del buffer dentro del minimap (cap).
32pub const LINE_PX: f32 = 2.0;
33/// Escala chars->pixels para el ancho de cada slab. ~75 chars caben
34/// completos en `PANEL_W - PAD * 2`; lo demas se trunca.
35pub const CHAR_PX: f32 = 1.4;
36/// Padding lateral del panel (los slabs no tocan los bordes).
37pub const PAD: f32 = 6.0;
38
39/// Estado interno. Hoy efectivamente vacio — la informacion del buffer
40/// la pasa el host en cada frame via [`view`] — pero existe como
41/// `Option<MiniMapState>` en el host para representar abierto/cerrado
42/// y para futuras extensiones (scrubbing, fold-aware, syntax per slab).
43#[derive(Debug, Default, Clone)]
44pub struct MiniMapState {
45 /// Reservado para drag-scrub: la y inicial en pixeles dentro del
46 /// panel cuando el usuario empieza a arrastrar. `None` = sin drag
47 /// activo. Hoy no se consume (click es suficiente); declarado para
48 /// que el contrato del state no cambie cuando se agregue.
49 pub drag_anchor_y: Option<f32>,
50}
51
52impl MiniMapState {
53 pub fn new() -> Self {
54 Self::default()
55 }
56}
57
58/// Vocabulario interno. El host lo wrapea en su Msg.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum MiniMapMsg {
61 /// Convencional: el host abre el panel guardando un `MiniMapState`
62 /// en el modelo. El modulo no construye state global.
63 Open,
64 Close,
65 /// El usuario clickeo o arrastro: salta a la linea indicada.
66 Jump(usize),
67}
68
69/// Efecto solicitado al host.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum MiniMapAction {
72 None,
73 /// El host deberia remover el state del modelo.
74 Close,
75 /// El host deberia centrar el viewport en esta linea del buffer
76 /// activo. El modulo NO se cierra — el minimap es persistente.
77 JumpTo(usize),
78}
79
80/// Snapshot del buffer que el host pasa en cada frame. El modulo no
81/// copia, solo lee. La cantidad de chars por linea es lo unico que
82/// necesita para dibujar; viewport + caret se overlayean encima.
83pub struct Snapshot<'a> {
84 /// `lines[i]` = numero de chars (no bytes) en la linea `i`.
85 pub lines: &'a [usize],
86 /// Rango visible en el editor: `[start, end)`.
87 pub viewport_start: usize,
88 pub viewport_end: usize,
89 /// Linea del caret (0-based). Se pinta como marker accent.
90 pub caret_line: usize,
91}
92
93/// Aplica un mensaje al estado.
94pub fn apply(state: &mut MiniMapState, msg: MiniMapMsg) -> MiniMapAction {
95 match msg {
96 MiniMapMsg::Open => MiniMapAction::None,
97 MiniMapMsg::Close => MiniMapAction::Close,
98 MiniMapMsg::Jump(line) => {
99 state.drag_anchor_y = None;
100 MiniMapAction::JumpTo(line)
101 }
102 }
103}
104
105/// Routing de teclas. El minimap NO captura teclas (es un viewer
106/// pasivo). Devolvemos `None`; el host sigue su routing normal.
107pub fn on_key(_state: &MiniMapState, _event: &KeyEvent) -> Option<MiniMapMsg> {
108 None
109}
110
111/// Atajo recomendado: **Ctrl+Shift+M** (mnemonic M = Minimap).
112pub fn open_shortcut(event: &KeyEvent) -> bool {
113 event.state == KeyState::Pressed
114 && event.modifiers.ctrl
115 && event.modifiers.shift
116 && matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("m"))
117}
118
119/// Convierte una posicion-y dentro del panel a indice de linea. La
120/// conversion es proporcional al total de lineas; clamping en ambos
121/// extremos.
122pub fn y_to_line(y: f32, panel_h: f32, total_lines: usize) -> usize {
123 if total_lines == 0 || panel_h <= 0.0 {
124 return 0;
125 }
126 let t = (y / panel_h).clamp(0.0, 1.0);
127 let line = (t * total_lines as f32) as usize;
128 line.min(total_lines.saturating_sub(1))
129}
130
131/// Paleta visual derivable del theme.
132#[derive(Debug, Clone)]
133pub struct MiniMapPalette {
134 /// Fondo del panel del minimap.
135 pub bg_panel: Color,
136 /// Color de los slabs (uno por linea de buffer).
137 pub fg_slab: Color,
138 /// Color del rect translucido que marca el viewport visible.
139 pub bg_viewport: Color,
140 /// Borde del rect del viewport.
141 pub border_viewport: Color,
142 /// Color del marker del caret.
143 pub fg_caret: Color,
144}
145
146impl MiniMapPalette {
147 pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
148 Self {
149 bg_panel: t.bg_panel_alt,
150 fg_slab: t.fg_muted,
151 bg_viewport: with_alpha(t.bg_selected, 0.35),
152 border_viewport: t.border_focus,
153 fg_caret: t.accent,
154 }
155 }
156}
157
158fn with_alpha(c: Color, alpha: f32) -> Color {
159 let rgba = c.to_rgba8();
160 let a = (alpha.clamp(0.0, 1.0) * 255.0) as u8;
161 Color::from_rgba8(rgba.r, rgba.g, rgba.b, a)
162}
163
164/// Render del panel. `to_host` mapea cada `MiniMapMsg` al `Msg` de la app.
165/// `snapshot` es la vista del buffer en este frame (sin copia).
166///
167/// Layout: columna fija de `PANEL_W` px que ocupa todo el alto del
168/// contenedor padre. El host la mete en el `Row` del editor
169/// (tipicamente al final, al estilo VS Code).
170pub fn view<HostMsg, F>(
171 _state: &MiniMapState,
172 snapshot: &Snapshot,
173 palette: &MiniMapPalette,
174 to_host: F,
175) -> View<HostMsg>
176where
177 HostMsg: Clone + 'static,
178 F: Fn(MiniMapMsg) -> HostMsg + Copy + Send + Sync + 'static,
179{
180 // Capturamos por valor porque el painter es Arc<dyn Fn>: 'static + Send + Sync.
181 let lines: Vec<usize> = snapshot.lines.to_vec();
182 let viewport_start = snapshot.viewport_start;
183 let viewport_end = snapshot.viewport_end;
184 let caret_line = snapshot.caret_line;
185 let pal = palette.clone();
186
187 let total_lines = lines.len();
188 let click_host = to_host;
189 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| {
190 let line = y_to_line(y, h, total_lines);
191 Some(click_host(MiniMapMsg::Jump(line)))
192 });
193
194 let mut view = View::new(Style {
195 size: Size { width: length(PANEL_W), height: percent(1.0_f32) },
196 flex_shrink: 0.0,
197 flex_direction: FlexDirection::Column,
198 ..Default::default()
199 })
200 .fill(pal.bg_panel)
201 .clip(true)
202 .paint_with(move |scene, _ts, rect| {
203 if rect.w <= 0.0 || rect.h <= 0.0 || lines.is_empty() {
204 return;
205 }
206 let n = lines.len() as f32;
207 let line_h = (rect.h / n).min(LINE_PX);
208 let usable_w = (rect.w - PAD * 2.0).max(1.0);
209
210 // 1) Viewport overlay debajo de los slabs.
211 if viewport_end > viewport_start {
212 let y0 = rect.y + (viewport_start as f32 / n) * rect.h;
213 let y1 = rect.y + (viewport_end as f32 / n) * rect.h;
214 let vp = KRect::new(
215 rect.x as f64,
216 y0 as f64,
217 (rect.x + rect.w) as f64,
218 y1.max(y0 + 2.0) as f64,
219 );
220 scene.fill(Fill::NonZero, Affine::IDENTITY, pal.bg_viewport, None, &vp);
221 }
222
223 // 2) Slabs: uno por linea de buffer.
224 for (i, &chars) in lines.iter().enumerate() {
225 if chars == 0 {
226 continue;
227 }
228 let w = (chars as f32 * CHAR_PX).min(usable_w);
229 let y = rect.y + (i as f32 / n) * rect.h;
230 let slab_h = line_h.max(1.0);
231 let r = KRect::new(
232 (rect.x + PAD) as f64,
233 y as f64,
234 (rect.x + PAD + w) as f64,
235 (y + slab_h) as f64,
236 );
237 scene.fill(Fill::NonZero, Affine::IDENTITY, pal.fg_slab, None, &r);
238 }
239
240 // 3) Borde del viewport encima de los slabs.
241 if viewport_end > viewport_start {
242 let y0 = rect.y + (viewport_start as f32 / n) * rect.h;
243 let y1 = (rect.y + (viewport_end as f32 / n) * rect.h).max(y0 + 2.0);
244 let top = KRect::new(
245 rect.x as f64,
246 y0 as f64,
247 (rect.x + rect.w) as f64,
248 (y0 + 1.0) as f64,
249 );
250 let bot = KRect::new(
251 rect.x as f64,
252 (y1 - 1.0) as f64,
253 (rect.x + rect.w) as f64,
254 y1 as f64,
255 );
256 scene.fill(Fill::NonZero, Affine::IDENTITY, pal.border_viewport, None, &top);
257 scene.fill(Fill::NonZero, Affine::IDENTITY, pal.border_viewport, None, &bot);
258 }
259
260 // 4) Marker del caret: barra horizontal accent.
261 if caret_line < lines.len() {
262 let y = rect.y + (caret_line as f32 / n) * rect.h;
263 let r = KRect::new(
264 rect.x as f64,
265 y as f64,
266 (rect.x + rect.w) as f64,
267 (y + 2.0) as f64,
268 );
269 scene.fill(Fill::NonZero, Affine::IDENTITY, pal.fg_caret, None, &r);
270 }
271 });
272 view.raro_mut().on_click_at = Some(on_click);
273 view
274}