gpui-cn 0.4.0

shadcn-style components for GPUI, built on gpui-base.
Documentation
//! The rows a command palette is given: items, groups of them under a
//! heading, and separators.

use std::rc::Rc;

use gpui_kit::{Action, AnyElement, App, FocusHandle, IntoElement, SharedString, Window};

use crate::Icon;

/// The state of a row a custom renderer draws; see
/// [`CommandItem::render`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct CommandRow {
    pub(crate) highlighted: bool,
    pub(crate) disabled: bool,
}

impl CommandRow {
    /// Whether the keyboard or the pointer is on the row.
    pub fn is_highlighted(&self) -> bool {
        self.highlighted
    }

    /// Whether the row's command is disabled.
    pub fn is_disabled(&self) -> bool {
        self.disabled
    }
}

pub(crate) type RowRenderer = Rc<dyn Fn(CommandRow, &mut Window, &mut App) -> AnyElement>;

/// One command: a key the palette reports it by, a label, and what it does.
#[non_exhaustive]
pub struct CommandItem {
    key: SharedString,
    label: SharedString,
    /// Boxed: an [`Icon`] carries a whole style, which would make every
    /// row kilobytes wide.
    icon: Option<Box<Icon>>,
    /// The label and the keywords in lower case, one per line: what the
    /// query is looked for in.
    search_text: String,
    action: Option<Box<dyn Action>>,
    action_context: Option<FocusHandle>,
    disabled: bool,
    render: Option<RowRenderer>,
}

impl Clone for CommandItem {
    fn clone(&self) -> Self {
        Self {
            key: self.key.clone(),
            label: self.label.clone(),
            icon: self.icon.clone(),
            search_text: self.search_text.clone(),
            action: self.action.as_ref().map(|action| action.boxed_clone()),
            action_context: self.action_context.clone(),
            disabled: self.disabled,
            render: self.render.clone(),
        }
    }
}

impl CommandItem {
    /// A command with a key that is stable across renders and a label.
    pub fn new(key: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
        let label = label.into();
        Self {
            key: key.into(),
            search_text: label.to_lowercase(),
            label,
            icon: None,
            action: None,
            action_context: None,
            disabled: false,
            render: None,
        }
    }

    /// The icon before the label.
    pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
        self.icon = Some(Box::new(icon.into()));
        self
    }

    /// Words the query matches besides the label, such as "shell" for a
    /// terminal.
    pub fn keywords(mut self, keywords: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
        for keyword in keywords {
            self.search_text.push('\n');
            self.search_text.push_str(&keyword.into().to_lowercase());
        }
        self
    }

    /// Dispatches `action` when the command is chosen, and shows the
    /// action's shortcut at the end of the row.
    pub fn action(mut self, action: impl Action) -> Self {
        self.action = Some(Box::new(action));
        self
    }

    /// The element the action goes to, and whose key bindings its shortcut
    /// shows. Without one it goes up from the palette's search field.
    pub fn action_context(mut self, focus_handle: &FocusHandle) -> Self {
        self.action_context = Some(focus_handle.clone());
        self
    }

    /// Whether the command can be chosen. A disabled row shows muted and
    /// the keyboard passes over it.
    pub fn disabled(mut self, disabled: bool) -> Self {
        self.disabled = disabled;
        self
    }

    /// Draws the inside of the row in place of the icon, the label, and
    /// the shortcut. The palette keeps the row's frame: its padding, its
    /// highlight, and the pointer and the keyboard. The label is still
    /// what the query matches and what assistive technology reads. The
    /// renderer runs each time the row is drawn, so it must not change
    /// anything.
    pub fn render<E: IntoElement>(
        mut self,
        render: impl Fn(CommandRow, &mut Window, &mut App) -> E + 'static,
    ) -> Self {
        self.render = Some(Rc::new(move |row, window, cx| {
            render(row, window, cx).into_any_element()
        }));
        self
    }

    /// The key the palette reports this command by.
    pub fn key(&self) -> &SharedString {
        &self.key
    }

    /// The text of the row.
    pub fn label(&self) -> &SharedString {
        &self.label
    }

    /// Whether the command is disabled.
    pub fn is_disabled(&self) -> bool {
        self.disabled
    }

    /// Whether the label or a keyword contains `query`, which must be in
    /// lower case. An empty query matches everything.
    pub(crate) fn matches(&self, query: &str) -> bool {
        query.is_empty() || self.search_text.contains(query)
    }

    pub(crate) fn leading_icon(&self) -> Option<&Icon> {
        self.icon.as_deref()
    }

    pub(crate) fn dispatched_action(&self) -> Option<&dyn Action> {
        self.action.as_deref()
    }

    pub(crate) fn own_action_context(&self) -> Option<&FocusHandle> {
        self.action_context.as_ref()
    }

    pub(crate) fn renderer(&self) -> Option<&RowRenderer> {
        self.render.as_ref()
    }
}

