use crate::geom::Vec2;
use crate::key::Key;
use crate::tree::OriginId;
use crate::value::Value;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum MenuRole {
#[default]
Custom,
Separator,
Cut,
Copy,
Paste,
SelectAll,
LookUp,
}
impl MenuRole {
pub const ALL: [MenuRole; 7] = [
MenuRole::Custom,
MenuRole::Separator,
MenuRole::Cut,
MenuRole::Copy,
MenuRole::Paste,
MenuRole::SelectAll,
MenuRole::LookUp,
];
pub fn from_name(name: &str) -> Option<MenuRole> {
Self::ALL.into_iter().find(|r| r.name() == name)
}
pub fn name(self) -> &'static str {
match self {
MenuRole::Custom => "custom",
MenuRole::Separator => "separator",
MenuRole::Cut => "cut",
MenuRole::Copy => "copy",
MenuRole::Paste => "paste",
MenuRole::SelectAll => "selectAll",
MenuRole::LookUp => "lookUp",
}
}
pub fn default_label(self) -> &'static str {
match self {
MenuRole::Custom | MenuRole::Separator => "",
MenuRole::Cut => "Cut",
MenuRole::Copy => "Copy",
MenuRole::Paste => "Paste",
MenuRole::SelectAll => "Select All",
MenuRole::LookUp => "Look Up",
}
}
pub fn default_accel(self) -> &'static str {
let mac = cfg!(target_os = "macos");
match self {
MenuRole::Cut if mac => "⌘X",
MenuRole::Copy if mac => "⌘C",
MenuRole::Paste if mac => "⌘V",
MenuRole::SelectAll if mac => "⌘A",
MenuRole::Cut => "Ctrl+X",
MenuRole::Copy => "Ctrl+C",
MenuRole::Paste => "Ctrl+V",
MenuRole::SelectAll => "Ctrl+A",
MenuRole::Custom | MenuRole::Separator | MenuRole::LookUp => "",
}
}
pub fn is_builtin(self) -> bool {
!matches!(self, MenuRole::Custom | MenuRole::Separator)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct MenuItem {
pub label: String,
pub role: MenuRole,
pub enabled: bool,
pub id: Option<Value>,
pub checked: bool,
pub accel: Option<String>,
pub submenu: Vec<MenuItem>,
}
impl MenuItem {
pub fn from_value(v: &Value) -> Result<Self, String> {
let Value::Map(_) = v else {
return Err("each menu item is an object".into());
};
let role = match v.get_str("role") {
None => MenuRole::Custom,
Some(name) => MenuRole::from_name(name)
.ok_or_else(|| format!("unknown menu item role {name:?}"))?,
};
let label = v.get_str("label").unwrap_or_default().to_string();
if label.is_empty() && role == MenuRole::Custom {
return Err("a custom menu item needs a label".into());
}
Ok(MenuItem {
label,
role,
enabled: v.get_bool("enabled").unwrap_or(true),
checked: v.get_bool("checked").unwrap_or(false),
id: v.get("id").filter(|id| **id != Value::Null).cloned(),
accel: v.get_str("accel").map(str::to_string),
submenu: match v.get("items") {
None | Some(Value::Null) => Vec::new(),
Some(items) => Self::list_from_value(items)?,
},
})
}
pub fn list_from_value(v: &Value) -> Result<Vec<Self>, String> {
let Value::List(rows) = v else {
return Err("a menu's items are an array".into());
};
rows.iter().map(Self::from_value).collect()
}
pub const KEYS: [&'static str; 7] = [
"label", "role", "enabled", "checked", "id", "accel", "items",
];
pub fn stray_keys(rows: &Value) -> Vec<String> {
let mut out = Vec::new();
Self::stray_into(rows, false, &mut out);
out
}
pub fn stray_option_keys(options: &Value) -> Vec<String> {
let mut out = Vec::new();
Self::stray_into(options, true, &mut out);
out
}
fn stray_into(rows: &Value, options: bool, out: &mut Vec<String>) {
let Value::List(rows) = rows else { return };
for row in rows {
let Value::Map(fields) = row else { continue };
for (k, v) in fields.iter() {
if !Self::KEYS.contains(&k.as_str()) || (options && k == "items") {
out.push(k.clone());
} else if k == "items" {
Self::stray_into(v, false, out);
}
}
}
}
pub const NAME: &'static str = "menuItem";
pub fn options_from_value(v: &Value) -> Result<Vec<Self>, String> {
let Value::List(rows) = v else {
return Err("a select's options are an array".into());
};
if rows.is_empty() {
return Err("a select needs at least one option".into());
}
rows.iter()
.map(|row| match row {
Value::Str(label) if !label.is_empty() => Ok(Self::new(label.as_str())),
Value::Str(_) => Err("an option needs a label".into()),
other => Self::from_value(other).map(|mut option| {
option.submenu = Vec::new();
option
}),
})
.collect()
}
pub fn new(label: impl Into<String>) -> Self {
Self {
label: label.into(),
role: MenuRole::Custom,
enabled: true,
checked: false,
id: None,
accel: None,
submenu: Vec::new(),
}
}
pub fn role(role: MenuRole) -> Self {
Self {
label: String::new(),
role,
enabled: true,
checked: false,
id: None,
accel: None,
submenu: Vec::new(),
}
}
pub fn submenu(label: impl Into<String>, items: Vec<MenuItem>) -> Self {
Self {
submenu: items,
..Self::new(label)
}
}
pub fn has_submenu(&self) -> bool {
!self.submenu.is_empty()
}
pub fn at_path<'a>(items: &'a [MenuItem], path: &[usize]) -> Option<&'a MenuItem> {
let (&last, outer) = path.split_last()?;
let mut level = items;
for &i in outer {
level = &level.get(i)?.submenu;
}
level.get(last)
}
pub fn separator() -> Self {
Self::role(MenuRole::Separator)
}
pub fn enabled(mut self, on: bool) -> Self {
self.enabled = on;
self
}
pub fn checked(mut self, on: bool) -> Self {
self.checked = on;
self
}
pub fn id(mut self, id: impl Into<Value>) -> Self {
self.id = Some(id.into());
self
}
pub fn accel(mut self, a: impl Into<String>) -> Self {
self.accel = Some(a.into());
self
}
pub fn text(&self) -> &str {
if self.label.is_empty() {
self.role.default_label()
} else {
&self.label
}
}
pub fn accel_text(&self) -> Option<&str> {
match &self.accel {
Some(accel) => Some(accel),
None => Some(self.role.default_accel()).filter(|a| !a.is_empty()),
}
}
pub fn accel_label(&self) -> Option<std::borrow::Cow<'_, str>> {
self.accel_text().map(Accel::label)
}
pub fn selectable(&self) -> bool {
self.enabled && self.role != MenuRole::Separator
}
pub(crate) fn choosable_at(items: &[MenuItem], path: &[usize]) -> bool {
let mut level = items;
for (n, &i) in path.iter().enumerate() {
let Some(item) = level.get(i).filter(|item| item.selectable()) else {
return false;
};
if n + 1 == path.len() {
return !item.has_submenu();
}
level = &item.submenu;
}
false
}
pub(crate) fn normalize_accels(items: &mut [MenuItem]) {
for item in items {
if let Some(accel) = &item.accel
&& let std::borrow::Cow::Owned(display) = Accel::label(accel)
{
item.accel = Some(display);
}
Self::normalize_accels(&mut item.submenu);
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Menu {
pub target: Key,
pub at: Vec2,
pub origin: OriginId,
pub items: Vec<MenuItem>,
}
impl Menu {
pub fn new(target: Key, at: Vec2, items: Vec<MenuItem>) -> Self {
Self {
target,
at,
origin: OriginId::HOST,
items,
}
}
pub fn origin(mut self, origin: OriginId) -> Self {
self.origin = origin;
self
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum MenuAction {
SetClipboard { text: String, html: Option<String> },
SetClipboardSecret { text: String },
Paste,
LookUp {
text: String,
at: crate::geom::Vec2,
},
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct BarMenu {
pub label: String,
pub items: Vec<MenuItem>,
pub enabled: bool,
}
impl BarMenu {
pub fn new(label: impl Into<String>, items: Vec<MenuItem>) -> Self {
Self {
label: label.into(),
items,
enabled: true,
}
}
pub fn enabled(mut self, on: bool) -> Self {
self.enabled = on;
self
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct MenuBar {
pub menus: Vec<BarMenu>,
}
impl MenuBar {
pub fn new(menus: Vec<BarMenu>) -> Self {
Self { menus }
}
pub fn from_value(v: &Value) -> Result<Self, String> {
let Value::List(menus) = v else {
return Err("menu is an array of menus".into());
};
let mut out = Vec::with_capacity(menus.len());
for entry in menus {
let Value::Map(_) = entry else {
return Err("each menu is an object { label, items }".into());
};
let label = entry
.get_str("label")
.ok_or("each menu needs a label")?
.to_string();
let items = entry
.get("items")
.ok_or_else(|| format!("menu entry `{label}` needs `items` (a list of rows)"))?;
out.push(BarMenu {
label,
items: MenuItem::list_from_value(items)?,
enabled: entry.get_bool("enabled").unwrap_or(true),
});
}
Ok(Self::new(out))
}
pub fn stray_keys(v: &Value) -> Vec<String> {
let Value::List(menus) = v else {
return Vec::new();
};
menus
.iter()
.filter_map(|menu| menu.get("items"))
.flat_map(MenuItem::stray_keys)
.collect()
}
pub fn is_empty(&self) -> bool {
self.menus.is_empty()
}
pub fn item(&self, menu: usize, item: usize) -> Option<&MenuItem> {
self.menus.get(menu)?.items.get(item)
}
pub fn item_at(&self, menu: usize, path: &[usize]) -> Option<&MenuItem> {
MenuItem::at_path(&self.menus.get(menu)?.items, path)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Accel {
pub code: crate::input::KeyCode,
pub mods: crate::input::KeyMods,
}
impl Accel {
pub fn parse(s: &str) -> Option<Accel> {
use crate::input::{KeyCode, KeyMods};
let mut mods = KeyMods::default();
let mut rest = s.trim();
loop {
let mut chars = rest.chars();
match chars.next() {
Some('\u{2303}') => mods.ctrl = true,
Some('\u{2325}') => mods.alt = true,
Some('\u{21e7}') => mods.shift = true,
Some('\u{2318}') => mods.super_key = true,
_ => break,
}
rest = chars.as_str();
}
let mut code = None;
for part in rest.split('+') {
let part = part.trim();
if part.is_empty() {
return None;
}
let lower = part.to_ascii_lowercase();
match lower.as_str() {
"mod" | "cmdorctrl" => {
if cfg!(target_os = "macos") {
mods.super_key = true;
} else {
mods.ctrl = true;
}
}
"cmd" | "command" | "super" | "meta" | "win" => mods.super_key = true,
"ctrl" | "control" => mods.ctrl = true,
"alt" | "option" | "opt" => mods.alt = true,
"shift" => mods.shift = true,
_ if code.is_some() => return None,
_ => {
code = Some(if part.chars().count() == 1 {
key_from_label(part).unwrap_or(KeyCode::Char(part.chars().next().unwrap()))
} else {
KeyCode::from_name(&lower).or_else(|| key_from_label(part))?
});
}
}
}
let code = code?;
let lock = matches!(
code,
KeyCode::CapsLock | KeyCode::NumLock | KeyCode::ScrollLock
);
(code != KeyCode::Unknown && !lock).then_some(Accel { code, mods })
}
pub fn display(&self) -> String {
let key = key_label(self.code);
if cfg!(target_os = "macos") {
let mut out = String::new();
for (on, glyph) in [
(self.mods.ctrl, '\u{2303}'),
(self.mods.alt, '\u{2325}'),
(self.mods.shift, '\u{21e7}'),
(self.mods.super_key, '\u{2318}'),
] {
if on {
out.push(glyph);
}
}
out.push_str(&key);
out
} else {
let mut parts = Vec::new();
for (on, name) in [
(self.mods.ctrl, "Ctrl"),
(self.mods.super_key, "Super"),
(self.mods.alt, "Alt"),
(self.mods.shift, "Shift"),
] {
if on {
parts.push(name);
}
}
parts.push(&key);
parts.join("+")
}
}
pub fn label(spelled: &str) -> std::borrow::Cow<'_, str> {
match Accel::parse(spelled) {
Some(a) => std::borrow::Cow::Owned(a.display()),
None => std::borrow::Cow::Borrowed(spelled),
}
}
pub fn spelling(&self) -> String {
let mut parts = Vec::new();
for (on, name) in [
(self.mods.ctrl, "ctrl"),
(self.mods.super_key, "super"),
(self.mods.alt, "alt"),
(self.mods.shift, "shift"),
] {
if on {
parts.push(name.to_string());
}
}
parts.push(match self.code {
crate::input::KeyCode::Char(c) => c.to_ascii_lowercase().to_string(),
code => code.name(),
});
parts.join("+")
}
pub fn key_equivalent(&self) -> Option<String> {
match self.code {
crate::input::KeyCode::Char(c) => Some(c.to_lowercase().to_string()),
crate::input::KeyCode::Space => Some(" ".into()),
crate::input::KeyCode::Enter => Some("\r".into()),
crate::input::KeyCode::Tab => Some("\t".into()),
crate::input::KeyCode::Backspace => Some("\u{8}".into()),
crate::input::KeyCode::Delete => Some("\u{7f}".into()),
crate::input::KeyCode::Escape => Some("\u{1b}".into()),
_ => None,
}
}
}
fn key_label(code: crate::input::KeyCode) -> String {
key_label_on(code, cfg!(target_os = "macos"))
}
fn key_from_label(label: &str) -> Option<crate::input::KeyCode> {
crate::input::KeyCode::named()
.iter()
.map(|(k, _)| *k)
.find(|&k| {
key_label_on(k, true) == label || key_label_on(k, false).eq_ignore_ascii_case(label)
})
}
fn key_label_on(code: crate::input::KeyCode, mac: bool) -> String {
use crate::input::KeyCode;
match code {
KeyCode::Char(c) => return c.to_uppercase().to_string(),
KeyCode::F(n) => return format!("F{n}"),
_ => {}
}
let s = match (code, mac) {
(KeyCode::Left, true) => "\u{2190}",
(KeyCode::Right, true) => "\u{2192}",
(KeyCode::Up, true) => "\u{2191}",
(KeyCode::Down, true) => "\u{2193}",
(KeyCode::Home, true) => "\u{2196}",
(KeyCode::End, true) => "\u{2198}",
(KeyCode::PageUp, true) => "\u{21de}",
(KeyCode::PageDown, true) => "\u{21df}",
(KeyCode::Backspace, true) => "\u{232b}",
(KeyCode::Delete, true) => "\u{2326}",
(KeyCode::Enter, true) => "\u{21a9}",
(KeyCode::Tab, true) => "\u{21e5}",
(KeyCode::Escape, true) => "\u{238b}",
(KeyCode::Space, true) => "\u{2423}",
(KeyCode::Left, _) => "Left",
(KeyCode::Right, _) => "Right",
(KeyCode::Up, _) => "Up",
(KeyCode::Down, _) => "Down",
(KeyCode::Home, _) => "Home",
(KeyCode::End, _) => "End",
(KeyCode::PageUp, _) => "Page Up",
(KeyCode::PageDown, _) => "Page Down",
(KeyCode::Backspace, _) => "Backspace",
(KeyCode::Delete, _) => "Delete",
(KeyCode::Enter, _) => "Enter",
(KeyCode::Tab, _) => "Tab",
(KeyCode::Escape, _) => "Esc",
(KeyCode::Space, _) => "Space",
(KeyCode::Insert, _) => "Insert",
(KeyCode::Clear, true) => "\u{2327}",
(KeyCode::Shift, true) => "\u{21e7}",
(KeyCode::Ctrl, true) => "\u{2303}",
(KeyCode::Alt, true) => "\u{2325}",
(KeyCode::Super, true) => "\u{2318}",
(KeyCode::CapsLock, true) => "\u{21ea}",
(KeyCode::PrintScreen, _) => "Print Screen",
(KeyCode::Pause, _) => "Pause",
(KeyCode::Menu, _) => "Menu",
(KeyCode::Clear, _) => "Clear",
(KeyCode::Shift, _) => "Shift",
(KeyCode::Ctrl, _) => "Ctrl",
(KeyCode::Alt, _) => "Alt",
(KeyCode::Super, _) => "Super",
(KeyCode::CapsLock, _) => "Caps Lock",
(KeyCode::NumLock, _) => "Num Lock",
(KeyCode::ScrollLock, _) => "Scroll Lock",
(KeyCode::MediaPlay, _) => "Play",
(KeyCode::MediaPause, _) => "Pause",
(KeyCode::MediaPlayPause, _) => "Play/Pause",
(KeyCode::MediaStop, _) => "Stop",
(KeyCode::MediaNext, _) => "Next Track",
(KeyCode::MediaPrev, _) => "Previous Track",
(KeyCode::MediaRecord, _) => "Record",
(KeyCode::MediaFastForward, _) => "Fast Forward",
(KeyCode::MediaRewind, _) => "Rewind",
(KeyCode::VolumeUp, _) => "Volume Up",
(KeyCode::VolumeDown, _) => "Volume Down",
(KeyCode::VolumeMute, _) => "Mute",
(KeyCode::Unknown, _) | (KeyCode::Char(_), _) | (KeyCode::F(_), _) => "",
};
s.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::input::{KeyCode, KeyMods};
#[test]
fn all_names_every_role_once() {
let mut seen = 0;
for role in MenuRole::ALL {
match role {
MenuRole::Custom
| MenuRole::Separator
| MenuRole::Cut
| MenuRole::Copy
| MenuRole::Paste
| MenuRole::SelectAll
| MenuRole::LookUp => seen += 1,
}
assert_eq!(MenuRole::from_name(role.name()), Some(role));
assert_eq!(
MenuRole::ALL.iter().filter(|r| **r == role).count(),
1,
"{role:?} is listed more than once"
);
}
assert_eq!(seen, MenuRole::ALL.len());
}
#[test]
fn mod_is_the_platform_primary() {
let a = Accel::parse("mod+s").unwrap();
assert_eq!(a.mods.super_key, cfg!(target_os = "macos"));
assert_eq!(a.mods.ctrl, !cfg!(target_os = "macos"));
assert_eq!(a.code, KeyCode::Char('s'));
}
#[test]
fn the_glyph_spelling_parses_back() {
let a = Accel::parse("\u{21e7}\u{2318}S").unwrap();
assert_eq!(
a,
Accel {
code: KeyCode::Char('S'),
mods: KeyMods::NONE.with_shift().with_super(),
}
);
}
#[test]
fn a_named_key_parses_and_reads_back_capitalised() {
let a = Accel::parse("ctrl+pageup").unwrap();
assert_eq!(a.code, KeyCode::PageUp);
let expect = if cfg!(target_os = "macos") {
"\u{21de}"
} else {
"Page Up"
};
assert!(a.display().ends_with(expect), "{}", a.display());
}
#[test]
fn the_portable_spelling_parses_back_to_the_same_chord() {
for s in [
"ctrl+shift+i",
"f12",
"cmd+alt+d",
"⌃⌥⇧⌘s",
"mod+pageup",
"shift+/",
] {
let a = Accel::parse(s).unwrap();
assert_eq!(
Accel::parse(&a.spelling()),
Some(a),
"{s} -> {}",
a.spelling()
);
}
assert_eq!(
Accel::parse("shift+ctrl+I").unwrap().spelling(),
"ctrl+shift+i"
);
assert_eq!(Accel::parse("win+f12").unwrap().spelling(), "super+f12");
}
#[test]
fn the_displayed_spelling_parses_back_to_the_same_chord() {
for (code, _) in KeyCode::named() {
let mods = KeyMods::NONE.with_shift().with_super();
let a = Accel { code: *code, mods };
if code.is_modifier() || *code == KeyCode::MediaPause {
continue;
}
assert_eq!(Accel::parse(&a.display()), Some(a), "{}", a.display());
for mac in [true, false] {
let label = key_label_on(*code, mac);
assert_eq!(key_from_label(&label), Some(*code), "{label}");
}
}
let trash = Accel::parse(&Accel::label("mod+backspace")).unwrap();
assert_eq!(trash.code, KeyCode::Backspace);
assert_eq!(trash.key_equivalent().as_deref(), Some("\u{8}"));
}
#[test]
fn a_shortcut_kui_cannot_name_is_not_a_shortcut() {
assert!(Accel::parse("mod+nope").is_none());
assert!(Accel::parse("ctrl+capslock").is_none());
assert!(Accel::parse("numlock").is_none());
assert!(Accel::parse("shift+scrolllock").is_none());
assert!(Accel::parse("s+s").is_none());
assert!(Accel::parse("").is_none());
}
#[test]
fn a_key_equivalent_is_lowercase() {
assert_eq!(
Accel::parse("cmd+S").unwrap().key_equivalent().as_deref(),
Some("s")
);
}
}