1use std::sync::Arc;
2
3use crate::app::input::command_registry::{CommandEntry, CommandId, CommandRegistry};
4use crate::callback::Callback;
5use crate::core::component::{Component, Context, Update};
6use crate::core::element::Element;
7use crate::overlay::OverlayScope;
8use crate::style::{Align, BorderStyle, Length, Padding, RichText, Span, Style};
9use crate::widgets::{
10 ItemDescription, ListItem, Modal, SearchEntry, SearchHighlight, SearchItem, SearchPalette,
11};
12
13#[derive(Clone, PartialEq)]
14struct CommandPaletteProps {
15 on_close: Option<Callback<()>>,
16 show_disabled: bool,
17 width: Length,
18 height: Length,
19 backdrop_style: Style,
20 frame_style: Style,
21 border: bool,
22 border_style: BorderStyle,
23 padding: Padding,
24 title: Option<RichText>,
25 title_style: Style,
26 title_alignment: Align,
27 scope: OverlayScope,
28}
29
30#[derive(Clone, Default, PartialEq)]
31struct CommandPaletteState {
32 entries: Vec<SearchEntry<CommandId>>,
33 disabled_ids: Vec<CommandId>,
34}
35
36#[derive(Clone, PartialEq)]
37struct DisabledRenderStyle {
38 style: Style,
39 disabled_ids: Vec<CommandId>,
40}
41
42struct CommandPaletteComponent;
43
44impl Component for CommandPaletteComponent {
45 type Message = ();
46 type Properties = CommandPaletteProps;
47 type State = CommandPaletteState;
48
49 fn create_state(&self, _props: &Self::Properties) -> Self::State {
50 CommandPaletteState::default()
51 }
52
53 fn init(&mut self, ctx: &mut Context<Self>) -> Option<crate::core::component::Command> {
54 rebuild_state(ctx);
55 None
56 }
57
58 fn on_props_changed(
59 &mut self,
60 old_props: &Self::Properties,
61 ctx: &mut Context<Self>,
62 ) -> Update {
63 if old_props.show_disabled != ctx.props.show_disabled
64 || old_props.on_close != ctx.props.on_close
65 || old_props.width != ctx.props.width
66 || old_props.height != ctx.props.height
67 || old_props.backdrop_style != ctx.props.backdrop_style
68 || old_props.frame_style != ctx.props.frame_style
69 || old_props.border != ctx.props.border
70 || old_props.border_style != ctx.props.border_style
71 || old_props.padding != ctx.props.padding
72 || old_props.title != ctx.props.title
73 || old_props.title_style != ctx.props.title_style
74 || old_props.title_alignment != ctx.props.title_alignment
75 || old_props.scope != ctx.props.scope
76 {
77 rebuild_state(ctx);
78 return Update::full();
79 }
80 Update::none()
81 }
82
83 fn view(&self, ctx: &Context<Self>) -> Element {
84 let muted_style = ctx.theme().muted.dim();
85 let disabled_style = DisabledRenderStyle {
86 style: muted_style,
87 disabled_ids: ctx.state.disabled_ids.clone(),
88 };
89 let render_item = Arc::new(
90 move |item: &SearchItem<CommandId>, _highlight: &SearchHighlight| {
91 if !disabled_style
92 .disabled_ids
93 .iter()
94 .any(|id| id == &item.value)
95 {
96 return None;
97 }
98
99 let mut spans = vec![Span::new(item.label.clone()).style(disabled_style.style)];
100 if let Some(description) = &item.description
101 && let Some(left) = &description.left
102 {
103 spans.push(Span::new(" - ").style(disabled_style.style));
104 spans.push(Span::new(left.clone()).style(disabled_style.style));
105 }
106
107 let mut row = ListItem::from_spans(spans).style(disabled_style.style);
108 if let Some(description) = &item.description
109 && let Some(right) = &description.right
110 {
111 row = row.description_spans([
112 Span::new(" ").style(disabled_style.style),
113 Span::new(right.clone()).style(disabled_style.style),
114 ]);
115 }
116
117 Some(row)
118 },
119 );
120
121 let registry = ctx.command_registry();
122 let disabled_for_activate = ctx.state.disabled_ids.clone();
123 let on_close_for_activate = ctx.props.on_close.clone();
124 let on_activate = Callback::new(move |event: crate::widgets::SearchEvent<CommandId>| {
125 if disabled_for_activate
126 .iter()
127 .any(|id| id == &event.item.value)
128 {
129 return;
130 }
131 if registry.execute(event.item.value.clone())
132 && let Some(on_close) = &on_close_for_activate
133 {
134 on_close.emit(());
135 }
136 });
137
138 let palette = SearchPalette::<CommandId>::new()
139 .entries(ctx.state.entries.clone())
140 .height(Length::Flex(1))
141 .input_border(false)
142 .list_border(false)
143 .list_selection_full_width(true)
144 .preserve_groups(true)
145 .on_activate(on_activate)
146 .render_item(render_item);
147
148 let mut modal = Modal::new()
149 .child(palette)
150 .width(ctx.props.width)
151 .height(ctx.props.height)
152 .backdrop_style(ctx.props.backdrop_style)
153 .frame_style(ctx.props.frame_style)
154 .border(ctx.props.border)
155 .border_style(ctx.props.border_style)
156 .padding(ctx.props.padding)
157 .title_style(ctx.props.title_style)
158 .title_alignment(ctx.props.title_alignment)
159 .scope(ctx.props.scope);
160
161 if let Some(title) = ctx.props.title.clone() {
162 modal = modal.title(title);
163 }
164 if let Some(on_close) = ctx.props.on_close.clone() {
165 modal = modal.on_close(on_close);
166 }
167
168 modal.into()
169 }
170
171 fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
172 Update::none()
173 }
174}
175
176fn rebuild_state(ctx: &mut Context<CommandPaletteComponent>) {
177 let registry = ctx.command_registry();
178 let (entries, disabled_ids) = build_palette_entries(®istry, ctx.props.show_disabled);
179 ctx.state.entries = entries;
180 ctx.state.disabled_ids = disabled_ids;
181}
182
183fn build_palette_entries(
184 registry: &CommandRegistry,
185 show_disabled: bool,
186) -> (Vec<SearchEntry<CommandId>>, Vec<CommandId>) {
187 let mut commands: Vec<CommandEntry> = registry.entries();
188 commands.sort_by(|left, right| {
189 let left_category = left.category.as_deref().unwrap_or("General");
190 let right_category = right.category.as_deref().unwrap_or("General");
191 left_category
192 .cmp(right_category)
193 .then_with(|| left.label.cmp(&right.label))
194 .then_with(|| left.id.as_str().cmp(right.id.as_str()))
195 });
196
197 let mut entries = Vec::new();
198 let mut disabled_ids = Vec::new();
199 let mut active_category: Option<Arc<str>> = None;
200
201 for command in commands {
202 if !command.enabled {
203 disabled_ids.push(command.id.clone());
204 if !show_disabled {
205 continue;
206 }
207 }
208
209 let category = command.category.unwrap_or_else(|| Arc::from("General"));
210 if active_category.as_ref() != Some(&category) {
211 entries.push(SearchEntry::header(category.clone()));
212 active_category = Some(category);
213 }
214
215 let mut entry = SearchEntry::item(command.label.clone(), command.id.clone());
216 if command.description.is_some() || command.keybinding_hint.is_some() {
217 let mut description = ItemDescription::new();
218 if let Some(left) = command.description {
219 description = description.left(left);
220 }
221 if let Some(right) = command.keybinding_hint {
222 description = description.right(right);
223 }
224 entry = entry.description(description);
225 }
226 entries.push(entry);
227 }
228
229 (entries, disabled_ids)
230}
231
232#[derive(Clone)]
234pub struct CommandPalette {
235 props: CommandPaletteProps,
236}
237
238impl CommandPalette {
239 pub fn new() -> Self {
241 Self::default()
242 }
243
244 pub fn on_close(mut self, cb: Callback<()>) -> Self {
246 self.props.on_close = Some(cb);
247 self
248 }
249
250 pub fn show_disabled(mut self, show: bool) -> Self {
252 self.props.show_disabled = show;
253 self
254 }
255
256 pub fn width(mut self, width: Length) -> Self {
258 self.props.width = width;
259 self
260 }
261
262 pub fn height(mut self, height: Length) -> Self {
264 self.props.height = height;
265 self
266 }
267
268 pub fn backdrop_style(mut self, style: Style) -> Self {
270 self.props.backdrop_style = style;
271 self
272 }
273
274 pub fn frame_style(mut self, style: Style) -> Self {
276 self.props.frame_style = style;
277 self
278 }
279
280 pub fn border(mut self, border: bool) -> Self {
282 self.props.border = border;
283 self
284 }
285
286 pub fn border_style(mut self, border_style: BorderStyle) -> Self {
288 self.props.border_style = border_style;
289 self
290 }
291
292 pub fn padding(mut self, padding: impl Into<Padding>) -> Self {
294 self.props.padding = padding.into();
295 self
296 }
297
298 pub fn title(mut self, title: impl Into<RichText>) -> Self {
300 self.props.title = Some(title.into());
301 self
302 }
303
304 pub fn title_style(mut self, style: Style) -> Self {
306 self.props.title_style = style;
307 self
308 }
309
310 pub fn title_alignment(mut self, alignment: Align) -> Self {
312 self.props.title_alignment = alignment;
313 self
314 }
315
316 pub fn scope(mut self, scope: OverlayScope) -> Self {
318 self.props.scope = scope;
319 self
320 }
321}
322
323impl Default for CommandPalette {
324 fn default() -> Self {
325 Self {
326 props: CommandPaletteProps {
327 on_close: None,
328 show_disabled: false,
329 width: Length::Px(80),
330 height: Length::Px(20),
331 backdrop_style: Style::default(),
332 frame_style: Style::default(),
333 border: true,
334 border_style: BorderStyle::Plain,
335 padding: 0.into(),
336 title: None,
337 title_style: Style::default(),
338 title_alignment: Align::Start,
339 scope: OverlayScope::RootPortal,
340 },
341 }
342 }
343}
344
345impl From<CommandPalette> for Element {
346 fn from(palette: CommandPalette) -> Self {
347 crate::child(|| CommandPaletteComponent, palette.props)
348 }
349}