Lightningbeam/lightningbeam-ui/lightningbeam-editor/src/mobile/topbar.rs

272 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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, Palette};
use crate::menu::{MenuAction, MenuDef, MenuItemDef};
use crate::RenderContext;
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,
pal: &Palette,
) {
ui.painter().rect_filled(bar, 0.0, pal.header);
ui.painter().hline(bar.x_range(), bar.bottom(), egui::Stroke::new(1.0, pal.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),
pal.text,
);
// 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 { pal.text } else { pal.text_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 { pal.text } else { pal.text_dim });
if oresp.clicked() {
state.overflow_open = !state.overflow_open;
state.palette_open = false;
}
if state.overflow_open {
render_overflow(ui, full, rc, state, pal);
} else if state.palette_open {
render_palette(ui, full, rc, state, pal);
}
}
/// Landscape: the app bar folded into the middle of the top pane header — filename + ⌕ + ⋯ as a
/// centered cluster (the pane's own grip/label sits to the left, its buttons to the right). No bar
/// background or divider line; the header already drew them.
pub fn render_inline(
ui: &mut egui::Ui,
header: egui::Rect,
full: egui::Rect,
rc: &mut RenderContext,
state: &mut MobileState,
pal: &Palette,
) {
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());
let btn = header.height().min(BTN);
let gap = 6.0;
// Reserve gutters so the cluster never overlaps the pane header's own grip/label (left) or its
// fullscreen / node-toggle buttons (right).
let left_gutter = 150.0;
let right_gutter = 2.0 * BTN + 16.0;
let span_left = header.left() + left_gutter;
let span_right = (header.right() - right_gutter).max(span_left);
let span_w = span_right - span_left;
// Filename elided to fit whatever's left after the two buttons.
let max_name_w = (span_w - gap - 2.0 * btn).max(0.0);
let galley = {
let mut job =
egui::text::LayoutJob::simple_singleline(name, egui::FontId::proportional(14.0), pal.text);
job.wrap.max_width = max_name_w;
job.wrap.max_rows = 1;
job.wrap.break_anywhere = true;
ui.painter().layout_job(job)
};
let name_w = galley.rect.width();
let name_h = galley.rect.height();
let cluster_w = name_w + gap + 2.0 * btn;
// Center within the reserved span, then clamp so the right edge stays inside it.
let x0 = (span_left + (span_w - cluster_w) / 2.0).clamp(span_left, (span_right - cluster_w).max(span_left));
let cy = header.center().y;
let bt = header.top() + (header.height() - btn) / 2.0;
ui.painter().galley(egui::pos2(x0, cy - name_h / 2.0), galley, pal.text);
let palette_rect = egui::Rect::from_min_size(egui::pos2(x0 + name_w + gap, bt), egui::vec2(btn, btn));
let overflow_rect = egui::Rect::from_min_size(egui::pos2(palette_rect.right(), bt), egui::vec2(btn, btn));
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 { pal.text } else { pal.text_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 { pal.text } else { pal.text_dim });
if oresp.clicked() {
state.overflow_open = !state.overflow_open;
state.palette_open = false;
}
if state.overflow_open {
render_overflow(ui, full, rc, state, pal);
} else if state.palette_open {
render_palette(ui, full, rc, state, pal);
}
}
/// Common modal scrim + panel. Returns (backdrop-tapped, panel inner rect).
fn open_panel(ui: &mut egui::Ui, full: egui::Rect, id: &str, pal: &Palette) -> (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, pal.scrim);
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, pal.surface);
ui.painter().rect_stroke(panel, 14.0, egui::Stroke::new(1.0, pal.line), egui::StrokeKind::Inside);
(scrim.clicked(), panel)
}
fn command_button(ui: &mut egui::Ui, r: egui::Rect, label: &str, key: (&str, usize), pal: &Palette) -> 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() { pal.surface_alt } else { pal.surface });
ui.painter().hline(r.x_range(), r.bottom(), egui::Stroke::new(1.0, pal.line));
ui.painter().text(
egui::pos2(r.left() + 14.0, r.center().y),
egui::Align2::LEFT_CENTER,
label,
egui::FontId::proportional(13.0),
pal.text,
);
resp.clicked()
}
fn render_overflow(ui: &mut egui::Ui, full: egui::Rect, rc: &mut RenderContext, state: &mut MobileState, pal: &Palette) {
let (backdrop, panel) = open_panel(ui, full, "overflow", pal);
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), pal) {
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, pal: &Palette) {
let (backdrop, panel) = open_panel(ui, full, "palette", pal);
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()),
);
// Focus the field only when it isn't already focused — re-requesting every frame would stomp
// focus and prevent anything else in the panel from taking it.
if !te.has_focus() {
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), pal) {
rc.shared.pending_menu_actions.push(*action);
close = true;
}
y += row_h;
}
if close {
state.palette_open = false;
}
}