/// Commands under a heading. A group with no command left after the query
/// hides with its heading.
#[derive(Clone)]
#[non_exhaustive]
pub struct CommandGroup {
    heading: Option<SharedString>,
    items: Vec<CommandItem>,
}

impl CommandGroup {
    /// A group with no heading.
    pub fn new() -> Self {
        Self {
            heading: None,
            items: Vec::new(),
        }
    }

    /// The muted heading over the group's commands.
    pub fn heading(mut self, heading: impl Into<SharedString>) -> Self {
        self.heading = Some(heading.into());
        self
    }

    /// Adds commands to the group.
    pub fn items(mut self, items: impl IntoIterator<Item = CommandItem>) -> Self {
        self.items.extend(items);
        self
    }
}

impl Default for CommandGroup {
    fn default() -> Self {
        Self::new()
    }
}

/// One entry of a palette.
#[derive(Clone)]
pub enum CommandEntry {
    /// A command outside any group.
    Item(CommandItem),
    /// Commands under a heading.
    Group(CommandGroup),
    /// A line between entries. One at either end, or next to another,
    /// once the query has filtered the rows, is not drawn.
    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)
    }
}

/// A row as the palette draws it, once the query has filtered the
/// entries.
#[derive(Clone)]
pub(crate) enum Row {
    Heading(SharedString),
    Separator,
    Item(CommandItem),
}

/// The rows of `entries` that match `query`, in lower case: groups with
/// no match drop with their heading, and separators keep only between
/// two runs of rows.
pub(crate) fn rows(entries: &[CommandEntry], query: &str) -> Vec<Row> {
    let mut rows = Vec::new();
    let mut pending_separator = false;
    let push = |block: Vec<Row>, rows: &mut Vec<Row>, separator: &mut bool| {
        if block.is_empty() {
            return;
        }
        if *separator && !rows.is_empty() {
            rows.push(Row::Separator);
        }
        *separator = false;
        rows.extend(block);
    };
    for entry in entries {
        match entry {
            CommandEntry::Separator => pending_separator = true,
            CommandEntry::Item(item) => {
                let block = if item.matches(query) {
                    vec![Row::Item(item.clone())]
                } else {
                    Vec::new()
                };
                push(block, &mut rows, &mut pending_separator);
            }
            CommandEntry::Group(group) => {
                let items: Vec<Row> = group
                    .items
                    .iter()
                    .filter(|item| item.matches(query))
                    .cloned()
                    .map(Row::Item)
                    .collect();
                let block = if items.is_empty() {
                    items
                } else {
                    group
                        .heading
                        .clone()
                        .map(Row::Heading)
                        .into_iter()
                        .chain(items)
                        .collect()
                };
                push(block, &mut rows, &mut pending_separator);
            }
        }
    }
    rows
}

#[cfg(test)]
mod tests {
    use super::*;

    fn shape(rows: &[Row]) -> Vec<String> {
        rows.iter()
            .map(|row| match row {
                Row::Heading(text) => format!("# {text}"),
                Row::Separator => "--".into(),
                Row::Item(item) => item.key().to_string(),
            })
            .collect()
    }

    #[test]
    fn a_query_matches_labels_and_keywords_and_drops_empty_groups() {
        let entries = vec![
            CommandItem::new("project", "Project").into(),
            CommandEntry::Separator,
            CommandGroup::new()
                .heading("Open")
                .items([
                    CommandItem::new("terminal", "Terminal").keywords(["Shell"]),
                    CommandItem::new("browser", "Browser"),
                ])
                .into(),
            CommandEntry::Separator,
            CommandItem::new("desktop", "Desktop").into(),
        ];
        assert_eq!(
            shape(&rows(&entries, "")),
            [
                "project", "--", "# Open", "terminal", "browser", "--", "desktop"
            ]
        );
        assert_eq!(shape(&rows(&entries, "shell")), ["# Open", "terminal"]);
        assert_eq!(
            shape(&rows(&entries, "o")),
            ["project", "--", "# Open", "browser", "--", "desktop"]
        );
        assert!(rows(&entries, "zzz").is_empty());
    }
}