use egui::{Align2, Key, NumExt as _};
use std::borrow::Cow;
use std::collections::BTreeSet;
pub trait Command: Copy + Sized {
fn text(&self) -> &str;
fn description(&self) -> Option<Cow<'static, str>> {
None
}
fn info_ui(&self, _ui: &mut egui::Ui) {}
fn formatted_kb_shortcut(&self, _ctx: &egui::Context) -> Option<String> {
None
}
}
#[derive(Default, serde::Deserialize, serde::Serialize)]
pub struct NodePalette {
visible: bool,
query: String,
selected_alternative: Option<usize>,
}
impl NodePalette {
pub fn new() -> Self {
Self::default()
}
pub fn toggle(&mut self) {
self.visible ^= true;
}
#[must_use = "Returns the command that was selected"]
pub fn show<T, I>(
&mut self,
egui_ctx: &egui::Context,
area: egui::Rect,
commands: I,
) -> Option<T>
where
T: Command,
I: IntoIterator<Item = T>,
{
self.visible &= !egui_ctx.input_mut(|i| i.key_pressed(Key::Escape));
if !self.visible {
self.query.clear();
return None;
}
let commands: Vec<T> = commands.into_iter().collect();
let width = 400.0.at_most(area.width());
let max_height = 320.0.at_most(area.height());
let window_response = egui::Window::new("Node Palette")
.fixed_pos(area.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| {
egui::Frame {
inner_margin: 2.0.into(),
..Default::default()
}
.show(ui, |ui| self.window_content_ui(ui, &commands))
.inner
});
if let Some(ref resp) = window_response {
if resp.response.clicked_elsewhere() {
self.visible = false;
self.query.clear();
return None;
}
}
window_response?.inner?
}
fn window_content_ui<T: Command>(&mut self, ui: &mut egui::Ui, commands: &[T]) -> Option<T> {
let enter_pressed = ui.input_mut(|i| i.consume_key(Default::default(), Key::Enter));
let text_response = ui.add(
egui::TextEdit::singleline(&mut self.query)
.desired_width(f32::INFINITY)
.lock_focus(true),
);
text_response.request_focus();
let mut scroll_to_selected_alternative = false;
if text_response.changed() {
self.selected_alternative = (!self.query.is_empty()).then_some(0);
scroll_to_selected_alternative = self.selected_alternative.is_some();
}
let selected_command = egui::ScrollArea::vertical()
.auto_shrink([false, true])
.show(ui, |ui| {
self.alternatives_ui(ui, commands, enter_pressed, scroll_to_selected_alternative)
})
.inner;
if selected_command.is_some() {
*self = Self::new();
}
selected_command
}
fn alternatives_ui<T: Command>(
&mut self,
ui: &mut egui::Ui,
commands: &[T],
enter_pressed: bool,
mut scroll_to_selected_alternative: bool,
) -> Option<T> {
scroll_to_selected_alternative |= ui.input(|i| i.key_pressed(Key::ArrowUp));
scroll_to_selected_alternative |= ui.input(|i| i.key_pressed(Key::ArrowDown));
let query = self.query.to_lowercase();
let item_height = 16.0;
let font_id = egui::TextStyle::Button.resolve(ui.style());
let mut num_alternatives: usize = 0;
let mut selected_command = None;
let matches = commands_that_match(&query, commands);
let row_width = ui.available_width();
let name_col_w = matches
.iter()
.map(|m| {
ui.painter()
.layout_no_wrap(
m.command.text().to_owned(),
font_id.clone(),
egui::Color32::WHITE,
)
.size()
.x
})
.fold(0.0_f32, f32::max);
let desc_x = (name_col_w + 12.0).at_most(row_width * 0.55);
for (i, fuzzy_match) in matches.iter().enumerate() {
let command = fuzzy_match.command;
let kb_shortcut_text = command.formatted_kb_shortcut(ui.ctx()).unwrap_or_default();
let (rect, response) = ui.allocate_at_least(
egui::vec2(ui.available_width(), item_height),
egui::Sense::click(),
);
let response = response.on_hover_ui(|ui| {
let max_width = ui.spacing().tooltip_width;
ui.set_max_width(max_width);
command.info_ui(ui);
});
if response.clicked() {
selected_command = Some(command);
}
let selected = self.selected_alternative == Some(i);
let style = ui.style().interact_selectable(&response, selected);
if selected {
ui.painter()
.rect_filled(rect, style.corner_radius, ui.visuals().selection.bg_fill);
if enter_pressed {
selected_command = Some(command);
}
if scroll_to_selected_alternative {
ui.scroll_to_rect(rect, None);
}
}
let text = format_match(fuzzy_match, ui, &font_id, style.text_color());
let galley = text.into_galley(
ui,
Some(egui::TextWrapMode::Extend),
f32::INFINITY,
egui::FontSelection::default(),
);
let text_rect = Align2::LEFT_CENTER
.anchor_rect(egui::Rect::from_min_size(rect.left_center(), galley.size()));
ui.painter()
.galley(text_rect.min, galley, style.text_color());
ui.painter().text(
rect.right_center(),
Align2::RIGHT_CENTER,
kb_shortcut_text,
font_id.clone(),
if selected {
style.text_color()
} else {
ui.visuals().weak_text_color()
},
);
if let Some(desc) = command.description() {
ui.painter().text(
egui::pos2(rect.left() + desc_x, rect.center().y),
Align2::LEFT_CENTER,
desc.as_ref(),
font_id.clone(),
if selected {
style.text_color()
} else {
ui.visuals().weak_text_color()
},
);
}
num_alternatives += 1;
}
if num_alternatives == 0 {
ui.weak("No matching results");
}
let up = ui.input_mut(|i| i.count_and_consume_key(Default::default(), Key::ArrowUp));
let down = ui.input_mut(|i| i.count_and_consume_key(Default::default(), Key::ArrowDown));
self.selected_alternative = if num_alternatives == 0 {
None
} else if up == 0 && down == 0 {
self.selected_alternative
.map(|sel| sel.min(num_alternatives - 1))
} else {
let next = match self.selected_alternative {
None => 0,
Some(cur) => (cur + down).saturating_sub(up),
};
Some(next.min(num_alternatives - 1))
};
selected_command
}
}
struct FuzzyMatch<T> {
command: T,
score: isize,
fuzzy_match: Option<sublime_fuzzy::Match>,
}
fn commands_that_match<T: Command>(query: &str, commands: &[T]) -> Vec<FuzzyMatch<T>> {
if query.is_empty() {
commands
.iter()
.map(|&command| FuzzyMatch {
command,
score: 0,
fuzzy_match: None,
})
.collect()
} else {
let mut matches: Vec<_> = commands
.iter()
.filter_map(|&command| {
let target_text = command.text();
sublime_fuzzy::best_match(query, target_text).map(|fuzzy_match| FuzzyMatch {
command,
score: fuzzy_match.score(),
fuzzy_match: Some(fuzzy_match),
})
})
.collect();
matches.sort_by_key(|m| -m.score); matches
}
}
fn format_match<T: Command>(
m: &FuzzyMatch<T>,
ui: &egui::Ui,
font_id: &egui::FontId,
default_text_color: egui::Color32,
) -> egui::WidgetText {
let target_text = m.command.text();
if let Some(fm) = &m.fuzzy_match {
let matched_indices: BTreeSet<_> = fm.matched_indices().collect();
let mut job = egui::text::LayoutJob::default();
for (i, c) in target_text.chars().enumerate() {
let color = if matched_indices.contains(&i) {
ui.visuals().strong_text_color()
} else {
default_text_color
};
job.append(
&c.to_string(),
0.0,
egui::text::TextFormat::simple(font_id.clone(), color),
);
}
job.into()
} else {
egui::RichText::new(target_text)
.color(default_text_color)
.into()
}
}