forked from 1b-agent/fieldcraft-upstream
UI: grey ribbon entries, search results and Solve that miss what they need
The ribbon buttons, drop-down rows, command search and the title bar's Solve read Session::availability: greyed, with what is missing (and where to get it) in the tooltip. Starting such an entry opens no form; the status bar names what is missing. ui.toolbar reports each entry's `enabled` and `unavailable`, ui.inspect the Solve button's. Fixes #220 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
e0d5adba23
commit
8409101483
7 files changed
+114
-37
No files matched your search
@@ -195,8 +195,11 @@ pub fn app_bar(app: &mut ModelApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
// Solve: the one prominent action, a filled button with its label.
|
||||
let solve = lay.solve;
|
||||
let resp = ui.interact(solve, ui.id().with("fc_solve"), Sense::click());
|
||||
let can = !app.session.model.steps().is_empty() && !busy;
|
||||
// Solve runs `solve.run`: greyed while the model misses what it needs, every missing
|
||||
// piece listed in the tooltip.
|
||||
let missing = app.unavailable("solve.run");
|
||||
let can = missing.is_none() && !busy;
|
||||
let resp = ui.interact(solve, ui.id().with("fc_solve"), if can { Sense::click() } else { Sense::hover() });
|
||||
let fill = if resp.hovered() && can {
|
||||
t.accent.gamma_multiply(1.15)
|
||||
} else if can {
|
||||
@@ -221,13 +224,10 @@ pub fn app_bar(app: &mut ModelApp, ui: &mut egui::Ui) {
|
||||
Color32::WHITE,
|
||||
);
|
||||
}
|
||||
let tip = if app.session.model.steps().is_empty() {
|
||||
"Solve: the model has no steps yet (try Help › Samples)"
|
||||
} else {
|
||||
"Solve every step and show the results"
|
||||
};
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Solve"));
|
||||
if resp.on_hover_text(tip).clicked() {
|
||||
let head = if missing.is_some() { "Solve: not ready yet (or try Help › Samples)" } else { "Solve every step and show the results" };
|
||||
let tip = fieldcraft_engine::requirement::Unavailable::tip(head, missing.as_ref());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, can, "Solve"));
|
||||
if resp.on_hover_text(tip).clicked() && can {
|
||||
clicked = Some(Quick::Solve);
|
||||
}
|
||||
anchors.push((Quick::Solve, solve));
|
||||
|
||||
@@ -187,6 +187,7 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
"results": app.session.results_report(),
|
||||
"probe": app.probe,
|
||||
"busy": app.busy(),
|
||||
"solve": solve_json(app),
|
||||
"selected": app.selected.as_ref().map(|(k, n)| json!({"kind": k, "name": n})),
|
||||
"nav": app.nav,
|
||||
"layout": app.layout.iter().map(|(k, r)| (k.to_string(), json!([r.left(), r.top(), r.width(), r.height()]))).collect::<serde_json::Map<String, Value>>(),
|
||||
@@ -506,6 +507,12 @@ pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// The title bar's Solve as `ui.inspect` reports it.
|
||||
fn solve_json(app: &ModelApp) -> Value {
|
||||
let missing = app.unavailable("solve.run");
|
||||
json!({"enabled": app.busy().is_none() && missing.is_none(), "unavailable": missing.map(|u| u.to_json())})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fieldcraft_engine::Session;
|
||||
@@ -638,6 +645,8 @@ mod tests {
|
||||
let ctx = egui::Context::default();
|
||||
for same_frame in [true, false] {
|
||||
let mut app = ModelApp::new(Session::default());
|
||||
// Plasticity needs a material to act on.
|
||||
app.run("model.material.create", json!({"name": "Steel", "elastic": {"type": "isotropic", "young": 1, "poisson": 0.3}})).unwrap();
|
||||
call_in(&mut app, &ctx, "ui.start", json!({"command": "model.material.create"}));
|
||||
for _ in 0..2 {
|
||||
frame(&mut app, &ctx);
|
||||
|
||||
@@ -421,8 +421,8 @@ impl ModelApp {
|
||||
|
||||
/// Solve every step (`solve.run`) in the background; the results come up when it is done.
|
||||
pub fn solve_all(&mut self) {
|
||||
if self.session.model.steps().is_empty() {
|
||||
self.set_status("Nothing to solve: the model has no steps (Steps › Step, or open a sample from Help › Samples)", true);
|
||||
if let Some(u) = self.unavailable("solve.run") {
|
||||
self.set_status(u.to_string(), true);
|
||||
return;
|
||||
}
|
||||
let n = self.session.model.steps().len();
|
||||
@@ -1232,6 +1232,8 @@ mod tests {
|
||||
app.services.pick_save = Some(Box::new(|_: &str, _: &[Filter]| -> Option<String> { panic!("a picker opened") }));
|
||||
app.start_programmatic("file.open").unwrap();
|
||||
assert_eq!(app.dialog.as_ref().map(|d| d.command), Some("file.open"));
|
||||
// A deck needs a model to write.
|
||||
app.run("file.sample", json!({"name": "cantilever"})).unwrap();
|
||||
app.start_programmatic("deck.write.abaqus").unwrap();
|
||||
assert_eq!(app.dialog.as_ref().map(|d| d.command), Some("deck.write"));
|
||||
// In-window choices are not pickers: they behave as a click.
|
||||
|
||||
@@ -515,6 +515,12 @@ impl ModelApp {
|
||||
None => (id, json!({})),
|
||||
};
|
||||
let spec = fieldcraft_engine::find_command(command).ok_or_else(|| format!("unknown command `{command}`"))?;
|
||||
// A command missing what it needs opens no form: the status names what is missing.
|
||||
if let Err(u) = self.session.availability(spec) {
|
||||
let e = u.to_string();
|
||||
self.set_status(e.clone(), true);
|
||||
return Err(e);
|
||||
}
|
||||
if self.file_action(spec.id, &preset, interactive) {
|
||||
self.dialog = None;
|
||||
return Ok(());
|
||||
@@ -1204,6 +1210,17 @@ const SHOT_TIMEOUT: f64 = 8.0;
|
||||
/// Vertical field of view of the perspective camera (radians).
|
||||
const PERSPECTIVE_FOV: f64 = 0.6;
|
||||
|
||||
impl ModelApp {
|
||||
/// Why a toolbar entry or a command cannot start now: what its command needs that the
|
||||
/// session lacks (`fieldcraft_engine::requirement`). `None` when it can start, and for
|
||||
/// coming-soon entries and unknown ids (which say so themselves).
|
||||
pub fn unavailable(&self, id: &str) -> Option<fieldcraft_engine::requirement::Unavailable> {
|
||||
let command = catalogue::find(id).map_or(Some(id), |c| c.command())?;
|
||||
let spec = fieldcraft_engine::find_command(command)?;
|
||||
self.session.availability(spec).err()
|
||||
}
|
||||
}
|
||||
|
||||
/// A command's name as people see it (its label).
|
||||
pub fn command_label(id: &str) -> &str {
|
||||
fieldcraft_engine::find_command(id).map_or(id, |c| c.label)
|
||||
@@ -1294,12 +1311,39 @@ mod tests {
|
||||
assert!(app.dialog.is_none());
|
||||
assert_eq!(app.session.journal.len(), journal);
|
||||
assert_eq!(app.status.as_ref().map(|s| s.error), Some(false));
|
||||
// A deck entry opens deck.write's form with its format filled in.
|
||||
// An entry whose command misses what it needs opens nothing and says what is missing.
|
||||
let err = app.start("deck.write.abaqus").unwrap_err();
|
||||
assert_eq!(err, "deck.write cannot run yet. Needs a mesh — Mesh › Tet Mesh");
|
||||
assert!(app.dialog.is_none());
|
||||
assert_eq!(app.status.as_ref().map(|s| (s.error, s.text.as_str())), Some((true, err.as_str())));
|
||||
// With a model, a deck entry opens deck.write's form with its format filled in.
|
||||
app.run("file.sample", json!({"name": "cantilever"})).unwrap();
|
||||
app.start("deck.write.abaqus").unwrap();
|
||||
let d = app.dialog.as_ref().unwrap();
|
||||
assert_eq!((d.command, d.text.as_str()), ("deck.write", r#"{"format":"abaqus"}"#));
|
||||
}
|
||||
|
||||
/// The ribbon, the command search and Solve grey what the model cannot run yet, and say why.
|
||||
#[test]
|
||||
fn entries_missing_what_they_need_are_greyed_with_the_reason() {
|
||||
let mut app = ModelApp::new(Session::default());
|
||||
let entry =
|
||||
|app: &ModelApp, id: &str| crate::toolbar::describe(app)["entries"].as_array().unwrap().iter().find(|e| e["id"] == id).unwrap().clone();
|
||||
let plasticity = entry(&app, "model.material.plasticity");
|
||||
assert_eq!(plasticity["enabled"], false);
|
||||
assert_eq!(plasticity["unavailable"]["reason"], "Needs a material — Properties › Material");
|
||||
assert_eq!(plasticity["unavailable"]["missing"][0]["provider"], "model.material.create");
|
||||
assert_eq!(entry(&app, "model.material.create")["enabled"], true);
|
||||
assert_eq!(entry(&app, "mesh.order.quadratic")["enabled"], false, "coming soon");
|
||||
let solve = app.unavailable("solve.run").unwrap();
|
||||
assert_eq!(solve.lines(), "Needs a mesh — Mesh › Tet Mesh\nNeeds a step — Steps › Step");
|
||||
|
||||
app.run("file.sample", json!({"name": "cantilever"})).unwrap();
|
||||
assert_eq!(entry(&app, "model.material.plasticity")["enabled"], true);
|
||||
assert!(app.unavailable("solve.run").is_none());
|
||||
assert_eq!(entry(&app, "results.contour")["unavailable"]["reason"], "Needs a solved step — Solve › Solve");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sample_fills_the_view_and_renders() {
|
||||
let mut app = ModelApp::new(Session::default());
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
//! 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. Adapted from SolidCraft's
|
||||
//! 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};
|
||||
@@ -169,14 +170,17 @@ pub fn popup(app: &mut ModelApp, ctx: &egui::Context) {
|
||||
}
|
||||
let list = app.search.as_ref().map(|(_, l)| l.clone()).unwrap_or_default();
|
||||
if entered {
|
||||
picked = list.iter().find(|h| h.soon.is_none()).map(|h| h.id);
|
||||
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;
|
||||
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 h.soon.is_none() { Sense::click() } else { Sense::hover() });
|
||||
if resp.hovered() && h.soon.is_none() {
|
||||
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(
|
||||
@@ -184,7 +188,7 @@ pub fn popup(app: &mut ModelApp, ctx: &egui::Context) {
|
||||
ui,
|
||||
h.icon,
|
||||
Rect::from_center_size(pos2(rect.left() + 14.0, rect.center().y), Vec2::splat(18.0)),
|
||||
Look::of(h.soon.is_none()),
|
||||
Look::of(enabled),
|
||||
);
|
||||
let p = ui.painter();
|
||||
p.text(
|
||||
@@ -192,15 +196,17 @@ pub fn popup(app: &mut ModelApp, ctx: &egui::Context) {
|
||||
Align2::LEFT_CENTER,
|
||||
h.label,
|
||||
FontId::proportional(13.0),
|
||||
if h.soon.is_none() { t.text } else { t.text_dim },
|
||||
if enabled { t.text } else { t.text_dim },
|
||||
);
|
||||
let right = match h.soon {
|
||||
Some(m) => format!("{} · soon ({m})", h.place),
|
||||
None => h.place.clone(),
|
||||
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, h.soon.is_none(), h.label));
|
||||
if resp.on_hover_text(h.id).clicked() && h.soon.is_none() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, Vec2, pos2, vec2};
|
||||
use fieldcraft_engine::catalogue::{self, Capability, Tab, Workspace};
|
||||
use fieldcraft_engine::requirement::Unavailable;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::ModelApp;
|
||||
@@ -189,7 +190,7 @@ pub fn toolbar(app: &mut ModelApp, ui: &mut egui::Ui) {
|
||||
ui,
|
||||
c.id,
|
||||
Rect::from_center_size(br.center() - vec2(0.0, 3.0), Vec2::splat(26.0)),
|
||||
Look::of(c.command().is_some()),
|
||||
Look::of(c.command().is_some() && app.unavailable(c.id).is_none()),
|
||||
);
|
||||
}
|
||||
caret(&painter, pos2(br.center().x, br.bottom() - 3.0), t.text_dim);
|
||||
@@ -319,7 +320,8 @@ fn workspace_switcher(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens, r: Rect
|
||||
|
||||
/// One entry's button; returns the id to start when it was clicked.
|
||||
fn button(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens, c: &'static Capability, br: Rect) -> Option<&'static str> {
|
||||
let enabled = c.command().is_some();
|
||||
let missing = app.unavailable(c.id);
|
||||
let enabled = c.command().is_some() && missing.is_none();
|
||||
let resp = ui.interact(br, ui.id().with(("fc_button", c.id)), if enabled { Sense::click() } else { Sense::hover() });
|
||||
app.toolbar_rects.push((c.id, br, Fit::Full));
|
||||
let running = app.dialog.as_ref().is_some_and(|d| c.command() == Some(d.command));
|
||||
@@ -333,7 +335,7 @@ fn button(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens, c: &'static Capabil
|
||||
badge(ui.painter(), t, br.right_top() + vec2(4.0, -2.0), m);
|
||||
}
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, c.label));
|
||||
let resp = resp.on_hover_text(c.tooltip());
|
||||
let resp = resp.on_hover_text(Unavailable::tip(&c.tooltip(), missing.as_ref()));
|
||||
resp.clicked().then_some(c.id)
|
||||
}
|
||||
|
||||
@@ -351,7 +353,8 @@ pub fn dropdown(app: &mut ModelApp, ui: &mut egui::Ui, entries: &'static [Capabi
|
||||
+ 96.0;
|
||||
let mut start = None;
|
||||
for c in entries {
|
||||
let enabled = c.command().is_some();
|
||||
let missing = app.unavailable(c.id);
|
||||
let enabled = c.command().is_some() && missing.is_none();
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(width, ROW_H), if enabled { Sense::click() } else { Sense::hover() });
|
||||
app.toolbar_rects.push((c.id, rect, Fit::Menu));
|
||||
if resp.hovered() && enabled {
|
||||
@@ -364,21 +367,30 @@ pub fn dropdown(app: &mut ModelApp, ui: &mut egui::Ui, entries: &'static [Capabi
|
||||
p.text(pos2(rect.right() - 8.0, rect.center().y), Align2::RIGHT_CENTER, format!("soon · {m}"), FontId::proportional(11.0), t.text_dim);
|
||||
}
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, c.label));
|
||||
if resp.on_hover_text(c.tooltip()).clicked() && enabled {
|
||||
if resp.on_hover_text(Unavailable::tip(&c.tooltip(), missing.as_ref())).clicked() && enabled {
|
||||
start = Some(c.id);
|
||||
}
|
||||
for id in c.more {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(width, ROW_H - 2.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
let missing = app.unavailable(id);
|
||||
let enabled = missing.is_none();
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(width, ROW_H - 2.0), if enabled { Sense::click() } else { Sense::hover() });
|
||||
if resp.hovered() && enabled {
|
||||
ui.painter().rect_filled(rect, 3.0, t.hover);
|
||||
}
|
||||
let label = crate::command_label(id);
|
||||
let p = ui.painter();
|
||||
p.text(pos2(rect.left() + 32.0, rect.center().y), Align2::LEFT_CENTER, label, FontId::proportional(12.5), t.text);
|
||||
p.text(
|
||||
pos2(rect.left() + 32.0, rect.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
label,
|
||||
FontId::proportional(12.5),
|
||||
if enabled { t.text } else { t.text_dim },
|
||||
);
|
||||
p.line_segment([pos2(rect.left() + 15.0, rect.top()), pos2(rect.left() + 15.0, rect.center().y)], Stroke::new(1.0, t.border));
|
||||
p.line_segment([pos2(rect.left() + 15.0, rect.center().y), pos2(rect.left() + 25.0, rect.center().y)], Stroke::new(1.0, t.border));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
|
||||
if resp.on_hover_text(*id).clicked() {
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label));
|
||||
let tip = Unavailable::tip(id, missing.as_ref());
|
||||
if resp.on_hover_text(tip).clicked() && enabled {
|
||||
start = Some(id);
|
||||
}
|
||||
}
|
||||
@@ -392,7 +404,8 @@ pub fn dropdown(app: &mut ModelApp, ui: &mut egui::Ui, entries: &'static [Capabi
|
||||
|
||||
/// The toolbar as the control channel reports it: the workspaces and tabs, the current ones, the
|
||||
/// panels' labels (screen points, last frame), the open menu, and every entry with its status
|
||||
/// and where it is: a button (`full`), a collapsed panel, the » menu, or its panel's drop-down
|
||||
/// (`enabled`, and `unavailable`: what its command is missing, `Unavailable::to_json`) and where
|
||||
/// it is: a button (`full`), a collapsed panel, the » menu, or its panel's drop-down
|
||||
/// (`menu`, with the row's rect while the drop-down is open).
|
||||
pub fn describe(app: &ModelApp) -> serde_json::Value {
|
||||
let rect = |r: &Rect| {
|
||||
@@ -402,6 +415,7 @@ pub fn describe(app: &ModelApp) -> serde_json::Value {
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let shown = app.toolbar_rects.iter().rev().find(|(id, ..)| *id == c.id);
|
||||
let missing = app.unavailable(c.id);
|
||||
serde_json::json!({
|
||||
"id": c.id,
|
||||
"label": c.label,
|
||||
@@ -410,6 +424,8 @@ pub fn describe(app: &ModelApp) -> serde_json::Value {
|
||||
"command": c.command(),
|
||||
"more": c.more,
|
||||
"coming_soon": c.milestone(),
|
||||
"enabled": c.command().is_some() && missing.is_none(),
|
||||
"unavailable": missing.map(|u| u.to_json()),
|
||||
"rect": shown.map(|(_, r, _)| rect(r)),
|
||||
"fit": shown.map(|(.., f)| f),
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user