use std::rc::Rc;
use iced::Element;
use iced::keyboard::{Key, key::Named};
use iced::widget::column;
use crate::icon::Glyph;
use crate::keys::{self, Chord, Keymap};
use crate::overlay::anchored::{Align, Placement, Side, anchored};
use crate::overlay::menu::{self, Leading, Parts, RowStatus, Trailing};
use crate::theme::space;
pub const WIDTH: f32 = 224.0;
#[derive(Debug, Clone, PartialEq)]
pub enum Kind<Id> {
Action,
Checkbox(bool),
Radio(bool),
Submenu(Vec<Entry<Id>>),
}
#[derive(Debug, Clone, PartialEq)]
pub struct Item<Id> {
pub id: Id,
pub label: String,
pub icon: Option<Glyph>,
pub shortcut: Option<String>,
pub disabled: bool,
pub destructive: bool,
pub kind: Kind<Id>,
}
impl<Id> Item<Id> {
fn enabled(&self) -> bool {
!self.disabled
}
fn submenu(&self) -> Option<&[Entry<Id>]> {
match &self.kind {
Kind::Submenu(entries) => Some(entries),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Entry<Id> {
Item(Item<Id>),
Label(String),
Separator,
}
fn new_item<Id>(id: Id, label: impl Into<String>, kind: Kind<Id>) -> Entry<Id> {
Entry::Item(Item {
id,
label: label.into(),
icon: None,
shortcut: None,
disabled: false,
destructive: false,
kind,
})
}
pub fn item<Id>(id: Id, label: impl Into<String>) -> Entry<Id> {
new_item(id, label, Kind::Action)
}
pub fn checkbox_item<Id>(id: Id, label: impl Into<String>, checked: bool) -> Entry<Id> {
new_item(id, label, Kind::Checkbox(checked))
}
pub fn radio_item<Id>(id: Id, label: impl Into<String>, checked: bool) -> Entry<Id> {
new_item(id, label, Kind::Radio(checked))
}
pub fn submenu<Id>(
id: Id,
label: impl Into<String>,
entries: impl IntoIterator<Item = Entry<Id>>,
) -> Entry<Id> {
new_item(id, label, Kind::Submenu(entries.into_iter().collect()))
}
pub fn group_label<Id>(label: impl Into<String>) -> Entry<Id> {
Entry::Label(label.into())
}
pub fn separator<Id>() -> Entry<Id> {
Entry::Separator
}
impl<Id> Entry<Id> {
pub fn icon(self, glyph: Glyph) -> Self {
self.with_item(|item| item.icon = Some(glyph))
}
pub fn shortcut(self, hint: impl Into<String>) -> Self {
let hint = hint.into();
self.with_item(|item| item.shortcut = Some(hint))
}
pub fn disabled(self, disabled: bool) -> Self {
self.with_item(|item| item.disabled = disabled)
}
pub fn destructive(self, destructive: bool) -> Self {
self.with_item(|item| item.destructive = destructive)
}
pub fn as_item(&self) -> Option<&Item<Id>> {
match self {
Entry::Item(item) => Some(item),
_ => None,
}
}
fn with_item(mut self, change: impl FnOnce(&mut Item<Id>)) -> Self {
if let Entry::Item(item) = &mut self {
change(item);
}
self
}
fn enabled_item(&self) -> Option<&Item<Id>> {
self.as_item().filter(|item| item.enabled())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Event<Id> {
Open,
Close,
Toggle,
Highlight(Id),
Next,
Previous,
First,
Last,
OpenSubmenu,
CloseSubmenu,
Activate(Id),
ActivateHighlighted,
Typeahead(char),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Output<Id> {
Activated(Id),
Toggled(Id, bool),
Selected(Id),
}
#[derive(Debug, Clone, PartialEq)]
pub struct State<Id> {
entries: Vec<Entry<Id>>,
levels: Vec<Option<usize>>,
}
impl<Id: Copy + PartialEq> State<Id> {
pub fn new(entries: impl IntoIterator<Item = Entry<Id>>) -> Self {
Self {
entries: entries.into_iter().collect(),
levels: Vec::new(),
}
}
pub fn entries(&self) -> &[Entry<Id>] {
&self.entries
}
pub fn is_open(&self) -> bool {
!self.levels.is_empty()
}
pub fn depth(&self) -> usize {
self.levels.len()
}
pub fn highlighted(&self) -> Option<Id> {
let path = self.active_path()?;
self.item_at(&path).map(|item| item.id)
}
pub fn highlighted_at(&self, depth: usize) -> Option<usize> {
self.levels.get(depth).copied().flatten()
}
pub fn item(&self, id: Id) -> Option<&Item<Id>> {
let path = path_of(&self.entries, id)?;
self.item_at(&path)
}
pub fn is_checked(&self, id: Id) -> bool {
self.item(id)
.is_some_and(|item| matches!(item.kind, Kind::Checkbox(true) | Kind::Radio(true)))
}
pub fn set_checked(&mut self, id: Id, checked: bool) {
let Some(path) = path_of(&self.entries, id) else {
return;
};
let Some((&index, parents)) = path.split_last() else {
return;
};
let Some(siblings) = entries_mut(&mut self.entries, parents) else {
return;
};
let is_radio = matches!(
siblings.get(index),
Some(Entry::Item(Item {
kind: Kind::Radio(_),
..
}))
);
if is_radio && checked {
for sibling in radio_group(siblings, index) {
if let Some(Entry::Item(item)) = siblings.get_mut(sibling) {
item.kind = Kind::Radio(false);
}
}
}
if let Some(Entry::Item(item)) = siblings.get_mut(index) {
match &mut item.kind {
Kind::Checkbox(value) | Kind::Radio(value) => *value = checked,
Kind::Action | Kind::Submenu(_) => {}
}
}
}
pub fn set_disabled(&mut self, id: Id, disabled: bool) {
let Some(path) = path_of(&self.entries, id) else {
return;
};
let Some((&index, parents)) = path.split_last() else {
return;
};
if let Some(Entry::Item(item)) =
entries_mut(&mut self.entries, parents).and_then(|entries| entries.get_mut(index))
{
item.disabled = disabled;
}
let highlighted = self
.levels
.iter()
.take(path.len())
.copied()
.eq(path.iter().copied().map(Some));
if disabled && highlighted {
self.levels.truncate(parents.len() + 1);
if let Some(level) = self.levels.get_mut(parents.len()) {
*level = None;
}
}
}
pub fn update(&mut self, event: Event<Id>) -> Option<Output<Id>> {
match event {
Event::Open => self.open(),
Event::Close => self.levels.clear(),
Event::Toggle if self.is_open() => self.levels.clear(),
Event::Toggle => self.open(),
Event::Highlight(id) => self.highlight(id),
Event::Next | Event::First if !self.is_open() => self.open_on(Edge::First),
Event::Previous | Event::Last if !self.is_open() => self.open_on(Edge::Last),
Event::Next => self.step(Step::Next),
Event::Previous => self.step(Step::Previous),
Event::First => self.step(Step::Edge(Edge::First)),
Event::Last => self.step(Step::Edge(Edge::Last)),
Event::OpenSubmenu => self.open_submenu(),
Event::CloseSubmenu => self.close_submenu(),
Event::Typeahead(character) => self.typeahead(character),
Event::Activate(id) => return self.activate(id),
Event::ActivateHighlighted if !self.is_open() => self.open_on(Edge::First),
Event::ActivateHighlighted => return self.activate_highlighted(),
}
None
}
pub fn key_event(&self, keymap: &Keymap<Action>, key: &keys::Event) -> Option<Event<Id>> {
if let Some(action) = keymap.resolve_event(key) {
return action.event(self);
}
if !self.is_open() {
return None;
}
typeahead(key).map(Event::Typeahead)
}
fn open(&mut self) {
if !self.is_open() {
self.levels.push(None);
}
}
fn open_on(&mut self, edge: Edge) {
self.levels = vec![edge.find(&self.entries)];
}
fn active_depth(&self) -> usize {
self.levels.iter().rposition(Option::is_some).unwrap_or(0)
}
fn active_path(&self) -> Option<Vec<usize>> {
let depth = self.active_depth();
self.levels.get(..=depth)?.iter().copied().collect()
}
fn entries_at(&self, depth: usize) -> Option<&[Entry<Id>]> {
let mut entries = self.entries.as_slice();
for level in self.levels.get(..depth)? {
entries = entries.get((*level)?)?.as_item()?.submenu()?;
}
Some(entries)
}
fn item_at(&self, path: &[usize]) -> Option<&Item<Id>> {
let (&index, parents) = path.split_last()?;
let mut entries = self.entries.as_slice();
for &parent in parents {
entries = entries.get(parent)?.as_item()?.submenu()?;
}
entries.get(index)?.as_item()
}
fn step(&mut self, step: Step) {
let depth = self.active_depth();
let Some(entries) = self.entries_at(depth) else {
return;
};
let current = self.highlighted_at(depth);
let target = match step {
Step::Next => next_enabled(entries, current, true, |_| true),
Step::Previous => next_enabled(entries, current, false, |_| true),
Step::Edge(edge) => edge.find(entries),
};
self.set_highlight(depth, target);
}
fn typeahead(&mut self, character: char) {
let depth = self.active_depth();
let Some(entries) = self.entries_at(depth) else {
return;
};
let wanted = character.to_lowercase().collect::<String>();
let target = next_enabled(entries, self.highlighted_at(depth), true, |item| {
item.label.to_lowercase().starts_with(&wanted)
});
self.set_highlight(depth, target);
}
fn set_highlight(&mut self, depth: usize, target: Option<usize>) {
if target.is_none() {
return;
}
self.levels.truncate(depth + 1);
if let Some(level) = self.levels.get_mut(depth) {
*level = target;
}
}
fn highlight(&mut self, id: Id) {
let Some(path) = path_of(&self.entries, id) else {
return;
};
let Some(item) = self.item_at(&path).filter(|item| item.enabled()) else {
return;
};
let opens = item.submenu().is_some();
let target = some(&path);
if self.levels.len() > target.len() && self.levels.starts_with(&target) {
return;
}
self.levels = target;
if opens {
self.levels.push(None);
}
}
fn open_submenu(&mut self) {
let depth = self.active_depth();
let Some(path) = self.active_path() else {
return;
};
let Some(children) = self
.item_at(&path)
.filter(|item| item.enabled())
.and_then(Item::submenu)
else {
return;
};
let first = Edge::First.find(children);
self.levels.truncate(depth + 1);
self.levels.push(first);
}
fn close_submenu(&mut self) {
if self.levels.len() <= 1 {
return;
}
let keep = self.active_depth().max(1);
self.levels.truncate(keep);
}
fn activate(&mut self, id: Id) -> Option<Output<Id>> {
let path = path_of(&self.entries, id)?;
let item = self.item_at(&path).filter(|item| item.enabled())?;
if item.submenu().is_some() {
self.highlight(id);
return None;
}
self.choose(id)
}
fn activate_highlighted(&mut self) -> Option<Output<Id>> {
let path = self.active_path()?;
let item = self.item_at(&path).filter(|item| item.enabled())?;
if item.submenu().is_some() {
self.open_submenu();
return None;
}
let id = item.id;
self.choose(id)
}
fn choose(&mut self, id: Id) -> Option<Output<Id>> {
let output = match self.item(id)?.kind {
Kind::Action => Output::Activated(id),
Kind::Checkbox(checked) => {
self.set_checked(id, !checked);
Output::Toggled(id, !checked)
}
Kind::Radio(_) => {
self.set_checked(id, true);
Output::Selected(id)
}
Kind::Submenu(_) => return None,
};
self.levels.clear();
Some(output)
}
}
fn some(path: &[usize]) -> Vec<Option<usize>> {
path.iter().copied().map(Some).collect()
}
#[derive(Debug, Clone, Copy)]
enum Edge {
First,
Last,
}
impl Edge {
fn find<Id>(self, entries: &[Entry<Id>]) -> Option<usize> {
let enabled = |(_, entry): &(usize, &Entry<Id>)| entry.enabled_item().is_some();
match self {
Edge::First => entries.iter().enumerate().find(enabled),
Edge::Last => entries.iter().enumerate().rev().find(enabled),
}
.map(|(index, _)| index)
}
}
#[derive(Debug, Clone, Copy)]
enum Step {
Next,
Previous,
Edge(Edge),
}
fn next_enabled<Id>(
entries: &[Entry<Id>],
from: Option<usize>,
forward: bool,
accept: impl Fn(&Item<Id>) -> bool,
) -> Option<usize> {
let len = entries.len();
if len == 0 {
return None;
}
let start = from.unwrap_or(if forward { len - 1 } else { 0 });
let offset = if forward { 1 } else { len - 1 };
(1..=len)
.map(|n| (start + offset * n) % len)
.find(|&index| {
entries
.get(index)
.and_then(Entry::enabled_item)
.is_some_and(&accept)
})
}
fn path_of<Id: Copy + PartialEq>(entries: &[Entry<Id>], id: Id) -> Option<Vec<usize>> {
entries.iter().enumerate().find_map(|(index, entry)| {
let item = entry.as_item()?;
if item.id == id {
return Some(vec![index]);
}
let mut path = path_of(item.submenu()?, id)?;
path.insert(0, index);
Some(path)
})
}
fn entries_mut<'a, Id>(
entries: &'a mut Vec<Entry<Id>>,
parents: &[usize],
) -> Option<&'a mut Vec<Entry<Id>>> {
let Some((&first, rest)) = parents.split_first() else {
return Some(entries);
};
match entries.get_mut(first)? {
Entry::Item(Item {
kind: Kind::Submenu(children),
..
}) => entries_mut(children, rest),
_ => None,
}
}
fn radio_group<Id>(entries: &[Entry<Id>], index: usize) -> std::ops::Range<usize> {
let is_radio = |entry: &Entry<Id>| {
matches!(
entry,
Entry::Item(Item {
kind: Kind::Radio(_),
..
})
)
};
let start = entries
.get(..index)
.and_then(|before| before.iter().rposition(|entry| !is_radio(entry)))
.map_or(0, |position| position + 1);
let end = entries
.get(index..)
.and_then(|after| after.iter().position(|entry| !is_radio(entry)))
.map_or(entries.len(), |position| index + position);
start..end
}
pub fn typeahead(key: &keys::Event) -> Option<char> {
let modifiers = key.modifiers;
if modifiers.control() || modifiers.alt() || modifiers.logo() {
return None;
}
let Key::Character(text) = &key.key else {
return None;
};
let mut chars = text.chars();
let character = chars.next()?;
if chars.next().is_some() || !character.is_alphanumeric() {
return None;
}
Some(character)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Action {
Open,
Next,
Previous,
First,
Last,
Activate,
Close,
OpenSubmenu,
CloseSubmenu,
}
impl Action {
pub fn event<Id: Copy + PartialEq>(self, state: &State<Id>) -> Option<Event<Id>> {
if !state.is_open() {
return (self == Action::Open).then_some(Event::First);
}
match self {
Action::Open => None,
Action::Next => Some(Event::Next),
Action::Previous => Some(Event::Previous),
Action::First => Some(Event::First),
Action::Last => Some(Event::Last),
Action::Activate => Some(Event::ActivateHighlighted),
Action::Close if state.depth() > 1 => Some(Event::CloseSubmenu),
Action::Close => Some(Event::Close),
Action::OpenSubmenu => Some(Event::OpenSubmenu),
Action::CloseSubmenu => Some(Event::CloseSubmenu),
}
}
}
impl keys::Action for Action {
const ALL: &'static [Self] = &[
Action::Open,
Action::Next,
Action::Previous,
Action::First,
Action::Last,
Action::Activate,
Action::Close,
Action::OpenSubmenu,
Action::CloseSubmenu,
];
fn defaults() -> Keymap<Self> {
default_keymap()
}
fn name(self) -> &'static str {
match self {
Action::Open => "Open",
Action::Next => "Next",
Action::Previous => "Previous",
Action::First => "First",
Action::Last => "Last",
Action::Activate => "Activate",
Action::Close => "Close",
Action::OpenSubmenu => "OpenSubmenu",
Action::CloseSubmenu => "CloseSubmenu",
}
}
fn description(self) -> &'static str {
match self {
Action::Open => "Opens the menu on its first item.",
Action::Next => "Highlights the next enabled item, wrapping.",
Action::Previous => "Highlights the previous enabled item, wrapping.",
Action::First => "Highlights the first enabled item.",
Action::Last => "Highlights the last enabled item.",
Action::Activate => "Chooses the highlighted item or opens its submenu.",
Action::Close => "Closes the innermost submenu, or the menu.",
Action::OpenSubmenu => "Opens the highlighted submenu.",
Action::CloseSubmenu => "Closes the innermost submenu.",
}
}
}
pub fn default_keymap() -> Keymap<Action> {
Keymap::new()
.bind(Chord::named(Named::ArrowDown), Action::Next)
.bind(Chord::named(Named::ArrowUp), Action::Previous)
.bind(Chord::named(Named::Home), Action::First)
.bind(Chord::named(Named::End), Action::Last)
.bind(Chord::named(Named::Enter), Action::Activate)
.bind(Chord::named(Named::Space), Action::Activate)
.bind(Chord::named(Named::Escape), Action::Close)
.bind(Chord::named(Named::ArrowRight), Action::OpenSubmenu)
.bind(Chord::named(Named::ArrowLeft), Action::CloseSubmenu)
}
pub struct DropdownMenu<'a, Id, Message> {
state: &'a State<Id>,
trigger: Element<'a, Message>,
placement: Placement,
width: f32,
keymap: Keymap<Action>,
on_event: Option<Box<dyn Fn(Event<Id>) -> Message + 'a>>,
}
impl<Id: std::fmt::Debug, Message> std::fmt::Debug for DropdownMenu<'_, Id, Message> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DropdownMenu")
.field("state", self.state)
.field("placement", &self.placement)
.field("width", &self.width)
.field("keymap", &self.keymap)
.finish_non_exhaustive()
}
}
pub fn dropdown_menu<'a, Id, Message>(
state: &'a State<Id>,
trigger: impl Into<Element<'a, Message>>,
) -> DropdownMenu<'a, Id, Message> {
DropdownMenu {
state,
trigger: trigger.into(),
placement: Placement::new(Side::Bottom, Align::Start),
width: WIDTH,
keymap: default_keymap(),
on_event: None,
}
}
impl<'a, Id, Message> DropdownMenu<'a, Id, Message> {
pub fn side(mut self, side: Side) -> Self {
self.placement.side = side;
self
}
pub fn align(mut self, align: Align) -> Self {
self.placement.align = align;
self
}
pub fn gap(mut self, gap: f32) -> Self {
self.placement.gap = gap;
self
}
pub fn width(mut self, width: f32) -> Self {
self.width = width;
self
}
pub fn keymap(mut self, keymap: Keymap<Action>) -> Self {
self.keymap = keymap;
self
}
pub fn on_event(mut self, on_event: impl Fn(Event<Id>) -> Message + 'a) -> Self {
self.on_event = Some(Box::new(on_event));
self
}
}
impl<'a, Id, Message> From<DropdownMenu<'a, Id, Message>> for Element<'a, Message>
where
Id: Copy + PartialEq + 'a,
Message: Clone + 'a,
{
fn from(menu: DropdownMenu<'a, Id, Message>) -> Self {
let DropdownMenu {
state,
trigger,
placement,
width,
keymap,
on_event,
} = menu;
let on_event: Option<Rc<dyn Fn(Event<Id>) -> Message + 'a>> = on_event.map(Rc::from);
let panel = state
.is_open()
.then(|| panel(state, on_event.as_deref(), width));
let anchored = anchored(trigger)
.content(panel)
.placement(placement)
.dismiss_keys([]);
let Some(on_event) = on_event.filter(|_| state.is_open()) else {
return anchored.into();
};
anchored
.on_dismiss(on_event(Event::Close))
.on_key(move |key| state.key_event(&keymap, key).map(&*on_event))
.into()
}
}
pub(crate) fn panel<'a, Id, Message>(
state: &'a State<Id>,
on_event: Option<&dyn Fn(Event<Id>) -> Message>,
width: f32,
) -> Element<'a, Message>
where
Id: Copy + PartialEq + 'a,
Message: Clone + 'a,
{
level(state, &state.entries, 0, on_event, width)
}
fn level<'a, Id, Message>(
state: &'a State<Id>,
entries: &'a [Entry<Id>],
depth: usize,
on_event: Option<&dyn Fn(Event<Id>) -> Message>,
width: f32,
) -> Element<'a, Message>
where
Id: Copy + PartialEq + 'a,
Message: Clone + 'a,
{
let highlighted = state.highlighted_at(depth);
let submenu_open = state.depth() > depth + 1;
let indented = has_marks(entries);
let slot = reserves_slot(entries);
let rows = entries
.iter()
.enumerate()
.map(|(index, entry)| match entry {
Entry::Separator => menu::separator(),
Entry::Label(label) => menu::group_label(label, indented),
Entry::Item(item) => {
let is_highlighted = highlighted == Some(index);
let row = item_row(item, is_highlighted, slot, on_event);
let Some(children) = item.submenu().filter(|_| is_highlighted && submenu_open)
else {
return row;
};
anchored(row)
.content(Some(level(state, children, depth + 1, on_event, width)))
.placement(
Placement::new(Side::Right, Align::Start)
.gap(space::XS)
.offset(-(space::XS + 1.0)),
)
.pass_through(true)
.into()
}
});
menu::surface(column(rows).width(width)).into()
}
fn has_marks<Id>(entries: &[Entry<Id>]) -> bool {
entries.iter().any(|entry| {
matches!(
entry.as_item().map(|item| &item.kind),
Some(Kind::Checkbox(_) | Kind::Radio(_))
)
})
}
fn reserves_slot<Id>(entries: &[Entry<Id>]) -> bool {
has_marks(entries)
|| entries
.iter()
.any(|entry| entry.as_item().is_some_and(|item| item.icon.is_some()))
}
fn leading<Id>(item: &Item<Id>, slot: bool) -> Leading {
match (&item.kind, slot) {
(_, false) => Leading::None,
(Kind::Checkbox(true), true) => Leading::Check,
(Kind::Radio(true), true) => Leading::Dot,
(_, true) => Leading::Empty,
}
}
fn item_row<'a, Id, Message>(
item: &'a Item<Id>,
highlighted: bool,
slot: bool,
on_event: Option<&dyn Fn(Event<Id>) -> Message>,
) -> Element<'a, Message>
where
Id: Copy + 'a,
Message: Clone + 'a,
{
let enabled = on_event.filter(|_| item.enabled());
let status = match (enabled.is_some(), highlighted) {
(false, _) => RowStatus::Disabled,
(true, true) => RowStatus::Highlighted,
(true, false) => RowStatus::Idle,
};
let leading = leading(item, slot);
let trailing = if item.submenu().is_some() {
Trailing::Chevron
} else {
Trailing::None
};
let parts = Parts {
icon: item.icon,
hint: item.shortcut.as_deref(),
leading,
trailing,
..Parts::label(&item.label)
};
let messages = enabled.map(|on_event| {
(
on_event(Event::Highlight(item.id)),
on_event(Event::Activate(item.id)),
)
});
menu::item(parts, status, item.destructive, messages)
}
#[cfg(test)]
mod tests {
use super::*;
use iced::keyboard::Modifiers;
use iced::widget::text;
const COPY: u8 = 1;
const PASTE: u8 = 2;
const DELETE: u8 = 3;
const SHARE: u8 = 4;
const EMAIL: u8 = 5;
const LINK: u8 = 6;
const GRID: u8 = 7;
const SMALL: u8 = 8;
const LARGE: u8 = 9;
const PREVIEW: u8 = 10;
fn state() -> State<u8> {
State::new([
group_label("Edit"),
item(COPY, "Copy").shortcut("Ctrl+C"),
item(PASTE, "Paste").disabled(true),
item(DELETE, "Delete").destructive(true),
separator(),
submenu(
SHARE,
"Share",
[item(EMAIL, "Email"), item(LINK, "Copy link")],
),
checkbox_item(GRID, "Show grid", true),
separator(),
radio_item(SMALL, "Small", true),
radio_item(LARGE, "Large", false),
item(PREVIEW, "Preview"),
])
}
fn open() -> State<u8> {
let mut state = state();
let _ = state.update(Event::Open);
state
}
#[test]
fn builders_set_item_fields_and_ignore_labels() {
let entry: Entry<u8> = item(1, "Save")
.icon(crate::lucide!(Save))
.shortcut("Ctrl+S")
.disabled(true)
.destructive(true);
let item = entry.as_item().unwrap();
assert_eq!(item.label, "Save");
assert_eq!(item.icon, Some(crate::lucide!(Save)));
assert_eq!(item.shortcut.as_deref(), Some("Ctrl+S"));
assert!(item.disabled && item.destructive);
assert_eq!(item.kind, Kind::Action);
let label: Entry<u8> = group_label("Group")
.icon(crate::lucide!(Save))
.disabled(true);
assert_eq!(label, Entry::Label("Group".into()));
assert_eq!(separator::<u8>().shortcut("X"), Entry::Separator);
}
#[test]
fn starts_closed_and_opens_without_a_highlight() {
let mut state = state();
assert!(!state.is_open());
assert_eq!(state.depth(), 0);
assert_eq!(state.update(Event::Open), None);
assert!(state.is_open());
assert_eq!(state.highlighted(), None);
let _ = state.update(Event::Open);
assert_eq!(state.depth(), 1);
}
#[test]
fn toggle_and_close() {
let mut state = state();
let _ = state.update(Event::Toggle);
assert!(state.is_open());
let _ = state.update(Event::Toggle);
assert!(!state.is_open());
let _ = state.update(Event::Next);
let _ = state.update(Event::Close);
assert!(!state.is_open());
assert_eq!(state.highlighted(), None);
}
#[test]
fn next_skips_labels_separators_and_disabled_items_and_wraps() {
let mut state = open();
let mut seen = Vec::new();
for _ in 0..8 {
let _ = state.update(Event::Next);
seen.push(state.highlighted().unwrap());
}
assert_eq!(
seen,
[COPY, DELETE, SHARE, GRID, SMALL, LARGE, PREVIEW, COPY]
);
}
#[test]
fn previous_wraps_backwards() {
let mut state = open();
let _ = state.update(Event::Previous);
assert_eq!(state.highlighted(), Some(PREVIEW));
let _ = state.update(Event::First);
let _ = state.update(Event::Previous);
assert_eq!(state.highlighted(), Some(PREVIEW));
}
#[test]
fn first_and_last() {
let mut state = open();
let _ = state.update(Event::Last);
assert_eq!(state.highlighted(), Some(PREVIEW));
let _ = state.update(Event::First);
assert_eq!(state.highlighted(), Some(COPY));
}
#[test]
fn moving_opens_a_closed_menu_on_an_end() {
let mut down = state();
let _ = down.update(Event::Next);
assert!(down.is_open());
assert_eq!(down.highlighted(), Some(COPY));
let mut up = state();
let _ = up.update(Event::Previous);
assert_eq!(up.highlighted(), Some(PREVIEW));
let mut enter = state();
assert_eq!(enter.update(Event::ActivateHighlighted), None);
assert_eq!(enter.highlighted(), Some(COPY));
}
#[test]
fn empty_and_all_disabled_menus_never_highlight() {
let mut empty: State<u8> = State::new([]);
let _ = empty.update(Event::Next);
assert!(empty.is_open());
assert_eq!(empty.highlighted(), None);
assert_eq!(empty.update(Event::ActivateHighlighted), None);
let mut disabled = State::new([item(1u8, "A").disabled(true), separator()]);
let _ = disabled.update(Event::Next);
let _ = disabled.update(Event::Last);
assert_eq!(disabled.highlighted(), None);
assert_eq!(disabled.update(Event::Activate(1)), None);
}
#[test]
fn activating_an_action_returns_it_and_closes() {
let mut state = open();
assert_eq!(
state.update(Event::Activate(COPY)),
Some(Output::Activated(COPY))
);
assert!(!state.is_open());
}
#[test]
fn disabled_and_unknown_items_do_nothing() {
let mut state = open();
assert_eq!(state.update(Event::Activate(PASTE)), None);
assert_eq!(state.update(Event::Activate(99)), None);
let _ = state.update(Event::Highlight(PASTE));
assert_eq!(state.highlighted(), None);
assert!(state.is_open());
}
#[test]
fn enter_activates_the_highlighted_item() {
let mut state = open();
let _ = state.update(Event::Next);
let _ = state.update(Event::Next);
assert_eq!(
state.update(Event::ActivateHighlighted),
Some(Output::Activated(DELETE))
);
let mut nothing = open();
assert_eq!(nothing.update(Event::ActivateHighlighted), None);
assert!(nothing.is_open());
}
#[test]
fn checkbox_items_toggle() {
let mut state = open();
assert!(state.is_checked(GRID));
assert_eq!(
state.update(Event::Activate(GRID)),
Some(Output::Toggled(GRID, false))
);
assert!(!state.is_checked(GRID));
let _ = state.update(Event::Open);
assert_eq!(
state.update(Event::Activate(GRID)),
Some(Output::Toggled(GRID, true))
);
}
#[test]
fn radio_items_check_one_of_their_run() {
let mut state = open();
assert_eq!(
state.update(Event::Activate(LARGE)),
Some(Output::Selected(LARGE))
);
assert!(state.is_checked(LARGE));
assert!(!state.is_checked(SMALL));
state.set_checked(SMALL, true);
assert!(state.is_checked(SMALL));
assert!(!state.is_checked(LARGE));
state.set_checked(SMALL, false);
assert!(!state.is_checked(SMALL));
}
#[test]
fn radio_group_is_the_adjacent_run() {
let entries: Vec<Entry<u8>> = vec![
radio_item(1, "A", false),
separator(),
radio_item(2, "B", false),
radio_item(3, "C", false),
item(4, "D"),
radio_item(5, "E", false),
];
assert_eq!(radio_group(&entries, 0), 0..1);
assert_eq!(radio_group(&entries, 2), 2..4);
assert_eq!(radio_group(&entries, 3), 2..4);
assert_eq!(radio_group(&entries, 5), 5..6);
}
#[test]
fn set_checked_ignores_actions_and_unknown_ids() {
let mut state = state();
state.set_checked(COPY, true);
state.set_checked(99, true);
assert!(!state.is_checked(COPY));
assert_eq!(state, self::state());
}
#[test]
fn set_disabled_drops_the_highlight() {
let mut state = open();
let _ = state.update(Event::Highlight(DELETE));
state.set_disabled(DELETE, true);
assert_eq!(state.highlighted(), None);
assert!(state.item(DELETE).unwrap().disabled);
state.set_disabled(PASTE, false);
let _ = state.update(Event::First);
let _ = state.update(Event::Next);
assert_eq!(state.highlighted(), Some(PASTE));
}
#[test]
fn disabling_an_item_in_another_submenu_keeps_the_active_highlight() {
let mut state = State::new([
submenu(1, "Share", [item(2, "Email")]),
submenu(3, "Export", [item(4, "PDF")]),
]);
let _ = state.update(Event::Open);
let _ = state.update(Event::Highlight(2));
state.set_disabled(4, true);
assert_eq!(state.highlighted(), Some(2));
assert_eq!(
state.update(Event::ActivateHighlighted),
Some(Output::Activated(2))
);
}
#[test]
fn pointer_on_a_submenu_opens_it_without_a_highlight() {
let mut state = open();
let _ = state.update(Event::Highlight(SHARE));
assert_eq!(state.depth(), 2);
assert_eq!(state.highlighted(), Some(SHARE));
assert_eq!(state.highlighted_at(1), None);
let _ = state.update(Event::Highlight(LINK));
assert_eq!(state.highlighted(), Some(LINK));
let _ = state.update(Event::Highlight(SHARE));
assert_eq!(
state.highlighted(),
Some(LINK),
"returning to the parent keeps the submenu"
);
let _ = state.update(Event::Highlight(COPY));
assert_eq!(state.depth(), 1, "another item closes the submenu");
}
#[test]
fn keyboard_opens_and_closes_submenus() {
let mut state = open();
let _ = state.update(Event::Highlight(SHARE));
let _ = state.update(Event::CloseSubmenu);
assert_eq!(state.depth(), 1);
assert_eq!(state.highlighted(), Some(SHARE));
let _ = state.update(Event::OpenSubmenu);
assert_eq!(state.highlighted(), Some(EMAIL));
let _ = state.update(Event::Next);
assert_eq!(state.highlighted(), Some(LINK));
let _ = state.update(Event::Next);
assert_eq!(state.highlighted(), Some(EMAIL), "wraps inside the submenu");
let _ = state.update(Event::CloseSubmenu);
assert_eq!(state.depth(), 1);
assert_eq!(state.highlighted(), Some(SHARE));
let _ = state.update(Event::CloseSubmenu);
assert!(state.is_open(), "the root menu stays open");
}
#[test]
fn enter_on_a_submenu_opens_it_and_items_inside_activate() {
let mut state = open();
let _ = state.update(Event::Highlight(SHARE));
let _ = state.update(Event::CloseSubmenu);
assert_eq!(state.update(Event::ActivateHighlighted), None);
assert_eq!(state.highlighted(), Some(EMAIL));
assert_eq!(
state.update(Event::ActivateHighlighted),
Some(Output::Activated(EMAIL))
);
assert!(!state.is_open());
}
#[test]
fn clicking_a_submenu_item_opens_it() {
let mut state = open();
assert_eq!(state.update(Event::Activate(SHARE)), None);
assert_eq!(state.depth(), 2);
assert_eq!(
state.update(Event::Activate(LINK)),
Some(Output::Activated(LINK))
);
}
#[test]
fn open_submenu_ignores_plain_items() {
let mut state = open();
let _ = state.update(Event::First);
let _ = state.update(Event::OpenSubmenu);
assert_eq!(state.depth(), 1);
}
#[test]
fn moving_in_the_parent_closes_the_submenu() {
let mut state = open();
let _ = state.update(Event::Highlight(SHARE));
let _ = state.update(Event::Next);
assert_eq!(state.depth(), 1);
assert_eq!(state.highlighted(), Some(GRID));
}
#[test]
fn typeahead_jumps_to_matching_labels_and_cycles() {
let mut state = open();
let _ = state.update(Event::Typeahead('s'));
assert_eq!(state.highlighted(), Some(SHARE));
let _ = state.update(Event::Typeahead('S'));
assert_eq!(state.highlighted(), Some(GRID));
let _ = state.update(Event::Typeahead('s'));
assert_eq!(state.highlighted(), Some(SMALL));
let _ = state.update(Event::Typeahead('s'));
assert_eq!(state.highlighted(), Some(SHARE));
let _ = state.update(Event::Typeahead('p'));
assert_eq!(
state.highlighted(),
Some(PREVIEW),
"skips the disabled Paste"
);
let _ = state.update(Event::Typeahead('z'));
assert_eq!(state.highlighted(), Some(PREVIEW));
}
#[test]
fn typeahead_starts_at_the_first_match() {
let mut state = open();
let _ = state.update(Event::Typeahead('c'));
assert_eq!(state.highlighted(), Some(COPY));
}
fn key(key: Key, modifiers: Modifiers) -> keys::Event {
keys::Event { key, modifiers }
}
fn character(c: &str) -> Key {
Key::Character(c.into())
}
#[test]
fn typeahead_accepts_plain_letters_and_digits_only() {
let none = Modifiers::empty();
assert_eq!(typeahead(&key(character("a"), none)), Some('a'));
assert_eq!(typeahead(&key(character("A"), Modifiers::SHIFT)), Some('A'));
assert_eq!(typeahead(&key(character("7"), none)), Some('7'));
assert_eq!(typeahead(&key(character("a"), Modifiers::CTRL)), None);
assert_eq!(typeahead(&key(character("a"), Modifiers::ALT)), None);
assert_eq!(typeahead(&key(character(" "), none)), None);
assert_eq!(typeahead(&key(character("/"), none)), None);
assert_eq!(typeahead(&key(character("ab"), none)), None);
assert_eq!(typeahead(&key(Key::Named(Named::Enter), none)), None);
}
#[test]
fn key_event_uses_the_keymap_then_typeahead() {
let keymap = default_keymap();
let closed = state();
let down = key(Key::Named(Named::ArrowDown), Modifiers::empty());
let enter = key(Key::Named(Named::Enter), Modifiers::empty());
let letter = key(character("d"), Modifiers::empty());
assert_eq!(
closed.key_event(&keymap, &down),
None,
"closed menus claim no keys"
);
assert_eq!(closed.key_event(&keymap, &enter), None);
assert_eq!(closed.key_event(&keymap, &letter), None);
let bound = default_keymap().bind(Chord::named(Named::F2), Action::Open);
let f2 = key(Key::Named(Named::F2), Modifiers::empty());
assert_eq!(closed.key_event(&bound, &f2), Some(Event::First));
let open = open();
assert_eq!(
open.key_event(&keymap, &letter),
Some(Event::Typeahead('d'))
);
let escape = key(Key::Named(Named::Escape), Modifiers::empty());
assert_eq!(open.key_event(&keymap, &escape), Some(Event::Close));
}
fn press(keymap: &Keymap<Action>, chord: &str) -> Option<Action> {
let chord: Chord = chord.parse().unwrap();
keymap.resolve(chord.key(), chord.modifiers())
}
#[test]
fn default_keymap_covers_every_action() {
let keymap = default_keymap();
assert_eq!(press(&keymap, "ArrowDown"), Some(Action::Next));
assert_eq!(press(&keymap, "ArrowUp"), Some(Action::Previous));
assert_eq!(press(&keymap, "Home"), Some(Action::First));
assert_eq!(press(&keymap, "End"), Some(Action::Last));
assert_eq!(press(&keymap, "Enter"), Some(Action::Activate));
assert_eq!(press(&keymap, "Space"), Some(Action::Activate));
assert_eq!(press(&keymap, "Escape"), Some(Action::Close));
assert_eq!(press(&keymap, "ArrowRight"), Some(Action::OpenSubmenu));
assert_eq!(press(&keymap, "ArrowLeft"), Some(Action::CloseSubmenu));
assert_eq!(press(&keymap, "Tab"), None);
for action in <Action as keys::Action>::ALL {
let bound = !keymap.chords(action).is_empty();
assert_eq!(bound, *action != Action::Open, "{action:?}");
}
assert_eq!(Keymap::<Action>::defaults(), keymap);
}
#[test]
fn keymap_overrides_and_unbinding() {
let keymap = default_keymap()
.unbind(&"Space".parse().unwrap())
.bind("J".parse().unwrap(), Action::Next);
assert_eq!(press(&keymap, "Space"), None);
assert_eq!(press(&keymap, "j"), Some(Action::Next));
assert_eq!(press(&keymap, "Enter"), Some(Action::Activate));
}
#[test]
fn a_closed_menu_only_answers_open() {
let closed = state();
assert_eq!(Action::Open.event(&closed), Some(Event::First));
for &action in <Action as keys::Action>::ALL {
if action != Action::Open {
assert_eq!(action.event(&closed), None, "{action:?}");
}
}
}
#[test]
fn an_open_menu_maps_every_action_but_open() {
let open = open();
assert_eq!(Action::Open.event(&open), None);
assert_eq!(Action::Next.event(&open), Some(Event::Next));
assert_eq!(Action::Previous.event(&open), Some(Event::Previous));
assert_eq!(Action::First.event(&open), Some(Event::First));
assert_eq!(Action::Last.event(&open), Some(Event::Last));
assert_eq!(
Action::Activate.event(&open),
Some(Event::ActivateHighlighted)
);
assert_eq!(Action::Close.event(&open), Some(Event::Close));
assert_eq!(Action::OpenSubmenu.event(&open), Some(Event::OpenSubmenu));
assert_eq!(Action::CloseSubmenu.event(&open), Some(Event::CloseSubmenu));
}
#[test]
fn close_in_a_submenu_closes_only_the_submenu() {
let mut state = open();
let _ = state.update(Event::Highlight(SHARE));
assert_eq!(Action::Close.event(&state), Some(Event::CloseSubmenu));
let _ = state.update(Event::CloseSubmenu);
assert_eq!(Action::Close.event(&state), Some(Event::Close));
}
#[test]
fn every_action_has_a_name_and_a_sentence() {
for &action in <Action as keys::Action>::ALL {
assert!(!keys::Action::name(action).is_empty());
assert!(keys::Action::description(action).ends_with('.'));
}
}
#[test]
fn builder_defaults() {
let state = state();
let menu: DropdownMenu<'_, u8, ()> = dropdown_menu(&state, text("Open"));
assert_eq!(menu.placement, Placement::new(Side::Bottom, Align::Start));
assert_eq!(menu.width, WIDTH);
assert_eq!(menu.keymap, default_keymap());
assert!(menu.on_event.is_none());
let menu: DropdownMenu<'_, u8, ()> = dropdown_menu(&state, text("Open"))
.side(Side::Top)
.align(Align::End)
.gap(8.0)
.width(300.0)
.keymap(Keymap::new())
.on_event(|_| ());
assert_eq!(menu.placement.side, Side::Top);
assert_eq!(menu.placement.align, Align::End);
assert_eq!(menu.placement.gap, 8.0);
assert_eq!(menu.width, 300.0);
assert!(menu.keymap.is_empty());
assert!(menu.on_event.is_some());
}
#[test]
fn a_slot_is_reserved_for_marks_or_icons() {
let plain: Vec<Entry<u8>> = vec![item(COPY, "Copy"), separator()];
assert!(!reserves_slot(&plain) && !has_marks(&plain));
let icons: Vec<Entry<u8>> = vec![
item(COPY, "Copy").icon(crate::lucide!(Copy)),
item(PASTE, "Paste"),
];
assert!(reserves_slot(&icons) && !has_marks(&icons));
let marks: Vec<Entry<u8>> = vec![item(COPY, "Copy"), radio_item(SMALL, "Small", false)];
assert!(reserves_slot(&marks) && has_marks(&marks));
}
#[test]
fn the_slot_holds_the_mark_while_checked_and_the_icon_otherwise() {
let entries: Vec<Entry<u8>> = vec![
checkbox_item(GRID, "Grid", true).icon(crate::lucide!(Grid3x3)),
checkbox_item(GRID, "Grid", false).icon(crate::lucide!(Grid3x3)),
radio_item(SMALL, "Small", true),
radio_item(LARGE, "Large", false),
item(COPY, "Copy").icon(crate::lucide!(Copy)),
];
let slots: Vec<Leading> = entries
.iter()
.filter_map(Entry::as_item)
.map(|item| leading(item, true))
.collect();
assert_eq!(
slots,
[
Leading::Check,
Leading::Empty,
Leading::Dot,
Leading::Empty,
Leading::Empty
]
);
let unslotted = entries.iter().filter_map(Entry::as_item);
assert!(
unslotted
.map(|item| leading(item, false))
.all(|l| l == Leading::None)
);
}
}