use eframe::egui;
use std::collections::HashMap;
#[derive(Clone, Debug)]
pub struct PaletteItem {
pub id: String,
pub label: String,
pub keywords: Vec<String>,
}
impl PaletteItem {
pub fn new(
id: impl Into<String>,
label: impl Into<String>,
keywords: Vec<String>,
) -> Self {
Self {
id: id.into(),
label: label.into(),
keywords,
}
}
fn display(&self) -> String {
self.label.clone()
}
}
#[derive(Default)]
pub struct Palette {
open: bool,
query: String,
title: String,
placeholder: String,
items: Vec<PaletteItem>,
want_focus: bool,
hits: HashMap<String, egui::Rect>,
}
impl Palette {
pub fn new() -> Self {
Self::default()
}
pub fn open(
&mut self,
mut items: Vec<PaletteItem>,
title: impl Into<String>,
placeholder: impl Into<String>,
) {
sort_by_label(&mut items);
self.items = items;
self.title = title.into();
self.placeholder = placeholder.into();
self.query.clear();
self.open = true;
self.want_focus = true;
}
pub fn is_open(&self) -> bool {
self.open
}
pub fn close(&mut self) {
self.open = false;
self.query.clear();
self.items.clear();
self.hits.clear();
}
pub fn hits(&self) -> &HashMap<String, egui::Rect> {
&self.hits
}
pub fn show(&mut self, ctx: &egui::Context) -> Option<String> {
if !self.open {
return None;
}
self.hits.clear();
let mut selected: Option<String> = None;
let mut enter_no_match = false;
let modal = egui::Modal::new(egui::Id::new("brep-command-palette")).show(ctx, |ui| {
ui.set_width(360.0);
if !self.title.is_empty() {
ui.heading(&self.title);
ui.add_space(4.0);
}
let input = ui.add(
egui::TextEdit::singleline(&mut self.query)
.hint_text(&self.placeholder)
.desired_width(f32::INFINITY),
);
self.hits.insert("input".into(), input.rect);
if self.want_focus {
input.request_focus();
self.want_focus = false;
}
if input.has_focus() {
ui.memory_mut(|m| {
m.set_focus_lock_filter(
input.id,
egui::EventFilter {
escape: true,
horizontal_arrows: true,
..Default::default()
},
)
});
}
let enter =
input.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
ui.add_space(4.0);
ui.separator();
let filtered = filter_items(&self.items, &self.query);
if enter {
match filtered.first() {
Some(top) => selected = Some(top.id.clone()),
None => enter_no_match = true,
}
}
egui::ScrollArea::vertical()
.max_height(320.0)
.auto_shrink([false, false])
.show(ui, |ui| {
if filtered.is_empty() {
ui.weak("No matches");
}
for (idx, item) in filtered.iter().enumerate() {
let is_top = idx == 0;
let row = ui.selectable_label(is_top, item.display());
if ui.is_rect_visible(row.rect) {
self.hits.insert(format!("item:{}", item.id), row.rect);
if is_top {
self.hits.insert("top".into(), row.rect);
}
}
if row.clicked() {
selected = Some(item.id.clone());
}
}
});
});
if let Some(id) = selected {
self.close();
return Some(id);
}
if modal.should_close() {
self.close();
} else if enter_no_match {
self.want_focus = true;
}
None
}
}
fn sort_key(label: &str) -> String {
label
.trim_start_matches(|c: char| !c.is_ascii_alphanumeric())
.to_lowercase()
}
pub(crate) fn sort_by_label(items: &mut [PaletteItem]) {
items.sort_by(|a, b| sort_key(&a.label).cmp(&sort_key(&b.label)));
}
pub fn filter_items<'a>(items: &'a [PaletteItem], query: &str) -> Vec<&'a PaletteItem> {
let needle = query.trim().to_lowercase();
if needle.is_empty() {
return items.iter().collect();
}
let mut scored: Vec<(i32, &PaletteItem)> = items
.iter()
.filter_map(|item| item_score(item, &needle).map(|s| (s, item)))
.collect();
scored.sort_by(|a, b| {
b.0.cmp(&a.0)
.then_with(|| sort_key(&a.1.label).cmp(&sort_key(&b.1.label)))
});
scored.into_iter().map(|(_, item)| item).collect()
}
fn item_score(item: &PaletteItem, needle: &str) -> Option<i32> {
const LABEL_BONUS: i32 = 10;
let mut best: Option<i32> = None;
let mut consider = |s: Option<i32>| {
if let Some(s) = s {
best = Some(best.map_or(s, |b| b.max(s)));
}
};
consider(fuzzy_score(&item.label, needle).map(|s| s + LABEL_BONUS));
for kw in &item.keywords {
consider(fuzzy_score(kw, needle));
}
best
}
fn fuzzy_score(haystack: &str, needle: &str) -> Option<i32> {
if needle.is_empty() {
return Some(0);
}
let hay = haystack.to_lowercase();
if let Some(pos) = hay.find(needle) {
return Some(1000 - pos as i32);
}
let mut chars = needle.chars().peekable();
let mut last = 0i32;
for (i, hc) in hay.chars().enumerate() {
match chars.peek() {
Some(&nc) if hc == nc => {
chars.next();
last = i as i32;
}
Some(_) => {}
None => break,
}
}
chars.peek().is_none().then_some(400 - last)
}
#[cfg(test)]
mod tests {
use super::*;
fn items() -> Vec<PaletteItem> {
let mut v = vec![
PaletteItem::new("P.CY", "Primitive Cylinder", vec!["P.CY".into()]),
PaletteItem::new("P.CU", "Primitive Cube", vec!["P.CU".into()]),
PaletteItem::new("B", "Boolean", vec!["B".into()]),
PaletteItem::new("P.S", "Primitive Sphere", vec!["P.S".into()]),
];
sort_by_label(&mut v);
v
}
#[test]
fn empty_query_is_alphabetical_by_label() {
let v = items();
let labels: Vec<&str> = filter_items(&v, "").iter().map(|i| i.label.as_str()).collect();
assert_eq!(
labels,
["Boolean", "Primitive Cube", "Primitive Cylinder", "Primitive Sphere"]
);
}
#[test]
fn substring_query_ranks_the_match_first() {
let v = items();
let out = filter_items(&v, "cyl");
assert_eq!(out.first().unwrap().id, "P.CY");
}
#[test]
fn query_is_case_insensitive() {
let v = items();
assert_eq!(filter_items(&v, "CYL").first().unwrap().id, "P.CY");
assert_eq!(filter_items(&v, "boolean").first().unwrap().id, "B");
}
#[test]
fn keyword_matches_even_when_label_does_not() {
let v = items();
let out = filter_items(&v, "p.cy");
assert_eq!(out.first().unwrap().id, "P.CY");
}
#[test]
fn non_matching_query_yields_nothing() {
let v = items();
assert!(filter_items(&v, "zzz").is_empty());
}
#[test]
fn light_fuzzy_subsequence_matches() {
let v = items();
let out = filter_items(&v, "cye");
assert_eq!(out.first().unwrap().id, "P.CY");
}
#[test]
fn prefix_substring_outranks_a_later_substring() {
let v = vec![
PaletteItem::new("a", "Rounded corner", vec![]),
PaletteItem::new("b", "Corner treatment", vec![]),
];
let out = filter_items(&v, "corner");
assert_eq!(out.first().unwrap().id, "b");
}
}