feat add command palette component
This commit is contained in:
@@ -36,6 +36,7 @@ full = [
|
|||||||
"field",
|
"field",
|
||||||
"status_dot",
|
"status_dot",
|
||||||
"combobox",
|
"combobox",
|
||||||
|
"command_palette",
|
||||||
"tooltip",
|
"tooltip",
|
||||||
"menu",
|
"menu",
|
||||||
"select",
|
"select",
|
||||||
@@ -62,7 +63,8 @@ segmented = []
|
|||||||
spinner = []
|
spinner = []
|
||||||
field = []
|
field = []
|
||||||
status_dot = []
|
status_dot = []
|
||||||
combobox = ["input"]
|
combobox = ["input", "scroll_area"]
|
||||||
|
command_palette = ["input", "scroll_area"]
|
||||||
tooltip = []
|
tooltip = []
|
||||||
menu = ["scroll_area"]
|
menu = ["scroll_area"]
|
||||||
select = ["menu"]
|
select = ["menu"]
|
||||||
|
|||||||
@@ -0,0 +1,493 @@
|
|||||||
|
//! The command palette: a modal query field over the application's actions.
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use gpui::prelude::FluentBuilder as _;
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, App, Bounds, Context, ElementId, Entity, InteractiveElement as _, IntoElement,
|
||||||
|
KeyBinding, MouseButton, MouseDownEvent, ParentElement as _, Pixels, Render, Role,
|
||||||
|
ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Window,
|
||||||
|
actions, canvas, div, px, relative,
|
||||||
|
};
|
||||||
|
use gpui_native_theme::{ActiveTheme as _, Size};
|
||||||
|
|
||||||
|
use crate::common::{h_flex, v_flex};
|
||||||
|
use crate::input::{Input, InputState, InputStateEvent};
|
||||||
|
use crate::scroll_area::{ScrollArea, ScrollAxes};
|
||||||
|
|
||||||
|
actions!(
|
||||||
|
command_palette,
|
||||||
|
[HighlightNext, HighlightPrevious, Choose, Dismiss]
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The key context the palette's panel declares.
|
||||||
|
pub const COMMAND_PALETTE_CONTEXT: &str = "CommandPalette";
|
||||||
|
|
||||||
|
/// Binds the palette's four keys: down, up, enter, and escape.
|
||||||
|
pub fn bind_command_palette_keys(cx: &mut App) {
|
||||||
|
let under_input = Some("CommandPalette > Input");
|
||||||
|
cx.bind_keys([
|
||||||
|
KeyBinding::new("down", HighlightNext, under_input),
|
||||||
|
KeyBinding::new("up", HighlightPrevious, under_input),
|
||||||
|
KeyBinding::new("enter", Choose, under_input),
|
||||||
|
KeyBinding::new("escape", Dismiss, under_input),
|
||||||
|
KeyBinding::new("down", HighlightNext, Some(COMMAND_PALETTE_CONTEXT)),
|
||||||
|
KeyBinding::new("up", HighlightPrevious, Some(COMMAND_PALETTE_CONTEXT)),
|
||||||
|
KeyBinding::new("enter", Choose, Some(COMMAND_PALETTE_CONTEXT)),
|
||||||
|
KeyBinding::new("escape", Dismiss, Some(COMMAND_PALETTE_CONTEXT)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One action the palette can run: a stable id and the label the matcher sees.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Command {
|
||||||
|
/// The stable domain id handed to `on_execute`.
|
||||||
|
pub id: SharedString,
|
||||||
|
/// What the matcher matches and the row shows.
|
||||||
|
pub label: SharedString,
|
||||||
|
/// Gray text on the row's right edge, e.g. the keybinding.
|
||||||
|
pub hint: Option<SharedString>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Command {
|
||||||
|
/// Creates a command without a hint.
|
||||||
|
pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
label: label.into(),
|
||||||
|
hint: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the row's right-edge hint.
|
||||||
|
pub fn hint(mut self, hint: impl Into<SharedString>) -> Self {
|
||||||
|
self.hint = Some(hint.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filters and ranks the commands for a query.
|
||||||
|
pub type CommandMatcher = Rc<dyn Fn(&str, &[Command]) -> Vec<Command>>;
|
||||||
|
/// Runs the chosen command.
|
||||||
|
pub type CommandHandler = Rc<dyn Fn(&Command, &mut Window, &mut App)>;
|
||||||
|
|
||||||
|
/// Builder for a command palette. Pass it to [`CommandPaletteState::new`].
|
||||||
|
pub struct CommandPalette {
|
||||||
|
id: ElementId,
|
||||||
|
name: SharedString,
|
||||||
|
placeholder: SharedString,
|
||||||
|
commands: Vec<Command>,
|
||||||
|
matcher: CommandMatcher,
|
||||||
|
on_execute: Option<CommandHandler>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandPalette {
|
||||||
|
/// Creates the builder, the caller-owned matcher decides what matches.
|
||||||
|
pub fn new<I, M>(id: I, commands: Vec<Command>, matcher: M) -> Self
|
||||||
|
where
|
||||||
|
I: Into<ElementId>,
|
||||||
|
M: Fn(&str, &[Command]) -> Vec<Command> + 'static,
|
||||||
|
{
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
name: SharedString::from("Command palette"),
|
||||||
|
placeholder: SharedString::from("Type a command..."),
|
||||||
|
commands,
|
||||||
|
matcher: Rc::new(matcher),
|
||||||
|
on_execute: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the text shown while the query is empty.
|
||||||
|
pub fn placeholder<L>(mut self, placeholder: L) -> Self
|
||||||
|
where
|
||||||
|
L: Into<SharedString>,
|
||||||
|
{
|
||||||
|
self.placeholder = placeholder.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs `handler` with the chosen command, then closes the palette.
|
||||||
|
pub fn on_execute<F>(mut self, handler: F) -> Self
|
||||||
|
where
|
||||||
|
F: Fn(&Command, &mut Window, &mut App) + 'static,
|
||||||
|
{
|
||||||
|
self.on_execute = Some(Rc::new(handler));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overrides the accessibility label of the panel.
|
||||||
|
pub fn accessibility_label<L>(mut self, label: L) -> Self
|
||||||
|
where
|
||||||
|
L: Into<SharedString>,
|
||||||
|
{
|
||||||
|
self.name = label.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stateful palette.
|
||||||
|
pub struct CommandPaletteState {
|
||||||
|
id: ElementId,
|
||||||
|
name: SharedString,
|
||||||
|
placeholder: SharedString,
|
||||||
|
commands: Vec<Command>,
|
||||||
|
matcher: CommandMatcher,
|
||||||
|
on_execute: Option<CommandHandler>,
|
||||||
|
query: Entity<InputState>,
|
||||||
|
/// The commands the matcher returned for the query, in display order.
|
||||||
|
matches: Vec<Command>,
|
||||||
|
/// The highlighted row, when the palette is open.
|
||||||
|
highlight: Option<usize>,
|
||||||
|
open: bool,
|
||||||
|
/// Keeps the query-event subscription tied to the state's lifetime.
|
||||||
|
_subscription: Subscription,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandPaletteState {
|
||||||
|
/// Builds the state, its query field, and the subscription tying them.
|
||||||
|
pub fn new(palette: CommandPalette, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let query = InputState::new(cx);
|
||||||
|
let subscription = cx.subscribe_in(&query, window, |state, _, event, window, cx| {
|
||||||
|
if event == &InputStateEvent::TextChanged {
|
||||||
|
state.text_changed(window, cx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let matches = (palette.matcher)("", &palette.commands);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
id: palette.id,
|
||||||
|
name: palette.name,
|
||||||
|
placeholder: palette.placeholder,
|
||||||
|
commands: palette.commands,
|
||||||
|
matcher: palette.matcher,
|
||||||
|
on_execute: palette.on_execute,
|
||||||
|
query,
|
||||||
|
matches,
|
||||||
|
highlight: None,
|
||||||
|
open: false,
|
||||||
|
_subscription: subscription,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the palette: fresh query, fresh matches, focus in the field.
|
||||||
|
pub fn open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
if self.open {
|
||||||
|
self.focus(window, cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.open = true;
|
||||||
|
|
||||||
|
self.query.update(cx, |query, cx| {
|
||||||
|
query.set_value_silent("", window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.refilter("");
|
||||||
|
self.highlight = (!self.matches.is_empty()).then_some(0);
|
||||||
|
self.focus(window, cx);
|
||||||
|
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes the palette.
|
||||||
|
pub fn close(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if !self.open {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.open = false;
|
||||||
|
self.highlight = None;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the palette when closed, closes it when open.
|
||||||
|
pub fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
if self.open {
|
||||||
|
self.close(cx);
|
||||||
|
} else {
|
||||||
|
self.open(window, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the palette is showing.
|
||||||
|
pub fn is_open(&self) -> bool {
|
||||||
|
self.open
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the command list and re-runs the current query's matches.
|
||||||
|
pub fn set_commands(&mut self, commands: Vec<Command>, cx: &mut Context<Self>) {
|
||||||
|
self.commands = commands;
|
||||||
|
let query = self.query.read(cx).value().to_string();
|
||||||
|
self.refilter(&query);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Focuses the query field.
|
||||||
|
pub fn focus(&self, window: &mut Window, cx: &mut App) {
|
||||||
|
window.focus(&self.query.read(cx).focus_handle(), cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks the caller's matcher who matches, and clamps the highlight.
|
||||||
|
fn refilter(&mut self, query: &str) {
|
||||||
|
self.matches = (self.matcher)(query, &self.commands);
|
||||||
|
if let Some(highlight) = self.highlight {
|
||||||
|
self.highlight = (highlight < self.matches.len()).then_some(highlight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Typing: fresh matches, highlight the first row.
|
||||||
|
fn text_changed(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let query = self.query.read(cx).value().to_string();
|
||||||
|
self.refilter(&query);
|
||||||
|
self.highlight = (!self.matches.is_empty()).then_some(0);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Down and Up over the matched rows, wrapping at the ends.
|
||||||
|
fn move_highlight(&mut self, delta: i32, cx: &mut Context<Self>) {
|
||||||
|
if self.matches.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let next = match self.highlight {
|
||||||
|
Some(row) => (row as i32 + delta).rem_euclid(self.matches.len() as i32) as usize,
|
||||||
|
None if delta > 0 => 0,
|
||||||
|
None => self.matches.len() - 1,
|
||||||
|
};
|
||||||
|
self.highlight = Some(next);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enter. Runs the highlighted command and closes.
|
||||||
|
fn choose(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let Some(row) = self.highlight else {
|
||||||
|
cx.propagate();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.choose_row(row, window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the command at a row and closes.
|
||||||
|
fn choose_row(&mut self, row: usize, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let Some(command) = self.matches.get(row) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let command = command.clone();
|
||||||
|
if let Some(on_execute) = self.on_execute.clone() {
|
||||||
|
on_execute(&command, window, cx);
|
||||||
|
}
|
||||||
|
self.close(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Escape. Closes the palette.
|
||||||
|
fn dismiss(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
if !self.open {
|
||||||
|
cx.propagate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.close(cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for CommandPaletteState {
|
||||||
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
if !self.open {
|
||||||
|
return div().into_any_element();
|
||||||
|
}
|
||||||
|
|
||||||
|
let (colors, radius, metrics, shadows) = {
|
||||||
|
let theme = cx.theme();
|
||||||
|
(
|
||||||
|
theme.colors,
|
||||||
|
theme.radius,
|
||||||
|
theme.sizes.resolve(Size::default()),
|
||||||
|
theme.elevations.dialog.shadows(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let id = self.id.clone();
|
||||||
|
let state = cx.entity();
|
||||||
|
|
||||||
|
let rows: Vec<AnyElement> = self
|
||||||
|
.matches
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(row, command)| {
|
||||||
|
let highlighted = self.highlight == Some(row);
|
||||||
|
let state = state.clone();
|
||||||
|
let row_ix = row;
|
||||||
|
|
||||||
|
h_flex()
|
||||||
|
.id(("command", row))
|
||||||
|
.debug_selector({
|
||||||
|
let name = format!("{id}-command-{row}");
|
||||||
|
move || name
|
||||||
|
})
|
||||||
|
.gap_2()
|
||||||
|
.px_2()
|
||||||
|
.py_1()
|
||||||
|
.rounded(radius.sm)
|
||||||
|
.when(highlighted, |row| row.bg(colors.muted))
|
||||||
|
.when(!highlighted, |row| row.hover(|row| row.bg(colors.muted)))
|
||||||
|
.on_mouse_move({
|
||||||
|
let state = state.clone();
|
||||||
|
move |_, _window, cx| {
|
||||||
|
if state.read(cx).highlight != Some(row_ix) {
|
||||||
|
state.update(cx, |state, cx| {
|
||||||
|
state.highlight = Some(row_ix);
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on_click({
|
||||||
|
let state = state.clone();
|
||||||
|
move |_, window, cx| {
|
||||||
|
state.update(cx, |state, cx| state.choose_row(row_ix, window, cx));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.overflow_hidden()
|
||||||
|
.whitespace_nowrap()
|
||||||
|
.text_ellipsis()
|
||||||
|
.child(command.label.clone()),
|
||||||
|
)
|
||||||
|
.when_some(command.hint.clone(), |row, hint| {
|
||||||
|
row.child(
|
||||||
|
div()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.text_size(metrics.caption_size)
|
||||||
|
.text_color(colors.muted_foreground)
|
||||||
|
.child(hint),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.into_any_element()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let rows: AnyElement = if rows.is_empty() {
|
||||||
|
div()
|
||||||
|
.px_2()
|
||||||
|
.py_1()
|
||||||
|
.text_size(metrics.caption_size)
|
||||||
|
.text_color(colors.muted_foreground)
|
||||||
|
.child("No matches")
|
||||||
|
.into_any_element()
|
||||||
|
} else {
|
||||||
|
let scroll =
|
||||||
|
window.use_keyed_state((id.clone(), "rows-scroll"), cx, |_, _| ScrollHandle::new());
|
||||||
|
|
||||||
|
ScrollArea::new((id.clone(), "rows"))
|
||||||
|
.handle(scroll.read(cx).clone())
|
||||||
|
.axes(ScrollAxes::Vertical)
|
||||||
|
.max_h(px(320.))
|
||||||
|
.children(rows)
|
||||||
|
.into_any_element()
|
||||||
|
};
|
||||||
|
|
||||||
|
let panel_bounds: Rc<RefCell<Option<Bounds<Pixels>>>> = Rc::new(RefCell::new(None));
|
||||||
|
let query_focus = self.query.read(cx).focus_handle();
|
||||||
|
|
||||||
|
let panel = v_flex()
|
||||||
|
.id((id.clone(), "panel"))
|
||||||
|
.debug_selector({
|
||||||
|
let name = format!("{id}-panel");
|
||||||
|
move || name
|
||||||
|
})
|
||||||
|
.key_context(COMMAND_PALETTE_CONTEXT)
|
||||||
|
.role(Role::Dialog)
|
||||||
|
.aria_label(self.name.clone())
|
||||||
|
.w_full()
|
||||||
|
.max_w(relative(0.6))
|
||||||
|
.p_2()
|
||||||
|
.gap_2()
|
||||||
|
.bg(colors.surface)
|
||||||
|
.text_color(colors.foreground)
|
||||||
|
.rounded(radius.overlay)
|
||||||
|
.border_1()
|
||||||
|
.border_color(colors.border)
|
||||||
|
.shadow(shadows)
|
||||||
|
.overflow_hidden()
|
||||||
|
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
|
||||||
|
.on_action({
|
||||||
|
let state = state.clone();
|
||||||
|
move |_: &HighlightNext, _window, cx| {
|
||||||
|
state.update(cx, |state, cx| state.move_highlight(1, cx));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on_action({
|
||||||
|
let state = state.clone();
|
||||||
|
move |_: &HighlightPrevious, _window, cx| {
|
||||||
|
state.update(cx, |state, cx| state.move_highlight(-1, cx));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on_action({
|
||||||
|
let state = state.clone();
|
||||||
|
move |_: &Choose, window, cx| {
|
||||||
|
state.update(cx, |state, cx| state.choose(window, cx));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on_action({
|
||||||
|
let state = state.clone();
|
||||||
|
move |_: &Dismiss, window, cx| {
|
||||||
|
state.update(cx, |state, cx| state.dismiss(window, cx));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
Input::new((id.clone(), "query"), self.query.clone())
|
||||||
|
.placeholder(self.placeholder.clone()),
|
||||||
|
)
|
||||||
|
.child(rows)
|
||||||
|
.child(
|
||||||
|
canvas(
|
||||||
|
{
|
||||||
|
let cell = panel_bounds.clone();
|
||||||
|
move |bounds, _window, _cx| *cell.borrow_mut() = Some(bounds)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
let focus = query_focus.clone();
|
||||||
|
move |_, _, window, cx| {
|
||||||
|
if !focus.contains_focused(window, cx) {
|
||||||
|
window.focus(&focus, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.absolute()
|
||||||
|
.inset_0(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let scrim = div()
|
||||||
|
.absolute()
|
||||||
|
.inset_0()
|
||||||
|
.bg(colors.overlay)
|
||||||
|
.occlude()
|
||||||
|
.on_mouse_down(MouseButton::Left, {
|
||||||
|
let state = state.clone();
|
||||||
|
let panel_bounds = panel_bounds.clone();
|
||||||
|
move |event: &MouseDownEvent, _window, cx| {
|
||||||
|
let on_panel = panel_bounds
|
||||||
|
.borrow()
|
||||||
|
.is_some_and(|bounds| bounds.contains(&event.position));
|
||||||
|
if on_panel {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.update(cx, |state, cx| state.close(cx));
|
||||||
|
cx.stop_propagation();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
div()
|
||||||
|
.absolute()
|
||||||
|
.inset_0()
|
||||||
|
.flex()
|
||||||
|
.justify_center()
|
||||||
|
.items_start()
|
||||||
|
.pt_10()
|
||||||
|
.child(scrim)
|
||||||
|
.child(panel)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,6 +78,9 @@ pub mod status_dot;
|
|||||||
#[cfg(feature = "combobox")]
|
#[cfg(feature = "combobox")]
|
||||||
pub mod combobox;
|
pub mod combobox;
|
||||||
|
|
||||||
|
#[cfg(feature = "command_palette")]
|
||||||
|
pub mod command_palette;
|
||||||
|
|
||||||
#[cfg(feature = "toast")]
|
#[cfg(feature = "toast")]
|
||||||
pub mod toast;
|
pub mod toast;
|
||||||
|
|
||||||
@@ -89,8 +92,8 @@ pub fn init(cx: &mut App) {
|
|||||||
gpui_native_theme::Theme::init(cx);
|
gpui_native_theme::Theme::init(cx);
|
||||||
#[cfg(feature = "input")]
|
#[cfg(feature = "input")]
|
||||||
input::bind_input_keys(cx, None);
|
input::bind_input_keys(cx, None);
|
||||||
// Load-bearing order: the combobox's bindings must win the depth tie
|
|
||||||
// with the input's, and gpui breaks ties by later registration.
|
|
||||||
#[cfg(feature = "combobox")]
|
#[cfg(feature = "combobox")]
|
||||||
combobox::bind_combobox_keys(cx);
|
combobox::bind_combobox_keys(cx);
|
||||||
|
#[cfg(feature = "command_palette")]
|
||||||
|
command_palette::bind_command_palette_keys(cx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use gpui::{
|
||||||
|
AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString,
|
||||||
|
Styled as _, TestAppContext, VisualTestContext, Window, div,
|
||||||
|
};
|
||||||
|
use gpui_native_core::command_palette::{Command, CommandPalette, CommandPaletteState};
|
||||||
|
use gpui_native_core::init;
|
||||||
|
|
||||||
|
fn commands() -> Vec<Command> {
|
||||||
|
vec![
|
||||||
|
Command::new("open-file", "Open File").hint("Cmd+O"),
|
||||||
|
Command::new("close-tab", "Close Tab"),
|
||||||
|
Command::new("save-all", "Save All").hint("Cmd+Alt+S"),
|
||||||
|
Command::new("toggle-theme", "Toggle Theme"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The caller-owned matcher: case-insensitive substring. `reverse` flips the
|
||||||
|
/// match order, exercising that ranking belongs to the caller.
|
||||||
|
fn substring_matcher(reverse: bool) -> impl Fn(&str, &[Command]) -> Vec<Command> {
|
||||||
|
move |query, commands| {
|
||||||
|
let query = query.to_lowercase();
|
||||||
|
let matched: Vec<Command> = commands
|
||||||
|
.iter()
|
||||||
|
.filter(|command| command.label.to_lowercase().contains(&query))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
if reverse {
|
||||||
|
matched.into_iter().rev().collect()
|
||||||
|
} else {
|
||||||
|
matched
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Host {
|
||||||
|
palette: Entity<CommandPaletteState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Host {
|
||||||
|
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
div().size_full().child(self.palette.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Executed = Rc<RefCell<Vec<SharedString>>>;
|
||||||
|
|
||||||
|
struct PaletteHost {
|
||||||
|
cx: TestAppContext,
|
||||||
|
state: Entity<CommandPaletteState>,
|
||||||
|
executed: Executed,
|
||||||
|
visual: VisualTestContext,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn palette_host(reverse: bool) -> PaletteHost {
|
||||||
|
let cx = TestAppContext::single();
|
||||||
|
// `init` registers the input bindings and then the palette's, in the
|
||||||
|
// load-bearing order.
|
||||||
|
cx.update(init);
|
||||||
|
let executed: Executed = Rc::new(RefCell::new(Vec::new()));
|
||||||
|
let state_cell: Rc<RefCell<Option<Entity<CommandPaletteState>>>> = Rc::new(RefCell::new(None));
|
||||||
|
let window = cx.update(|cx| {
|
||||||
|
let executed_for_builder = executed.clone();
|
||||||
|
let cell = state_cell.clone();
|
||||||
|
cx.open_window(gpui::WindowOptions::default(), |window, cx| {
|
||||||
|
let palette =
|
||||||
|
CommandPalette::new("test-palette", commands(), substring_matcher(reverse))
|
||||||
|
.on_execute(move |command: &Command, _, _| {
|
||||||
|
executed_for_builder.borrow_mut().push(command.id.clone())
|
||||||
|
});
|
||||||
|
let state = cx.new(|cx| CommandPaletteState::new(palette, window, cx));
|
||||||
|
cell.borrow_mut().replace(state.clone());
|
||||||
|
cx.new(|_| Host { palette: state })
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
});
|
||||||
|
let state = state_cell.borrow().clone().expect("state captured");
|
||||||
|
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
||||||
|
draw(&mut visual);
|
||||||
|
PaletteHost {
|
||||||
|
cx,
|
||||||
|
state,
|
||||||
|
executed,
|
||||||
|
visual,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(visual: &mut VisualTestContext) {
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
window.draw(cx).clear(cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open(host: &mut PaletteHost) {
|
||||||
|
let state = host.state.clone();
|
||||||
|
host.visual.update(|window, cx| {
|
||||||
|
state.update(cx, |state, cx| state.open(window, cx));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn type_text(host: &mut PaletteHost, text: &str) {
|
||||||
|
for letter in text.chars() {
|
||||||
|
host.visual.simulate_keystrokes(&letter.to_string());
|
||||||
|
}
|
||||||
|
draw(&mut host.visual);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_lists_the_callers_commands() {
|
||||||
|
let mut host = palette_host(false);
|
||||||
|
open(&mut host);
|
||||||
|
draw(&mut host.visual);
|
||||||
|
|
||||||
|
assert!(host.visual.debug_bounds("test-palette-command-0").is_some());
|
||||||
|
assert!(host.visual.debug_bounds("test-palette-command-3").is_some());
|
||||||
|
assert!(host.visual.debug_bounds("test-palette-panel").is_some());
|
||||||
|
assert!(host.cx.update(|cx| host.state.read(cx).is_open()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_runs_the_highlighted_command_and_closes() {
|
||||||
|
let mut host = palette_host(false);
|
||||||
|
open(&mut host);
|
||||||
|
host.visual.simulate_keystrokes("enter");
|
||||||
|
draw(&mut host.visual);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
*host.executed.borrow(),
|
||||||
|
vec![SharedString::from("open-file")],
|
||||||
|
"the first row starts highlighted"
|
||||||
|
);
|
||||||
|
assert!(host.visual.debug_bounds("test-palette-panel").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rows_follow_the_callers_match_order() {
|
||||||
|
let mut host = palette_host(true);
|
||||||
|
open(&mut host);
|
||||||
|
host.visual.simulate_keystrokes("enter");
|
||||||
|
draw(&mut host.visual);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
*host.executed.borrow(),
|
||||||
|
vec![SharedString::from("toggle-theme")],
|
||||||
|
"enter runs the first row, and the caller's matcher owns the row order"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typing_filters_through_the_caller_matcher() {
|
||||||
|
let mut host = palette_host(false);
|
||||||
|
open(&mut host);
|
||||||
|
type_text(&mut host, "save");
|
||||||
|
|
||||||
|
assert!(host.visual.debug_bounds("test-palette-command-0").is_some());
|
||||||
|
assert!(
|
||||||
|
host.visual.debug_bounds("test-palette-command-1").is_none(),
|
||||||
|
"only the matching command stays listed"
|
||||||
|
);
|
||||||
|
|
||||||
|
host.visual.simulate_keystrokes("enter");
|
||||||
|
draw(&mut host.visual);
|
||||||
|
assert_eq!(
|
||||||
|
*host.executed.borrow(),
|
||||||
|
vec![SharedString::from("save-all")]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_closes_without_running() {
|
||||||
|
let mut host = palette_host(false);
|
||||||
|
open(&mut host);
|
||||||
|
host.visual.simulate_keystrokes("escape");
|
||||||
|
draw(&mut host.visual);
|
||||||
|
|
||||||
|
assert!(host.visual.debug_bounds("test-palette-panel").is_none());
|
||||||
|
assert!(host.executed.borrow().is_empty());
|
||||||
|
assert!(!host.cx.update(|cx| host.state.read(cx).is_open()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arrow_keys_move_the_highlight() {
|
||||||
|
let mut host = palette_host(false);
|
||||||
|
open(&mut host);
|
||||||
|
host.visual.simulate_keystrokes("down");
|
||||||
|
host.visual.simulate_keystrokes("enter");
|
||||||
|
draw(&mut host.visual);
|
||||||
|
assert_eq!(
|
||||||
|
*host.executed.borrow(),
|
||||||
|
vec![SharedString::from("close-tab")]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Up from the top wraps to the last row.
|
||||||
|
open(&mut host);
|
||||||
|
host.visual.simulate_keystrokes("up");
|
||||||
|
host.visual.simulate_keystrokes("enter");
|
||||||
|
draw(&mut host.visual);
|
||||||
|
assert_eq!(
|
||||||
|
*host.executed.borrow(),
|
||||||
|
vec![
|
||||||
|
SharedString::from("close-tab"),
|
||||||
|
SharedString::from("toggle-theme")
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clicking_a_row_runs_it() {
|
||||||
|
let mut host = palette_host(false);
|
||||||
|
open(&mut host);
|
||||||
|
let row = host
|
||||||
|
.visual
|
||||||
|
.debug_bounds("test-palette-command-2")
|
||||||
|
.expect("save-all row missing");
|
||||||
|
host.visual
|
||||||
|
.simulate_click(row.center(), gpui::Modifiers::default());
|
||||||
|
draw(&mut host.visual);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
*host.executed.borrow(),
|
||||||
|
vec![SharedString::from("save-all")]
|
||||||
|
);
|
||||||
|
assert!(host.visual.debug_bounds("test-palette-panel").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clicking_the_scrim_closes() {
|
||||||
|
let mut host = palette_host(false);
|
||||||
|
open(&mut host);
|
||||||
|
draw(&mut host.visual);
|
||||||
|
host.visual.simulate_click(
|
||||||
|
gpui::point(gpui::px(10.), gpui::px(10.)),
|
||||||
|
gpui::Modifiers::default(),
|
||||||
|
);
|
||||||
|
draw(&mut host.visual);
|
||||||
|
|
||||||
|
assert!(host.visual.debug_bounds("test-palette-panel").is_none());
|
||||||
|
assert!(host.executed.borrow().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reopen_starts_from_a_fresh_query() {
|
||||||
|
let mut host = palette_host(false);
|
||||||
|
open(&mut host);
|
||||||
|
type_text(&mut host, "zzzz");
|
||||||
|
assert!(
|
||||||
|
host.visual.debug_bounds("test-palette-command-0").is_none(),
|
||||||
|
"nothing matches nonsense"
|
||||||
|
);
|
||||||
|
|
||||||
|
host.visual.simulate_keystrokes("escape");
|
||||||
|
open(&mut host);
|
||||||
|
draw(&mut host.visual);
|
||||||
|
assert!(
|
||||||
|
host.visual.debug_bounds("test-palette-command-0").is_some(),
|
||||||
|
"a fresh open clears the query"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_commands_replaces_the_registry() {
|
||||||
|
let mut host = palette_host(false);
|
||||||
|
host.cx.update(|cx| {
|
||||||
|
host.state.update(cx, |state, cx| {
|
||||||
|
state.set_commands(vec![Command::new("only", "Only Command")], cx)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
open(&mut host);
|
||||||
|
draw(&mut host.visual);
|
||||||
|
|
||||||
|
assert!(host.visual.debug_bounds("test-palette-command-0").is_some());
|
||||||
|
assert!(host.visual.debug_bounds("test-palette-command-1").is_none());
|
||||||
|
|
||||||
|
host.visual.simulate_keystrokes("enter");
|
||||||
|
draw(&mut host.visual);
|
||||||
|
assert_eq!(*host.executed.borrow(), vec![SharedString::from("only")]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use gpui::{
|
||||||
|
AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString,
|
||||||
|
Styled as _, TestAppContext, Window, div,
|
||||||
|
};
|
||||||
|
use gpui_native_core::command_palette::{Command, CommandPalette, CommandPaletteState};
|
||||||
|
use gpui_native_core::init;
|
||||||
|
|
||||||
|
fn command(label: &'static str) -> Command {
|
||||||
|
Command::new(SharedString::from(label), SharedString::from(label))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Host {
|
||||||
|
palette: Entity<CommandPaletteState>,
|
||||||
|
renders: Rc<RefCell<usize>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Host {
|
||||||
|
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
*self.renders.borrow_mut() += 1;
|
||||||
|
div().size_full().child(self.palette.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typing_with_the_palette_open_is_not_pathologically_slow() {
|
||||||
|
let cx = TestAppContext::single();
|
||||||
|
cx.update(init);
|
||||||
|
let (state, renders, window) = cx.update(|cx| {
|
||||||
|
let renders = Rc::new(RefCell::new(0));
|
||||||
|
let state_cell: Rc<RefCell<Option<Entity<CommandPaletteState>>>> =
|
||||||
|
Rc::new(RefCell::new(None));
|
||||||
|
let renders_for_host = renders.clone();
|
||||||
|
let cell = state_cell.clone();
|
||||||
|
let window = cx
|
||||||
|
.open_window(gpui::WindowOptions::default(), |window, cx| {
|
||||||
|
let matcher = |query: &str, commands: &[Command]| {
|
||||||
|
let query = query.to_lowercase();
|
||||||
|
commands
|
||||||
|
.iter()
|
||||||
|
.filter(|command| command.label.to_lowercase().contains(&query))
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let state = cx.new(|cx| {
|
||||||
|
CommandPaletteState::new(
|
||||||
|
CommandPalette::new(
|
||||||
|
"bench-palette",
|
||||||
|
vec![
|
||||||
|
command("Open File"),
|
||||||
|
command("Close Tab"),
|
||||||
|
command("Save All"),
|
||||||
|
command("Toggle Theme"),
|
||||||
|
],
|
||||||
|
matcher,
|
||||||
|
),
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let state_for_host = state.clone();
|
||||||
|
cell.borrow_mut().replace(state.clone());
|
||||||
|
cx.new(|_| Host {
|
||||||
|
palette: state_for_host,
|
||||||
|
renders: renders_for_host,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let state = state_cell.borrow().clone().expect("state captured");
|
||||||
|
(state, renders, window)
|
||||||
|
});
|
||||||
|
let mut visual = gpui::VisualTestContext::from_window(window.into(), &cx);
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
state.update(cx, |state, cx| state.open(window, cx));
|
||||||
|
window.draw(cx).clear(cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
let started = Instant::now();
|
||||||
|
for letter in "abcdefghijklmnopqrstuvwxyz".chars() {
|
||||||
|
visual.simulate_keystrokes(&letter.to_string());
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
window.draw(cx).clear(cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let elapsed = started.elapsed();
|
||||||
|
|
||||||
|
let total_renders = *renders.borrow();
|
||||||
|
println!(
|
||||||
|
"26 real keystrokes with the palette open: {elapsed:?}, host renders: {total_renders} ({} per keystroke)",
|
||||||
|
total_renders as f32 / 26.
|
||||||
|
);
|
||||||
|
assert!(elapsed.as_millis() < 2000, "typing took {elapsed:?}");
|
||||||
|
}
|
||||||
+4
-5
@@ -9,18 +9,16 @@ is the first downstream consumer; its screens drive the order.
|
|||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
- [ ] **Combobox** (M11) — type-to-filter pickers; adopt gpuikit's
|
- [ ] **Command palette** (M12) — filtered actions; the matcher stays
|
||||||
`on_create` / `keep_unmatched` blur modes.
|
caller-owned.
|
||||||
- [x] API sketch
|
- [x] API sketch
|
||||||
- [x] Implementation (theme-baked, motion inline per AGENTS.md)
|
- [x] Implementation (theme-baked, no entry motion per AGENTS.md)
|
||||||
- [x] Tests
|
- [x] Tests
|
||||||
- [x] Gallery panel
|
- [x] Gallery panel
|
||||||
- [ ] Review
|
- [ ] Review
|
||||||
|
|
||||||
## Queue — to follow
|
## Queue — to follow
|
||||||
|
|
||||||
- [ ] **Command palette** (M12) — filtered actions; the matcher stays
|
|
||||||
caller-owned.
|
|
||||||
- [ ] **VirtualList** (M13, `data`) — thin `uniform_list` binding for commit
|
- [ ] **VirtualList** (M13, `data`) — thin `uniform_list` binding for commit
|
||||||
histories and the inbox.
|
histories and the inbox.
|
||||||
- [ ] **Code view** (M14, opens `crates/editor`) — tree-sitter highlighting.
|
- [ ] **Code view** (M14, opens `crates/editor`) — tree-sitter highlighting.
|
||||||
@@ -63,6 +61,7 @@ is the first downstream consumer; its screens drive the order.
|
|||||||
settings-row variants
|
settings-row variants
|
||||||
- [x] Status dot — colored state bead, Badge's variant vocabulary, optional
|
- [x] Status dot — colored state bead, Badge's variant vocabulary, optional
|
||||||
screen-reader label
|
screen-reader label
|
||||||
|
- [x] Combobox — type-to-filter picker, create / keep / revert blur modes
|
||||||
|
|
||||||
### data (`crates/data`)
|
### data (`crates/data`)
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext as _, Bounds, Context, Entity, FontWeight, IntoElement, ParentElement as _,
|
App, AppContext as _, Bounds, Context, Entity, FontWeight, InteractiveElement as _,
|
||||||
Pixels, Render, SharedString, Styled as _, WeakEntity, Window, WindowBounds, WindowOptions,
|
IntoElement, KeyBinding, ParentElement as _, Pixels, Render, SharedString, Styled as _,
|
||||||
div, px, size,
|
WeakEntity, Window, WindowBounds, WindowOptions, actions, div, px, size,
|
||||||
};
|
};
|
||||||
use gpui_native_core::alert::{Alert, AlertSeverity, Alerts, AlertsView};
|
use gpui_native_core::alert::{Alert, AlertSeverity, Alerts, AlertsView};
|
||||||
use gpui_native_core::avatar::Avatar;
|
use gpui_native_core::avatar::Avatar;
|
||||||
@@ -12,6 +12,7 @@ use gpui_native_core::badge::{Badge, BadgeVariant};
|
|||||||
use gpui_native_core::button::{Button, ButtonVariant};
|
use gpui_native_core::button::{Button, ButtonVariant};
|
||||||
use gpui_native_core::checkbox::Checkbox;
|
use gpui_native_core::checkbox::Checkbox;
|
||||||
use gpui_native_core::combobox::{Combobox, ComboboxOption, ComboboxState};
|
use gpui_native_core::combobox::{Combobox, ComboboxOption, ComboboxState};
|
||||||
|
use gpui_native_core::command_palette::{Command, CommandPalette, CommandPaletteState};
|
||||||
use gpui_native_core::common::{Sizable as _, Size, h_flex, v_flex};
|
use gpui_native_core::common::{Sizable as _, Size, h_flex, v_flex};
|
||||||
use gpui_native_core::context_menu::ContextMenu;
|
use gpui_native_core::context_menu::ContextMenu;
|
||||||
use gpui_native_core::dialog::Dialog;
|
use gpui_native_core::dialog::Dialog;
|
||||||
@@ -85,6 +86,7 @@ const CATALOG: &[(&str, &[(&str, &str)])] = &[
|
|||||||
("alert", "Alert"),
|
("alert", "Alert"),
|
||||||
("toast", "Toast"),
|
("toast", "Toast"),
|
||||||
("dialog", "Dialog"),
|
("dialog", "Dialog"),
|
||||||
|
("command-palette", "Command palette"),
|
||||||
("menu", "Menu"),
|
("menu", "Menu"),
|
||||||
("context-menu", "Context menu"),
|
("context-menu", "Context menu"),
|
||||||
("popover", "Popover"),
|
("popover", "Popover"),
|
||||||
@@ -174,11 +176,26 @@ impl PresetChoice {
|
|||||||
/// The context menu demo's actions.
|
/// The context menu demo's actions.
|
||||||
const CONTEXT_ACTIONS: [&str; 3] = ["Rename", "Duplicate", "Archive"];
|
const CONTEXT_ACTIONS: [&str; 3] = ["Rename", "Duplicate", "Archive"];
|
||||||
|
|
||||||
|
actions!(gallery, [OpenCommandPalette]);
|
||||||
|
|
||||||
|
/// The command palette demo's actions.
|
||||||
|
fn gallery_commands() -> Vec<Command> {
|
||||||
|
vec![
|
||||||
|
Command::new("open-settings", "Open Settings").hint("Cmd+,"),
|
||||||
|
Command::new("toggle-theme", "Toggle Theme").hint("Shift+Cmd+T"),
|
||||||
|
Command::new("save-all", "Save All").hint("Cmd+Alt+S"),
|
||||||
|
Command::new("close-tab", "Close Tab").hint("Cmd+W"),
|
||||||
|
Command::new("reload-window", "Reload Window"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
struct Gallery {
|
struct Gallery {
|
||||||
layout_state: Entity<LayoutState>,
|
layout_state: Entity<LayoutState>,
|
||||||
dock_state: Entity<DockState>,
|
dock_state: Entity<DockState>,
|
||||||
alerts: Entity<Alerts>,
|
alerts: Entity<Alerts>,
|
||||||
toasts: Entity<Toasts>,
|
toasts: Entity<Toasts>,
|
||||||
|
palette: Option<Entity<CommandPaletteState>>,
|
||||||
|
palette_runs: Vec<SharedString>,
|
||||||
dialog_open: bool,
|
dialog_open: bool,
|
||||||
alert_open: bool,
|
alert_open: bool,
|
||||||
}
|
}
|
||||||
@@ -352,6 +369,7 @@ impl Gallery {
|
|||||||
layout_state: Entity<LayoutState>,
|
layout_state: Entity<LayoutState>,
|
||||||
alerts: Entity<Alerts>,
|
alerts: Entity<Alerts>,
|
||||||
toasts: Entity<Toasts>,
|
toasts: Entity<Toasts>,
|
||||||
|
palette: Entity<CommandPaletteState>,
|
||||||
gallery: WeakEntity<Self>,
|
gallery: WeakEntity<Self>,
|
||||||
cx: &mut Context<DockState>,
|
cx: &mut Context<DockState>,
|
||||||
) {
|
) {
|
||||||
@@ -539,6 +557,7 @@ impl Gallery {
|
|||||||
);
|
);
|
||||||
dock.register_panel(
|
dock.register_panel(
|
||||||
PanelDescriptor::new("dialog", "Dialog", {
|
PanelDescriptor::new("dialog", "Dialog", {
|
||||||
|
let gallery = gallery.clone();
|
||||||
move |_, cx| {
|
move |_, cx| {
|
||||||
cx.new(|_| DialogDemo {
|
cx.new(|_| DialogDemo {
|
||||||
gallery: gallery.clone(),
|
gallery: gallery.clone(),
|
||||||
@@ -548,6 +567,16 @@ impl Gallery {
|
|||||||
}),
|
}),
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
|
dock.register_panel(
|
||||||
|
PanelDescriptor::new("command-palette", "Command palette", {
|
||||||
|
let palette = palette.clone();
|
||||||
|
move |_, cx| {
|
||||||
|
cx.new(|cx| CommandPaletteDemo::new(gallery.clone(), palette.clone(), cx))
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
dock.register_panel(
|
dock.register_panel(
|
||||||
PanelDescriptor::new("menu", "Menu", |_, cx| {
|
PanelDescriptor::new("menu", "Menu", |_, cx| {
|
||||||
cx.new(|_| MenuDemo { open: false }).into()
|
cx.new(|_| MenuDemo { open: false }).into()
|
||||||
@@ -608,6 +637,7 @@ impl Render for Gallery {
|
|||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
let gallery = cx.entity().downgrade();
|
let gallery = cx.entity().downgrade();
|
||||||
|
let palette_for_action = self.palette.clone();
|
||||||
|
|
||||||
// Dialogs are window-modal, so they mount at the window root; the
|
// Dialogs are window-modal, so they mount at the window root; the
|
||||||
// Dialog demo panel only flips these flags.
|
// Dialog demo panel only flips these flags.
|
||||||
@@ -617,11 +647,17 @@ impl Render for Gallery {
|
|||||||
div()
|
div()
|
||||||
.size_full()
|
.size_full()
|
||||||
.font_family(".SystemUIFont")
|
.font_family(".SystemUIFont")
|
||||||
|
.on_action(move |_: &OpenCommandPalette, window, cx| {
|
||||||
|
if let Some(palette) = &palette_for_action {
|
||||||
|
palette.update(cx, |palette, cx| palette.toggle(window, cx));
|
||||||
|
}
|
||||||
|
})
|
||||||
.bg(theme.colors.background)
|
.bg(theme.colors.background)
|
||||||
.text_color(theme.colors.foreground)
|
.text_color(theme.colors.foreground)
|
||||||
.child(self.render_shell(cx))
|
.child(self.render_shell(cx))
|
||||||
.child(dialog)
|
.child(dialog)
|
||||||
.child(alert_dialog)
|
.child(alert_dialog)
|
||||||
|
.when_some(self.palette.clone(), |root, palette| root.child(palette))
|
||||||
.child(AlertsView::new("gallery-alerts", self.alerts.clone()))
|
.child(AlertsView::new("gallery-alerts", self.alerts.clone()))
|
||||||
.child(ToastsView::new("gallery-toasts", self.toasts.clone()))
|
.child(ToastsView::new("gallery-toasts", self.toasts.clone()))
|
||||||
}
|
}
|
||||||
@@ -2194,6 +2230,92 @@ impl Render for DialogDemo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct CommandPaletteDemo {
|
||||||
|
gallery: WeakEntity<Gallery>,
|
||||||
|
palette: Entity<CommandPaletteState>,
|
||||||
|
_subscription: gpui::Subscription,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandPaletteDemo {
|
||||||
|
fn new(
|
||||||
|
gallery: WeakEntity<Gallery>,
|
||||||
|
palette: Entity<CommandPaletteState>,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Self {
|
||||||
|
// The panel is a cached view in the dock: the readouts re-render only
|
||||||
|
// when the palette state they read is notified.
|
||||||
|
let subscription = cx.observe(&palette, |_, _, cx| cx.notify());
|
||||||
|
Self {
|
||||||
|
gallery,
|
||||||
|
palette,
|
||||||
|
_subscription: subscription,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for CommandPaletteDemo {
|
||||||
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let colors = cx.theme().colors;
|
||||||
|
let open = self.palette.read(cx).is_open();
|
||||||
|
let runs = self
|
||||||
|
.gallery
|
||||||
|
.upgrade()
|
||||||
|
.map(|gallery| gallery.read(cx).palette_runs.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let runs = if runs.is_empty() {
|
||||||
|
"none yet".to_string()
|
||||||
|
} else {
|
||||||
|
runs.iter()
|
||||||
|
.map(|id| id.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
};
|
||||||
|
let palette_toggle = self.palette.clone();
|
||||||
|
|
||||||
|
ScrollArea::new("command-palette-demo")
|
||||||
|
.size_full()
|
||||||
|
.p_4()
|
||||||
|
.child(demo_section(
|
||||||
|
"window-modal, mounted at the window root",
|
||||||
|
colors,
|
||||||
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
Button::new("command-palette-demo-open")
|
||||||
|
.label("Open command palette")
|
||||||
|
.variant(ButtonVariant::Secondary)
|
||||||
|
.on_click(move |_, window, cx| {
|
||||||
|
palette_toggle.update(cx, |palette, cx| palette.toggle(window, cx));
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(colors.muted_foreground)
|
||||||
|
.child("Cmd+K (Ctrl+K elsewhere) opens it anywhere in the window."),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.child(demo_section(
|
||||||
|
"runs",
|
||||||
|
colors,
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(colors.muted_foreground)
|
||||||
|
.child(if open { "open" } else { "closed" }),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(colors.muted_foreground)
|
||||||
|
.child(format!("executed: {runs}")),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct MenuDemo {
|
struct MenuDemo {
|
||||||
open: bool,
|
open: bool,
|
||||||
}
|
}
|
||||||
@@ -2791,6 +2913,10 @@ fn main() {
|
|||||||
.with_http_client(std::sync::Arc::new(reqwest_client::ReqwestClient::new()))
|
.with_http_client(std::sync::Arc::new(reqwest_client::ReqwestClient::new()))
|
||||||
.run(|cx: &mut App| {
|
.run(|cx: &mut App| {
|
||||||
gpui_native_core::init(cx);
|
gpui_native_core::init(cx);
|
||||||
|
cx.bind_keys([
|
||||||
|
KeyBinding::new("cmd-k", OpenCommandPalette, None),
|
||||||
|
KeyBinding::new("ctrl-k", OpenCommandPalette, None),
|
||||||
|
]);
|
||||||
let layout_state = LayoutState::new(cx);
|
let layout_state = LayoutState::new(cx);
|
||||||
let dock_state = DockState::new(cx);
|
let dock_state = DockState::new(cx);
|
||||||
// A panel that declares a right sidebar view asks for it to open on
|
// A panel that declares a right sidebar view asks for it to open on
|
||||||
@@ -2842,16 +2968,47 @@ fn main() {
|
|||||||
dock_state: dock_state.clone(),
|
dock_state: dock_state.clone(),
|
||||||
alerts: alerts.clone(),
|
alerts: alerts.clone(),
|
||||||
toasts: toasts.clone(),
|
toasts: toasts.clone(),
|
||||||
|
palette: None,
|
||||||
|
palette_runs: Vec::new(),
|
||||||
dialog_open: false,
|
dialog_open: false,
|
||||||
alert_open: false,
|
alert_open: false,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
let palette = cx.new(|cx| {
|
||||||
|
let matcher = |query: &str, commands: &[Command]| {
|
||||||
|
let needle = query.to_lowercase();
|
||||||
|
commands
|
||||||
|
.iter()
|
||||||
|
.filter(|command| command.label.to_lowercase().contains(&needle))
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let gallery_for_execute = gallery.downgrade();
|
||||||
|
CommandPaletteState::new(
|
||||||
|
CommandPalette::new("gallery-palette", gallery_commands(), matcher)
|
||||||
|
.on_execute(move |command: &Command, _, cx| {
|
||||||
|
gallery_for_execute
|
||||||
|
.update(cx, |gallery, cx| {
|
||||||
|
gallery.palette_runs.push(command.id.clone());
|
||||||
|
cx.notify();
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}),
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
gallery.update(cx, |gallery, cx| {
|
||||||
|
gallery.palette = Some(palette.clone());
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
dock_state.update(cx, |dock, cx| {
|
dock_state.update(cx, |dock, cx| {
|
||||||
Gallery::register_panels(
|
Gallery::register_panels(
|
||||||
dock,
|
dock,
|
||||||
layout_state.clone(),
|
layout_state.clone(),
|
||||||
alerts.clone(),
|
alerts.clone(),
|
||||||
toasts.clone(),
|
toasts.clone(),
|
||||||
|
palette.clone(),
|
||||||
gallery.downgrade(),
|
gallery.downgrade(),
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user