tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 614Markdown 274TOML 149RON 310606 Definiciones

⎇ main widgets/context-menu/examples/menu_demo.rs Rust · 383 líneas · 13.9 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

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}