1use crate::dialog::{DialogProps, dialog};
6use crate::theme::Theme;
7use crate::tokens::DEFAULT_RADIUS;
8use egui::{
9 Align, Color32, CornerRadius, Frame, Id, Key, Layout, Margin, Response, RichText, ScrollArea,
10 Sense, Stroke, Ui, Vec2, WidgetText, vec2,
11};
12use lucide_icons::Icon;
13use std::fmt::{self, Debug};
14use std::hash::Hash;
15
16pub struct OnCommandSelect<'a>(pub Box<dyn FnMut() + 'a>);
21
22impl<'a> Debug for OnCommandSelect<'a> {
23 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24 f.debug_struct("OnCommandSelect").finish()
25 }
26}
27
28#[derive(Debug)]
29pub struct CommandProps {
30 pub id_source: Id,
31 pub min_width: Option<f32>,
32 pub show_border: bool,
33 pub show_shadow: bool,
34}
35
36impl CommandProps {
37 pub fn new(id_source: Id) -> Self {
38 Self {
39 id_source,
40 min_width: None,
41 show_border: true,
42 show_shadow: true,
43 }
44 }
45
46 pub fn min_width(mut self, width: f32) -> Self {
47 self.min_width = Some(width);
48 self
49 }
50
51 pub fn show_border(mut self, show: bool) -> Self {
52 self.show_border = show;
53 self
54 }
55
56 pub fn show_shadow(mut self, show: bool) -> Self {
57 self.show_shadow = show;
58 self
59 }
60}
61
62#[derive(Clone, Debug)]
63pub struct CommandInputProps {
64 pub placeholder: String,
65}
66
67impl CommandInputProps {
68 pub fn new(placeholder: impl Into<String>) -> Self {
69 Self {
70 placeholder: placeholder.into(),
71 }
72 }
73}
74
75#[derive(Clone, Debug)]
76pub struct CommandListProps {
77 pub max_height: f32,
78}
79
80impl Default for CommandListProps {
81 fn default() -> Self {
82 Self { max_height: 300.0 }
83 }
84}
85
86#[derive(Clone, Debug)]
87pub struct CommandGroupProps {
88 pub heading: Option<String>,
89}
90
91impl CommandGroupProps {
92 pub fn new(heading: impl Into<String>) -> Self {
93 Self {
94 heading: Some(heading.into()),
95 }
96 }
97}
98
99#[derive(Debug)]
100pub struct CommandItemProps<'a, IdSource> {
101 pub id_source: IdSource,
102 pub label: WidgetText,
103 pub keywords: Vec<String>,
104 pub icon: Option<Icon>,
105 pub shortcut: Option<String>,
106 pub disabled: bool,
107 pub on_select: Option<OnCommandSelect<'a>>,
108}
109
110impl<'a, IdSource: Hash> CommandItemProps<'a, IdSource> {
111 pub fn new(id_source: IdSource, label: impl Into<WidgetText>) -> Self {
112 Self {
113 id_source,
114 label: label.into(),
115 keywords: Vec::new(),
116 icon: None,
117 shortcut: None,
118 disabled: false,
119 on_select: None,
120 }
121 }
122
123 pub fn keywords(mut self, keywords: &[&str]) -> Self {
124 self.keywords = keywords.iter().map(|k| k.to_string()).collect();
125 self
126 }
127
128 pub fn icon(mut self, icon: Icon) -> Self {
129 self.icon = Some(icon);
130 self
131 }
132
133 pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
134 self.shortcut = Some(shortcut.into());
135 self
136 }
137
138 pub fn disabled(mut self, disabled: bool) -> Self {
139 self.disabled = disabled;
140 self
141 }
142
143 pub fn on_select(mut self, callback: impl FnMut() + 'a) -> Self {
144 self.on_select = Some(OnCommandSelect(Box::new(callback)));
145 self
146 }
147}
148
149#[derive(Clone, Debug, Default)]
150struct CommandState {
151 query: String,
152 selected_index: usize,
153 selectable_count: usize,
154}
155
156#[derive(Clone, Debug, Default)]
157struct CommandRenderState {
158 visible_count: usize,
159 selectable_count: usize,
160 empty_text: Option<String>,
161 enter_pressed: bool,
162}
163
164#[derive(Clone, Copy, Debug)]
165struct CommandTokens {
166 bg: Color32,
167 text: Color32,
168 muted: Color32,
169 border: Color32,
170 accent: Color32,
171 accent_text: Color32,
172}
173
174#[derive(Clone, Copy, Debug)]
175struct CommandMetrics {
176 input_height: f32,
177 item_height: f32,
178 item_padding: Margin,
179 group_padding: Margin,
180 separator_margin: f32,
181}
182
183fn command_tokens(theme: &Theme) -> CommandTokens {
184 CommandTokens {
185 bg: theme.palette.popover,
186 text: theme.palette.popover_foreground,
187 muted: theme.palette.muted_foreground,
188 border: theme.palette.border,
189 accent: theme.palette.accent,
190 accent_text: theme.palette.accent_foreground,
191 }
192}
193
194fn command_metrics() -> CommandMetrics {
195 CommandMetrics {
196 input_height: 48.0,
197 item_height: 36.0,
198 item_padding: Margin::symmetric(8, 6),
199 group_padding: Margin::symmetric(8, 4),
200 separator_margin: 6.0,
201 }
202}
203
204pub struct CommandContext<'a> {
205 state: &'a mut CommandState,
206 render: &'a mut CommandRenderState,
207 tokens: CommandTokens,
208 metrics: CommandMetrics,
209}
210
211pub fn command<R>(
216 ui: &mut Ui,
217 theme: &Theme,
218 props: CommandProps,
219 add_contents: impl FnOnce(&mut Ui, &mut CommandContext) -> R,
220) -> R {
221 let state_id = ui.make_persistent_id(props.id_source);
222 let mut state = ui
223 .ctx()
224 .data(|data| data.get_temp::<CommandState>(state_id))
225 .unwrap_or_default();
226
227 let (up, down, enter) = ui.input(|i| {
228 (
229 i.key_pressed(Key::ArrowUp),
230 i.key_pressed(Key::ArrowDown),
231 i.key_pressed(Key::Enter),
232 )
233 });
234
235 if state.selectable_count > 0 {
236 if down {
237 state.selected_index = (state.selected_index + 1) % state.selectable_count;
238 } else if up {
239 state.selected_index = if state.selected_index == 0 {
240 state.selectable_count - 1
241 } else {
242 state.selected_index - 1
243 };
244 }
245 } else {
246 state.selected_index = 0;
247 }
248
249 let tokens = command_tokens(theme);
250 let metrics = command_metrics();
251 let rounding = CornerRadius::same(DEFAULT_RADIUS.r3 as u8);
252 let shadow = if props.show_shadow {
253 ui.style().visuals.popup_shadow
254 } else {
255 egui::Shadow::NONE
256 };
257 let stroke = if props.show_border {
258 Stroke::new(1.0, tokens.border)
259 } else {
260 Stroke::NONE
261 };
262
263 let mut render = CommandRenderState {
264 enter_pressed: enter,
265 ..Default::default()
266 };
267
268 let inner = Frame::NONE
269 .fill(tokens.bg)
270 .stroke(stroke)
271 .corner_radius(rounding)
272 .shadow(shadow)
273 .show(ui, |command_ui| {
274 if let Some(min_width) = props.min_width {
275 command_ui.set_min_width(min_width);
276 }
277 command_ui.visuals_mut().override_text_color = Some(tokens.text);
278 command_ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
279 let mut ctx = CommandContext {
280 state: &mut state,
281 render: &mut render,
282 tokens,
283 metrics,
284 };
285 add_contents(command_ui, &mut ctx)
286 })
287 .inner;
288
289 state.selectable_count = render.selectable_count;
290 if state.selectable_count == 0 {
291 state.selected_index = 0;
292 } else if state.selected_index >= state.selectable_count {
293 state.selected_index = state.selectable_count - 1;
294 }
295
296 ui.ctx().data_mut(|data| data.insert_temp(state_id, state));
297
298 inner
299}
300
301pub fn command_input(ui: &mut Ui, ctx: &mut CommandContext, props: CommandInputProps) -> Response {
306 let desired = vec2(ui.available_width(), ctx.metrics.input_height);
307 let inner = ui.allocate_ui_with_layout(desired, Layout::left_to_right(Align::Center), |row| {
308 row.spacing_mut().item_spacing = vec2(8.0, 0.0);
309 row.visuals_mut().override_text_color = Some(ctx.tokens.muted);
310
311 let icon_text = RichText::new(Icon::Search.unicode()).size(14.0);
312 row.label(icon_text);
313 row.visuals_mut().override_text_color = Some(ctx.tokens.text);
314
315 let mut edit = egui::TextEdit::singleline(&mut ctx.state.query)
316 .hint_text(props.placeholder)
317 .frame(false);
318 edit = edit.desired_width(f32::INFINITY);
319 let response = row.add(edit);
320
321 if response.changed() {
322 ctx.state.selected_index = 0;
323 }
324
325 response
326 });
327
328 let response = inner.inner;
329 let rect = inner.response.rect;
330 let stroke = Stroke::new(1.0, ctx.tokens.border);
331 ui.painter()
332 .line_segment([rect.left_bottom(), rect.right_bottom()], stroke);
333
334 response
335}
336
337pub fn command_list<R>(
342 ui: &mut Ui,
343 ctx: &mut CommandContext,
344 props: CommandListProps,
345 add_contents: impl FnOnce(&mut Ui, &mut CommandContext) -> R,
346) -> R {
347 ctx.render.visible_count = 0;
348 ctx.render.selectable_count = 0;
349 ctx.render.empty_text = None;
350
351 ScrollArea::vertical()
352 .max_height(props.max_height)
353 .show(ui, |list_ui| {
354 list_ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
355 let inner = add_contents(list_ui, ctx);
356 if ctx.render.visible_count == 0
357 && let Some(text) = ctx.render.empty_text.take()
358 {
359 list_ui.add_space(8.0);
360 list_ui.with_layout(Layout::top_down(Align::Center), |ui| {
361 ui.label(RichText::new(text).color(ctx.tokens.muted).size(12.0));
362 });
363 list_ui.add_space(8.0);
364 }
365 inner
366 })
367 .inner
368}
369
370pub fn command_empty(ui: &mut Ui, ctx: &mut CommandContext, text: &str) -> Response {
371 ctx.render.empty_text = Some(text.to_string());
372 ui.allocate_response(Vec2::ZERO, Sense::hover())
373}
374
375pub fn command_group<R>(
376 ui: &mut Ui,
377 ctx: &mut CommandContext,
378 props: CommandGroupProps,
379 add_contents: impl FnOnce(&mut Ui, &mut CommandContext) -> R,
380) -> R {
381 Frame::NONE
382 .inner_margin(ctx.metrics.group_padding)
383 .show(ui, |group_ui| {
384 group_ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
385 if let Some(heading) = props.heading {
386 group_ui.label(
387 RichText::new(heading)
388 .size(11.0)
389 .color(ctx.tokens.muted)
390 .strong(),
391 );
392 }
393 add_contents(group_ui, ctx)
394 })
395 .inner
396}
397
398pub fn command_separator(ui: &mut Ui, ctx: &mut CommandContext) -> Response {
399 ui.add_space(ctx.metrics.separator_margin);
400 let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width(), 1.0), Sense::hover());
401 ui.painter().line_segment(
402 [rect.left_center(), rect.right_center()],
403 Stroke::new(1.0, ctx.tokens.border),
404 );
405 ui.add_space(ctx.metrics.separator_margin);
406 response
407}
408
409pub fn command_item<'a, IdSource: Hash>(
414 ui: &mut Ui,
415 ctx: &mut CommandContext,
416 mut props: CommandItemProps<'a, IdSource>,
417) -> Option<Response> {
418 let query = ctx.state.query.trim();
419 let label_text = props.label.text().to_string();
420 if !command_matches(query, &label_text, &props.keywords) {
421 return None;
422 }
423
424 ctx.render.visible_count += 1;
425 let selectable_index = if props.disabled {
426 None
427 } else {
428 let index = ctx.render.selectable_count;
429 ctx.render.selectable_count += 1;
430 Some(index)
431 };
432
433 let is_selected = selectable_index == Some(ctx.state.selected_index);
434 let rounding = CornerRadius::same(4);
435 let desired = vec2(ui.available_width(), ctx.metrics.item_height);
436 let item_id = ui.make_persistent_id(&props.id_source);
437
438 let inner = ui.allocate_ui_with_layout(desired, Layout::left_to_right(Align::Center), |row| {
439 row.spacing_mut().item_spacing = vec2(8.0, 0.0);
440 let rect = row.max_rect();
441 let response = row.interact(rect, item_id, Sense::click());
442 let hovered = response.hovered();
443
444 let fill = if is_selected {
445 ctx.tokens.accent
446 } else if hovered {
447 ctx.tokens.accent.gamma_multiply(0.35)
448 } else {
449 Color32::TRANSPARENT
450 };
451
452 if fill != Color32::TRANSPARENT {
453 row.painter().rect_filled(rect, rounding, fill);
454 }
455
456 Frame::NONE
457 .inner_margin(ctx.metrics.item_padding)
458 .show(row, |content| {
459 let mut text_color = ctx.tokens.text;
460 if props.disabled {
461 text_color = ctx.tokens.muted;
462 } else if is_selected {
463 text_color = ctx.tokens.accent_text;
464 }
465
466 if let Some(icon) = props.icon {
467 content.label(RichText::new(icon.unicode()).size(16.0).color(text_color));
468 }
469
470 content.label(RichText::new(label_text.as_str()).color(text_color));
471
472 if let Some(shortcut) = props.shortcut.take() {
473 content.with_layout(Layout::right_to_left(Align::Center), |ui| {
474 command_shortcut(ui, ctx, &shortcut);
475 });
476 }
477 });
478
479 response
480 });
481
482 let response = inner.inner;
483 if let Some(index) = selectable_index {
484 if response.hovered() && !props.disabled {
485 ctx.state.selected_index = index;
486 }
487 if (response.clicked() || ctx.render.enter_pressed && is_selected)
488 && !props.disabled
489 && let Some(callback) = props.on_select.as_mut()
490 {
491 (callback.0)();
492 }
493 }
494
495 Some(response)
496}
497
498pub fn command_shortcut(ui: &mut Ui, ctx: &CommandContext, text: &str) -> Response {
499 ui.label(
500 RichText::new(text)
501 .size(10.0)
502 .color(ctx.tokens.muted)
503 .monospace(),
504 )
505}
506
507fn command_matches(query: &str, label: &str, keywords: &[String]) -> bool {
508 if query.is_empty() {
509 return true;
510 }
511 if fuzzy_match(query, label) {
512 return true;
513 }
514 keywords.iter().any(|kw| fuzzy_match(query, kw))
515}
516
517fn fuzzy_match(query: &str, text: &str) -> bool {
518 let query_lower = query.to_lowercase();
519 let mut q = query_lower.chars();
520 let mut q_next = q.next();
521 if q_next.is_none() {
522 return true;
523 }
524 for ch in text.to_lowercase().chars() {
525 if Some(ch) == q_next {
526 q_next = q.next();
527 if q_next.is_none() {
528 return true;
529 }
530 }
531 }
532 false
533}
534
535#[derive(Debug)]
540pub struct CommandDialogProps<'a> {
541 pub id_source: Id,
542 pub open: &'a mut bool,
543 pub title: String,
544 pub description: String,
545 pub show_close_button: bool,
546}
547
548impl<'a> CommandDialogProps<'a> {
549 pub fn new(id_source: Id, open: &'a mut bool) -> Self {
550 Self {
551 id_source,
552 open,
553 title: "Command Palette".to_string(),
554 description: "Search for a command to run...".to_string(),
555 show_close_button: true,
556 }
557 }
558
559 pub fn title(mut self, title: impl Into<String>) -> Self {
560 self.title = title.into();
561 self
562 }
563
564 pub fn description(mut self, description: impl Into<String>) -> Self {
565 self.description = description.into();
566 self
567 }
568
569 pub fn show_close_button(mut self, show: bool) -> Self {
570 self.show_close_button = show;
571 self
572 }
573}
574
575pub fn command_dialog<R>(
576 ui: &mut Ui,
577 theme: &Theme,
578 props: CommandDialogProps<'_>,
579 add_contents: impl FnOnce(&mut Ui, &mut CommandContext) -> R,
580) -> Option<R> {
581 let dialog_props = DialogProps::new(props.id_source, props.open)
582 .title(props.title)
583 .description(props.description)
584 .scrollable(false)
585 .show_close_button(props.show_close_button)
586 .size(vec2(520.0, 0.0));
587
588 dialog(ui, theme, dialog_props, |dialog_ui| {
589 command(
590 dialog_ui,
591 theme,
592 CommandProps::new(props.id_source.with("command"))
593 .show_border(false)
594 .show_shadow(false),
595 add_contents,
596 )
597 })
598}