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

⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8 widgets/menubar/src/lib.rs Rust · 337 líneas · 11.7 KB Historia Quién tocó qué crudo

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