Add mobile top bar: filename + command palette (⌕) + overflow (⋯)

Reserve a slim top bar in the mobile shell showing the project filename (or
"Lightningbeam" when unsaved) on the left, and ⌕/⋯ on the right. ⋯ opens a
curated overflow sheet (Save/Save As/Open/New/Import/Export/Undo/Redo/
Preferences); ⌕ opens a command palette that searches the whole menu tree
(flattened from MenuItemDef::menu_structure()). Both dispatch MenuActions.
Completes the P4 omnibutton + global menu.
This commit is contained in:
Skyler Lehmkuhl 2026-07-01 06:09:09 -04:00
parent b8419778a5
commit a0ac4f2d35
3 changed files with 217 additions and 3 deletions

View File

@ -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}";

View File

@ -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<StackDrag>,
/// 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);
}

View File

@ -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;
}
}