tawasuyu

tawasuyu/llimphi

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

Rust 238TOML 102Markdown 83Shell 24178 Definiciones

⎇ main widgets/menubar/src/lib.rs Rust · 355 líneas · 12.4 KB Historia Quién tocó qué crudo

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

1//! `llimphi-widget-menubar` — la barra de menú principal de una app.
2//!
3//! Toda app Llimphi declara un [`app_bus::AppMenu`] (Archivo / Editar /
4//! Ver / Ayuda …) y lo monta in-window con este widget. Es el gemelo de
5//! la barra global de [`launcher_llimphi`], pero vive **dentro** de la
6//! ventana de la app — para las apps que corren standalone y no bajo el
7//! shell del launcher.
8//!
9//! Sin estado, al estilo Llimphi: el `Model` del host lleva qué menú raíz
10//! está abierto (`Option<usize>`); el widget aplana el `AppMenu` y emite
11//! `Msg` en cada interacción.
12//!
13//! Dos entradas:
14//! - [`menubar_view`] → la fila de títulos, para el tope de `App::view`.
15//! - [`menubar_overlay`] → el dropdown del menú abierto, para
16//!   `App::view_overlay` (devuelve `None` si no hay nada abierto).
17//!
18//! El `command` de cada ítem es el id que la app entiende (convención
19//! `menu.<verbo>`, ver [`app_bus::AppMenu::standard`]); el widget lo
20//! rebota por `on_command`.
21
22#![forbid(unsafe_code)]
23
24
25// Sin `std`: este widget no usa nada del sistema —sólo `alloc` para
26// `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula
27// WASM o al kernel. Antes enlazaba `std` por omisión (W4.6).
28#![no_std]
29
30extern crate alloc;
31
32use alloc::string::String;
33use alloc::vec::Vec;
34use alloc::vec;
35use alloc::sync::Arc;
36
37use app_bus::{AppMenu, Menu};
38use llimphi_theme::Theme;
39use llimphi_compositor::llimphi_layout::taffy::{
40    prelude::{auto, length, percent, AlignItems, FlexDirection, JustifyContent, Position, Size, Style},
41    Rect,
42};
43use llimphi_compositor::llimphi_text::Alignment;
44use llimphi_compositor::View;
45use llimphi_widget_button::{button_styled, ButtonPalette};
46use llimphi_widget_context_menu::{
47    context_menu_view_ex, step_active, ContextMenuExtras, ContextMenuItem, ContextMenuPalette,
48    ContextMenuSpec,
49};
50
51type MsgFromMenu<Msg> = Arc<dyn Fn(Option<usize>) -> Msg + Send + Sync>;
52type MsgFromStr<Msg> = Arc<dyn Fn(&str) -> Msg + Send + Sync>;
53
54/// Todo lo que el render necesita. El host lo arma en cada `view()`.
55pub struct MenuBarSpec<'a, Msg: Clone + 'static> {
56    /// El menú a pintar (típicamente `AppMenu::standard()` + menús propios).
57    pub menu: &'a AppMenu,
58    /// Índice del menú raíz abierto (estado del host). `None` = ninguno.
59    pub open: Option<usize>,
60    pub theme: &'a Theme,
61    /// Tamaño de la ventana — para clampear el dropdown.
62    pub viewport: (f32, f32),
63    /// Alto de la barra (px). Usar [`DEFAULT_HEIGHT`] si no hay razón.
64    pub height: f32,
65    /// Abrir/cerrar un menú raíz por índice (`None` = cerrar).
66    pub on_open: MsgFromMenu<Msg>,
67    /// command id → Msg, al elegir un ítem.
68    pub on_command: MsgFromStr<Msg>,
69}
70
71/// Alto recomendado de la barra de menú.
72pub const DEFAULT_HEIGHT: f32 = 30.0;
73
74fn title_palette(theme: &Theme) -> ButtonPalette {
75    ButtonPalette::from_theme(theme)
76}
77
78fn title_palette_active(theme: &Theme) -> ButtonPalette {
79    let base = ButtonPalette::from_theme(theme);
80    ButtonPalette {
81        bg: theme.accent,
82        bg_hover: theme.accent,
83        fg: theme.bg_panel,
84        radius: base.radius,
85    }
86}
87
88/// La fila de títulos (Archivo / Editar / …). Click sobre un título
89/// togglea su dropdown vía `on_open`. El abierto se resalta con el accent.
90/// `hover_switch = true` agrega `on_pointer_enter` a cada título para que,
91/// con un menú ya abierto, pasar el mouse sobre otro título cambie de menú
92/// (comportamiento clásico de barra de menú) — sólo se usa en el overlay,
93/// donde los títulos quedan por encima del scrim y son hovereables.
94fn titles_row<Msg: Clone + 'static>(spec: &MenuBarSpec<Msg>, hover_switch: bool) -> View<Msg> {
95    let pal = title_palette(spec.theme);
96    let pal_on = title_palette_active(spec.theme);
97
98    let mut titles: Vec<View<Msg>> = Vec::with_capacity(spec.menu.menus.len());
99    for (i, root) in spec.menu.menus.iter().enumerate() {
100        let open = spec.open == Some(i);
101        let target = if open { None } else { Some(i) };
102        let mut title = button_styled(
103            root.label.clone(),
104            title_style(),
105            Alignment::Center,
106            if open { &pal_on } else { &pal },
107            (spec.on_open)(target),
108        );
109        // `button_styled` ya declaró Role::Button; acá lo precisamos: un
110        // título de barra de menú es un MenuItem que despliega, y el estado
111        // desplegado es información que el lector necesita.
112        title = title
113            .role(llimphi_compositor::Role::MenuItem)
114            .aria_expanded(open);
115        // Con un menú abierto, hover sobre otro título lo abre.
116        if hover_switch && !open {
117            title = title.on_pointer_enter((spec.on_open)(Some(i)));
118        }
119        titles.push(title);
120    }
121
122    View::new(Style {
123        size: Size {
124            width: percent(1.0_f32),
125            height: length(spec.height),
126        },
127        flex_shrink: 0.0,
128        flex_direction: FlexDirection::Row,
129        align_items: Some(AlignItems::CENTER),
130        padding: Rect {
131            left: length(6.0_f32),
132            right: length(6.0_f32),
133            top: length(0.0_f32),
134            bottom: length(0.0_f32),
135        },
136        gap: Size {
137            width: length(2.0_f32),
138            height: length(0.0_f32),
139        },
140        ..Default::default()
141    })
142    .fill(spec.theme.bg_panel_alt)
143    .role(llimphi_compositor::Role::MenuBar)
144    .children(titles)
145}
146
147/// La barra de menú principal — primer hijo del column raíz de `view()`.
148pub fn menubar_view<Msg: Clone + 'static>(spec: &MenuBarSpec<Msg>) -> View<Msg> {
149    titles_row(spec, false)
150}
151
152/// Aplana un menú raíz al par alineado `(items, commands)` que consume el
153/// context-menu (los separadores `separator_before` se insertan como
154/// filas y llevan `command = None`). Es la única fuente de verdad del
155/// orden de filas — la navegación por teclado y el render comparten esto.
156fn dropdown_items(root: &Menu) -> (Vec<ContextMenuItem>, Vec<Option<String>>) {
157    let mut items: Vec<ContextMenuItem> = Vec::new();
158    let mut commands: Vec<Option<String>> = Vec::new();
159    for (k, src) in root.items.iter().enumerate() {
160        if src.separator_before && k != 0 {
161            items.push(ContextMenuItem::separator());
162            commands.push(None);
163        }
164        let mut cm = ContextMenuItem::action(src.label.clone());
165        if let Some(s) = &src.shortcut {
166            cm = cm.with_shortcut(s.clone());
167        }
168        if let Some(ic) = &src.icon {
169            cm = cm.icon(ic.clone());
170        }
171        if !src.enabled {
172            cm = cm.disabled();
173        }
174        items.push(cm);
175        commands.push(Some(src.command.clone()));
176    }
177    (items, commands)
178}
179
180/// El dropdown del menú abierto, para `App::view_overlay`. `None` si no
181/// hay menú abierto. Hospeda además una copia de la fila de títulos por
182/// encima del scrim: así, con el menú abierto, mover el mouse a otro
183/// título cambia de menú (hover-switch).
184pub fn menubar_overlay<Msg: Clone + 'static>(spec: &MenuBarSpec<Msg>) -> Option<View<Msg>> {
185    menubar_overlay_core(spec, usize::MAX, 1.0)
186}
187
188/// Como [`menubar_overlay`] pero con `active` (fila resaltada por teclado;
189/// `usize::MAX` = ninguna) y `appear` (0..1, animación de aparición — útil
190/// para que el dropdown se deslice/funda al cambiar de menú por hover o
191/// flechas). La app guarda el `active` y un `Tween` para el `appear`.
192pub fn menubar_overlay_animated<Msg: Clone + 'static>(
193    spec: &MenuBarSpec<Msg>,
194    active: usize,
195    appear: f32,
196) -> Option<View<Msg>> {
197    menubar_overlay_core(spec, active, appear)
198}
199
200fn menubar_overlay_core<Msg: Clone + 'static>(
201    spec: &MenuBarSpec<Msg>,
202    active: usize,
203    appear: f32,
204) -> Option<View<Msg>> {
205    let idx = spec.open?;
206    let root = spec.menu.menus.get(idx)?;
207
208    let mut x = 6.0_f32;
209    for prev in spec.menu.menus.iter().take(idx) {
210        x += approx_title_width(&prev.label);
211    }
212
213    let (items, commands) = dropdown_items(root);
214
215    let on_command = spec.on_command.clone();
216    let on_open = spec.on_open.clone();
217    let commands = Arc::new(commands);
218    let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(move |i: usize| {
219        match commands.get(i).and_then(|c| c.clone()) {
220            Some(cmd) => (on_command)(&cmd),
221            None => (on_open)(None),
222        }
223    });
224
225    let dropdown = context_menu_view_ex(
226        ContextMenuSpec {
227            anchor: (x, spec.height),
228            viewport: spec.viewport,
229            header: Some(root.label.clone()),
230            items,
231            active,
232            on_pick,
233            on_dismiss: (spec.on_open)(None),
234            palette: ContextMenuPalette::from_theme(spec.theme),
235        },
236        ContextMenuExtras {
237            appear,
238            ..ContextMenuExtras::default()
239        },
240    );
241
242    // Fila de títulos por encima del scrim del dropdown: queda hovereable
243    // para cambiar de menú con el mouse. Absoluta al tope para no consumir
244    // el layout; se pinta después del dropdown ⇒ arriba en z-order ⇒ gana
245    // el hit-test.
246    let titles = View::new(Style {
247        position: Position::Absolute,
248        inset: Rect {
249            left: length(0.0_f32),
250            top: length(0.0_f32),
251            right: auto(),
252            bottom: auto(),
253        },
254        size: Size {
255            width: percent(1.0_f32),
256            height: length(spec.height),
257        },
258        ..Default::default()
259    })
260    .children(vec![titles_row(spec, true)]);
261
262    Some(
263        View::new(Style {
264            size: Size {
265                width: percent(1.0_f32),
266                height: percent(1.0_f32),
267            },
268            ..Default::default()
269        })
270        .children(vec![dropdown, titles]),
271    )
272}
273
274/// Navegación por teclado dentro del dropdown del menú `menu_idx`: dado el
275/// `active` actual y la dirección (`+1` baja, `-1` sube), devuelve el
276/// próximo índice de fila válido (saltea separadores y deshabilitados).
277/// `usize::MAX` si no hay menú abierto o sin filas elegibles.
278pub fn menubar_nav(menu: &AppMenu, menu_idx: usize, active: usize, dir: i32) -> usize {
279    let Some(root) = menu.menus.get(menu_idx) else {
280        return usize::MAX;
281    };
282    let (items, _) = dropdown_items(root);
283    step_active(&items, active, dir)
284}
285
286/// El `command` de la fila `active` del menú `menu_idx` (para ejecutar con
287/// Enter). `None` si el índice no es una fila-acción.
288pub fn menubar_command_at(menu: &AppMenu, menu_idx: usize, active: usize) -> Option<String> {
289    let root = menu.menus.get(menu_idx)?;
290    let (_, commands) = dropdown_items(root);
291    commands.get(active).cloned().flatten()
292}
293
294fn title_style() -> Style {
295    Style {
296        size: Size {
297            width: llimphi_compositor::llimphi_layout::taffy::prelude::auto(),
298            height: length(24.0_f32),
299        },
300        flex_shrink: 0.0,
301        padding: Rect {
302            left: length(10.0_f32),
303            right: length(10.0_f32),
304            top: length(0.0_f32),
305            bottom: length(0.0_f32),
306        },
307        align_items: Some(AlignItems::CENTER),
308        justify_content: Some(JustifyContent::CENTER),
309        ..Default::default()
310    }
311}
312
313/// Ancho real de un título de la barra, para anclar el dropdown bajo él.
314/// Mide los glifos con la fuente UI (el `button_styled` del título usa 13px)
315/// y suma el padding horizontal de `title_style` (10+10) más el gap de fila
316/// (2) — así el `x` del dropdown cae clavado bajo el título, sin el viejo
317/// `chars*8` monoespaciado que derivaba al acumular títulos anchos.
318fn approx_title_width(label: &str) -> f32 {
319    llimphi_compositor::llimphi_text::text_width(label, 13.0) + 20.0 + 2.0
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn overlay_none_si_no_hay_abierto() {
328        let menu = AppMenu::standard();
329        let spec = MenuBarSpec {
330            menu: &menu,
331            open: None,
332            theme: &Theme::dark(),
333            viewport: (800.0, 600.0),
334            height: DEFAULT_HEIGHT,
335            on_open: Arc::new(|_| 0u8),
336            on_command: Arc::new(|_| 1u8),
337        };
338        assert!(menubar_overlay(&spec).is_none());
339    }
340
341    #[test]
342    fn overlay_some_si_hay_abierto() {
343        let menu = AppMenu::standard();
344        let spec = MenuBarSpec {
345            menu: &menu,
346            open: Some(0),
347            theme: &Theme::dark(),
348            viewport: (800.0, 600.0),
349            height: DEFAULT_HEIGHT,
350            on_open: Arc::new(|_| 0u8),
351            on_command: Arc::new(|_| 1u8),
352        };
353        assert!(menubar_overlay(&spec).is_some());
354    }
355}