Files
OneCAD-Simulation/crates/ui-egui/src/palette.rs
T

354 lines
16 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 line (bottom) and the command search (S or Cmd/Ctrl+K).
//!
//! The command line runs `id {json}` (`mesh.node.add {"label": 1, "xyz": [0, 0, 0]}`), starts a
//! command given by its id alone, or starts the best match for a name. Beside it, the status and
//! the model's size. The search lists every catalogue entry and related command by name, id or
//! tip; coming-soon entries are listed greyed with their milestone, and commands missing what
//! they need greyed with what is missing. Adapted from SolidCraft's
//! `crates/ui-egui/src/palette.rs` (MIT OR Apache-2.0), see ATTRIBUTION.md.
use egui::{Align2, FontId, Rect, RichText, Sense, Vec2, pos2, vec2};
use fieldcraft_engine::catalogue::{self, CATALOGUE};
use serde_json::Value;
use crate::ModelApp;
use crate::icons::{self, Look};
use crate::theme::Tokens;
/// The command line's height.
pub const HEIGHT: f32 = 28.0;
/// Most results the search lists.
const MAX_RESULTS: usize = 60;
/// One search result.
#[derive(Clone, Debug, PartialEq)]
pub struct Hit {
/// What starting it starts: a catalogue entry or a command.
pub id: &'static str,
pub label: &'static str,
/// Where it is on the toolbar: `Tab › Panel`.
pub place: String,
/// The icon (its catalogue entry's).
pub icon: &'static str,
/// The milestone that brings it, while it is coming soon.
pub soon: Option<&'static str>,
}
/// Every entry and related command matching `query` (by label, id, panel or tip), best first:
/// labels starting with it, then ids starting with it, then the rest, ready before coming soon.
pub fn matches(query: &str) -> Vec<Hit> {
let q = query.trim().to_lowercase();
let mut hits: Vec<(u8, Hit)> = Vec::new();
let rank = |label: &str, id: &str| -> u8 {
if label.to_lowercase().starts_with(&q) {
0
} else if id.starts_with(&q) {
1
} else {
2
}
};
for c in CATALOGUE {
let place = format!("{} › {}", c.tab.label(), c.panel);
let text = format!("{} {} {} {}", c.label, c.id, c.panel, c.tip).to_lowercase();
if q.is_empty() || text.contains(&q) {
let soon = c.milestone();
hits.push((
rank(c.label, c.id) + if soon.is_some() { 3 } else { 0 },
Hit { id: c.id, label: c.label, place: place.clone(), icon: c.id, soon },
));
}
for m in c.more.iter().chain(c.covers) {
let label = crate::command_label(m);
if q.is_empty() || format!("{label} {m}").to_lowercase().contains(&q) {
hits.push((rank(label, m), Hit { id: m, label, place: place.clone(), icon: c.id, soon: None }));
}
}
}
hits.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.label.cmp(b.1.label)));
hits.dedup_by(|a, b| a.1.id == b.1.id);
hits.into_iter().map(|(_, h)| h).collect()
}
/// Run a line of command-line input: `id {json}` runs the command with those parameters, `id`
/// starts it, anything else starts the best match.
pub fn submit(app: &mut ModelApp, line: &str) {
app.with_origin(fieldcraft_engine::usage::Origin::Palette, |app| submit_line(app, line));
}
/// A line split into its first word and the rest (`id {json}`).
fn split_line(line: &str) -> (&str, &str) {
let line = line.trim();
match line.find(|c: char| c.is_whitespace() || c == '{') {
Some(i) => (line.get(..i).unwrap_or(line), line.get(i..).unwrap_or("").trim()),
None => (line, ""),
}
}
fn is_command(id: &str) -> bool {
fieldcraft_engine::find_command(id).is_some() || catalogue::find(id).is_some()
}
/// The command a line typed into the search names with its parameters (`id {json}`), which the
/// search offers to run as typed instead of searching for it.
pub fn typed_command(line: &str) -> Option<&str> {
let (id, rest) = split_line(line);
(is_command(id) && !rest.is_empty()).then_some(id)
}
fn submit_line(app: &mut ModelApp, line: &str) {
let line = line.trim();
if line.is_empty() {
return;
}
let (id, rest) = split_line(line);
if is_command(id) {
if rest.is_empty() {
let _ = app.start(id);
} else {
match serde_json::from_str::<Value>(rest) {
Ok(p) => {
let _ = app.run(id, p);
}
Err(e) => app.set_status(format!("the parameters must be JSON: {e}"), true),
}
}
return;
}
match matches(line).into_iter().find(|h| h.soon.is_none()) {
Some(h) => {
let _ = app.start(h.id);
}
None => app.set_status(format!("no command matches `{line}`"), true),
}
}
/// The command line's width: 40 % of the bar, within these.
const MIN_LINE_W: f32 = 150.0;
const MAX_LINE_W: f32 = 440.0;
/// The command line's placeholders, longest first: the longest that fits whole is shown.
const PLACEHOLDERS: [&str; 5] = [
r#"Type a command, e.g. mesh.node.add {"label": 1, "xyz": [0, 0, 0]} — or press S to search"#,
"Type a command, e.g. mesh.tet.generate — or press S to search",
"Type a command — or press S to search",
"Command, or S to search",
"Command",
];
/// The longest [`PLACEHOLDERS`] that fits a command line `width` wide, so it is never cut.
fn placeholder(ui: &egui::Ui, width: f32) -> &'static str {
let font = egui::TextStyle::Body.resolve(ui.style());
// A text field's default margin is 4 points a side.
let room = width - 8.0;
let shortest = PLACEHOLDERS[PLACEHOLDERS.len() - 1];
PLACEHOLDERS.iter().copied().find(|p| crate::elide::width(ui, p, &font) <= room).unwrap_or(shortest)
}
/// The command line, the status and the model's size, above the timeline.
pub fn command_line(app: &mut ModelApp, ui: &mut egui::Ui) {
let t = Tokens::get(app.ui.dark);
egui::Panel::bottom("fc_command_line")
.exact_size(HEIGHT)
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 3)))
.show(ui, |ui| {
app.place("command_line", ui.max_rect());
ui.horizontal_centered(|ui| {
ui.label(RichText::new("›").strong().color(t.accent));
// The rest of the bar keeps room for the status and the model's size.
let width = (ui.available_width() * 0.4).clamp(MIN_LINE_W, MAX_LINE_W);
let hint = if app.command_line.is_empty() { placeholder(ui, width) } else { "" };
let r = ui.add(
egui::TextEdit::singleline(&mut app.command_line).id(egui::Id::new("fc_command_line_text")).hint_text(hint).desired_width(width),
);
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
let line = std::mem::take(&mut app.command_line);
submit(app, &line);
}
let (nodes, elements) = (app.session.model.nodes().len(), app.session.model.elements().len());
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(RichText::new(app.gpu_info.as_deref().unwrap_or("CPU renderer")).size(11.5).color(t.text_dim));
ui.separator();
ui.label(RichText::new(if app.ui.perspective { "Perspective" } else { "Orthographic" }).size(11.5).color(t.text_dim));
ui.separator();
ui.label(RichText::new(format!("{nodes} nodes {elements} elements")).size(11.5).color(t.text_dim));
ui.separator();
crate::updates::notice(app, ui);
let (text, color) = match &app.status {
Some(s) => (s.text.as_str(), if s.error { t.error } else { t.text }),
None => ("Ready", t.text_dim),
};
// Elided in the middle (both the subject and the outcome stay), whole on hover.
let font = FontId::proportional(11.5);
let shown = crate::elide::middle_in(ui, text, &font, ui.available_width());
let r = ui.add(egui::Label::new(RichText::new(shown.as_ref()).font(font).color(color)));
if shown != text {
r.on_hover_text(text);
}
});
});
});
}
/// The command search, centred near the top of the window.
pub fn popup(app: &mut ModelApp, ctx: &egui::Context) {
if !app.ui.palette {
return;
}
let t = Tokens::get(app.ui.dark);
let mut picked: Option<&'static str> = None;
let mut run_typed = false;
let mut open = true;
let center = ctx.content_rect().center();
let r = crate::frame::window(ctx, "Search commands", crate::frame::Width::Normal)
.id(egui::Id::new("fc_search"))
.pivot(Align2::CENTER_TOP)
.fixed_pos(pos2(center.x, 140.0))
.fixed_size(vec2(440.0, 380.0))
.open(&mut open)
.show(ctx, |ui| {
let r = ui.add(
egui::TextEdit::singleline(&mut app.palette_text)
.id(egui::Id::new("fc_search_text"))
.hint_text("command name, id or what it does")
.desired_width(f32::INFINITY),
);
let entered = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
r.request_focus();
// Searched again only when the query changes.
if app.search.as_ref().is_none_or(|(q, _)| *q != app.palette_text) {
app.search = Some((app.palette_text.clone(), std::sync::Arc::new(matches(&app.palette_text))));
}
let list = app.search.as_ref().map(|(_, l)| l.clone()).unwrap_or_default();
// `id {json}` typed here (say, typing began with no field focused) runs as typed.
let typed = typed_command(&app.palette_text).is_some();
if entered {
if typed {
run_typed = true;
} else {
picked = list.iter().find(|h| h.soon.is_none() && app.unavailable(h.id).is_none()).map(|h| h.id);
}
}
egui::ScrollArea::vertical().max_height(320.0).auto_shrink([false, true]).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
if typed {
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click());
ui.painter().rect_filled(rect, 3.0, t.hover);
let font = FontId::proportional(13.0);
let text = format!("Run {}", app.palette_text.trim());
let shown = crate::elide::middle_in(ui, &text, &font, rect.width() - 80.0);
ui.painter().text(pos2(rect.left() + 8.0, rect.center().y), Align2::LEFT_CENTER, shown.as_ref(), font, t.text);
ui.painter().text(
pos2(rect.right() - 6.0, rect.center().y),
Align2::RIGHT_CENTER,
"Enter",
FontId::proportional(11.0),
t.text_dim,
);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Run as typed"));
if resp.on_hover_text("Run the command with these parameters").clicked() {
run_typed = true;
}
}
for h in list.iter().take(MAX_RESULTS) {
// Coming soon, or missing what its command needs: listed, greyed, with why.
let missing = app.unavailable(h.id);
let enabled = h.soon.is_none() && missing.is_none();
let (rect, resp) =
ui.allocate_exact_size(vec2(ui.available_width(), 26.0), if enabled { Sense::click() } else { Sense::hover() });
if resp.hovered() && enabled {
ui.painter().rect_filled(rect, 3.0, t.hover);
}
icons::paint(
app,
ui,
h.icon,
Rect::from_center_size(pos2(rect.left() + 14.0, rect.center().y), Vec2::splat(18.0)),
Look::of(enabled),
);
let p = ui.painter();
p.text(
pos2(rect.left() + 30.0, rect.center().y),
Align2::LEFT_CENTER,
h.label,
FontId::proportional(13.0),
if enabled { t.text } else { t.text_dim },
);
let right = match (h.soon, &missing) {
(Some(m), _) => format!("{} · soon ({m})", h.place),
(None, Some(u)) => u.missing.first().map_or_else(|| h.place.clone(), |m| format!("needs {}", m.0.what())),
(None, None) => h.place.clone(),
};
p.text(pos2(rect.right() - 6.0, rect.center().y), Align2::RIGHT_CENTER, right, FontId::proportional(11.0), t.text_dim);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, h.label));
let tip = fieldcraft_engine::requirement::Unavailable::tip(h.id, missing.as_ref());
if resp.on_hover_text(tip).clicked() && enabled {
picked = Some(h.id);
}
}
});
});
if let Some(r) = r {
app.place("palette", r.response.rect);
}
if run_typed {
let line = std::mem::take(&mut app.palette_text);
app.ui.palette = false;
submit(app, &line);
} else if let Some(id) = picked {
app.palette_text.clear();
app.ui.palette = false;
let _ = app.with_origin(fieldcraft_engine::usage::Origin::Palette, |app| app.start(id));
} else if !open || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
app.ui.palette = false;
app.palette_text.clear();
}
}
#[cfg(test)]
mod tests {
use fieldcraft_engine::Session;
use super::*;
#[test]
fn search_finds_entries_and_related_commands_ready_first() {
let hits = matches("material");
assert_eq!(hits.first().map(|h| h.id), Some("model.material.create"));
assert!(hits.iter().any(|h| h.id == "model.material.delete"), "related commands are found");
let soon = hits.iter().position(|h| h.soon.is_some()).unwrap();
assert!(hits.iter().skip(soon).all(|h| h.soon.is_some()), "coming soon last");
assert_eq!(matches("").len(), {
let mut ids: Vec<&str> = CATALOGUE.iter().flat_map(|c| std::iter::once(c.id).chain(c.more.iter().chain(c.covers).copied())).collect();
ids.sort();
ids.dedup();
ids.len()
});
assert!(matches("no such thing at all").is_empty());
}
#[test]
fn the_command_line_runs_ids_with_json_and_starts_names() {
let mut app = ModelApp::new(Session::default());
submit(&mut app, r#"mesh.node.add {"label": 4, "xyz": [1, 2, 3]}"#);
assert!(app.session.model.node(4).is_some());
submit(&mut app, "mesh.node.add");
assert_eq!(app.dialog.as_ref().map(|d| d.command), Some("mesh.node.add"));
app.dialog = None;
submit(&mut app, "material");
assert_eq!(app.dialog.as_ref().map(|d| d.command), Some("model.material.create"));
submit(&mut app, "mesh.node.add {not json");
assert!(app.status.as_ref().is_some_and(|s| s.error));
submit(&mut app, "zzzz qqqq");
assert!(app.status.as_ref().is_some_and(|s| s.error && s.text.contains("no command")));
}
#[test]
fn the_search_offers_to_run_a_typed_command() {
assert_eq!(typed_command(r#"results.contour {"field":"von_mises"}"#), Some("results.contour"));
assert_eq!(typed_command(r#" mesh.node.add{"label": 1}"#), Some("mesh.node.add"));
assert_eq!(typed_command("mesh.node.add"), None, "an id alone is searched for");
assert_eq!(typed_command("material steel"), None);
}
}