use std::rc::Rc;
use gpui::{Action, AnyElement, App, IntoElement, SharedString, Window};
use crate::{Disableable, Icon};
pub struct CommandItem {
label: Option<SharedString>,
keywords: Vec<SharedString>,
pub(crate) icon: Option<Box<Icon>>,
pub(crate) action: Option<Box<dyn Action>>,
pub(crate) checked: bool,
disabled: bool,
pub(crate) content: Option<Rc<CommandItemContent>>,
}
impl Clone for CommandItem {
fn clone(&self) -> Self {
Self {
label: self.label.clone(),
keywords: self.keywords.clone(),
icon: self.icon.clone(),
action: self.action.as_ref().map(|action| action.boxed_clone()),
checked: self.checked,
disabled: self.disabled,
content: self.content.clone(),
}
}
}
impl CommandItem {
pub fn new() -> Self {
Self {
label: None,
keywords: Vec::new(),
icon: None,
action: None,
checked: false,
disabled: false,
content: None,
}
}
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
self.icon = Some(Box::new(icon.into()));
self
}
pub fn action(mut self, action: Box<dyn Action>) -> Self {
self.action = Some(action);
self
}
pub fn checked(mut self, checked: bool) -> Self {
self.checked = checked;
self
}
pub fn keywords<I, S>(mut self, keywords: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<SharedString>,
{
self.keywords
.extend(keywords.into_iter().map(|keyword| keyword.into()));
self
}
pub fn child<F, E>(mut self, builder: F) -> Self
where
F: Fn(&mut Window, &mut App) -> E + 'static,
E: IntoElement,
{
self.content = Some(Rc::new(move |window, cx| {
builder(window, cx).into_any_element()
}));
self
}
pub(crate) fn is_disabled(&self) -> bool {
self.disabled
}
pub(crate) fn matches(&self, query: &str) -> bool {
if query.is_empty() {
return true;
}
let query = query.to_lowercase();
self.label
.as_ref()
.is_some_and(|label| label.to_lowercase().contains(&query))
|| self
.keywords
.iter()
.any(|keyword| keyword.to_lowercase().contains(&query))
}
pub(crate) fn label_text(&self) -> Option<&SharedString> {
self.label.as_ref()
}
}
impl Default for CommandItem {
fn default() -> Self {
Self::new()
}
}
pub(crate) type CommandItemContent = dyn Fn(&mut Window, &mut App) -> AnyElement;
impl Disableable for CommandItem {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
pub struct CommandGroup {
heading: Option<SharedString>,
pub(crate) items: Vec<CommandItem>,
}
impl Clone for CommandGroup {
fn clone(&self) -> Self {
Self {
heading: self.heading.clone(),
items: self.items.clone(),
}
}
}
impl CommandGroup {
pub fn new() -> Self {
Self {
heading: None,
items: Vec::new(),
}
}
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.heading = Some(label.into());
self
}
pub fn item(mut self, item: CommandItem) -> Self {
self.items.push(item);
self
}
pub fn items(mut self, items: impl IntoIterator<Item = CommandItem>) -> Self {
self.items.extend(items);
self
}
pub fn heading(&self) -> Option<&SharedString> {
self.heading.as_ref()
}
}
pub enum CommandEntry {
Item(CommandItem),
Group(CommandGroup),
Separator,
}
impl Clone for CommandEntry {
fn clone(&self) -> Self {
match self {
Self::Item(item) => Self::Item(item.clone()),
Self::Group(group) => Self::Group(group.clone()),
Self::Separator => Self::Separator,
}
}
}
impl From<CommandItem> for CommandEntry {
fn from(item: CommandItem) -> Self {
Self::Item(item)
}
}
impl From<CommandGroup> for CommandEntry {
fn from(group: CommandGroup) -> Self {
Self::Group(group)
}
}
#[cfg(test)]
mod tests {
use std::{cell::Cell, rc::Rc};
use gpui::{TestAppContext, actions, div};
use super::*;
actions!(command_item_test, [CloneAction]);
#[gpui::test]
fn cloned_entries_keep_actions_and_lazy_children_usable(cx: &mut TestAppContext) {
let action_count = Rc::new(Cell::new(0));
let child_count = Rc::new(Cell::new(0));
let action_count_for_handler = action_count.clone();
cx.update(|cx| {
cx.on_action(move |_: &CloneAction, _| {
action_count_for_handler.set(action_count_for_handler.get() + 1);
});
});
let child_count_for_builder = child_count.clone();
let entry = CommandEntry::Group(
CommandGroup::new().label("Group").item(
CommandItem::new()
.label("cloneable")
.action(Box::new(CloneAction))
.child(move |_, _| {
child_count_for_builder.set(child_count_for_builder.get() + 1);
div()
}),
),
);
let cloned = entry.clone();
let CommandEntry::Group(group) = cloned else {
panic!("the cloned entry should remain a group");
};
let cloned_item = group.items.into_iter().next().unwrap();
let cx = cx.add_empty_window();
cx.update(|window, cx| {
let child = cloned_item.content.as_ref().unwrap().clone();
_ = child(window, cx);
window.dispatch_action(cloned_item.action.as_ref().unwrap().boxed_clone(), cx);
});
assert_eq!(child_count.get(), 1);
assert_eq!(action_count.get(), 1);
}
#[test]
fn label_is_optional_for_custom_content() {
assert_eq!(CommandItem::new().label_text(), None);
assert_eq!(
CommandItem::new().label("Calendar").label_text(),
Some(&"Calendar".into())
);
}
#[test]
fn matches_label_and_keywords() {
let item = CommandItem::new()
.label("Profile")
.keywords(["account", "user"]);
assert!(item.matches(""));
assert!(item.matches("PRO"));
assert!(item.matches("Account"));
assert!(!item.matches("billing"));
}
}