use egui::{Align2, Color32, Key, Order, Stroke, Ui, vec2};
use facett_core::theme;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Command {
pub id: &'static str,
pub label: String,
pub group: &'static str,
pub keywords: Vec<&'static str>,
pub shortcut: Option<&'static str>,
pub enabled: bool,
pub icon: Option<&'static str>,
}
impl Command {
pub fn new(id: &'static str, label: impl Into<String>, group: &'static str) -> Self {
Self { id, label: label.into(), group, keywords: Vec::new(), shortcut: None, enabled: true, icon: None }
}
pub fn shortcut(mut self, s: &'static str) -> Self {
self.shortcut = Some(s);
self
}
pub fn icon(mut self, glyph: &'static str) -> Self {
self.icon = Some(glyph);
self
}
pub fn keywords(mut self, kw: &[&'static str]) -> Self {
self.keywords = kw.to_vec();
self
}
pub fn enabled(mut self, on: bool) -> Self {
self.enabled = on;
self
}
fn haystack(&self) -> String {
let mut h = format!("{} {}", self.label, self.id);
for k in &self.keywords {
h.push(' ');
h.push_str(k);
}
h.to_lowercase()
}
}
fn fuzzy_score(needle: &str, haystack: &str) -> Option<i32> {
if needle.is_empty() {
return Some(0);
}
let hay: Vec<char> = haystack.chars().collect();
let mut score = 0i32;
let mut hi = 0usize;
let mut first = None;
let mut last = 0usize;
let mut prev_matched = false;
for nc in needle.chars() {
let mut found = false;
while hi < hay.len() {
if hay[hi] == nc {
found = true;
break;
}
hi += 1;
}
if !found {
return None;
}
if first.is_none() {
first = Some(hi);
}
last = hi;
score += 1;
if prev_matched {
score += 5; }
let at_word_start = hi == 0 || matches!(hay[hi - 1], ' ' | '.' | '_' | '-' | '/');
if at_word_start {
score += 3;
}
prev_matched = true;
hi += 1;
}
let span = last - first.unwrap_or(0);
score -= span as i32 / 4;
Some(score)
}
pub fn rank<'a>(query: &str, commands: &'a [Command]) -> Vec<&'a Command> {
let q = query.to_lowercase();
let mut scored: Vec<(i32, usize, &Command)> = commands
.iter()
.enumerate()
.filter(|(_, c)| c.enabled)
.filter_map(|(i, c)| fuzzy_score(&q, &c.haystack()).map(|s| (s, i, c)))
.collect();
scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
scored.into_iter().map(|(_, _, c)| c).collect()
}
#[derive(Default)]
pub struct CommandPalette {
open: bool,
query: String,
cursor: usize,
invoked: Option<&'static str>,
}
impl CommandPalette {
pub fn open(&mut self) {
self.open = true;
self.query.clear();
self.cursor = 0;
}
pub fn close(&mut self) {
self.open = false;
}
pub fn is_open(&self) -> bool {
self.open
}
pub fn invoked(&self) -> Option<&'static str> {
self.invoked
}
pub fn query(&self) -> &str {
&self.query
}
pub fn cursor(&self) -> usize {
self.cursor
}
pub fn move_cursor(&mut self, delta: i32, len: usize) {
if len == 0 {
self.cursor = 0;
return;
}
let n = len as i32;
self.cursor = (((self.cursor as i32 + delta) % n + n) % n) as usize;
}
pub fn invoke(&mut self, id: &'static str) -> &'static str {
self.invoked = Some(id);
self.open = false;
id
}
pub fn state_json(&self, commands: &[Command]) -> serde_json::Value {
serde_json::json!({
"open": self.open,
"query": self.query,
"cursor": self.cursor,
"commands": commands.iter().map(|c| c.id).collect::<Vec<_>>(),
"hits": rank(&self.query, commands).iter().map(|c| c.id).collect::<Vec<_>>(),
"invoked": self.invoked,
})
}
pub fn handle_hotkeys(&mut self, ctx: &egui::Context) {
ctx.input(|i| {
let cmd = i.modifiers.command;
if cmd && i.key_pressed(Key::K) {
self.open = true;
self.query.clear();
self.cursor = 0;
}
if cmd && i.modifiers.shift && i.key_pressed(Key::P) {
self.open = true;
self.query.clear();
self.cursor = 0;
}
if i.key_pressed(Key::Escape) {
self.open = false;
}
});
}
pub fn ui(&mut self, ctx: &egui::Context, commands: &[Command]) -> Option<&'static str> {
self.handle_hotkeys(ctx);
if !self.open {
return None;
}
let th = ctx.data(|d| d.get_temp::<facett_core::Theme>(egui::Id::new("facett_theme"))).unwrap_or_default();
let screen = ctx.content_rect();
egui::Area::new("facett_palette_scrim".into())
.order(Order::Foreground)
.fixed_pos(screen.min)
.interactable(false)
.show(ctx, |ui| {
ui.painter().rect_filled(screen, 0.0, Color32::from_black_alpha(160));
});
let mut chosen = None;
egui::Area::new("facett_palette".into())
.order(Order::Foreground)
.anchor(Align2::CENTER_TOP, vec2(0.0, 80.0))
.show(ctx, |ui| {
egui::Frame::popup(ui.style())
.fill(th.panel_bg)
.stroke(Stroke::new(1.0, th.panel_stroke))
.show(ui, |ui| {
ui.set_width(520.0);
let edit = ui.add(
egui::TextEdit::singleline(&mut self.query).hint_text("Type a command…").desired_width(f32::INFINITY),
);
edit.request_focus();
let hits = rank(&self.query, commands);
let (down, up, enter) = ui.input(|i| {
(i.key_pressed(Key::ArrowDown), i.key_pressed(Key::ArrowUp), i.key_pressed(Key::Enter))
});
if down {
self.move_cursor(1, hits.len());
}
if up {
self.move_cursor(-1, hits.len());
}
if hits.is_empty() {
self.cursor = 0;
} else if self.cursor >= hits.len() {
self.cursor = hits.len() - 1;
}
ui.separator();
for (i, c) in hits.iter().enumerate() {
let sel = i == self.cursor;
let row = row_text(c);
let resp = ui.selectable_label(sel, row);
if sel {
let r = resp.rect;
let p = ui.painter();
for w in 1..=4 {
let a = (70 / w) as u8;
let g = Color32::from_rgba_unmultiplied(th.glow.r(), th.glow.g(), th.glow.b(), a);
p.line_segment(
[r.left_bottom() + vec2(0.0, w as f32), r.right_bottom() + vec2(0.0, w as f32)],
Stroke::new(1.0, g),
);
}
p.line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.5, th.accent));
}
if resp.clicked() {
chosen = Some(c.id);
}
}
if hits.is_empty() {
ui.weak("No matching commands");
}
if enter {
chosen = hits.get(self.cursor).map(|c| c.id);
}
});
});
if let Some(id) = chosen {
self.open = false;
self.invoked = Some(id);
}
chosen
}
}
fn row_text(c: &Command) -> String {
let mut s = String::new();
if let Some(ic) = c.icon {
s.push_str(ic);
s.push(' ');
}
s.push_str(&c.label);
s.push_str(" · ");
s.push_str(c.group);
if let Some(sc) = c.shortcut {
s.push_str(" ");
s.push_str(sc);
}
s
}
pub fn context_menu(ui: &mut Ui, commands: &[Command]) -> Option<&'static str> {
let th = theme(ui);
let mut chosen = None;
let mut last_group: Option<&'static str> = None;
for c in commands {
if last_group.is_some() && last_group != Some(c.group) {
ui.separator();
}
last_group = Some(c.group);
let btn = egui::Button::new(menu_label(c)).wrap_mode(egui::TextWrapMode::Extend);
let resp = ui.add_enabled(c.enabled, btn);
if resp.hovered() && c.enabled {
let r = resp.rect;
ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, th.accent));
}
if resp.clicked() {
chosen = Some(c.id);
ui.close();
}
}
chosen
}
pub fn attach_context_menu(response: &egui::Response, commands: &[Command], mut on_pick: impl FnMut(&'static str)) {
response.context_menu(|ui| {
if let Some(id) = context_menu(ui, commands) {
on_pick(id);
}
});
}
pub fn menu_bar(ui: &mut Ui, commands: &[Command]) -> Option<&'static str> {
let mut chosen = None;
egui::MenuBar::new().ui(ui, |ui| {
let mut groups: Vec<&'static str> = Vec::new();
for c in commands {
if !groups.contains(&c.group) {
groups.push(c.group);
}
}
for g in groups {
ui.menu_button(g, |ui| {
let in_group: Vec<&Command> = commands.iter().filter(|c| c.group == g).collect();
for c in in_group {
let btn = egui::Button::new(menu_label(c)).wrap_mode(egui::TextWrapMode::Extend);
let resp = ui.add_enabled(c.enabled, btn);
if resp.clicked() {
chosen = Some(c.id);
ui.close();
}
}
});
}
});
chosen
}
fn menu_label(c: &Command) -> String {
let mut s = String::new();
if let Some(ic) = c.icon {
s.push_str(ic);
s.push(' ');
}
s.push_str(&c.label);
if let Some(sc) = c.shortcut {
s.push_str(" ");
s.push_str(sc);
}
s
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Vec<Command> {
vec![
Command::new("copy", "Copy", "Edit").shortcut("Ctrl+C"),
Command::new("cut", "Cut", "Edit").enabled(false),
Command::new("paste", "Paste", "Edit"),
Command::new("case.new", "New case", "Case").keywords(&["create", "investigation"]),
Command::new("case.open", "Open case", "Case"),
Command::new("view.zoom", "Zoom in", "View"),
]
}
#[test]
fn fuzzy_matches_subsequence_only() {
assert!(fuzzy_score("nc", "new case").is_some());
assert!(fuzzy_score("zzz", "copy").is_none());
assert_eq!(fuzzy_score("", "anything"), Some(0));
}
#[test]
fn fuzzy_prefers_word_start_and_contiguous() {
let contiguous = fuzzy_score("cop", "copy").unwrap();
let scattered = fuzzy_score("cy", "copy").unwrap();
assert!(contiguous > scattered, "contiguous {contiguous} should beat scattered {scattered}");
}
#[test]
fn rank_filters_disabled_and_ranks_relevant_first() {
let cmds = sample();
let hits = rank("", &cmds);
assert!(hits.iter().all(|c| c.id != "cut"), "disabled command must be filtered out");
let hits = rank("case", &cmds);
assert!(hits.len() >= 2);
assert!(hits[0].group == "Case" && hits[1].group == "Case", "case.* should rank first, got {:?}", hits.iter().map(|c| c.id).collect::<Vec<_>>());
}
#[test]
fn rank_matches_keywords_not_just_label() {
let cmds = sample();
let hits = rank("investigation", &cmds);
assert_eq!(hits.first().map(|c| c.id), Some("case.new"));
}
#[test]
fn cursor_wraps_both_directions() {
let mut p = CommandPalette::default();
let len = 3;
p.move_cursor(1, len); p.move_cursor(1, len); p.move_cursor(1, len); assert_eq!(p.cursor(), 0);
p.move_cursor(-1, len);
assert_eq!(p.cursor(), len - 1);
p.move_cursor(1, 0);
assert_eq!(p.cursor(), 0);
}
#[test]
fn invoke_sets_invoked_and_closes() {
let mut p = CommandPalette::default();
p.open();
assert!(p.is_open());
let id = p.invoke("copy");
assert_eq!(id, "copy");
assert_eq!(p.invoked(), Some("copy"));
assert!(!p.is_open(), "invoke closes the palette");
}
#[test]
fn state_json_carries_hits_and_invoked() {
let cmds = sample();
let mut p = CommandPalette::default();
p.open();
p.invoke("case.new");
let j = p.state_json(&cmds);
assert_eq!(j["invoked"], "case.new");
assert_eq!(j["commands"].as_array().unwrap().len(), cmds.len());
let hits: Vec<&str> = j["hits"].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect();
assert!(!hits.contains(&"cut"));
}
#[test]
fn command_builders_compose() {
let c = Command::new("x", "X", "G").shortcut("Ctrl+X").icon(">").keywords(&["a", "b"]).enabled(false);
assert_eq!(c.shortcut, Some("Ctrl+X"));
assert_eq!(c.icon, Some(">"));
assert_eq!(c.keywords, vec!["a", "b"]);
assert!(!c.enabled);
}
#[test]
fn disabled_command_never_matched_by_palette() {
let cmds = sample();
let hits = rank("cut", &cmds);
assert!(hits.iter().all(|c| c.id != "cut"));
}
}