use frust::IconSource;
#[derive(Clone, Copy, Debug)]
pub struct MenuIcon(pub IconSource);
impl PartialEq for MenuIcon {
fn eq(&self, other: &Self) -> bool {
self.0.d == other.0.d && self.0.design == other.0.design
}
}
impl From<IconSource> for MenuIcon {
fn from(source: IconSource) -> Self {
MenuIcon(source)
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum MenuNode {
Entry(MenuEntry),
Selectable(MenuSelectable),
Toggleable(MenuToggleable),
Divider,
Group(MenuGroup),
Submenu(MenuSubmenu),
}
impl MenuNode {
pub fn span(&self) -> usize {
match self {
MenuNode::Group(group) => spans(&group.children),
MenuNode::Submenu(submenu) => 1 + spans(&submenu.children),
_ => 1,
}
}
pub fn is_activatable(&self) -> bool {
match self {
MenuNode::Entry(e) => e.enabled,
MenuNode::Selectable(e) => e.enabled,
MenuNode::Toggleable(e) => e.enabled,
MenuNode::Submenu(e) => e.enabled,
MenuNode::Divider | MenuNode::Group(_) => false,
}
}
}
pub fn spans(nodes: &[MenuNode]) -> usize {
nodes.iter().map(MenuNode::span).sum()
}
#[derive(Clone, Debug, PartialEq)]
pub struct MenuEntry {
pub(super) label: String,
pub(super) leading: Option<MenuIcon>,
pub(super) trailing: Option<MenuIcon>,
pub(super) shortcut: Option<String>,
pub(super) supporting: Option<String>,
pub(super) enabled: bool,
pub(super) destructive: bool,
}
pub fn menu_entry(label: impl Into<String>) -> MenuEntry {
MenuEntry {
label: label.into(),
leading: None,
trailing: None,
shortcut: None,
supporting: None,
enabled: true,
destructive: false,
}
}
impl MenuEntry {
pub fn leading(mut self, icon: impl Into<MenuIcon>) -> Self {
self.leading = Some(icon.into());
self
}
pub fn trailing(mut self, icon: impl Into<MenuIcon>) -> Self {
self.trailing = Some(icon.into());
self
}
pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
self.shortcut = Some(shortcut.into());
self
}
pub fn supporting(mut self, supporting: impl Into<String>) -> Self {
self.supporting = Some(supporting.into());
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn destructive(mut self, destructive: bool) -> Self {
self.destructive = destructive;
self
}
pub fn label(&self) -> &str {
&self.label
}
}
impl From<MenuEntry> for MenuNode {
fn from(entry: MenuEntry) -> Self {
MenuNode::Entry(entry)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct MenuSelectable {
pub(super) label: String,
pub(super) value: String,
pub(super) leading: Option<MenuIcon>,
pub(super) trailing: Option<MenuIcon>,
pub(super) shortcut: Option<String>,
pub(super) supporting: Option<String>,
pub(super) selected: bool,
pub(super) enabled: bool,
}
pub fn menu_selectable(label: impl Into<String>, value: impl Into<String>) -> MenuSelectable {
MenuSelectable {
label: label.into(),
value: value.into(),
leading: None,
trailing: None,
shortcut: None,
supporting: None,
selected: false,
enabled: true,
}
}
impl MenuSelectable {
pub fn leading(mut self, icon: impl Into<MenuIcon>) -> Self {
self.leading = Some(icon.into());
self
}
pub fn trailing(mut self, icon: impl Into<MenuIcon>) -> Self {
self.trailing = Some(icon.into());
self
}
pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
self.shortcut = Some(shortcut.into());
self
}
pub fn supporting(mut self, supporting: impl Into<String>) -> Self {
self.supporting = Some(supporting.into());
self
}
pub fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn label(&self) -> &str {
&self.label
}
pub fn value(&self) -> &str {
&self.value
}
}
impl From<MenuSelectable> for MenuNode {
fn from(item: MenuSelectable) -> Self {
MenuNode::Selectable(item)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct MenuToggleable {
pub(super) label: String,
pub(super) checked: bool,
pub(super) leading: Option<MenuIcon>,
pub(super) trailing: Option<MenuIcon>,
pub(super) shortcut: Option<String>,
pub(super) supporting: Option<String>,
pub(super) enabled: bool,
}
pub fn menu_toggleable(label: impl Into<String>, checked: bool) -> MenuToggleable {
MenuToggleable {
label: label.into(),
checked,
leading: None,
trailing: None,
shortcut: None,
supporting: None,
enabled: true,
}
}
impl MenuToggleable {
pub fn leading(mut self, icon: impl Into<MenuIcon>) -> Self {
self.leading = Some(icon.into());
self
}
pub fn trailing(mut self, icon: impl Into<MenuIcon>) -> Self {
self.trailing = Some(icon.into());
self
}
pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
self.shortcut = Some(shortcut.into());
self
}
pub fn supporting(mut self, supporting: impl Into<String>) -> Self {
self.supporting = Some(supporting.into());
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn label(&self) -> &str {
&self.label
}
pub fn checked(&self) -> bool {
self.checked
}
}
impl From<MenuToggleable> for MenuNode {
fn from(item: MenuToggleable) -> Self {
MenuNode::Toggleable(item)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct MenuGroup {
pub(super) label: Option<String>,
pub(super) children: Vec<MenuNode>,
}
pub fn menu_group(children: Vec<MenuNode>) -> MenuGroup {
MenuGroup {
label: None,
children,
}
}
impl MenuGroup {
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
pub fn children(&self) -> &[MenuNode] {
&self.children
}
pub fn section_label(&self) -> Option<&str> {
self.label.as_deref()
}
}
impl From<MenuGroup> for MenuNode {
fn from(group: MenuGroup) -> Self {
MenuNode::Group(group)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct MenuSubmenu {
pub(super) label: String,
pub(super) leading: Option<MenuIcon>,
pub(super) children: Vec<MenuNode>,
pub(super) enabled: bool,
}
pub fn menu_submenu(label: impl Into<String>, children: Vec<MenuNode>) -> MenuSubmenu {
MenuSubmenu {
label: label.into(),
leading: None,
children,
enabled: true,
}
}
impl MenuSubmenu {
pub fn leading(mut self, icon: impl Into<MenuIcon>) -> Self {
self.leading = Some(icon.into());
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn label(&self) -> &str {
&self.label
}
pub fn children(&self) -> &[MenuNode] {
&self.children
}
}
impl From<MenuSubmenu> for MenuNode {
fn from(submenu: MenuSubmenu) -> Self {
MenuNode::Submenu(submenu)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MenuAction {
Press,
Select(String),
Toggle(bool),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MenuSelection {
pub index: usize,
pub label: String,
pub action: MenuAction,
}
impl MenuSelection {
pub fn value(&self) -> Option<&str> {
match &self.action {
MenuAction::Select(value) => Some(value.as_str()),
_ => None,
}
}
pub fn checked(&self) -> Option<bool> {
match &self.action {
MenuAction::Toggle(checked) => Some(*checked),
_ => None,
}
}
}
pub fn partition_surfaces(nodes: &[MenuNode]) -> Vec<MenuGroup> {
let mut surfaces: Vec<MenuGroup> = Vec::new();
let mut flat: Vec<MenuNode> = Vec::new();
for node in nodes {
match node {
MenuNode::Group(group) => {
if !flat.is_empty() {
surfaces.push(menu_group(std::mem::take(&mut flat)));
}
surfaces.push(group.clone());
}
other => flat.push(other.clone()),
}
}
if !flat.is_empty() {
surfaces.push(menu_group(flat));
}
surfaces
}
#[cfg(test)]
mod tests {
use super::*;
use crate::icons;
fn sample() -> Vec<MenuNode> {
vec![
menu_entry("Cut").shortcut("⌘X").into(),
MenuNode::Divider,
menu_group(vec![
menu_selectable("Grid", "grid").into(),
menu_selectable("List", "list").into(),
])
.label("View")
.into(),
menu_submenu(
"Share",
vec![
menu_entry("Copy link").into(),
menu_toggleable("Public", true).into(),
],
)
.into(),
]
}
#[test]
fn a_group_opens_its_own_surface_and_a_flat_run_shares_one() {
let surfaces = partition_surfaces(&sample());
assert_eq!(surfaces.len(), 3, "flat run, the group, then the flat tail");
assert_eq!(surfaces[0].section_label(), None);
assert_eq!(surfaces[0].children().len(), 2, "the entry and the divider");
assert_eq!(surfaces[1].section_label(), Some("View"));
assert_eq!(surfaces[1].children().len(), 2);
assert_eq!(surfaces[2].section_label(), None);
assert!(matches!(surfaces[2].children()[0], MenuNode::Submenu(_)));
}
#[test]
fn an_empty_tree_partitions_into_no_surfaces() {
assert!(partition_surfaces(&[]).is_empty());
}
#[test]
fn consecutive_groups_never_merge() {
let nodes = vec![
menu_group(vec![menu_entry("a").into()]).into(),
menu_group(vec![menu_entry("b").into()]).into(),
];
assert_eq!(partition_surfaces(&nodes).len(), 2);
}
#[test]
fn span_counts_the_node_plus_its_whole_subtree() {
let nodes = sample();
assert_eq!(nodes[0].span(), 1, "a leaf entry");
assert_eq!(nodes[1].span(), 1, "a divider still takes an index");
assert_eq!(nodes[2].span(), 2, "a group is a surface, not a row");
assert_eq!(nodes[3].span(), 3, "the submenu row plus its two children");
assert_eq!(spans(&nodes), 7);
}
#[test]
fn only_enabled_leaf_rows_are_activatable() {
assert!(MenuNode::from(menu_entry("a")).is_activatable());
assert!(!MenuNode::from(menu_entry("a").enabled(false)).is_activatable());
assert!(MenuNode::from(menu_selectable("a", "v")).is_activatable());
assert!(MenuNode::from(menu_toggleable("a", false)).is_activatable());
assert!(MenuNode::from(menu_submenu("a", vec![])).is_activatable());
assert!(!MenuNode::from(menu_submenu("a", vec![]).enabled(false)).is_activatable());
assert!(!MenuNode::Divider.is_activatable());
assert!(!MenuNode::from(menu_group(vec![])).is_activatable());
}
#[test]
fn an_icon_slot_compares_by_path_and_design_box() {
let a: MenuIcon = icons::CHECK_ROUNDED.into();
let b: MenuIcon = icons::CHECK_ROUNDED.into();
let c: MenuIcon = icons::CONTENT_COPY.into();
assert_eq!(a, b);
assert_ne!(a, c);
assert_eq!(
menu_entry("x").leading(icons::CHECK),
menu_entry("x").leading(icons::CHECK)
);
assert_ne!(menu_entry("x").leading(icons::CHECK), menu_entry("x"));
}
#[test]
fn a_selection_reads_its_own_payload() {
let select = MenuSelection {
index: 2,
label: "Grid".into(),
action: MenuAction::Select("grid".into()),
};
assert_eq!(select.value(), Some("grid"));
assert_eq!(select.checked(), None);
let toggle = MenuSelection {
index: 3,
label: "Public".into(),
action: MenuAction::Toggle(false),
};
assert_eq!(toggle.checked(), Some(false));
assert_eq!(toggle.value(), None);
}
}