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(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PaletteDisplay {
CompactSingle,
CompactMulti,
#[default]
LargeIcons,
MediumIcons,
}
impl PaletteDisplay {
const ALL: [Self; 4] = [
Self::CompactSingle,
Self::CompactMulti,
Self::LargeIcons,
Self::MediumIcons,
];
fn label(self) -> &'static str {
match self {
Self::CompactSingle => "Compact list — 1 column",
Self::CompactMulti => "Compact list — multi column",
Self::LargeIcons => "Icons large",
Self::MediumIcons => "Icons medium",
}
}
fn columns(self, width: f32, gap: f32) -> usize {
let minimum = match self {
Self::CompactSingle => return 1,
Self::CompactMulti => 230.0,
Self::LargeIcons => 140.0,
Self::MediumIcons => 110.0,
};
((width + gap) / (minimum + gap)).floor().max(1.0) as usize
}
}
fn icon_tile(ui: &mut egui::Ui, label: &str, top: bool, width: f32, large: bool) -> egui::Response {
let height = if large { 120.0 } else { 96.0 };
let response = ui.add_sized([width, height], egui::Button::new("").selected(top));
let (icon, caption) = crate::icon_text::split_caption(label);
if ui.is_rect_visible(response.rect) {
let color = ui.style().interact_selectable(&response, top).text_color();
if let Some(icon) = icon {
egui_extras::install_image_loaders(ui.ctx());
let side: f32 = if large { 56.0 } else { 36.0 };
let art_height = side.min((width - 16.0) / icon.artwork_aspect.max(1.0));
let rect = egui::Rect::from_center_size(
egui::pos2(
response.rect.center().x,
response.rect.top() + 10.0 + side / 2.0,
),
egui::vec2(art_height * icon.artwork_aspect, art_height),
);
let mut art = egui::Image::new(egui::ImageSource::Bytes {
uri: format!("{}-artwork.svg", icon.uri).into(),
bytes: egui::load::Bytes::Static(icon.artwork_svg.as_bytes()),
})
.fit_to_exact_size(rect.size());
if icon.mono {
art = art.tint(color);
}
art.paint_at(ui, rect);
}
let font = egui::TextStyle::Button.resolve(ui.style());
let mut job = egui::text::LayoutJob::simple(caption.to_owned(), font, color, width - 12.0);
job.halign = egui::Align::Center;
job.wrap.max_rows = 2;
let galley = ui.fonts_mut(|fonts| fonts.layout_job(job));
ui.painter().galley(
egui::pos2(
response.rect.center().x,
response.rect.top() + if large { 76.0 } else { 54.0 },
),
galley,
color,
);
}
response.widget_info(|| {
egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), caption)
});
response.on_hover_text(caption)
}
#[derive(Default)]
pub struct Palette {
open: bool,
pub display: PaletteDisplay,
display_changed: 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 take_display_change(&mut self) -> bool {
std::mem::take(&mut self.display_changed)
}
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| {
let desired_width: f32 = if self.display == PaletteDisplay::CompactSingle {
360.0
} else {
620.0
};
ui.set_width(desired_width.min((ctx.content_rect().width() - 40.0).max(160.0)));
if !self.title.is_empty() {
ui.heading(&self.title);
ui.add_space(4.0);
}
ui.horizontal(|ui| {
ui.label("Display");
let combo = egui::ComboBox::from_id_salt("palette-display")
.selected_text(self.display.label())
.show_ui(ui, |ui| {
for mode in PaletteDisplay::ALL {
let option = ui.selectable_value(&mut self.display, mode, mode.label());
self.hits.insert(format!("display:{mode:?}"), option.rect);
if option.changed() {
self.display_changed = true;
self.want_focus = true;
}
}
});
self.hits.insert("display".into(), combo.response.rect);
});
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((ctx.content_rect().height() - 200.0).clamp(100.0, 480.0))
.auto_shrink([false, false])
.show(ui, |ui| {
if filtered.is_empty() {
ui.weak("No matches");
}
let gap = ui.spacing().item_spacing.x;
let columns = self.display.columns(ui.available_width(), gap);
let width = ((ui.available_width() - gap * (columns - 1) as f32)
/ columns as f32)
.max(1.0);
for (row_index, items) in filtered.chunks(columns).enumerate() {
ui.horizontal(|ui| {
for (column, item) in items.iter().enumerate() {
let is_top = row_index == 0 && column == 0;
let row = ui
.push_id(&item.id, |ui| match self.display {
PaletteDisplay::LargeIcons
| PaletteDisplay::MediumIcons => icon_tile(
ui,
&item.label,
is_top,
width,
self.display == PaletteDisplay::LargeIcons,
),
PaletteDisplay::CompactSingle => {
crate::icon_text::selectable_icon_label(
ui,
is_top,
&item.display(),
)
}
PaletteDisplay::CompactMulti => {
let (icon, caption) =
crate::icon_text::split_caption(&item.label);
let button = if let Some(icon) = icon {
egui_extras::install_image_loaders(ui.ctx());
egui::Button::selectable(
is_top,
(
crate::icon_text::image(
icon,
ui.text_style_height(
&egui::TextStyle::Body,
),
),
caption,
),
)
.image_tint_follows_text_color(icon.mono)
} else {
egui::Button::selectable(is_top, caption)
};
ui.add_sized([width, 24.0], button.truncate())
.on_hover_text(caption)
}
})
.inner;
if ui.is_rect_visible(row.rect) {
let visible = row.rect.intersect(ui.clip_rect());
self.hits.insert(format!("item:{}", item.id), visible);
if is_top {
self.hits.insert("top".into(), visible);
}
}
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::*;
#[test]
fn all_layouts_fit_and_keep_ranked_selection_at_desktop_and_phone_widths() {
for mode in PaletteDisplay::ALL {
for screen_width in [360.0, 1400.0] {
let ctx = egui::Context::default();
let mut palette = Palette::new();
palette.display = mode;
palette.open(items(), "Add feature", "Search features…");
for frame in 0..4 {
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(screen_width, 800.0),
)),
time: Some(frame as f64 / 60.0),
..Default::default()
};
let _ = ctx.run_ui(raw, |_| {
assert_eq!(palette.show(&ctx), None);
});
}
let top = palette.hits()["top"];
assert_eq!(top, palette.hits()["item:B"]);
for rect in palette.hits().values() {
assert!(
rect.left() >= 0.0 && rect.right() <= screen_width,
"{mode:?}: {rect:?}"
);
}
let second = palette.hits()["item:P.CU"];
if mode == PaletteDisplay::CompactSingle
|| (screen_width == 360.0 && mode == PaletteDisplay::CompactMulti)
{
assert!(second.top() > top.top());
} else {
assert_eq!(second.top(), top.top());
assert!(second.left() > top.left());
}
palette.query = "sphere".into();
let _ = ctx.run_ui(egui::RawInput::default(), |_| {
palette.show(&ctx);
});
assert_eq!(palette.hits()["top"], palette.hits()["item:P.S"]);
assert!(!palette.hits().contains_key("item:B"));
}
}
}
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");
}
}