forked from 1b-agent/fieldcraft-upstream
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
354 lines
16 KiB
Rust
354 lines
16 KiB
Rust
//! 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);
|
||
}
|
||
}
|