//! The application menu bar (`docs/adr/0018-a-menu-bar-the-app-declares.md`).
//! The frame declares it, in one call — `widgets::menu_bar` says what it
//! is and where its strip goes when it has to be drawn — and the host
//! decides who shows it: on macOS the same declaration goes to the OS
//! and is the bar at the top of the screen, and the strip in the window
//! is not there; everywhere else the core draws the strip. The dock's
//! `menus` row switches between the two on a host that has both.
//!
//! Its rows are the rows a context menu has, so View ▸ Wrap checks itself
//! (`checked`, rebuilt from the model every frame with nothing retained),
//! View ▸ Clear is `enabled` only when there is something to clear, Edit
//! ▸ Select All and Copy are the same ones the core performs itself (the
//! card is a selection scope, so they have something to act on), and
//! every choice — standard or the app's own — arrives as the same one
//! `menu` event. The menu titled `Window` is the platform's by name
//! (`docs/adr/0030-the-standard-menus-the-runner-keeps.md`): on macOS
//! its row is joined by Fill, Center, the tiling submenus and Enter Full
//! Screen, and their shortcuts work; elsewhere it is one more drawn menu.
//!
//! Run: cargo run -p kui-native --example menu_bar [-- --headless]
use kui_devtools::{Drive, Example};
use kui_native::widgets;
use kui_native::{
Align, App, BarMenu, Core, MenuBar, MenuItem, MenuRole, NodeSpec, Span, TextStyle, TextWrap,
Ui, UiEvent, Value,
};
#[derive(Default)]
struct Demo {
/// A setting one of the bar's rows toggles: a `checked` row doing
/// what a checked row is for.
wrap: bool,
/// Rows "About" was chosen, for a row that is plain.
abouts: u32,
/// Something to clear, for a row that is `enabled` conditionally.
notes: Vec<String>,
/// The last thing the bar reported, shown at the bottom.
last: Option<String>,
/// Whether the counters line is hidden: the one row of the `Window`
/// menu, which on macOS shares that menu with the platform's rows.
hide_counters: bool,
}
impl Demo {
/// The bar, rebuilt every frame from the model. Every row is an
/// ordinary `MenuItem`: the app's own with `id`s, a `checked`
/// setting, and the standard Edit rows the core performs itself.
fn menu(&self) -> MenuBar {
let mine = |what: &str| Value::map([("do", Value::str(what))]);
MenuBar::new(vec![
// First, which on macOS is the position the OS titles with the
// app's own name whatever this label says.
BarMenu::new(
"Demo",
vec![
MenuItem::new("About this example").id(mine("about")),
MenuItem::separator(),
MenuItem::new("Add a note").id(mine("note")).accel("mod+n"),
],
),
BarMenu::new(
"Edit",
vec![
MenuItem::role(MenuRole::Cut),
MenuItem::role(MenuRole::Copy),
MenuItem::role(MenuRole::Paste),
MenuItem::separator(),
MenuItem::role(MenuRole::SelectAll),
],
),
BarMenu::new(
"View",
vec![
MenuItem::new("Wrap the paragraph")
.id(mine("wrap"))
.accel("mod+shift+w")
.checked(self.wrap),
MenuItem::new("Clear the notes")
.id(mine("clear"))
.enabled(!self.notes.is_empty()),
],
),
// `Window` by name: the platform's Window menu where there is
// one, the app's row beside whatever the platform adds (macOS
// puts its own rows first).
BarMenu::new(
"Window",
vec![
MenuItem::new("Hide the counters")
.id(mine("counters"))
.checked(self.hide_counters),
],
),
])
}
}
impl App for Demo {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(NodeSpec::column().fill().bg(t.bg), |ui| {
// The application menu: full width and flush with the top,
// because that is where a menu bar goes — the padding the rest
// of the page has moves inside. On macOS this draws nothing.
widgets::menu_bar(ui, self.menu());
ui.with(
NodeSpec::column()
.fill()
.pad(24.0)
.gap(12.0)
.cross_align(Align::Center),
|ui| {
// A selection scope, so Edit ▸ Select All and Copy —
// the standard rows the core performs itself — have
// something to act on.
ui.with(
NodeSpec::column()
.grow_width()
.max_width(560.0)
.pad(20.0)
.gap(10.0)
.bg(t.surface)
.radius(10.0)
.border(1.0, t.border)
.selectable(),
|ui| {
ui.text(
"View ▸ Wrap the paragraph",
TextStyle::new(12.0).color(t.muted),
);
ui.rich_text(
&[
Span::new("A paragraph whose wrapping is a "),
Span::new("checked").bold(),
Span::new(
" row of the bar: the row reads the model, the model \
reads the row, and nothing in between remembers \
anything — the bar is rebuilt from `wrap` every frame.",
),
],
TextStyle::new(15.0).line_height(22.0).wrap(if self.wrap {
TextWrap::Word
} else {
TextWrap::None
}),
);
if !self.hide_counters {
ui.text(
&format!(
"About chosen {} time{} · {} note{}",
self.abouts,
if self.abouts == 1 { "" } else { "s" },
self.notes.len(),
if self.notes.len() == 1 { "" } else { "s" },
),
TextStyle::new(13.0).color(t.muted),
);
}
for note in &self.notes {
ui.text(note, TextStyle::new(13.0));
}
},
);
ui.with(
NodeSpec::row().grow_width().max_width(560.0).gap(8.0),
|ui| {
ui.text("last menu event —", TextStyle::new(12.0).color(t.muted));
ui.text(
self.last
.as_deref()
.unwrap_or("choose something from the bar"),
TextStyle::new(12.0).color(t.accent),
);
},
);
},
);
});
}
fn on_event(&mut self, ev: UiEvent) {
if ev.kind() != Some("menu") {
return;
}
// Every chosen row, standard or not, arrives here with the role it
// played; a custom row hands back whatever its `id` carried.
let role = ev.payload.get_str("role").unwrap_or("");
let item = ev.payload.get("item").cloned().unwrap_or(Value::Null);
let did = item.get_str("do");
match did {
Some("about") => self.abouts += 1,
Some("note") => self.notes.push(format!("note {}", self.notes.len() + 1)),
Some("wrap") => self.wrap = !self.wrap,
Some("clear") => self.notes.clear(),
Some("counters") => self.hide_counters = !self.hide_counters,
_ => {}
}
self.last = Some(match did {
Some(d) => d.to_string(),
None => role.to_string(),
});
}
}
impl Example for Demo {
const KEYS: &'static [(&'static str, &'static str)] =
&[("⌘N", "Demo ▸ Add a note"), ("⌘⇧W", "View ▸ Wrap")];
fn window(&self) -> kui_devtools::Window {
kui_devtools::Window::default().size(640.0, 360.0)
}
/// Drawn menus, so the strip is in the frame on every host — which
/// is what makes the drive below the same everywhere.
fn native_menus(&self) -> Option<bool> {
Some(false)
}
/// The bar as data: the declaration the frame made, read back from
/// the core, and one of its rows chosen.
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
let mut d = Drive::new(core, 640.0, 360.0);
d.core.set_native_menus(false);
d.frame(self);
let bar = d.core.menu_bar().ok_or("the frame declared no menu bar")?;
d.check(bar.menus.len() == 4, "the bar has its four menus")?;
let view = &bar.menus[2];
d.check(
view.items.iter().all(|i| !i.checked),
"Wrap is a checked row, unchecked to start",
)?;
d.check(
view.items.iter().any(|i| !i.enabled),
"Clear is disabled with nothing to clear",
)?;
// Choosing a row, both ways. The drawn bar: open View (what a
// press on its title does) and click its first row, keyed by
// index under the panel. The platform's bar reports the same
// choice as `activate_menu_bar_item`, and both land in the same
// `menu` event — with the row checked on the frame after.
d.core.set_menu_bar_open(Some(2));
d.frame(self);
let panel = d
.key_of("kui.menubar.menu")
.ok_or("the View menu did not open")?;
d.click_key(self, panel.index(0));
d.frame(self);
d.check(
self.wrap,
"a click on the drawn row arrives as a `menu` event",
)?;
d.check(
self.last.as_deref() == Some("wrap"),
"with the row's own id",
)?;
let bar = d.core.menu_bar().ok_or("the bar went away")?;
d.check(
bar.menus[2].items.iter().any(|i| i.checked),
"and the row is checked on the next frame",
)?;
let evs = d.core.activate_menu_bar_item(2, 0);
for ev in evs {
self.on_event(ev);
}
d.check(
!self.wrap,
"the platform's report of the same row is the same event",
)?;
// And by pointer, the way a person does it: a press on the View
// title opens it, a press on its first row chooses it — and the
// paragraph is taller wrapped than not.
d.frame(self);
let height = |d: &mut Drive<'_>| -> f32 {
d.core
.access_tree()
.nodes
.iter()
.find(|n| {
n.name
.as_deref()
.is_some_and(|s| s.starts_with("A paragraph whose"))
})
.map(|n| n.rect.h)
.unwrap_or(-1.0)
};
let unwrapped = height(&mut d);
// The titles are keyed under the bar by index, then by the one
// title key; View is the third.
let bar = d.key_of(widgets::MENU_BAR_KEY).ok_or("no bar")?;
let title = bar.index(2).str("kui.menubar.title");
let tr = d.rect_of(title).ok_or("the title has no rect")?;
d.click(self, tr.x + tr.w / 2.0, tr.y + tr.h / 2.0);
d.frame(self);
let row = d
.key_of("kui.menubar.menu")
.ok_or("the click did not open View")?
.index(0);
let rr = d.rect_of(row).ok_or("the row has no rect")?;
d.click(self, rr.x + rr.w / 2.0, rr.y + rr.h / 2.0);
d.frame(self);
d.check(self.wrap, "a pointer press on the row chooses it")?;
let wrapped = height(&mut d);
println!("paragraph: {unwrapped} unwrapped, {wrapped} wrapped");
d.check(
wrapped > unwrapped + 10.0,
"and the paragraph wraps onto more lines",
)
}
}
kui_devtools::main!(Demo::default());