use ashlar_command::shortcut::Platform;
use ashlar_command::{CommandInfo, fuzzy_match};
use egui::text::{LayoutJob, TextFormat};
use egui::{Align2, Id, Key, Modifiers, Rect, pos2, vec2};
use crate::automation::{self, Details, Role};
use crate::cache;
use crate::search::SearchField;
use crate::tokens::{self, Tokens};
use crate::virtual_list::{RowHeights, VirtualList};
#[derive(Clone, Debug)]
struct Hit {
index: usize,
score: i32,
positions: Vec<usize>,
}
pub struct CommandPalette {
open: bool,
query: String,
cursor: usize,
hits: Vec<Hit>,
key: Option<(u64, u64, usize)>,
focus_pending: bool,
max_results: usize,
hint: String,
}
impl Default for CommandPalette {
fn default() -> Self {
Self::new()
}
}
impl CommandPalette {
pub fn new() -> Self {
CommandPalette {
open: false,
query: String::new(),
cursor: 0,
hits: Vec::new(),
key: None,
focus_pending: false,
max_results: 200,
hint: "Type a command".into(),
}
}
pub fn hint(mut self, hint: impl Into<String>) -> Self {
self.hint = hint.into();
self
}
pub fn open(&mut self) {
self.open = true;
self.focus_pending = true;
self.query.clear();
self.cursor = 0;
self.key = None;
}
pub fn close(&mut self) {
self.open = false;
}
pub fn toggle(&mut self) {
if self.open { self.close() } else { self.open() }
}
pub fn is_open(&self) -> bool {
self.open
}
pub fn query(&self) -> &str {
&self.query
}
pub fn results<'c>(&self, commands: &'c [CommandInfo]) -> Vec<&'c str> {
self.hits.iter().filter_map(|h| commands.get(h.index)).map(|c| c.id.as_str()).collect()
}
pub fn rank(&mut self, commands: &[CommandInfo], revision: u64) {
let key = (cache::hash_of(&self.query), revision, commands.len());
if self.key == Some(key) {
return;
}
self.key = Some(key);
self.hits.clear();
let query = self.query.trim();
for (index, c) in commands.iter().enumerate() {
if query.is_empty() {
self.hits.push(Hit { index, score: 0, positions: Vec::new() });
} else if let Some(m) = fuzzy_match(query, &c.label) {
self.hits.push(Hit { index, score: m.score, positions: m.positions });
} else if let Some(m) = fuzzy_match(query, &c.id) {
self.hits.push(Hit { index, score: m.score - 1000, positions: Vec::new() });
}
}
self.hits.sort_by_key(|h| (std::cmp::Reverse(h.score), commands.get(h.index).is_none_or(|c| !c.enabled), h.index));
self.hits.truncate(self.max_results);
self.cursor = 0;
}
pub fn show(&mut self, ctx: &egui::Context, commands: &[CommandInfo], revision: u64, platform: Platform) -> Option<String> {
if !self.open {
return None;
}
if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) {
self.close();
return None;
}
let t = tokens::current(ctx);
let (up, down, enter) = ctx.input_mut(|i| {
(i.consume_key(Modifiers::NONE, Key::ArrowUp), i.consume_key(Modifiers::NONE, Key::ArrowDown), i.consume_key(Modifiers::NONE, Key::Enter))
});
let id = Id::new("ashlar_ui::palette");
let width = (ctx.content_rect().width() - 32.0).clamp(200.0, 560.0);
let area = egui::Modal::default_area(id).anchor(Align2::CENTER_TOP, vec2(0.0, 72.0)).default_width(width);
let frame = egui::Frame::new()
.fill(t.surface.raised)
.stroke(egui::Stroke::new(1.0, t.border))
.corner_radius(t.radius.lg)
.inner_margin(egui::Margin::same(t.space.md as i8))
.shadow(ctx.global_style().visuals.popup_shadow);
let mut run = None;
let modal = egui::Modal::new(id).area(area).frame(frame).show(ctx, |ui| {
ui.set_width(width);
if self.focus_pending {
SearchField::focus(ui, "palette.search");
}
let search = SearchField::new("palette.search", &mut self.query).hint(&self.hint).show(ui);
if search.response.has_focus() {
self.focus_pending = false;
} else if search.response.lost_focus() && !enter {
search.response.request_focus();
}
self.rank(commands, revision);
let n = self.hits.len();
if n > 0 {
if up {
self.cursor = self.cursor.checked_sub(1).unwrap_or(n - 1);
}
if down {
self.cursor = (self.cursor + 1) % n;
}
self.cursor = self.cursor.min(n - 1);
}
if enter
&& let Some(c) = self.hits.get(self.cursor).and_then(|h| commands.get(h.index))
&& c.enabled
{
run = Some(c.id.clone());
}
ui.add_space(t.space.sm);
let row_h = t.control_height + 4.0;
let hits = &self.hits;
let label = |i: usize| hits.get(i).and_then(|h| commands.get(h.index)).map_or("", |c| c.label.as_str());
let query_key = cache::hash_of(&self.query);
let list = VirtualList::new("palette.results", n, RowHeights::Uniform(row_h))
.selected(Some(self.cursor))
.scroll_to((up || down).then_some(self.cursor))
.max_height(row_h * 10.0)
.row_label(&label)
.show(ui, |ui, row| {
let Some(hit) = hits.get(row.index) else { return };
let Some(c) = commands.get(hit.index) else { return };
paint_row(ui, &t, row.rect, c, hit, query_key, row.selected, platform);
});
if let Some(i) = list.clicked
&& let Some(c) = hits.get(i).and_then(|h| commands.get(h.index))
{
self.cursor = i;
if c.enabled {
run = Some(c.id.clone());
}
}
if n == 0 {
ui.label(egui::RichText::new("No matching commands").color(t.text.secondary));
}
automation::register_fmt(
ui,
ui.min_rect(),
format_args!("palette"),
"Command palette",
Role::Palette,
true,
Details { value: Some(&self.query), selected: None },
);
});
if run.is_some() || modal.backdrop_response.clicked() {
self.close();
}
run
}
}
fn paint_row(ui: &mut egui::Ui, t: &Tokens, rect: Rect, c: &CommandInfo, hit: &Hit, query_key: u64, selected: bool, platform: Platform) {
let font = Tokens::font(t.type_scale.body);
let base = if !c.enabled {
t.text.disabled
} else if selected {
t.selection_text
} else {
t.text.primary
};
let strong = if c.enabled && !selected { t.accent } else { base };
let revision = cache::hash_of(&(query_key, base, strong, font.size.to_bits(), &c.label, &c.menu));
let galley = cache::galley(ui, ("ashlar_ui::palette-row", &c.id), revision, || {
let mut job = LayoutJob::default();
let plain = TextFormat::simple(font.clone(), base);
let hl = TextFormat { underline: egui::Stroke::new(1.0, strong), ..TextFormat::simple(font.clone(), strong) };
let mut buf = [0u8; 4];
for (k, ch) in c.label.chars().enumerate() {
let fmt = if hit.positions.binary_search(&k).is_ok() { hl.clone() } else { plain.clone() };
job.append(ch.encode_utf8(&mut buf), 0.0, fmt);
}
if !c.menu.is_empty() {
let path = TextFormat::simple(Tokens::font(t.type_scale.small), if selected { base } else { t.text.secondary });
job.append(&c.menu.join(" › "), t.space.md, path);
}
job
});
let right = if c.enabled {
c.shortcut.as_deref().and_then(|s| super::keys::shortcut_label(ui.ctx(), s, platform, &Tokens::font(t.type_scale.small)))
} else {
Some(c.disabled_reason.clone().unwrap_or_else(|| "Unavailable".into()))
};
let pad = t.space.sm;
let text_pos = pos2(rect.left() + pad, rect.center().y - galley.size().y / 2.0);
ui.painter().galley(text_pos, galley, base);
if let Some(r) = right {
let color = if selected { t.selection_text } else { t.text.secondary };
let g = cache::galley(ui, ("ashlar_ui::palette-right", &c.id), cache::hash_of(&(&r, color)), || {
LayoutJob::simple_singleline(r.clone(), Tokens::font(t.type_scale.small), color)
});
let area = Rect::from_min_max(rect.min, pos2(rect.right() - pad, rect.bottom()));
ui.painter().galley(Align2::RIGHT_CENTER.align_size_within_rect(g.size(), area).min, g, color);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::menu::tests::info;
use egui_kittest::Harness;
fn commands() -> Vec<CommandInfo> {
vec![
info("file.export", "Export As…", &["File"], Some("Mod+Shift+E"), true),
info("edit.paste", "Paste", &["Edit"], Some("Mod+V"), false),
info("file.exit", "Exit", &["File"], None, true),
info("view.zoom_in", "Zoom In", &["View"], Some("Mod+="), true),
]
}
#[test]
fn ranking_is_fuzzy_cached_and_puts_label_matches_first() {
let cmds = commands();
let mut p = CommandPalette::new();
p.rank(&cmds, 1);
assert_eq!(p.results(&cmds).len(), 4);
p.query = "ex".into();
p.rank(&cmds, 1);
let r = p.results(&cmds);
let mut top = r[..2].to_vec();
top.sort_unstable();
assert_eq!(top, ["file.exit", "file.export"]);
p.query = "zoomin".into();
p.rank(&cmds, 1);
assert_eq!(p.results(&cmds), ["view.zoom_in"]);
p.query = "view.z".into();
p.rank(&cmds, 1);
assert_eq!(p.results(&cmds), ["view.zoom_in"], "matches the id when the label does not");
p.query = "qqq".into();
p.rank(&cmds, 1);
assert!(p.results(&cmds).is_empty());
}
struct S {
palette: CommandPalette,
cmds: Vec<CommandInfo>,
ran: Vec<String>,
}
fn harness() -> Harness<'static, S> {
let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state(
|ui, s: &mut S| {
if let Some(id) = s.palette.show(ui.ctx(), &s.cmds, 1, Platform::Other) {
s.ran.push(id);
}
},
S { palette: CommandPalette::new(), cmds: commands(), ran: Vec::new() },
);
automation::install(&h.ctx);
h.state_mut().palette.open();
h.step();
h.step();
h
}
#[test]
fn type_arrow_enter_runs_and_closes() {
let mut h = harness();
h.event(egui::Event::Text("zo".into()));
h.step();
h.step();
assert_eq!(h.state().palette.query(), "zo");
let row = automation::find(&h.ctx, "palette.results.row.0").unwrap();
assert_eq!(row.label, "Zoom In");
h.key_press(Key::Enter);
h.step();
assert_eq!(h.state().ran, ["view.zoom_in"]);
assert!(!h.state().palette.is_open());
}
#[test]
fn disabled_commands_are_listed_but_do_not_run_and_escape_closes() {
let mut h = harness();
h.event(egui::Event::Text("paste".into()));
h.step();
h.step();
assert_eq!(automation::find(&h.ctx, "palette.results.row.0").map(|e| e.label), Some("Paste".into()));
h.key_press(Key::Enter);
h.step();
assert!(h.state().ran.is_empty());
assert!(h.state().palette.is_open());
h.key_press(Key::Escape);
h.step();
assert!(!h.state().palette.is_open());
}
#[test]
fn arrows_wrap_and_click_runs() {
let mut h = harness();
h.key_press(Key::ArrowUp);
h.step();
h.step();
assert_eq!(automation::find(&h.ctx, "palette.results.row.3").and_then(|e| e.selected), Some(true));
let row = automation::find(&h.ctx, "palette.results.row.0").unwrap();
crate::test_support::click(&mut h, row.center());
assert_eq!(h.state().ran, ["file.export"]);
}
}