tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
1//! Demo de `llimphi-widget-context-menu`: paridad con un menú contextual
2//! nativo / Material, en vivo.
3//!
4//! Corre con: `cargo run -p llimphi-widget-context-menu --example menu_demo --release`.
5//!
6//! Qué mirar / probar:
7//! - **Teclado** (el foco lo maneja este host con los helpers puros del widget):
8//! - `↑`/`↓` navegan (saltean separadores y deshabilitados) — `step_active`.
9//! - `Inicio`/`Fin` van al primero/último — `edge_active`.
10//! - **Teclea una letra** para saltar por mnemónico (repetí para rotar) —
11//! `type_ahead`. Ej: `t` salta a «Tema», `e` a «Exportar»/«Eliminar».
12//! - `→` entra al submenú resaltado, `←` vuelve, `Enter` activa, `Esc` cierra.
13//! - **Items chequeables**: «Ajustar texto» / «Números de línea» muestran ✓ y
14//! alternan al activarlos (accent en el gutter).
15//! - **Submenús con flip**: «Tema» y «Exportar» abren un flyout. El menú arranca
16//! pegado a la izquierda → abre a la derecha; **muévelo con `Tab`** para
17//! anclarlo al borde derecho y ver el flyout **flipear a la izquierda**.
18//! - **Elevación**: el panel flota con sombra suave + borde hairline + esquinas
19//! redondeadas (look webpage). Aparición animada (fade + leve slide).
20//! - Separador, item deshabilitado («Pegar»), item destructivo («Eliminar»).
21
22use std::sync::Arc;
23
24use llimphi_motion::{animate, motion, Tween};
25use llimphi_ui::llimphi_layout::taffy::{
26 prelude::{length, percent, FlexDirection, Size, Style},
27 AlignItems, Rect,
28};
29use llimphi_ui::llimphi_raster::peniko::Color;
30use llimphi_ui::llimphi_text::Alignment;
31use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, View};
32use llimphi_widget_context_menu::{
33 context_menu_view_ex, edge_active, step_active, type_ahead, ContextMenuExtras, ContextMenuItem,
34 ContextMenuPalette, ContextMenuSpec,
35};
36
37#[derive(Clone, Debug)]
38enum Msg {
39 Pick(usize),
40 PickSub(usize, usize),
41 Hover(Option<usize>),
42 Key(KeyEvent),
43 Resize(f32, f32),
44 Dismiss,
45 Tick,
46}
47
48struct Model {
49 items: Vec<ContextMenuItem>,
50 active: usize,
51 open_sub: Option<usize>,
52 sub_active: usize,
53 appear: Tween<f32>,
54 viewport: (f32, f32),
55 /// Posiciones de ancla que rotan con `Tab` (para probar el flip).
56 anchors: Vec<(f32, f32)>,
57 anchor_idx: usize,
58 log: String,
59}
60
61/// Construye el menú raíz. `wrap`/`line_nums` son el estado de los checkables.
62fn build_items(wrap: bool, line_nums: bool) -> Vec<ContextMenuItem> {
63 vec![
64 ContextMenuItem::action("Cortar")
65 .icon("✂")
66 .with_shortcut("Ctrl+X"),
67 ContextMenuItem::action("Copiar")
68 .icon("⧉")
69 .with_shortcut("Ctrl+C"),
70 ContextMenuItem::action("Pegar")
71 .icon("⎘")
72 .with_shortcut("Ctrl+V")
73 .disabled(),
74 ContextMenuItem::separator(),
75 ContextMenuItem::action("Ajustar texto").checked(wrap),
76 ContextMenuItem::action("Números de línea").checked(line_nums),
77 ContextMenuItem::separator(),
78 ContextMenuItem::action("Tema").icon("◐").submenu(vec![
79 ContextMenuItem::action("Oscuro").checked(true),
80 ContextMenuItem::action("Claro").checked(false),
81 ContextMenuItem::action("Sistema").checked(false),
82 ]),
83 ContextMenuItem::action("Exportar").icon("⇪").submenu(vec![
84 ContextMenuItem::action("PDF"),
85 ContextMenuItem::action("PNG"),
86 ContextMenuItem::action("SVG").disabled(),
87 ]),
88 ContextMenuItem::separator(),
89 ContextMenuItem::action("Eliminar")
90 .icon("🗑")
91 .with_shortcut("Supr")
92 .destructive(),
93 ]
94}
95
96struct Demo;
97
98impl Demo {
99 fn reappear(m: &mut Model, handle: &Handle<Msg>) {
100 m.appear = Tween::new(0.0, 1.0, motion::NORMAL, motion::ease_out_cubic);
101 animate(handle, motion::NORMAL, || Msg::Tick);
102 }
103}
104
105impl App for Demo {
106 type Model = Model;
107 type Msg = Msg;
108
109 fn title() -> &'static str {
110 "llimphi · menú contextual (paridad nativa)"
111 }
112
113 fn initial_size() -> (u32, u32) {
114 (920, 640)
115 }
116
117 fn init(handle: &Handle<Msg>) -> Model {
118 animate(handle, motion::NORMAL, || Msg::Tick);
119 Model {
120 items: build_items(true, false),
121 active: usize::MAX,
122 open_sub: None,
123 sub_active: usize::MAX,
124 appear: Tween::new(0.0, 1.0, motion::NORMAL, motion::ease_out_cubic),
125 viewport: (920.0, 640.0),
126 anchors: vec![(60.0, 90.0), (430.0, 90.0), (9000.0, 90.0)],
127 anchor_idx: 0,
128 log: String::from("Prueba el teclado: ↑↓ · Inicio/Fin · una letra (type-ahead) · → submenú · Enter · Tab mueve el ancla"),
129 }
130 }
131
132 fn on_resize(_m: &Model, w: u32, h: u32) -> Option<Msg> {
133 Some(Msg::Resize(w as f32, h as f32))
134 }
135
136 fn on_key(_m: &Model, event: &KeyEvent) -> Option<Msg> {
137 (event.state == KeyState::Pressed).then(|| Msg::Key(event.clone()))
138 }
139
140 fn update(model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
141 let mut m = model;
142 match msg {
143 Msg::Tick => {}
144 Msg::Resize(w, h) => m.viewport = (w, h),
145 Msg::Hover(sub) => {
146 m.open_sub = sub;
147 if let Some(p) = sub {
148 // Al abrir por hover, resalta el primero del submenú.
149 if let Some(parent) = m.items.get(p) {
150 m.sub_active = edge_active(&parent.children, false);
151 }
152 } else {
153 m.sub_active = usize::MAX;
154 }
155 }
156 Msg::Pick(i) => Demo::pick_root(&mut m, i, handle),
157 Msg::PickSub(p, c) => Demo::pick_sub(&mut m, p, c),
158 Msg::Dismiss => {
159 m.active = usize::MAX;
160 m.open_sub = None;
161 m.sub_active = usize::MAX;
162 m.log = String::from("(cerrado — clickea o navega para reabrir)");
163 Demo::reappear(&mut m, handle);
164 }
165 Msg::Key(e) => Demo::on_key_press(&mut m, e, handle),
166 }
167 m
168 }
169
170 fn view(model: &Model) -> View<Msg> {
171 // Fondo con instrucciones + el log de la última acción.
172 let bg = View::new(Style {
173 flex_direction: FlexDirection::Column,
174 size: Size {
175 width: percent(1.0_f32),
176 height: percent(1.0_f32),
177 },
178 padding: Rect {
179 left: length(40.0_f32),
180 right: length(40.0_f32),
181 top: length(28.0_f32),
182 bottom: length(28.0_f32),
183 },
184 gap: Size {
185 width: length(0.0_f32),
186 height: length(10.0_f32),
187 },
188 align_items: Some(AlignItems::FLEX_END),
189 ..Default::default()
190 })
191 .fill(Color::from_rgba8(16, 19, 26, 255))
192 .children(vec![View::new(Style {
193 size: Size {
194 width: percent(0.62_f32),
195 height: length(28.0_f32),
196 },
197 ..Default::default()
198 })
199 .text_aligned(
200 model.log.clone(),
201 13.0,
202 Color::from_rgba8(150, 160, 180, 255),
203 Alignment::End,
204 )]);
205 bg
206 }
207
208 fn view_overlay(model: &Model) -> Option<View<Msg>> {
209 let mut pal = ContextMenuPalette::from_theme(&llimphi_theme::Theme::default());
210 pal.scrim = Color::from_rgba8(0, 0, 0, 90);
211 let items = model.items.clone();
212 let spec = ContextMenuSpec {
213 anchor: model.anchors[model.anchor_idx],
214 viewport: model.viewport,
215 header: Some("Editar".into()),
216 items,
217 active: model.active,
218 on_pick: Arc::new(Msg::Pick),
219 on_dismiss: Msg::Dismiss,
220 palette: pal,
221 };
222 let extras = ContextMenuExtras {
223 open_sub: model.open_sub,
224 appear: model.appear.value(),
225 on_pick_sub: Some(Arc::new(Msg::PickSub)),
226 on_hover: Some(Arc::new(Msg::Hover)),
227 };
228 Some(context_menu_view_ex(spec, extras))
229 }
230}
231
232impl Demo {
233 fn pick_root(m: &mut Model, i: usize, handle: &Handle<Msg>) {
234 let Some(item) = m.items.get(i) else { return };
235 if item.has_submenu() {
236 // Click en un item con submenú: alterná su flyout.
237 m.open_sub = if m.open_sub == Some(i) { None } else { Some(i) };
238 m.sub_active = edge_active(&item.children.clone(), false);
239 return;
240 }
241 // Toggle de los checkables por label (demo).
242 match item.label.as_str() {
243 "Ajustar texto" => {
244 let cur = item.is_checked();
245 Self::rebuild(m, |w| *w = !cur, |_| {});
246 m.log = format!("Ajustar texto → {}", if !cur { "sí" } else { "no" });
247 }
248 "Números de línea" => {
249 let cur = item.is_checked();
250 Self::rebuild(m, |_| {}, |l| *l = !cur);
251 m.log = format!("Números de línea → {}", if !cur { "sí" } else { "no" });
252 }
253 other => m.log = format!("Elegiste: {other}"),
254 }
255 m.open_sub = None;
256 Self::reappear(m, handle);
257 }
258
259 fn pick_sub(m: &mut Model, p: usize, c: usize) {
260 let label = m
261 .items
262 .get(p)
263 .and_then(|it| it.children.get(c))
264 .map(|it| it.label.clone())
265 .unwrap_or_default();
266 let parent = m.items.get(p).map(|it| it.label.clone()).unwrap_or_default();
267 m.log = format!("{parent} → {label}");
268 m.open_sub = None;
269 m.sub_active = usize::MAX;
270 }
271
272 /// Reconstruye los items preservando el estado de los dos checkables.
273 fn rebuild(m: &mut Model, mutate_wrap: impl FnOnce(&mut bool), mutate_ln: impl FnOnce(&mut bool)) {
274 let mut wrap = m
275 .items
276 .iter()
277 .find(|it| it.label == "Ajustar texto")
278 .map(|it| it.is_checked())
279 .unwrap_or(false);
280 let mut line_nums = m
281 .items
282 .iter()
283 .find(|it| it.label == "Números de línea")
284 .map(|it| it.is_checked())
285 .unwrap_or(false);
286 mutate_wrap(&mut wrap);
287 mutate_ln(&mut line_nums);
288 m.items = build_items(wrap, line_nums);
289 }
290
291 fn on_key_press(m: &mut Model, e: KeyEvent, handle: &Handle<Msg>) {
292 match e.key {
293 Key::Named(NamedKey::ArrowDown) => {
294 if let Some(p) = m.open_sub {
295 let ch = m.items[p].children.clone();
296 m.sub_active = step_active(&ch, m.sub_active, 1);
297 } else {
298 m.active = step_active(&m.items, m.active, 1);
299 }
300 }
301 Key::Named(NamedKey::ArrowUp) => {
302 if let Some(p) = m.open_sub {
303 let ch = m.items[p].children.clone();
304 m.sub_active = step_active(&ch, m.sub_active, -1);
305 } else {
306 m.active = step_active(&m.items, m.active, -1);
307 }
308 }
309 Key::Named(NamedKey::Home) => {
310 if let Some(p) = m.open_sub {
311 m.sub_active = edge_active(&m.items[p].children.clone(), false);
312 } else {
313 m.active = edge_active(&m.items, false);
314 }
315 }
316 Key::Named(NamedKey::End) => {
317 if let Some(p) = m.open_sub {
318 m.sub_active = edge_active(&m.items[p].children.clone(), true);
319 } else {
320 m.active = edge_active(&m.items, true);
321 }
322 }
323 Key::Named(NamedKey::ArrowRight) => {
324 if m.open_sub.is_none()
325 && m.active != usize::MAX
326 && m.items.get(m.active).map(|it| it.has_submenu()) == Some(true)
327 {
328 m.open_sub = Some(m.active);
329 m.sub_active = edge_active(&m.items[m.active].children.clone(), false);
330 }
331 }
332 Key::Named(NamedKey::ArrowLeft) => {
333 if m.open_sub.is_some() {
334 m.open_sub = None;
335 m.sub_active = usize::MAX;
336 }
337 }
338 Key::Named(NamedKey::Enter) => {
339 if let Some(p) = m.open_sub {
340 if m.sub_active != usize::MAX {
341 Self::pick_sub(m, p, m.sub_active);
342 }
343 } else if m.active != usize::MAX {
344 Self::pick_root(m, m.active, handle);
345 }
346 }
347 Key::Named(NamedKey::Escape) => {
348 if m.open_sub.is_some() {
349 m.open_sub = None;
350 m.sub_active = usize::MAX;
351 } else {
352 m.active = usize::MAX;
353 }
354 }
355 Key::Named(NamedKey::Tab) => {
356 m.anchor_idx = (m.anchor_idx + 1) % m.anchors.len();
357 m.log = format!("Ancla #{} — mira de qué lado abre el submenú", m.anchor_idx + 1);
358 }
359 _ => {
360 // Type-ahead: primera letra del texto tecleado.
361 if let Some(ch) = e.text.as_deref().and_then(|t| t.chars().next()) {
362 if ch.is_alphanumeric() {
363 if let Some(p) = m.open_sub {
364 let sub = type_ahead(&m.items[p].children.clone(), m.sub_active, ch);
365 if sub != usize::MAX {
366 m.sub_active = sub;
367 }
368 } else {
369 let a = type_ahead(&m.items, m.active, ch);
370 if a != usize::MAX {
371 m.active = a;
372 }
373 }
374 }
375 }
376 }
377 }
378 }
379}
380
381fn main() {
382 llimphi_ui::run::<Demo>();
383}