use std::cell::{Ref, RefCell};
use std::rc::Rc;
use teksilo_core::signal::{Prop, Signal};
use teksilo_core::widget::EventContext;
use teksilo_core::{Intent, MenuItemId};
use teksilo_data::CheckState;
use teksilo_i18n::LocalizedString;
use teksilo_platform::native_menu::StandardMenuRole;
use crate::menu_item::MenuItem;
use crate::menu_list::MenuList;
#[derive(Clone)]
pub enum MenuItemState {
Plain,
Check(Signal<bool>),
ReflectCheck(Signal<bool>),
TriCheck(Signal<CheckState>),
Radio {
value: usize,
selected: Signal<usize>,
},
}
#[derive(Clone)]
pub struct MenuEntry {
pub(crate) title: LocalizedString,
pub(crate) intent: Option<&'static str>,
pub(crate) action: Option<Rc<dyn Fn(&mut EventContext)>>,
pub(crate) shortcut_id: Option<&'static str>,
pub(crate) enabled: Prop<bool>,
pub(crate) visible: Prop<bool>,
pub(crate) state: MenuItemState,
pub(crate) id: MenuItemId,
}
impl MenuEntry {
pub fn new(title: impl Into<LocalizedString>) -> Self {
Self {
title: title.into(),
intent: None,
action: None,
shortcut_id: None,
enabled: Prop::Static(true),
visible: Prop::Static(true),
state: MenuItemState::Plain,
id: MenuItemId::next(),
}
}
pub fn intent(mut self, name: &'static str) -> Self {
self.intent = Some(name);
self
}
pub fn on_activate(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
self.action = Some(Rc::new(f));
self
}
pub fn shortcut(mut self, id: &'static str) -> Self {
self.shortcut_id = Some(id);
self
}
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
self.enabled = enabled.into();
self
}
pub fn visible(mut self, visible: impl Into<Prop<bool>>) -> Self {
self.visible = visible.into();
self
}
pub fn checkable(mut self, state: Signal<bool>) -> Self {
self.state = MenuItemState::Check(state);
self
}
pub fn checked(mut self, state: Signal<bool>) -> Self {
self.state = MenuItemState::ReflectCheck(state);
self
}
pub fn tri_checkable(mut self, state: Signal<CheckState>) -> Self {
self.state = MenuItemState::TriCheck(state);
self
}
pub fn radio(mut self, value: usize, selected: Signal<usize>) -> Self {
self.state = MenuItemState::Radio { value, selected };
self
}
pub fn id(&self) -> MenuItemId {
self.id
}
pub(crate) fn to_menu_item(&self) -> MenuItem {
let mut mi = MenuItem::new(self.title.clone()).enabled(self.enabled.clone());
if let Some(id) = self.shortcut_id {
mi = mi.for_shortcut(id);
}
let intent = self.intent;
let action = self.action.clone();
mi = mi.on_activate_fn(move |ctx| {
if let Some(name) = intent {
ctx.send_intent(Intent::new(name));
}
if let Some(a) = &action {
a(ctx);
}
});
match &self.state {
MenuItemState::Plain => {}
MenuItemState::Check(s) => mi = mi.checked(s.clone()),
MenuItemState::ReflectCheck(s) => mi = mi.reflect_checked(s.clone()),
MenuItemState::TriCheck(s) => mi = mi.check_state(s.clone()),
MenuItemState::Radio { value, selected } => mi = mi.radio(*value, selected.clone()),
}
mi
}
}
#[derive(Clone)]
pub enum MenuNode {
Item(MenuEntry),
Submenu {
id: MenuItemId,
title: LocalizedString,
children: Vec<MenuNode>,
},
Separator,
Standard(StandardMenu),
}
#[derive(Clone)]
pub struct StandardMenu {
role: StandardMenuRole,
title: LocalizedString,
about: LocalizedString,
settings: LocalizedString,
hide: LocalizedString,
quit: LocalizedString,
minimize: LocalizedString,
zoom: LocalizedString,
quit_route: Option<(&'static str, MenuItemId)>,
settings_route: Option<(&'static str, MenuItemId)>,
quit_shortcut: Option<&'static str>,
settings_shortcut: Option<&'static str>,
}
impl StandardMenu {
pub fn app() -> Self {
Self {
role: StandardMenuRole::App,
title: LocalizedString::literal("App"),
about: LocalizedString::literal("About"),
settings: LocalizedString::literal("Settings…"),
hide: LocalizedString::literal("Hide"),
quit: LocalizedString::literal("Quit"),
minimize: LocalizedString::literal(""),
zoom: LocalizedString::literal(""),
quit_route: None,
settings_route: None,
quit_shortcut: None,
settings_shortcut: None,
}
}
pub fn window() -> Self {
Self {
role: StandardMenuRole::Window,
title: LocalizedString::literal("Window"),
about: LocalizedString::literal(""),
settings: LocalizedString::literal(""),
hide: LocalizedString::literal(""),
quit: LocalizedString::literal(""),
minimize: LocalizedString::literal("Minimize"),
zoom: LocalizedString::literal("Zoom"),
quit_route: None,
settings_route: None,
quit_shortcut: None,
settings_shortcut: None,
}
}
pub fn help() -> Self {
Self {
role: StandardMenuRole::Help,
title: LocalizedString::literal("Help"),
about: LocalizedString::literal(""),
settings: LocalizedString::literal(""),
hide: LocalizedString::literal(""),
quit: LocalizedString::literal(""),
minimize: LocalizedString::literal(""),
zoom: LocalizedString::literal(""),
quit_route: None,
settings_route: None,
quit_shortcut: None,
settings_shortcut: None,
}
}
pub fn for_role(role: StandardMenuRole) -> Self {
match role {
StandardMenuRole::App => Self::app(),
StandardMenuRole::Window => Self::window(),
StandardMenuRole::Help => Self::help(),
}
}
pub fn role(&self) -> StandardMenuRole {
self.role
}
pub fn title(mut self, title: impl Into<LocalizedString>) -> Self {
self.title = title.into();
self
}
pub fn about(mut self, label: impl Into<LocalizedString>) -> Self {
self.about = label.into();
self
}
pub fn settings(mut self, label: impl Into<LocalizedString>) -> Self {
self.settings = label.into();
self
}
pub fn hide(mut self, label: impl Into<LocalizedString>) -> Self {
self.hide = label.into();
self
}
pub fn quit(mut self, label: impl Into<LocalizedString>) -> Self {
self.quit = label.into();
self
}
pub fn minimize(mut self, label: impl Into<LocalizedString>) -> Self {
self.minimize = label.into();
self
}
pub fn zoom(mut self, label: impl Into<LocalizedString>) -> Self {
self.zoom = label.into();
self
}
pub fn quit_intent(mut self, intent: &'static str) -> Self {
self.quit_route = Some((intent, MenuItemId::next()));
self
}
pub fn quit_intent_name(&self) -> Option<&'static str> {
self.quit_route.map(|(intent, _)| intent)
}
pub(crate) fn quit_route(&self) -> Option<(&'static str, MenuItemId)> {
self.quit_route
}
pub fn settings_intent(mut self, intent: &'static str) -> Self {
self.settings_route = Some((intent, MenuItemId::next()));
self
}
pub fn settings_intent_name(&self) -> Option<&'static str> {
self.settings_route.map(|(intent, _)| intent)
}
pub fn quit_shortcut(mut self, id: &'static str) -> Self {
self.quit_shortcut = Some(id);
self
}
pub fn settings_shortcut(mut self, id: &'static str) -> Self {
self.settings_shortcut = Some(id);
self
}
pub fn quit_shortcut_id(&self) -> Option<&'static str> {
self.quit_shortcut
}
pub fn settings_shortcut_id(&self) -> Option<&'static str> {
self.settings_shortcut
}
pub(crate) fn settings_route(&self) -> Option<(&'static str, MenuItemId)> {
self.settings_route
}
pub(crate) fn resolve_labels(&self) -> teksilo_platform::native_menu::StandardLabels {
teksilo_platform::native_menu::StandardLabels {
title: self.title.resolve_now(),
about: self.about.resolve_now(),
settings: self.settings.resolve_now(),
hide: self.hide.resolve_now(),
quit: self.quit.resolve_now(),
minimize: self.minimize.resolve_now(),
zoom: self.zoom.resolve_now(),
}
}
}
#[derive(Clone, Default)]
pub struct MenuItems {
pub(crate) nodes: Vec<MenuNode>,
}
impl MenuItems {
pub fn new() -> Self {
Self::default()
}
pub fn item(mut self, entry: MenuEntry) -> Self {
self.nodes.push(MenuNode::Item(entry));
self
}
pub fn separator(mut self) -> Self {
self.nodes.push(MenuNode::Separator);
self
}
pub fn submenu(
self,
title: impl Into<LocalizedString>,
build: impl FnOnce(MenuItems) -> MenuItems,
) -> Self {
self.submenu_with_id(MenuItemId::next(), title, build)
}
pub fn submenu_with_id(
mut self,
id: MenuItemId,
title: impl Into<LocalizedString>,
build: impl FnOnce(MenuItems) -> MenuItems,
) -> Self {
let children = build(MenuItems::new()).nodes;
self.nodes.push(MenuNode::Submenu {
id,
title: title.into(),
children,
});
self
}
}
#[derive(Clone)]
pub struct MenuModel {
nodes: Rc<RefCell<Vec<MenuNode>>>,
version: Signal<u64>,
}
impl Default for MenuModel {
fn default() -> Self {
Self::new()
}
}
impl MenuModel {
pub fn new() -> Self {
Self {
nodes: Rc::new(RefCell::new(Vec::new())),
version: Signal::new(0),
}
}
pub fn menu(
self,
title: impl Into<LocalizedString>,
build: impl FnOnce(MenuItems) -> MenuItems,
) -> Self {
self.menu_with_id(MenuItemId::next(), title, build)
}
pub fn menu_with_id(
self,
id: MenuItemId,
title: impl Into<LocalizedString>,
build: impl FnOnce(MenuItems) -> MenuItems,
) -> Self {
let children = build(MenuItems::new()).nodes;
self.nodes.borrow_mut().push(MenuNode::Submenu {
id,
title: title.into(),
children,
});
self.bump();
self
}
pub fn standard(self, role: StandardMenuRole) -> Self {
self.standard_menu(StandardMenu::for_role(role))
}
pub fn standard_menu(self, menu: StandardMenu) -> Self {
self.nodes.borrow_mut().push(MenuNode::Standard(menu));
self.bump();
self
}
pub fn version(&self) -> Signal<u64> {
self.version.clone()
}
pub fn nodes(&self) -> Ref<'_, Vec<MenuNode>> {
self.nodes.borrow()
}
pub fn modify(&self, f: impl FnOnce(&mut Vec<MenuNode>)) {
f(&mut self.nodes.borrow_mut());
self.bump();
}
pub fn push_menu(
&self,
title: impl Into<LocalizedString>,
build: impl FnOnce(MenuItems) -> MenuItems,
) -> MenuItemId {
let id = MenuItemId::next();
let children = build(MenuItems::new()).nodes;
self.nodes.borrow_mut().push(MenuNode::Submenu {
id,
title: title.into(),
children,
});
self.bump();
id
}
pub fn push_item(&self, into: MenuItemId, entry: MenuEntry) -> bool {
let ok = {
let mut nodes = self.nodes.borrow_mut();
push_into_submenu(&mut nodes, into, MenuNode::Item(entry))
};
if ok {
self.bump();
}
ok
}
pub fn push_separator(&self, into: MenuItemId) -> bool {
let ok = {
let mut nodes = self.nodes.borrow_mut();
push_into_submenu(&mut nodes, into, MenuNode::Separator)
};
if ok {
self.bump();
}
ok
}
pub fn insert_menu_at(
&self,
index: usize,
id: MenuItemId,
title: impl Into<LocalizedString>,
build: impl FnOnce(MenuItems) -> MenuItems,
) {
let children = build(MenuItems::new()).nodes;
{
let mut nodes = self.nodes.borrow_mut();
let at = index.min(nodes.len());
nodes.insert(
at,
MenuNode::Submenu {
id,
title: title.into(),
children,
},
);
}
self.bump();
}
pub fn contains(&self, id: MenuItemId) -> bool {
fn find(nodes: &[MenuNode], id: MenuItemId) -> bool {
nodes.iter().any(|n| match n {
MenuNode::Item(entry) => entry.id == id,
MenuNode::Submenu {
id: sid, children, ..
} => *sid == id || find(children, id),
_ => false,
})
}
find(&self.nodes.borrow(), id)
}
pub fn remove(&self, id: MenuItemId) -> bool {
let removed = {
let mut nodes = self.nodes.borrow_mut();
remove_by_id(&mut nodes, id)
};
if removed {
self.bump();
}
removed
}
fn bump(&self) {
let v = self.version.get();
self.version.set(v.wrapping_add(1));
}
}
fn push_into_submenu(nodes: &mut [MenuNode], into: MenuItemId, node: MenuNode) -> bool {
fn find(nodes: &mut [MenuNode], into: MenuItemId) -> Option<&mut Vec<MenuNode>> {
for n in nodes {
if let MenuNode::Submenu { id, children, .. } = n {
if *id == into {
return Some(children);
}
if let Some(found) = find(children, into) {
return Some(found);
}
}
}
None
}
match find(nodes, into) {
Some(children) => {
children.push(node);
true
}
None => false,
}
}
fn remove_by_id(nodes: &mut Vec<MenuNode>, id: MenuItemId) -> bool {
if let Some(pos) = nodes.iter().position(|n| match n {
MenuNode::Item(e) => e.id == id,
MenuNode::Submenu { id: sid, .. } => *sid == id,
_ => false,
}) {
nodes.remove(pos);
return true;
}
for n in nodes.iter_mut() {
if let MenuNode::Submenu { children, .. } = n {
if remove_by_id(children, id) {
return true;
}
}
}
false
}
pub(crate) fn build_menu_list(nodes: &[MenuNode]) -> MenuList {
let mut list = MenuList::new();
for node in nodes {
match node {
MenuNode::Item(entry) => {
list = list.item_when(entry.to_menu_item(), entry.visible.clone());
}
MenuNode::Separator => {
list = list.separator();
}
MenuNode::Submenu {
title, children, ..
} => {
let children = children.clone();
list = list.item(MenuItem::submenu(title.clone(), move || {
Box::new(build_menu_list(&children))
}));
}
MenuNode::Standard(_) => {}
}
}
list
}
#[cfg(test)]
mod tests {
use super::*;
use teksilo_i18n::lit;
#[test]
fn menu_appends_nodes_and_bumps_version() {
let model = MenuModel::new();
let v0 = model.version().get();
let model = model
.menu(lit!("File"), |m| {
m.item(MenuEntry::new(lit!("New"))).separator()
})
.standard(StandardMenuRole::Window);
assert!(
model.version().get() > v0,
"structural change bumps version"
);
let nodes = model.nodes();
assert_eq!(nodes.len(), 2);
assert!(matches!(nodes[0], MenuNode::Submenu { .. }));
let MenuNode::Standard(sm) = &nodes[1] else {
panic!("expected standard menu");
};
assert_eq!(sm.role(), StandardMenuRole::Window);
}
#[test]
fn each_entry_gets_a_unique_id() {
let a = MenuEntry::new(lit!("A"));
let b = MenuEntry::new(lit!("B"));
assert_ne!(a.id(), b.id());
}
#[test]
fn push_item_into_submenu_by_id_and_remove() {
let recent = teksilo_core::MenuItemId::next();
let model = MenuModel::new().menu_with_id(recent, lit!("File"), |m| m);
let v0 = model.version().get();
let doc = MenuEntry::new(lit!("doc.txt"));
let doc_id = doc.id();
assert!(model.push_item(recent, doc));
assert!(model.version().get() > v0, "push bumps version");
{
let nodes = model.nodes();
let MenuNode::Submenu { children, .. } = &nodes[0] else {
panic!("expected submenu");
};
assert_eq!(children.len(), 1);
}
assert!(!model.push_item(teksilo_core::MenuItemId::next(), MenuEntry::new(lit!("x"))));
assert!(model.remove(doc_id));
{
let nodes = model.nodes();
let MenuNode::Submenu { children, .. } = &nodes[0] else {
panic!("expected submenu");
};
assert!(children.is_empty());
}
assert!(!model.remove(doc_id), "second remove is a no-op");
}
#[test]
fn push_menu_and_modify_at_runtime() {
let model = MenuModel::new();
let id = model.push_menu(lit!("Edit"), |m| m.item(MenuEntry::new(lit!("Cut"))));
assert_eq!(model.nodes().len(), 1);
model.modify(|nodes| {
nodes.push(MenuNode::Separator);
});
assert_eq!(model.nodes().len(), 2);
assert!(model.remove(id));
assert_eq!(model.nodes().len(), 1);
}
#[test]
fn submenu_nesting_is_preserved() {
let model = MenuModel::new().menu(lit!("File"), |m| {
m.submenu(lit!("Recent"), |s| s.item(MenuEntry::new(lit!("doc.txt"))))
});
let nodes = model.nodes();
let MenuNode::Submenu { children, .. } = &nodes[0] else {
panic!("expected submenu");
};
assert!(matches!(children[0], MenuNode::Submenu { .. }));
}
}