gpui_cn/command_dialog.rs
1//! A command palette in a dialog over the page, as shadcn's
2//! `CommandDialog`.
3
4use std::rc::Rc;
5
6use gpui_kit::{
7 App, Entity, Focusable as _, IntoElement, ParentElement as _, RenderOnce, SharedString,
8 Styled as _, Subscription, Window,
9};
10
11use crate::{ActiveTheme as _, Command, CommandEvent, CommandState, Dialog, menu::MenuLook};
12
13type OpenChange = Rc<dyn Fn(bool, &mut Window, &mut App)>;
14
15/// Closes the dialog when a command is chosen. It lives in the window's
16/// keyed state, so the subscription is made once and the handler is the
17/// latest one the application gave.
18struct Closer {
19 handler: Option<OpenChange>,
20 open: bool,
21 _subscription: Subscription,
22}
23
24/// A [`Command`] palette in a [`Dialog`], as shadcn's `CommandDialog`
25/// and the palettes opened with Cmd+K.
26///
27/// The dialog has no title and no close button: Escape and a press on the
28/// backdrop close it. It sits at the center horizontally and at the
29/// theme's `command_dialog_top` below the top edge, so it rests in the
30/// upper third of the window as Spotlight does, and does not move when
31/// its height changes. It is the theme's `command_dialog_width` wide, and
32/// narrower in a window with no room for it, with the dialog layer's
33/// margin on both sides.
34///
35/// The search field takes focus when the dialog opens, and focus returns
36/// to where it was when it closes. The box is as tall as a palette's
37/// list can be, in every state, so the dialog does not jump while the
38/// number of matches changes; the list scrolls inside it. Choosing a
39/// command closes the dialog after the state reports
40/// [`CommandEvent::Confirmed`].
41///
42/// ```
43/// use gpui_cn::{CommandDialog, CommandState};
44/// use gpui_kit::{Entity, IntoElement};
45///
46/// fn palette(state: &Entity<CommandState>, open: bool) -> impl IntoElement {
47/// CommandDialog::new("palette", state)
48/// .open(open)
49/// .empty("Nothing matches.")
50/// }
51/// ```
52///
53/// The application owns the open state: [`open`](Self::open) sets it, and
54/// [`on_open_change`](Self::on_open_change) reports Escape, a backdrop
55/// press, and a chosen command, which should clear it. The commands, the
56/// query, and the placeholder live in the [`CommandState`]. The id must
57/// be stable across frames.
58#[derive(IntoElement)]
59pub struct CommandDialog {
60 id: gpui_kit::ElementId,
61 state: Entity<CommandState>,
62 open: bool,
63 empty: Option<SharedString>,
64 on_open_change: Option<OpenChange>,
65}
66
67impl CommandDialog {
68 /// A closed palette dialog on `state`, with a stable id.
69 pub fn new(id: impl Into<gpui_kit::ElementId>, state: &Entity<CommandState>) -> Self {
70 Self {
71 id: id.into(),
72 state: state.clone(),
73 open: false,
74 empty: None,
75 on_open_change: None,
76 }
77 }
78
79 /// Shows or hides the dialog.
80 pub fn open(mut self, open: bool) -> Self {
81 self.open = open;
82 self
83 }
84
85 /// The text shown while no command matches. The default is the
86 /// palette's own.
87 pub fn empty(mut self, text: impl Into<SharedString>) -> Self {
88 self.empty = Some(text.into());
89 self
90 }
91
92 /// Called with `false` when Escape, a press on the backdrop, or a
93 /// chosen command asks to close the dialog.
94 pub fn on_open_change(
95 mut self,
96 handler: impl Fn(bool, &mut Window, &mut App) + 'static,
97 ) -> Self {
98 self.on_open_change = Some(Rc::new(handler));
99 self
100 }
101}
102
103impl RenderOnce for CommandDialog {
104 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
105 let child = |name: &'static str| {
106 gpui_kit::ElementId::NamedChild(self.id.clone().into(), name.into())
107 };
108 let state = self.state.clone();
109 let closer = window.use_keyed_state(child("closer"), cx, |window, cx| Closer {
110 handler: None,
111 open: false,
112 _subscription: cx.subscribe_in(
113 &state,
114 window,
115 |this: &mut Closer, _, event: &CommandEvent, window, cx| {
116 if this.open
117 && matches!(
118 event,
119 CommandEvent::Confirmed(_) | CommandEvent::Submitted(_)
120 )
121 && let Some(handler) = this.handler.clone()
122 {
123 handler(false, window, cx);
124 }
125 },
126 ),
127 });
128 let (handler, open) = (self.on_open_change.clone(), self.open);
129 closer.update(cx, |closer, _| {
130 closer.handler = handler;
131 closer.open = open;
132 });
133
134 let theme = cx.theme();
135 let look = MenuLook::of(theme, window.rem_size());
136 let (width, top) = (
137 theme.metrics.command_dialog_width,
138 theme.metrics.command_dialog_top,
139 );
140 let focus = self.state.focus_handle(cx);
141 let mut command = Command::new(child("command"), &self.state)
142 .bordered(false)
143 .w_full()
144 .max_w_full()
145 .h(look.palette_height());
146 if let Some(empty) = self.empty {
147 command = command.empty(empty);
148 }
149 let on_open_change = self.on_open_change;
150 let mut dialog = Dialog::new(self.id.clone())
151 .open(self.open)
152 .show_close_button(false)
153 .top(top)
154 .w(width)
155 .p_0()
156 .overflow_hidden()
157 .track_focus(&focus)
158 .child(command);
159 if let Some(handler) = on_open_change {
160 dialog = dialog.on_open_change(move |open, window, cx| handler(open, window, cx));
161 }
162 dialog
163 }
164}