Files
vectorcraft/crates/ui-egui/src/palette.rs
T
bflatasticandClaude Opus 5.5 2d12b39261 Palette test: look twice until shortcuts and plug-ins stay put
The cached entries' key includes two global counters that tests running
alongside bump (shortcut edits, plug-in installs), so the 'kept while the
language stays' check failed now and then. It now compares two lookups
only when the key didn't change between them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:15:09 +07:00

151 lines
6.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The command palette: fuzzy search over every command and tool.
use std::sync::Arc;
use serde_json::json;
use crate::theme::Tokens;
use crate::{VectorcraftApp, menus};
/// Everything the palette searches: (label, command id or `tool:<id>`, shortcut).
pub fn items() -> Vec<(String, String, String)> {
let mut items = vec![];
// Aliases kept for older scripts run a command that is listed already.
for c in vectorcraft_engine::command_specs().iter().filter(|c| !vectorcraft_engine::cmd::is_alias(c.id)) {
let path = c.menu.join(" › ");
let label = if path.is_empty() { c.label.to_string() } else { format!("{path} › {}", c.label) };
items.push((label, c.id.to_string(), menus::shortcut_of(c.id).unwrap_or("").to_string()));
}
for c in menus::UI_COMMANDS {
items.push((c.1.to_string(), c.0.to_string(), menus::shortcut_of(c.0).unwrap_or("").to_string()));
}
for tool in vectorcraft_tools::catalog::all_tools() {
items.push((tool.label.to_string(), format!("tool:{}", tool.id), crate::shortcut_editor::tool_shortcut(tool.id).unwrap_or("").to_string()));
}
items
}
/// A palette label in `lang`: each `›`-separated menu segment and the command label translate on
/// their own.
fn shown_label(lang: crate::i18n::Lang, label: &str) -> String {
label.split(" › ").map(|s| crate::i18n::tr(lang, s)).collect::<Vec<_>>().join(" › ")
}
/// One palette entry, with what it shows and what a query matches worked out once.
struct Entry {
id: String,
shortcut: String,
/// The label in the UI language.
shown: String,
/// The English label (what agents document), the shown label and the id, lowercased, one per
/// line: a query word (which has no line break) matches within one of them.
haystack: String,
}
/// The palette's entries in `lang`. They are built when the language, the installed plug-ins or
/// the keyboard shortcuts (shown beside each entry) change, not on every frame while a query is
/// typed.
/// What the entries depend on: the language, the installed plug-ins and the shortcuts.
fn cache_key(lang: crate::i18n::Lang) -> (&'static str, u64, u64) {
(lang.code(), menus::plugin_revision(), crate::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed))
}
fn entries(ctx: &egui::Context, lang: crate::i18n::Lang) -> Arc<Vec<Entry>> {
type Cached = ((&'static str, u64, u64), Arc<Vec<Entry>>);
let key = cache_key(lang);
let id = egui::Id::new("palette-entries");
if let Some((k, v)) = ctx.data(|d| d.get_temp::<Cached>(id))
&& k == key
{
return v;
}
let built: Arc<Vec<Entry>> = Arc::new(
items()
.into_iter()
.map(|(label, id, shortcut)| {
let shown = shown_label(lang, &label);
let haystack = format!("{}\n{}\n{}", label.to_lowercase(), shown.to_lowercase(), id.to_lowercase());
Entry { id, shortcut, shown, haystack }
})
.collect(),
);
ctx.data_mut(|d| d.insert_temp::<Cached>(id, (key, built.clone())));
built
}
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
if !app.ui.palette_open {
return;
}
let t = Tokens::get(ctx);
let q = app.ui.palette_query.to_lowercase();
let entries = entries(ctx, crate::i18n::current());
let matches: Vec<&Entry> = entries.iter().filter(|e| q.split_whitespace().all(|w| e.haystack.contains(w))).take(14).collect();
let mut run: Option<String> = None;
egui::Area::new(egui::Id::new("palette")).order(egui::Order::Foreground).anchor(egui::Align2::CENTER_TOP, [0.0, 90.0]).show(ctx, |ui| {
egui::Frame::popup(ui.style()).fill(t.panel).inner_margin(egui::Margin::same(10)).show(ui, |ui| {
ui.set_width(520.0);
let r = ui.add(egui::TextEdit::singleline(&mut app.ui.palette_query).hint_text(tl!("Search commands and tools…")).desired_width(500.0));
r.request_focus();
ui.add_space(6.0);
for (i, e) in matches.iter().enumerate() {
let resp = ui.add(
egui::Button::new(e.shown.as_str())
.shortcut_text(menus::pretty_shortcut(&e.shortcut))
.min_size(egui::vec2(500.0, 24.0))
.selected(i == 0),
);
if resp.clicked() {
run = Some(e.id.clone());
}
}
if ui.input(|i| i.key_pressed(egui::Key::Enter))
&& let Some(first) = matches.first()
{
run = Some(first.id.clone());
}
});
});
if let Some(id) = run {
app.ui.palette_open = false;
if let Some(tool) = id.strip_prefix("tool:") {
app.select_tool(tool);
} else {
menus::invoke(app, &id, json!({}));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::i18n::Lang;
#[test]
fn entries_are_built_once_per_language_and_match_english_shown_text_and_ids() {
let ctx = egui::Context::default();
let zh = Lang::from_code("zh-hant").unwrap();
// Tests running alongside edit shortcuts and install plug-ins, which rebuilds the entries:
// look twice until nothing changed in between.
let (en, again) = (0..100)
.find_map(|_| {
let key = cache_key(Lang::EN);
let pair = (entries(&ctx, Lang::EN), entries(&ctx, Lang::EN));
(cache_key(Lang::EN) == key).then_some(pair)
})
.unwrap();
assert!(Arc::ptr_eq(&en, &again), "kept while the language stays");
let zh_entries = entries(&ctx, zh);
assert!(!Arc::ptr_eq(&en, &zh_entries), "rebuilt for another language");
// Editing a shortcut rebuilds them, so the palette shows the new one.
crate::shortcut_editor::GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
assert!(!Arc::ptr_eq(&zh_entries, &entries(&ctx, zh)), "rebuilt after a shortcut edit");
let group = zh_entries.iter().find(|e| e.id == "object.group").unwrap();
assert!(group.shown.ends_with(crate::i18n::tr(zh, "Group")), "{}", group.shown);
// A query matches the English label, the shown label or the id.
for word in ["group", crate::i18n::tr(zh, "Group"), "object.group"] {
assert!(group.haystack.contains(&word.to_lowercase()), "{word}");
}
}
}