use std::collections::BTreeSet;
use egui::text::LayoutJob;
use egui::{Key, NumExt as _};
use crate::fuzzy::{FuzzyMatch, FuzzyQuery};
pub struct CmdRow {
pub job: LayoutJob,
pub kb_shortcut: String,
pub tooltip: Option<String>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum RowState {
Normal,
Selected,
Disabled,
}
const ROW_MIN_HEIGHT: f32 = 16.0;
pub fn paint_command_row(ui: &mut egui::Ui, row: CmdRow, state: RowState) -> egui::Response {
let CmdRow {
job,
kb_shortcut,
tooltip,
} = row;
let selected = state == RowState::Selected;
let enabled = state != RowState::Disabled;
ui.spacing_mut().button_padding.y = 2.0;
ui.spacing_mut().item_spacing.y = 2.0;
let mut button = egui::Button::new(job)
.wrap()
.min_size(egui::vec2(ui.available_width(), ROW_MIN_HEIGHT));
if selected {
button = button.selected(true);
}
if !kb_shortcut.is_empty() {
let shortcut_text = if selected {
egui::RichText::new(kb_shortcut)
} else {
egui::RichText::new(kb_shortcut).weak()
};
button = button.right_text(shortcut_text);
}
let mut response = ui.add_enabled(enabled, button);
if let Some(tooltip) = tooltip {
response = if enabled {
response.on_hover_text(tooltip)
} else {
response.on_disabled_hover_text(tooltip)
};
}
if enabled {
response = response.on_hover_cursor(egui::CursorIcon::PointingHand);
}
response
}
pub struct MatchedCmd<Cmd> {
pub command: Cmd,
pub fuzzy_match: FuzzyMatch,
pub enabled: bool,
}
impl<Cmd> MatchedCmd<Cmd> {
fn score(&self) -> i64 {
self.fuzzy_match.score()
}
}
pub type MatchGroup<Cmd> = Vec<MatchedCmd<Cmd>>;
pub trait CommandPaletteProvider<Cmd> {
fn initial_hint_ui(&mut self, _ui: &mut egui::Ui) {}
fn all_matching(&mut self, query: &FuzzyQuery) -> Vec<MatchGroup<Cmd>>;
fn cmd_row(&self, ui: &egui::Ui, cmd: &MatchedCmd<Cmd>, selected: bool) -> CmdRow;
}
#[derive(Default)]
pub struct CommandPalette {
visible: bool,
raw_query: String,
selected_alternative: usize,
expanded_groups: BTreeSet<usize>,
}
impl CommandPalette {
fn reset(&mut self) {
*self = Default::default();
}
pub fn toggle(&mut self) {
self.visible ^= true;
}
#[must_use = "Returns the command that was selected"]
pub fn show<Cmd>(
&mut self,
egui_ctx: &egui::Context,
provider: &mut dyn CommandPaletteProvider<Cmd>,
) -> Option<Cmd> {
self.visible &= !egui_ctx.input_mut(|i| i.key_pressed(Key::Escape));
if !self.visible {
self.reset();
return None;
}
let screen_rect = egui_ctx.content_rect();
let width = 640.0.at_most(0.9 * screen_rect.width());
let max_height = 320.0.at_most(screen_rect.height());
let response = egui::Window::new("Command Palette")
.fixed_pos(screen_rect.center() - 0.5 * max_height * egui::Vec2::Y)
.fixed_size([width, max_height])
.pivot(egui::Align2::CENTER_TOP)
.resizable(false)
.scroll(false)
.title_bar(false)
.show(egui_ctx, |ui| self.window_content_ui(ui, provider))?;
let pressed_outside = egui_ctx.input(|input| {
input.pointer.any_pressed()
&& input
.pointer
.interact_pos()
.is_some_and(|pos| !response.response.rect.contains(pos))
});
if pressed_outside {
self.visible = false; return None;
}
response.inner?
}
#[must_use = "Returns the command that was selected"]
fn window_content_ui<Cmd>(
&mut self,
ui: &mut egui::Ui,
provider: &mut dyn CommandPaletteProvider<Cmd>,
) -> Option<Cmd> {
let (enter_pressed, up, down) = ui.input_mut(|i| {
(
i.consume_key(Default::default(), Key::Enter),
i.count_and_consume_key(Default::default(), Key::ArrowUp),
i.count_and_consume_key(Default::default(), Key::ArrowDown),
)
});
let text_response = ui.add(
egui::TextEdit::singleline(&mut self.raw_query)
.desired_width(f32::INFINITY)
.lock_focus(true),
);
text_response.request_focus();
let scroll_to_selected_alternative = if text_response.changed() {
self.selected_alternative = 0;
self.expanded_groups.clear();
true
} else {
false
};
let selected_command = egui::ScrollArea::vertical()
.auto_shrink([false, true])
.show(ui, |ui| {
self.alternatives_ui(
ui,
provider,
enter_pressed,
up,
down,
scroll_to_selected_alternative,
)
})
.inner;
if selected_command.is_some() {
self.reset();
}
selected_command
}
#[must_use = "Returns the command that was selected"]
#[expect(clippy::fn_params_excessive_bools)] fn alternatives_ui<Cmd>(
&mut self,
ui: &mut egui::Ui,
provider: &mut dyn CommandPaletteProvider<Cmd>,
enter_pressed: bool,
up: usize,
down: usize,
mut scroll_to_selected_alternative: bool,
) -> Option<Cmd> {
re_tracing::profile_function!();
let query = FuzzyQuery::new(self.raw_query.clone());
if query.is_empty() {
provider.initial_hint_ui(ui);
}
let mut groups = provider.all_matching(&query);
groups.sort_by_cached_key(|group| {
std::cmp::Reverse(group.iter().map(|cmd| cmd.score()).max())
});
groups.retain(|g| !g.is_empty());
let num_groups = groups.len();
if num_groups == 0 {
ui.weak("No matching results");
return None;
}
let mut num_truncated = vec![0; num_groups];
for (group_idx, group) in groups.iter_mut().enumerate() {
group.sort_by_key(|cmd| std::cmp::Reverse(cmd.score()));
if !self.expanded_groups.contains(&group_idx) {
let max_per_group = if 1 < num_groups {
5
} else {
50
};
num_truncated[group_idx] = group.len().saturating_sub(max_per_group);
group.truncate(max_per_group);
}
}
let enabled: Vec<bool> = groups
.iter()
.enumerate()
.flat_map(|(group_idx, group)| {
std::iter::chain(
group.iter().map(|cmd| cmd.enabled),
(0 < num_truncated[group_idx]).then_some(true),
)
})
.collect();
scroll_to_selected_alternative |= (up + down) != 0;
let enabled_indices: Vec<usize> = enabled
.iter()
.enumerate()
.filter_map(|(idx, &enabled)| enabled.then_some(idx))
.collect();
if enabled_indices.is_empty() {
self.selected_alternative = usize::MAX;
} else {
let mut pos = enabled_indices
.partition_point(|&idx| idx < self.selected_alternative)
.at_most(enabled_indices.len() - 1);
pos = pos
.saturating_add(down)
.saturating_sub(up)
.at_most(enabled_indices.len() - 1);
self.selected_alternative = enabled_indices[pos];
}
let mut selected_command = None;
let mut alternative_idx = 0;
for (group_idx, group) in groups.into_iter().enumerate() {
re_tracing::profile_scope!("group_ui");
for matched_cmd in group {
let selected = alternative_idx == self.selected_alternative;
let enabled = matched_cmd.enabled;
let state = if !enabled {
RowState::Disabled
} else if selected {
RowState::Selected
} else {
RowState::Normal
};
let row = provider.cmd_row(ui, &matched_cmd, selected);
let response = paint_command_row(ui, row, state);
if selected && scroll_to_selected_alternative {
ui.scroll_to_rect(response.rect, None);
}
if enabled && (selected && enter_pressed || response.clicked()) {
selected_command = Some(matched_cmd.command);
}
alternative_idx += 1;
}
if 0 < num_truncated[group_idx] {
let selected = alternative_idx == self.selected_alternative;
let text_color = if selected {
ui.visuals().selection.stroke.color
} else {
ui.visuals().weak_text_color()
};
let row = CmdRow {
job: LayoutJob::simple(
format!(
"+ {} more",
re_format::format_uint(num_truncated[group_idx])
),
egui::TextStyle::Button.resolve(ui.style()),
text_color,
f32::INFINITY,
),
kb_shortcut: String::new(),
tooltip: Some("Show all matches in this group".to_owned()),
};
let state = if selected {
RowState::Selected
} else {
RowState::Normal
};
let response = paint_command_row(ui, row, state);
if selected && scroll_to_selected_alternative {
ui.scroll_to_rect(response.rect, None);
}
if selected && enter_pressed || response.clicked() {
self.expanded_groups.insert(group_idx);
}
alternative_idx += 1;
}
if group_idx + 1 < num_groups {
ui.add_space(8.0);
}
}
selected_command
}
}