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,
SmallIcons,
}
impl PaletteDisplay {
const ALL: [Self; 5] = [
Self::CompactSingle,
Self::CompactMulti,
Self::LargeIcons,
Self::MediumIcons,
Self::SmallIcons,
];
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",
Self::SmallIcons => "Icons small",
}
}
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,
Self::SmallIcons => 88.0,
};
((width + gap) / (minimum + gap)).floor().max(1.0) as usize
}
fn tile(self) -> Option<TileMetrics> {
let (height, side, caption_top) = match self {
Self::CompactSingle | Self::CompactMulti => return None,
Self::LargeIcons => (120.0, 56.0, 76.0),
Self::MediumIcons => (96.0, 36.0, 54.0),
Self::SmallIcons => (80.0, 24.0, 40.0),
};
Some(TileMetrics {
height,
side,
caption_top,
})
}
}
#[derive(Clone, Copy, Debug)]
struct TileMetrics {
height: f32,
side: f32,
caption_top: f32,
}
const ICON_TOP: f32 = 10.0;
fn icon_tile(
ui: &mut egui::Ui,
label: &str,
top: bool,
width: f32,
tile: TileMetrics,
) -> egui::Response {
let response = ui.add_sized([width, tile.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 = tile.side;
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() + ICON_TOP + 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() + tile.caption_top,
),
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,
}
}
let chrome = ui.min_rect().height();
let max_height = (ctx.content_rect().height() - chrome - 48.0).max(120.0);
let budget = egui::Rect::from_min_size(
ui.cursor().min,
egui::vec2(ui.available_width(), max_height),
);
ui.scope_builder(egui::UiBuilder::new().max_rect(budget), |ui| {
egui::ScrollArea::vertical()
.max_height(max_height)
.auto_shrink([false, true])
.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.tile() {
Some(tile) => {
icon_tile(ui, &item.label, is_top, width, tile)
}
None if self.display
== PaletteDisplay::CompactSingle =>
{
crate::icon_text::selectable_icon_label(
ui,
is_top,
&item.display(),
)
}
None => {
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)
}