forked from 1b-agent/fieldcraft-upstream
- describe.rs: a definition's fields in its form's labels and units (sets by
members and what they resolve to), for the context panel.
- reply::done: status lines that say what changed ("Showing Load deformed",
"Mode 2: 1637 Hz", "Added material Al").
- The context panel puts the form and the selection above reports; opening
another document clears both; what a form makes is selected.
- Use selection reuses a set with the same members, otherwise names one made
on OK; Cancel leaves the model unchanged. The Target's set row is labelled.
- The browser's state marks sit in one column at its left edge; a right click
selects the row its menu acts on.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
437 lines
19 KiB
Rust
437 lines
19 KiB
Rust
//! What a command run from the UI answered, made readable: a one-line summary for the status bar
|
||
//! ([`summary`]) and, when the reply holds more than that line, the whole reply in the context
|
||
//! panel ([`show`]): Check Model as a list of its diagnostics with a way to each fix, the Step
|
||
//! Table as a grid of steps, any other reply as labelled rows and tables. Programmatic callers
|
||
//! (the control channel's `engine.execute`, MCP, scripts) still get the reply's JSON.
|
||
|
||
use egui::{RichText, vec2};
|
||
use serde_json::{Map, Value};
|
||
|
||
use crate::browser::{self, Def};
|
||
use crate::frame::{Level, heading};
|
||
use crate::results::number;
|
||
use crate::theme::{Tokens, label_font};
|
||
use crate::{ModelApp, Tab};
|
||
|
||
/// The reply shown in the context panel.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct Reply {
|
||
pub command: String,
|
||
pub label: String,
|
||
pub params: Value,
|
||
pub value: Value,
|
||
}
|
||
|
||
/// Replies that only read the model, refreshed when it changes; any other reply closes then.
|
||
pub const LIVE: [&str; 2] = ["model.check", "model.activity"];
|
||
|
||
/// The status line's text (after the command's label) is cut to this many characters.
|
||
const MAX_LINE: usize = 120;
|
||
/// Table rows shown before "… n more".
|
||
const MAX_ROWS: usize = 200;
|
||
/// Table columns shown at most.
|
||
const MAX_COLUMNS: usize = 8;
|
||
/// Nesting shown at most; deeper values are compact text.
|
||
const MAX_DEPTH: usize = 4;
|
||
|
||
/// One line saying what `command` answered: the reply's own `summary`, a sentence for the
|
||
/// commands with a well-known reply, otherwise its top-level numbers and words. Never JSON.
|
||
pub fn summary(command: &str, v: &Value) -> String {
|
||
if let Some(s) = v.get("summary").and_then(Value::as_str) {
|
||
return one_line(s);
|
||
}
|
||
let count = |key: &str| v.get(key).map_or(0, |x| x.as_array().map(Vec::len).or_else(|| x.as_object().map(Map::len)).unwrap_or(0));
|
||
let line = match command {
|
||
"model.check" => {
|
||
let (e, w) = (v["errors"].as_u64().unwrap_or(0), v["warnings"].as_u64().unwrap_or(0));
|
||
if e + w == 0 { "ready to solve, no issues".to_string() } else { format!("{}, {}", plural(e, "error"), plural(w, "warning")) }
|
||
}
|
||
"model.activity" => format!(
|
||
"{}, {}, {}",
|
||
plural(count("steps") as u64, "step"),
|
||
plural(count("boundary_conditions") as u64, "boundary condition"),
|
||
plural(count("loads") as u64, "load")
|
||
),
|
||
"geometry.bodies" => match v.as_array().map(Vec::len) {
|
||
Some(0) => "no bodies (open a design first)".to_string(),
|
||
Some(n) => plural(n as u64, "body").replace("bodys", "bodies"),
|
||
None => scalars(v),
|
||
},
|
||
"model.info" => {
|
||
let elements: u64 = v["elements"].as_object().into_iter().flatten().filter_map(|(_, n)| n.as_u64()).sum();
|
||
format!(
|
||
"{}, {}, units {}",
|
||
plural(v["nodes"].as_u64().unwrap_or(0), "node"),
|
||
plural(elements, "element"),
|
||
v["units"].as_str().unwrap_or("—")
|
||
)
|
||
}
|
||
"interaction.contact.find" => {
|
||
format!("{} among {}", plural(count("touching") as u64, "touching pair"), plural(v["parts"].as_u64().unwrap_or(0), "part"))
|
||
}
|
||
"results.reactions" => {
|
||
let f = |k: usize| v.pointer(&format!("/total/force/{k}")).and_then(Value::as_f64).map_or("—".into(), number);
|
||
format!("total reaction force ({}, {}, {})", f(0), f(1), f(2))
|
||
}
|
||
_ => scalars(v),
|
||
};
|
||
if line.is_empty() { "done".into() } else { one_line(&line) }
|
||
}
|
||
|
||
/// The status line after `command` changed the model or the display, written for the person
|
||
/// rather than from the command's label: "Showing Load deformed ×2", "Mode 2: 1637 Hz", "Added
|
||
/// load Push". Commands without a sentence of their own say "<label> done".
|
||
pub fn done(command: &str, label: &str, params: &Value, v: &Value) -> String {
|
||
let view = v.get("view").filter(|x| x.is_object());
|
||
let step = view.and_then(|x| x["step"].as_str()).unwrap_or("the step");
|
||
let line = match command {
|
||
"results.deformed" if view.is_some_and(|x| x["deformed"] == false) => Some(format!("Showing {step} undeformed")),
|
||
"results.deformed" => {
|
||
let scale = view.and_then(|x| x["scale"].as_f64()).map(|f| format!(" ×{}", crate::describe::quantity(f))).unwrap_or_default();
|
||
Some(format!("Showing {step} deformed{scale}"))
|
||
}
|
||
"results.contour" => Some(match v.pointer("/contour/label").and_then(Value::as_str) {
|
||
Some(field) => format!("Showing {field} on {step}"),
|
||
None => format!("Contours off on {step}"),
|
||
}),
|
||
"results.modes" => {
|
||
let mode = view.and_then(|x| x["mode"].as_u64()).unwrap_or(1);
|
||
let hz = v
|
||
.get("modes")
|
||
.and_then(Value::as_array)
|
||
.and_then(|m| m.iter().find(|m| m["mode"].as_u64() == Some(mode)))
|
||
.and_then(|m| m["frequency"].as_f64());
|
||
Some(match hz {
|
||
Some(hz) => format!("Mode {mode}: {} Hz", crate::describe::quantity(hz)),
|
||
None => format!("Showing mode {mode} of {step}"),
|
||
})
|
||
}
|
||
"results.animate" => Some(if view.is_some_and(|x| x["animate"] == true) { "Animating" } else { "Animation stopped" }.to_string()),
|
||
"results.frame" => Some(match view.and_then(|x| x["frame"].as_u64()) {
|
||
Some(f) => format!("Showing {step} frame {f}"),
|
||
None => format!("Showing {step}, last frame"),
|
||
}),
|
||
"results.clear" => Some("Back to the model".to_string()),
|
||
"solve.run" => v.get("steps").and_then(Value::as_array).map(|s| format!("Solved {}", plural(s.len() as u64, "step"))),
|
||
c if c.starts_with("solve.") => Some(format!("Solved {step}", step = params["step"].as_str().unwrap_or("the step"))),
|
||
_ => None,
|
||
};
|
||
if let Some(line) = line {
|
||
return line;
|
||
}
|
||
// A definition added, changed or deleted, by its kind and name.
|
||
let name = params.get("name").and_then(Value::as_str);
|
||
let def = Def::ALL
|
||
.into_iter()
|
||
.find_map(|kind| ["create", "edit", "delete"].into_iter().find(|verb| kind.command(verb) == command).map(|verb| (kind, verb)));
|
||
match (def, name) {
|
||
(Some((kind, verb)), Some(name)) => {
|
||
let did = match verb {
|
||
"create" => "Added",
|
||
"edit" => "Changed",
|
||
_ => "Deleted",
|
||
};
|
||
format!("{did} {} {name}", kind.label().to_lowercase())
|
||
}
|
||
_ => format!("{label} done"),
|
||
}
|
||
}
|
||
|
||
/// Whether the reply says more than its summary line, so it is worth the panel.
|
||
pub fn is_rich(v: &Value) -> bool {
|
||
match v {
|
||
Value::Object(m) => m.iter().any(|(k, x)| k != "summary" && !x.is_null()),
|
||
Value::Array(a) => !a.is_empty(),
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
fn plural(n: u64, word: &str) -> String {
|
||
if n == 1 { format!("1 {word}") } else { format!("{n} {word}s") }
|
||
}
|
||
|
||
/// A scalar as short text.
|
||
fn scalar(v: &Value) -> Option<String> {
|
||
match v {
|
||
Value::Bool(b) => Some(if *b { "yes" } else { "no" }.into()),
|
||
Value::Number(n) => Some(
|
||
n.as_u64().map(|u| u.to_string()).or_else(|| n.as_i64().map(|i| i.to_string())).unwrap_or_else(|| number(n.as_f64().unwrap_or(f64::NAN))),
|
||
),
|
||
Value::String(s) => Some(s.clone()),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// The top-level scalars as `key value` pairs (lists as their length).
|
||
fn scalars(v: &Value) -> String {
|
||
match v {
|
||
Value::Object(m) => {
|
||
let parts: Vec<String> = m
|
||
.iter()
|
||
.filter_map(|(k, x)| match x {
|
||
Value::Array(a) => Some(format!("{} {}", k.replace('_', " "), a.len())),
|
||
_ => scalar(x).map(|s| format!("{} {s}", k.replace('_', " "))),
|
||
})
|
||
.collect();
|
||
parts.join(", ")
|
||
}
|
||
Value::Array(a) => plural(a.len() as u64, "item"),
|
||
other => scalar(other).unwrap_or_default(),
|
||
}
|
||
}
|
||
|
||
/// `s` on one line of at most [`MAX_LINE`] characters.
|
||
fn one_line(s: &str) -> String {
|
||
let s = s.split_whitespace().collect::<Vec<_>>().join(" ");
|
||
if s.chars().count() > MAX_LINE { format!("{}…", s.chars().take(MAX_LINE).collect::<String>()) } else { s }
|
||
}
|
||
|
||
/// What a click in the panel asks for, done after drawing.
|
||
enum Act {
|
||
Close,
|
||
Select(Def, String),
|
||
Start(&'static str),
|
||
Tab(Tab),
|
||
Rerun,
|
||
}
|
||
|
||
/// The reply's section of the context panel.
|
||
pub fn show(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
|
||
let Some(reply) = app.reply.clone() else { return };
|
||
let mut acts = Vec::new();
|
||
heading(ui, t, Level::Title, |ui| {
|
||
ui.label(RichText::new(reply.label.to_uppercase()).font(label_font(ui.ctx(), 12.0)));
|
||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||
if ui.small_button("×").on_hover_text("Close").clicked() {
|
||
acts.push(Act::Close);
|
||
}
|
||
if ui.small_button("Run again").on_hover_text(reply.command.as_str()).clicked() {
|
||
acts.push(Act::Rerun);
|
||
}
|
||
});
|
||
});
|
||
ui.add_space(4.0);
|
||
ui.add(egui::Label::new(RichText::new(summary(&reply.command, &reply.value)).color(t.text_dim)).wrap());
|
||
ui.add_space(4.0);
|
||
match reply.command.as_str() {
|
||
"model.check" => diagnostics(app, ui, t, &reply.value, &mut acts),
|
||
"model.activity" => activity(ui, t, &reply.value),
|
||
_ => value(ui, t, egui::Id::new("fc_reply"), &reply.value, 0),
|
||
}
|
||
for a in acts {
|
||
match a {
|
||
Act::Close => app.reply = None,
|
||
Act::Select(kind, name) => app.selected = Some((kind, name)),
|
||
Act::Start(id) => {
|
||
let _ = app.start(id);
|
||
}
|
||
Act::Tab(tab) => app.ui.select_tab(tab),
|
||
Act::Rerun => {
|
||
let _ = app.run(&reply.command, reply.params.clone());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The definition a diagnostic's subject names (``section `web` ``), when the model has it.
|
||
fn subject_definition(app: &ModelApp, subject: &str) -> Option<(Def, String)> {
|
||
let (kind, rest) = subject.split_once(" `")?;
|
||
let name = rest.strip_suffix('`')?;
|
||
let kind: Def = serde_json::from_value(Value::String(kind.replace(' ', "_"))).ok()?;
|
||
browser::definition(&app.session.model, kind, name).map(|_| (kind, name.to_string()))
|
||
}
|
||
|
||
/// Where a whole-model diagnostic is fixed: a button's text, tooltip and what it does.
|
||
fn model_fix(message: &str) -> Option<(&'static str, Act)> {
|
||
if message.contains("mesh has no elements") {
|
||
Some(("Mesh…", Act::Tab(Tab::Mesh)))
|
||
} else if message.contains("no unit system") {
|
||
Some(("Units…", Act::Start("model.units")))
|
||
} else if message.contains("no analysis step") {
|
||
Some(("Step…", Act::Start("model.step.create")))
|
||
} else if message.contains("have no section") {
|
||
Some(("Section…", Act::Start("model.section.create")))
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
/// Check Model: each diagnostic with its severity, the item it is about and its message, and a
|
||
/// button to where it is fixed.
|
||
fn diagnostics(app: &ModelApp, ui: &mut egui::Ui, t: &Tokens, v: &Value, acts: &mut Vec<Act>) {
|
||
let list = v["diagnostics"].as_array().map(Vec::as_slice).unwrap_or_default();
|
||
if list.is_empty() {
|
||
ui.label(RichText::new("✔ The model is ready to solve.").color(t.ok));
|
||
return;
|
||
}
|
||
for (i, d) in list.iter().enumerate() {
|
||
let error = d["severity"] == "error";
|
||
let subject = d["subject"].as_str().unwrap_or_default();
|
||
let message = d["message"].as_str().unwrap_or_default();
|
||
ui.horizontal_top(|ui| {
|
||
let (mark, colour, tip) = if error { ("⛔", t.error, "Error: blocks solving") } else { ("⚠", t.warm, "Warning") };
|
||
ui.label(RichText::new(mark).color(colour)).on_hover_text(tip);
|
||
ui.vertical(|ui| {
|
||
ui.set_width(ui.available_width() - 70.0);
|
||
ui.label(RichText::new(subject).strong());
|
||
ui.add(egui::Label::new(message).wrap());
|
||
});
|
||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
|
||
if let Some((kind, name)) = subject_definition(app, subject) {
|
||
if ui.small_button("Show").on_hover_text(format!("Select {subject} to see and edit it")).clicked() {
|
||
acts.push(Act::Select(kind, name));
|
||
}
|
||
} else if subject == "model"
|
||
&& let Some((text, act)) = model_fix(message)
|
||
&& ui.small_button(text).clicked()
|
||
{
|
||
acts.push(act);
|
||
}
|
||
});
|
||
});
|
||
if i + 1 < list.len() {
|
||
ui.separator();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The Step Table: each boundary condition and load against the steps, ticked where active.
|
||
fn activity(ui: &mut egui::Ui, t: &Tokens, v: &Value) {
|
||
let steps: Vec<&str> = v["steps"].as_array().into_iter().flatten().filter_map(|s| s["name"].as_str()).collect();
|
||
if steps.is_empty() {
|
||
ui.label(RichText::new("No analysis steps yet: Steps › Step creates one.").color(t.text_dim));
|
||
return;
|
||
}
|
||
egui::ScrollArea::horizontal().id_salt("fc_reply_activity").show(ui, |ui| {
|
||
egui::Grid::new("fc_reply_activity_grid").striped(true).spacing(vec2(10.0, 3.0)).show(ui, |ui| {
|
||
ui.label("");
|
||
for (s, step) in steps.iter().zip(v["steps"].as_array().into_iter().flatten()) {
|
||
ui.label(RichText::new(*s).strong()).on_hover_text(step["procedure"].as_str().unwrap_or_default());
|
||
}
|
||
ui.end_row();
|
||
for (key, kind) in [("boundary_conditions", "BC"), ("loads", "Load")] {
|
||
for (name, active) in v[key].as_object().into_iter().flatten() {
|
||
ui.label(name.as_str()).on_hover_text(kind);
|
||
for s in &steps {
|
||
let on = active.as_array().is_some_and(|a| a.iter().any(|x| x == *s));
|
||
ui.label(if on { RichText::new("✔").color(t.ok) } else { RichText::new("—").color(t.text_dim) });
|
||
}
|
||
ui.end_row();
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
/// Any reply: objects as labelled rows, lists of objects as tables, nested parts folded.
|
||
fn value(ui: &mut egui::Ui, t: &Tokens, id: egui::Id, v: &Value, depth: usize) {
|
||
match v {
|
||
Value::Object(m) => {
|
||
egui::Grid::new(id).num_columns(2).spacing(vec2(10.0, 3.0)).show(ui, |ui| {
|
||
for (k, x) in m.iter().filter(|(k, x)| k.as_str() != "summary" && inline(x).is_some()) {
|
||
ui.label(RichText::new(k.replace('_', " ")).color(t.text_dim));
|
||
ui.add(egui::Label::new(inline(x).unwrap_or_default()).wrap());
|
||
ui.end_row();
|
||
}
|
||
});
|
||
for (k, x) in m.iter().filter(|(_, x)| inline(x).is_none()) {
|
||
let title = match x {
|
||
Value::Array(a) => format!("{} ({})", k.replace('_', " "), a.len()),
|
||
_ => k.replace('_', " "),
|
||
};
|
||
egui::CollapsingHeader::new(RichText::new(title).size(12.0)).id_salt(id.with(k)).default_open(depth == 0).show(ui, |ui| {
|
||
value(ui, t, id.with(k), x, depth + 1);
|
||
});
|
||
}
|
||
}
|
||
Value::Array(a) if depth < MAX_DEPTH && a.iter().all(Value::is_object) => table(ui, t, id, a),
|
||
Value::Array(a) if depth < MAX_DEPTH => {
|
||
for (i, x) in a.iter().take(MAX_ROWS).enumerate() {
|
||
value(ui, t, id.with(i), x, depth + 1);
|
||
}
|
||
more(ui, t, a.len());
|
||
}
|
||
other => {
|
||
ui.add(egui::Label::new(inline(other).unwrap_or_else(|| crate::context::value_text(other))).wrap());
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A value short enough for one cell: scalars, and lists of up to four scalars.
|
||
fn inline(v: &Value) -> Option<String> {
|
||
match v {
|
||
Value::Null => Some("—".into()),
|
||
Value::Array(a) if a.len() <= 4 && a.iter().all(|x| !x.is_object() && !x.is_array()) => {
|
||
Some(a.iter().filter_map(scalar).collect::<Vec<_>>().join(", "))
|
||
}
|
||
Value::Array(_) | Value::Object(_) => None,
|
||
other => scalar(other),
|
||
}
|
||
}
|
||
|
||
/// A list of objects as a table: the columns are their keys (first seen first).
|
||
fn table(ui: &mut egui::Ui, t: &Tokens, id: egui::Id, rows: &[Value]) {
|
||
let mut columns: Vec<&str> = Vec::new();
|
||
for r in rows.iter().take(MAX_ROWS) {
|
||
for k in r.as_object().into_iter().flat_map(Map::keys) {
|
||
if !columns.contains(&k.as_str()) && columns.len() < MAX_COLUMNS {
|
||
columns.push(k);
|
||
}
|
||
}
|
||
}
|
||
egui::ScrollArea::horizontal().id_salt(id.with("scroll")).show(ui, |ui| {
|
||
egui::Grid::new(id).striped(true).spacing(vec2(8.0, 2.0)).show(ui, |ui| {
|
||
for c in &columns {
|
||
ui.label(RichText::new(c.replace('_', " ")).small().color(t.text_dim));
|
||
}
|
||
ui.end_row();
|
||
for r in rows.iter().take(MAX_ROWS) {
|
||
for c in &columns {
|
||
ui.label(r.get(*c).map_or_else(String::new, |x| inline(x).unwrap_or_else(|| crate::context::value_text(x))));
|
||
}
|
||
ui.end_row();
|
||
}
|
||
});
|
||
});
|
||
more(ui, t, rows.len());
|
||
}
|
||
|
||
fn more(ui: &mut egui::Ui, t: &Tokens, n: usize) {
|
||
if n > MAX_ROWS {
|
||
ui.label(RichText::new(format!("… {} more", n - MAX_ROWS)).small().color(t.text_dim));
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use serde_json::json;
|
||
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn summaries_are_sentences() {
|
||
let check = json!({"ready": false, "errors": 2, "warnings": 1, "diagnostics": []});
|
||
assert_eq!(summary("model.check", &check), "2 errors, 1 warning");
|
||
assert_eq!(summary("model.check", &json!({"errors": 0, "warnings": 0})), "ready to solve, no issues");
|
||
let activity = json!({"boundary_conditions": {"Clamp": ["Load"]}, "loads": {}, "steps": [{"name": "Load"}]});
|
||
assert_eq!(summary("model.activity", &activity), "1 step, 1 boundary condition, 0 loads");
|
||
assert_eq!(summary("geometry.bodies", &json!([])), "no bodies (open a design first)");
|
||
assert_eq!(summary("geometry.bodies", &json!([{}, {}])), "2 bodies");
|
||
assert_eq!(summary("solve.static", &json!({"dofs": 945, "modes": [1, 2], "x": {"y": 1}})), "dofs 945, modes 2");
|
||
assert_eq!(summary("any", &json!({"summary": "a\nb"})), "a b");
|
||
assert_eq!(summary("any", &json!({})), "done");
|
||
assert!(summary("any", &json!({"s": "x".repeat(500)})).chars().count() <= MAX_LINE + 1);
|
||
}
|
||
|
||
#[test]
|
||
fn model_level_diagnostics_lead_to_their_fix() {
|
||
let mut s = fieldcraft_engine::Session::default();
|
||
let check = s.execute("model.check", &json!({})).unwrap();
|
||
let messages: Vec<&str> = check["diagnostics"].as_array().unwrap().iter().filter_map(|d| d["message"].as_str()).collect();
|
||
assert!(messages.len() >= 3, "{check}");
|
||
for m in messages {
|
||
assert!(model_fix(m).is_some(), "no fix for `{m}`");
|
||
}
|
||
}
|
||
}
|