diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs index 725dd19..22c9eaf 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs @@ -78,3 +78,4 @@ pub const FOLDER: &str = "\u{e0d7}"; pub const FILE_PLUS: &str = "\u{e0c9}"; pub const COPY: &str = "\u{e09e}"; pub const LAYERS: &str = "\u{e529}"; +pub const ELLIPSIS: &str = "\u{e0b6}"; diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs index 3e42b9f..00d0806 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs @@ -23,6 +23,7 @@ pub mod intent; mod omni; mod stack; mod surface; +mod topbar; mod transport; /// Reserved sentinel namespace for mobile pane-instance paths. Desktop layout paths are built @@ -31,6 +32,7 @@ mod transport; pub const MOBILE_NS: usize = usize::MAX; const TRANSPORT_H: f32 = 60.0; +const TOPBAR_H: f32 = 40.0; /// Returns true if the mobile UI is requested via the `LB_MOBILE_UI` env var. /// Any non-empty value other than "0" enables it. @@ -155,6 +157,11 @@ pub struct MobileState { pub omni_grid_open: bool, /// Whether the omnibutton "+New" create grid is open. pub omni_create_open: bool, + /// Top-bar overflow (⋯ commands) sheet open. + pub overflow_open: bool, + /// Top-bar command palette (⌕) open, and its search query. + pub palette_open: bool, + pub palette_query: String, /// Active handle drag (transient). pub drag: Option, /// In-flight layout ease (transient). @@ -173,6 +180,9 @@ impl Default for MobileState { omni_open: false, omni_grid_open: false, omni_create_open: false, + overflow_open: false, + palette_open: false, + palette_query: String::new(), drag: None, anim: None, } @@ -190,14 +200,18 @@ pub fn render_mobile_shell( ui.painter() .rect_filled(available_rect, 0.0, egui::Color32::from_rgb(0x14, 0x16, 0x1b)); + let topbar_rect = egui::Rect::from_min_max( + available_rect.min, + egui::pos2(available_rect.right(), available_rect.top() + TOPBAR_H), + ); let transport_rect = egui::Rect::from_min_max( egui::pos2(available_rect.left(), available_rect.bottom() - TRANSPORT_H), available_rect.max, ); - // Region above the transport, shared between the stack and (when something is selected) the - // inspector sheet that rises above the transport. + // Region between the top bar and the transport, shared between the stack and (when something is + // selected) the inspector sheet that rises above the transport. let region = egui::Rect::from_min_max( - available_rect.min, + egui::pos2(available_rect.left(), topbar_rect.bottom()), egui::pos2(available_rect.right(), transport_rect.top()), ); // When the inspector is up, the sheet overlays the lower part of the stack. If the selected @@ -234,4 +248,7 @@ pub fn render_mobile_shell( // Omnibutton FAB (radial tool menu) — drawn above the stack region, on top of everything else. omni::render(ui, region, rc, state); + + // Top bar (filename + ⌕ palette + ⋯ commands). Its menus overlay the whole shell, so it's last. + topbar::render(ui, topbar_rect, available_rect, rc, state); } diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/topbar.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/topbar.rs new file mode 100644 index 0000000..9f4ac72 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/topbar.rs @@ -0,0 +1,196 @@ +//! The mobile top bar: the project filename on the left, and on the right ⌕ (command palette) and +//! ⋯ (overflow commands). Both open a modal sheet whose items dispatch `MenuAction`s (which +//! `main.rs::handle_menu_action` already implements). Per the wireframe these are the +//! "commands/destinations", as opposed to the omnibutton's "tools/objects". + +use eframe::egui; + +use super::{icons, MobileState}; +use crate::menu::{MenuAction, MenuDef, MenuItemDef}; +use crate::RenderContext; + +const C_PANEL: egui::Color32 = egui::Color32::from_rgb(0x1f, 0x24, 0x2c); +const C_PANEL2: egui::Color32 = egui::Color32::from_rgb(0x27, 0x2d, 0x37); +const C_LINE: egui::Color32 = egui::Color32::from_rgb(0x36, 0x3d, 0x49); +const C_BRIGHT: egui::Color32 = egui::Color32::from_rgb(0xea, 0xee, 0xf3); +const C_DIM: egui::Color32 = egui::Color32::from_rgb(0x8b, 0x95, 0xa1); +const BTN: f32 = 40.0; + +/// Curated overflow (⋯) commands. +fn overflow_items() -> [(&'static str, MenuAction); 9] { + [ + ("Save", MenuAction::Save), + ("Save As…", MenuAction::SaveAs), + ("Open File…", MenuAction::OpenFile), + ("New file…", MenuAction::NewFile), + ("Import…", MenuAction::Import), + ("Export…", MenuAction::Export), + ("Undo", MenuAction::Undo), + ("Redo", MenuAction::Redo), + ("Preferences", MenuAction::Preferences), + ] +} + +/// Flatten the whole menu tree into (path-label, action) for the command palette. +fn flatten(defs: &'static [MenuDef], prefix: &str, out: &mut Vec<(String, MenuAction)>) { + for d in defs { + match d { + MenuDef::Item(item) => out.push((format!("{prefix}{}", item.label), item.action)), + MenuDef::Submenu { label, children } => { + flatten(children, &format!("{prefix}{label} › "), out); + } + MenuDef::Separator => {} + } + } +} + +fn all_commands() -> Vec<(String, MenuAction)> { + let mut v = Vec::new(); + flatten(MenuItemDef::menu_structure(), "", &mut v); + v +} + +pub fn render( + ui: &mut egui::Ui, + bar: egui::Rect, + full: egui::Rect, + rc: &mut RenderContext, + state: &mut MobileState, +) { + ui.painter().rect_filled(bar, 0.0, C_PANEL); + ui.painter().hline(bar.x_range(), bar.bottom(), egui::Stroke::new(1.0, C_LINE)); + + // Filename (or app name when unsaved). + let name = rc + .shared + .container_path + .as_ref() + .and_then(|p| p.file_name()) + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "Lightningbeam".to_string()); + ui.painter().text( + egui::pos2(bar.left() + 14.0, bar.center().y), + egui::Align2::LEFT_CENTER, + name, + egui::FontId::proportional(14.0), + C_BRIGHT, + ); + + // Right cluster: ⌕ (palette) then ⋯ (overflow). + let overflow_rect = egui::Rect::from_min_size(egui::pos2(bar.right() - BTN, bar.top()), egui::vec2(BTN, bar.height())); + let palette_rect = egui::Rect::from_min_size(egui::pos2(bar.right() - 2.0 * BTN, bar.top()), egui::vec2(BTN, bar.height())); + + let sresp = ui.interact(palette_rect, ui.id().with("mobile_topbar_search"), egui::Sense::click()); + ui.painter().text(palette_rect.center(), egui::Align2::CENTER_CENTER, icons::SEARCH, icons::font(17.0), + if sresp.hovered() || state.palette_open { C_BRIGHT } else { C_DIM }); + if sresp.clicked() { + state.palette_open = !state.palette_open; + state.overflow_open = false; + state.palette_query.clear(); + } + + let oresp = ui.interact(overflow_rect, ui.id().with("mobile_topbar_overflow"), egui::Sense::click()); + ui.painter().text(overflow_rect.center(), egui::Align2::CENTER_CENTER, icons::ELLIPSIS, icons::font(18.0), + if oresp.hovered() || state.overflow_open { C_BRIGHT } else { C_DIM }); + if oresp.clicked() { + state.overflow_open = !state.overflow_open; + state.palette_open = false; + } + + if state.overflow_open { + render_overflow(ui, full, rc, state); + } else if state.palette_open { + render_palette(ui, full, rc, state); + } +} + +/// Common modal scrim + panel. Returns (backdrop-tapped, panel inner rect). +fn open_panel(ui: &mut egui::Ui, full: egui::Rect, id: &str) -> (bool, egui::Rect) { + let scrim = ui.interact(full, ui.id().with(("mobile_topbar_scrim", id)), egui::Sense::click()); + ui.painter().rect_filled(full, 0.0, egui::Color32::from_rgba_premultiplied(8, 10, 14, 170)); + let panel = egui::Rect::from_min_max( + egui::pos2(full.left() + 16.0, full.top() + 44.0), + egui::pos2(full.right() - 16.0, full.bottom() - 60.0), + ); + ui.painter().rect_filled(panel, 14.0, C_PANEL); + ui.painter().rect_stroke(panel, 14.0, egui::Stroke::new(1.0, C_LINE), egui::StrokeKind::Inside); + (scrim.clicked(), panel) +} + +fn command_button(ui: &mut egui::Ui, r: egui::Rect, label: &str, key: (&str, usize)) -> bool { + let resp = ui.interact(r, ui.id().with(("mobile_cmd", key.0, key.1)), egui::Sense::click()); + ui.painter().rect_filled(r, 8.0, if resp.hovered() { C_PANEL2 } else { C_PANEL }); + ui.painter().hline(r.x_range(), r.bottom(), egui::Stroke::new(1.0, C_LINE)); + ui.painter().text( + egui::pos2(r.left() + 14.0, r.center().y), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(13.0), + C_BRIGHT, + ); + resp.clicked() +} + +fn render_overflow(ui: &mut egui::Ui, full: egui::Rect, rc: &mut RenderContext, state: &mut MobileState) { + let (backdrop, panel) = open_panel(ui, full, "overflow"); + let mut close = backdrop; + let items = overflow_items(); + let row_h = 44.0; + let inner = panel.shrink(8.0); + for (i, (label, action)) in items.iter().enumerate() { + let r = egui::Rect::from_min_size( + egui::pos2(inner.left(), inner.top() + i as f32 * row_h), + egui::vec2(inner.width(), row_h), + ); + if r.bottom() > inner.bottom() { + break; + } + if command_button(ui, r, label, ("of", i)) { + rc.shared.pending_menu_actions.push(*action); + close = true; + } + } + if close { + state.overflow_open = false; + } +} + +fn render_palette(ui: &mut egui::Ui, full: egui::Rect, rc: &mut RenderContext, state: &mut MobileState) { + let (backdrop, panel) = open_panel(ui, full, "palette"); + let mut close = backdrop; + let inner = panel.shrink(8.0); + + // Search field (real egui widget). + let field = egui::Rect::from_min_size(inner.min, egui::vec2(inner.width(), 30.0)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(field).layout(egui::Layout::left_to_right(egui::Align::Center))); + let te = child.add( + egui::TextEdit::singleline(&mut state.palette_query) + .hint_text("Search commands…") + .desired_width(inner.width()), + ); + te.request_focus(); + + // Filtered list. + let q = state.palette_query.to_lowercase(); + let cmds = all_commands(); + let row_h = 38.0; + let list_top = inner.top() + 38.0; + let mut y = list_top; + for (i, (label, action)) in cmds.iter().enumerate() { + if !q.is_empty() && !label.to_lowercase().contains(&q) { + continue; + } + let r = egui::Rect::from_min_size(egui::pos2(inner.left(), y), egui::vec2(inner.width(), row_h)); + if r.bottom() > inner.bottom() { + break; + } + if command_button(ui, r, label, ("pal", i)) { + rc.shared.pending_menu_actions.push(*action); + close = true; + } + y += row_h; + } + if close { + state.palette_open = false; + } +}