use std::cell::RefCell;
use std::rc::Rc;
use day_reactive::Scope;
use crate::{IntoReactive, IntoText, Reactive, TextSource};
#[derive(Clone)]
pub struct MenuEntry {
id: Option<String>,
label: TextSource,
shortcut: Option<day_spec::Shortcut>,
enabled: Reactive<bool>,
role: Option<day_spec::MenuRole>,
icon: Option<day_spec::Icon>,
checked: Option<Reactive<bool>>,
action: Option<Rc<dyn Fn()>>,
children: Option<Vec<MenuEntry>>,
separator: bool,
bar_role: Option<day_spec::MenuBarRole>,
}
impl MenuEntry {
fn command(label: TextSource) -> MenuEntry {
MenuEntry {
id: None,
label,
shortcut: None,
enabled: Reactive::Const(true),
role: None,
icon: None,
bar_role: None,
checked: None,
action: None,
children: None,
separator: false,
}
}
pub fn icon(mut self, s: day_spec::Symbol) -> MenuEntry {
self.icon = Some(day_spec::Icon::Symbol(s));
self
}
pub fn image(mut self, name: impl Into<String>) -> MenuEntry {
self.icon = Some(day_spec::Icon::Image(name.into()));
self
}
pub fn action(mut self, f: impl Fn() + 'static) -> MenuEntry {
self.action = Some(Rc::new(f));
self
}
pub fn shortcut(mut self, s: day_spec::Shortcut) -> MenuEntry {
self.shortcut = Some(s);
self
}
pub fn key(mut self, key: impl Into<String>) -> MenuEntry {
self.shortcut = Some(day_spec::Shortcut::primary(key));
self
}
pub fn id(mut self, id: day_core::ElementId) -> MenuEntry {
self.id = Some(id.as_str().to_owned());
self
}
pub fn checked<M>(mut self, on: impl IntoReactive<bool, M>) -> MenuEntry {
self.checked = Some(on.into_reactive());
self
}
pub fn enabled<M>(mut self, on: impl IntoReactive<bool, M>) -> MenuEntry {
self.enabled = on.into_reactive();
self
}
pub fn role(mut self, role: day_spec::MenuRole) -> MenuEntry {
self.role = Some(role);
self
}
}
pub fn menu_item<M>(label: impl IntoText<M>) -> MenuEntry {
MenuEntry::command(label.into_text())
}
pub fn sub_menu<M>(label: impl IntoText<M>, items: Vec<MenuEntry>) -> MenuEntry {
MenuEntry {
children: Some(items),
..MenuEntry::command(label.into_text())
}
}
impl MenuEntry {
pub fn bar_role(mut self, role: day_spec::MenuBarRole) -> MenuEntry {
self.bar_role = Some(role);
self
}
}
pub fn menu_separator() -> MenuEntry {
MenuEntry {
separator: true,
..MenuEntry::command(TextSource::Static(String::new()))
}
}
pub fn menu_role(role: day_spec::MenuRole) -> MenuEntry {
MenuEntry {
role: Some(role),
..MenuEntry::command(TextSource::Static(String::new()))
}
}
fn role_catalog_key(role: day_spec::MenuRole) -> &'static str {
use day_spec::MenuRole as R;
match role {
R::Cut => "day_cut",
R::Copy => "day_copy",
R::Paste => "day_paste",
R::SelectAll => "day_select_all",
R::Undo => "day_undo",
R::Redo => "day_redo",
R::Delete => "day_delete",
R::About => "day_about",
R::Quit => "day_quit",
R::Preferences => "day_preferences",
R::Minimize => "day_minimize",
R::CloseWindow => "day_close",
R::Fullscreen => "day_fullscreen",
R::NewWindow => "day_new_window",
R::CheckForUpdates => "day_check_for_updates",
}
}
pub(crate) fn lower_menu(entries: Vec<MenuEntry>) -> Vec<day_spec::MenuItem> {
lower_menu_with(entries, &day_core::register_menu_action)
}
pub(crate) fn lower_menu_scoped(entries: Vec<MenuEntry>) -> Vec<day_spec::MenuItem> {
lower_menu_with(entries, &day_core::register_scoped_menu_action)
}
fn lower_menu_with(
entries: Vec<MenuEntry>,
register: &dyn Fn(Rc<dyn Fn()>) -> u64,
) -> Vec<day_spec::MenuItem> {
entries
.into_iter()
.map(|e| {
if e.separator {
day_spec::MenuItem::Separator
} else if let Some(children) = e.children {
day_spec::MenuItem::Submenu {
label: e.label.resolve(),
items: lower_menu_with(children, register),
role: e.bar_role,
}
} else {
let mut action = e.action.map(register).unwrap_or(0);
let mut enabled = e.enabled.get();
let mut shortcut = e.shortcut;
if action == 0 {
match e.role {
Some(day_spec::MenuRole::NewWindow) => {
action = day_core::windows::new_window_action_id();
enabled = enabled && action != 0;
shortcut = shortcut.or(Some(day_spec::Shortcut::primary("n")));
}
Some(day_spec::MenuRole::Undo) => {
action = day_core::undo_action_id(false);
shortcut = shortcut.or(Some(day_spec::Shortcut::primary("z")));
}
Some(day_spec::MenuRole::Redo) => {
action = day_core::undo_action_id(true);
shortcut = shortcut.or(Some(day_spec::Shortcut::primary("z").shift()));
}
Some(day_spec::MenuRole::Cut) => {
action = day_core::edit_action_id(day_spec::EditOp::Cut);
shortcut = shortcut.or(Some(day_spec::Shortcut::primary("x")));
}
Some(day_spec::MenuRole::Copy) => {
action = day_core::edit_action_id(day_spec::EditOp::Copy);
shortcut = shortcut.or(Some(day_spec::Shortcut::primary("c")));
}
Some(day_spec::MenuRole::Paste) => {
action = day_core::edit_action_id(day_spec::EditOp::Paste);
shortcut = shortcut.or(Some(day_spec::Shortcut::primary("v")));
}
Some(day_spec::MenuRole::SelectAll) => {
action = day_core::edit_action_id(day_spec::EditOp::SelectAll);
shortcut = shortcut.or(Some(day_spec::Shortcut::primary("a")));
}
Some(day_spec::MenuRole::Preferences) => {
action = day_core::windows::preferences_action_id();
shortcut = shortcut.or(Some(day_spec::Shortcut::primary(",")));
}
Some(day_spec::MenuRole::About) => {
action = day_core::windows::about_action_id();
}
Some(day_spec::MenuRole::CheckForUpdates) => {
action = day_core::windows::check_for_updates_action_id();
enabled = enabled && action != 0;
}
_ => {}
}
}
let label = e.label.resolve();
let label = match (label.is_empty(), e.role) {
(true, Some(role)) => day_localization::t(role_catalog_key(role)),
_ => label,
};
day_spec::MenuItem::Action {
id: e.id,
action,
label,
shortcut,
enabled,
checked: e.checked.as_ref().map(Reactive::get),
role: e.role,
icon: e.icon,
}
}
})
.collect()
}
#[derive(Clone)]
pub enum MenuSource {
Fixed(Vec<MenuEntry>),
Derived(Rc<dyn Fn() -> Vec<MenuEntry>>),
}
impl MenuSource {
pub(crate) fn entries(&self) -> Vec<MenuEntry> {
match self {
MenuSource::Fixed(v) => v.clone(),
MenuSource::Derived(f) => f(),
}
}
}
pub trait MenuContent<M> {
fn into_source(self) -> MenuSource;
}
impl MenuContent<crate::ManyMark> for Vec<MenuEntry> {
fn into_source(self) -> MenuSource {
MenuSource::Fixed(self)
}
}
impl<const N: usize> MenuContent<crate::ManyMark> for [MenuEntry; N] {
fn into_source(self) -> MenuSource {
MenuSource::Fixed(Vec::from(self))
}
}
impl<C, F> MenuContent<crate::DerivedMark> for F
where
C: IntoIterator<Item = MenuEntry>,
F: Fn() -> C + 'static,
{
fn into_source(self) -> MenuSource {
MenuSource::Derived(Rc::new(move || self().into_iter().collect()))
}
}
thread_local! {
static APP_MENU_SCOPE: RefCell<Option<Scope>> = const { RefCell::new(None) };
static DOCK_MENU_SCOPE: RefCell<Option<Scope>> = const { RefCell::new(None) };
}
fn install_menu(
slot: &'static std::thread::LocalKey<RefCell<Option<Scope>>>,
source: MenuSource,
install: fn(Vec<day_spec::MenuItem>),
) {
if let Some(old) = slot.with(|s| s.borrow_mut().take()) {
old.dispose();
}
let scope = Scope::root().enter(Scope::child);
scope.enter(|| {
day_reactive::bind(
move || {
let _ = day_localization::locale().get();
lower_menu(source.entries())
},
move |items: &Vec<day_spec::MenuItem>| install(items.clone()),
);
});
slot.with(|s| *s.borrow_mut() = Some(scope));
}
pub fn app_menu<M>(menus: impl MenuContent<M>) {
install_menu(&APP_MENU_SCOPE, menus.into_source(), day_core::set_app_menu);
}
pub fn dock_menu<M>(entries: impl MenuContent<M>) {
install_menu(
&DOCK_MENU_SCOPE,
entries.into_source(),
day_core::set_dock_menu,
);
}
const COMPOSED_CHECK_ON: &str = "\u{2713}\u{2002}";
const COMPOSED_CHECK_OFF: &str = "\u{2002}\u{2002}";
const COMPOSED_MENU_W: f64 = 220.0;
const COMPOSED_SUBMENU_INDENT: f64 = 12.0;
pub(crate) fn mount_composed_menu(
cx: &mut day_core::BuildCx,
node: day_core::RNode,
provider: day_spec::ContextMenuFn,
) {
use day_reactive::Signal;
use std::cell::RefCell;
struct Pending {
items: Vec<day_spec::MenuItem>,
at: day_spec::Point,
}
let armed = Signal::new(false);
let open: Signal<Option<String>> = Signal::new(None);
let pending: Rc<RefCell<Pending>> = Rc::new(RefCell::new(Pending {
items: Vec::new(),
at: day_spec::Point::ZERO,
}));
{
let pending = pending.clone();
cx.on(node, move |ev| {
if let day_spec::Event::ContextMenu { local, window } = ev {
let items = provider(*local);
if items.is_empty() {
return;
}
*pending.borrow_mut() = Pending { items, at: *window };
armed.set(true);
open.set(Some("menu".into()));
}
});
}
let host = crate::when(
move || armed.get(),
move || {
let pending = pending.clone();
crate::cover(open, move |_: &String| {
let (items, at) = {
let p = pending.borrow();
(p.items.clone(), p.at)
};
composed_menu_host(items, at, open)
})
.unrouted()
.background(|_| day_spec::Color::rgba(0.0, 0.0, 0.0, 0.0))
},
);
let _ = day_core::Piece::build(host, cx);
}
fn composed_menu_host(
items: Vec<day_spec::MenuItem>,
at: day_spec::Point,
open: day_reactive::Signal<Option<String>>,
) -> impl day_core::Piece {
use crate::Decorate;
let mut rows: Vec<day_core::AnyPiece> = Vec::new();
let mut next = 0usize;
composed_menu_rows(&items, 0.0, &mut next, open, &mut rows);
let panel = crate::column(rows)
.padding(day_spec::Insets::symmetric(0.0, 5.0))
.background(|| {
if day_core::dark_mode() {
day_spec::Color::rgb(0.22, 0.22, 0.24)
} else {
day_spec::Color::rgb(0.97, 0.97, 0.97)
}
})
.corner_radius(8.0);
let catcher = crate::spacer()
.background(day_spec::Color::rgba(0.0, 0.0, 0.0, 0.0))
.on_tap(move || open.set(None));
ComposedMenuHost {
at,
catcher: day_core::AnyPiece::new(catcher),
panel: day_core::AnyPiece::new(panel),
}
}
fn composed_menu_rows(
items: &[day_spec::MenuItem],
indent: f64,
next: &mut usize,
open: day_reactive::Signal<Option<String>>,
out: &mut Vec<day_core::AnyPiece>,
) {
use crate::Decorate;
for item in items {
match item {
day_spec::MenuItem::Separator => {
out.push(day_core::AnyPiece::new(
crate::divider().padding(day_spec::Insets::symmetric(0.0, 4.0)),
));
}
day_spec::MenuItem::Submenu { label, items, .. } => {
out.push(day_core::AnyPiece::new(
crate::label(label.clone())
.padding(day_spec::Insets::symmetric(12.0 + indent, 5.0))
.width(COMPOSED_MENU_W)
.opacity(0.55),
));
composed_menu_rows(items, indent + COMPOSED_SUBMENU_INDENT, next, open, out);
}
day_spec::MenuItem::Action {
id,
action,
label,
enabled,
checked,
..
} => {
let n = *next;
*next += 1;
let text = match checked {
Some(true) => format!("{COMPOSED_CHECK_ON}{label}"),
Some(false) => format!("{COMPOSED_CHECK_OFF}{label}"),
None => label.clone(),
};
let element_id = id
.clone()
.unwrap_or_else(|| day_core::id!(day_menu_item, n).as_str().to_owned());
let base = crate::label(text)
.padding(day_spec::Insets::symmetric(12.0 + indent, 5.0))
.width(COMPOSED_MENU_W);
let row = if *enabled && *action != 0 {
let action = *action;
base.background(day_spec::Color::rgba(0.0, 0.0, 0.0, 0.0))
.on_tap(move || {
open.set(None);
day_core::dispatch_menu_action(action);
})
.tweak(move |node| {
day_core::with_tree(|tree| tree.set_id(node, element_id))
})
} else {
base.opacity(0.45).tweak(move |node| {
day_core::with_tree(|tree| tree.set_id(node, element_id))
})
};
out.push(day_core::AnyPiece::new(row));
}
}
}
}
struct MenuPlace {
at: day_spec::Point,
}
impl day_core::Layout for MenuPlace {
fn measure(
&self,
_cx: &mut dyn day_core::LayoutOps,
_children: &[day_core::RNode],
p: day_spec::Proposal,
) -> day_spec::Size {
day_spec::Size::new(p.width.unwrap_or(0.0), p.height.unwrap_or(0.0))
}
fn place(
&self,
cx: &mut dyn day_core::LayoutOps,
children: &[day_core::RNode],
bounds: day_spec::Rect,
) {
if let Some(&c) = children.first() {
let _ = cx.measure_child(c, day_spec::Proposal::exact(bounds.size));
cx.place_child(c, day_spec::Rect::from_size(bounds.size));
}
if let Some(&c) = children.get(1) {
let s = cx.measure_child(c, day_spec::Proposal::new(Some(COMPOSED_MENU_W), None));
let (x, y) = if bounds.size.width > 1.0 && bounds.size.height > 1.0 {
(
self.at.x.min(bounds.size.width - s.width).max(0.0),
self.at.y.min(bounds.size.height - s.height).max(0.0),
)
} else {
(self.at.x, self.at.y)
};
cx.place_child(c, day_spec::Rect::new(x, y, s.width, s.height));
}
}
}
struct ComposedMenuHost {
at: day_spec::Point,
catcher: day_core::AnyPiece,
panel: day_core::AnyPiece,
}
impl day_core::Piece for ComposedMenuHost {
fn build(self, cx: &mut day_core::BuildCx) -> day_core::RNode {
let n = cx.layout_only(
Rc::new(MenuPlace { at: self.at }),
day_core::Flex {
grow_w: true,
grow_h: true,
..Default::default()
},
day_core::Boundary::No,
);
cx.under(n, |cx| {
let _ = self.catcher.build(cx);
let _ = self.panel.build(cx);
});
n
}
}
#[cfg(feature = "conformance")]
pub(crate) mod conformance {
use day_core::conformance::{Case, Drive};
use day_reactive::Signal;
use crate::*;
#[day_macros::test(day_core)]
fn context_menu_runs_action() -> Case {
Case::new()
.proves_modifier("context_menu")
.proves_duty("set_context_menu")
.page(|| {
let last = Signal::new(String::from("none"));
column((
label(move || format!("ran {}", last.get())).id(day_core::id!(ran)),
label("Target")
.padding(8.0)
.context_menu(vec![
menu_item("Copy it").action(move || last.set("copy".into())),
menu_item("Paste it").action(move || last.set("paste".into())),
])
.id(day_core::id!(target)),
))
})
.drive(|d: Drive| async move {
d.context_menu("target", "Paste it").await?;
d.assert_text("ran", "ran paste").await?;
d.context_menu("target", "Copy it").await?;
d.assert_text("ran", "ran copy").await
})
}
#[day_macros::test(day_core)]
fn context_menu_fn_builds_on_summon() -> Case {
Case::new()
.proves_modifier("context_menu")
.proves_duty("set_context_menu_fn")
.page(|| {
let count = Signal::new(0i64);
let chose = Signal::new(0i64);
column((
label(move || format!("chose {}", chose.get())).id(day_core::id!(chose)),
button("Add")
.action(move || count.update(|n| *n += 1))
.id(day_core::id!(add)),
label("Target")
.padding(8.0)
.context_menu(move || {
let n = count.get_untracked();
vec![menu_item(format!("Take {n}")).action(move || chose.set(n))]
})
.id(day_core::id!(target)),
))
})
.drive(|d: Drive| async move {
d.tap("add").await?;
d.tap("add").await?;
d.context_menu("target", "Take 2").await?;
d.assert_text("chose", "chose 2").await
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn role_items_take_standard_shortcuts() {
use day_spec::{MenuItem, MenuRole};
let lowered = lower_menu(vec![
menu_role(MenuRole::Undo),
menu_role(MenuRole::Redo),
menu_role(MenuRole::Cut),
menu_role(MenuRole::Copy),
menu_role(MenuRole::Paste),
menu_role(MenuRole::SelectAll),
]);
let expect = [
("z", false),
("z", true),
("x", false),
("c", false),
("v", false),
("a", false),
];
for (item, (key, shift)) in lowered.iter().zip(expect) {
let MenuItem::Action {
shortcut, action, ..
} = item
else {
panic!("role item lowered to a non-action");
};
let sc = shortcut.as_ref().expect("role item has a default shortcut");
assert_eq!(sc.key, key);
assert!(sc.primary);
assert_eq!(sc.shift, shift);
assert_ne!(*action, 0, "role item carries its standing dispatcher");
}
}
}