UI: command replies read as one line, the whole reply in the context panel

Commands run from the UI whose reply had no `summary` printed their raw
JSON into the status bar (Check Model, Step Table, Model Info, Bodies,
Find Contacts, the solve steps...). The status bar now gets one sentence
for every reply, and a reply with more to it opens in the context panel:
Check Model as its diagnostics with severity marks and a button to each
fix, the Step Table as a grid of loads and BCs against steps, any other
reply as labelled rows and tables. Check Model and the Step Table follow
the model as it changes. Programmatic replies stay JSON.

Fixes #217

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-14 20:18:04 -04:00
1 parent f89545a66a
commit a84a8339ae
3 files changed
+455 -6

No files matched your search

+6 -2
View File
@@ -16,7 +16,7 @@ pub const WIDTH: f32 = 310.0;
/// Whether the panel has anything to show.
pub fn has_context(app: &mut ModelApp) -> bool {
app.dialog.is_some() || app.selected.is_some() || app.plot().is_some()
app.dialog.is_some() || app.selected.is_some() || app.reply.is_some() || app.plot().is_some()
}
pub fn panel(app: &mut ModelApp, ui: &mut egui::Ui) {
@@ -51,6 +51,10 @@ pub fn panel(app: &mut ModelApp, ui: &mut egui::Ui) {
}
ui.add_space(10.0);
}
if app.reply.is_some() {
crate::reply::show(app, ui, &t);
ui.add_space(10.0);
}
if let Some((kind, name)) = app.selected.clone() {
properties(app, ui, &t, kind, &name);
}
@@ -231,7 +235,7 @@ fn properties(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens, kind: Def, name
/// A field's value as short text: numbers and strings as they are, long lists as their length
/// and first items, other lists and objects as compact JSON (long ones shortened).
fn value_text(v: &Value) -> String {
pub(crate) fn value_text(v: &Value) -> String {
const MAX: usize = 160;
/// Lists longer than this show their length and first items.
const LIST: usize = 12;
+72 -4
View File
@@ -35,6 +35,7 @@ pub mod frame;
pub mod gpu;
pub mod icons;
pub mod palette;
pub mod reply;
pub mod results;
pub mod results_panel;
pub mod scene;
@@ -285,6 +286,8 @@ pub struct ModelApp {
pub probe: Option<Probe>,
/// The reaction sums asked for last in the results panel.
pub reactions: Option<Value>,
/// The last command reply with more to it than its status line, shown in the context panel.
pub reply: Option<reply::Reply>,
/// Whether results were shown last frame (the Results tab comes up when they appear).
results_shown: bool,
/// Tessellated design bodies with their names, kept while the same design is open.
@@ -371,6 +374,7 @@ impl ModelApp {
plot: (None, None),
probe: None,
reactions: None,
reply: None,
results_shown: false,
bodies: (None, Vec::new()),
prepared: (None, results::Prepared::default()),
@@ -451,9 +455,16 @@ impl ModelApp {
}
match &r {
Ok(v) if v.is_null() || changed || display => self.set_status(format!("{label} done"), false),
// A reply with a `summary` line shows it instead of its JSON.
Ok(v) if let Some(summary) = v.get("summary").and_then(Value::as_str) => self.set_status(format!("{label}: {summary}"), false),
Ok(v) => self.set_status(format!("{label}: {v}"), false),
// A reply reads as one line; the whole of a rich one shows in the context panel (the
// reaction sums have their own place in the results panel).
Ok(v) => {
let mut line = format!("{label}: {}", reply::summary(id, v));
if reply::is_rich(v) && !(id == "results.reactions" && self.plot().is_some()) {
line.push_str(" (details in the panel)");
self.reply = Some(reply::Reply { command: id.to_string(), label: label.to_string(), params: params.clone(), value: v.clone() });
}
self.set_status(line, false)
}
Err(e) => self.set_status(format!("{label}: {e}"), true),
}
r
@@ -644,6 +655,12 @@ impl ModelApp {
/// The model may have changed: display data and the scene are rebuilt when next drawn.
pub fn model_changed(&mut self) {
self.revision = self.revision.wrapping_add(1);
// A reply about the model is read again; any other one is out of date.
self.reply = self.reply.take().and_then(|mut r| {
reply::LIVE.contains(&r.command.as_str()).then_some(())?;
r.value = self.session.execute(&r.command, &r.params).ok()?;
Some(r)
});
}
/// The model's display data with its geometry (the open design's bodies, tessellated once
@@ -1245,7 +1262,58 @@ mod tests {
let err = app.run("mesh.node.add", json!({"label": 0, "xyz": [0, 0, 0]})).unwrap_err();
assert!(app.status.as_ref().is_some_and(|s| s.error && s.text.starts_with("Add Node:")), "{err}");
app.run("model.info", json!({})).unwrap();
assert!(app.status.as_ref().is_some_and(|s| s.text.contains("\"nodes\":1")));
assert!(app.status.as_ref().is_some_and(|s| s.text.starts_with("Model Info: 1 node, 0 elements")), "{:?}", app.status);
}
/// Every command run from the UI reports one readable line, never its JSON, in every state
/// tried (empty, a solved sample); a reply with more to it opens in the context panel.
#[test]
fn every_reply_reads_as_one_line() {
let empty = Session::default();
let mut solved = ModelApp::new(Session::default());
solved.open_sample("cantilever");
solved.run("solve.run", json!({})).unwrap();
let mut ran = 0;
for session in [empty, solved.session.clone()] {
// Every command bare, and the queries that need parameters with ones the sample has.
let bare = fieldcraft_engine::commands().iter().filter(|c| !c.id.starts_with("file.")).map(|c| (c.id, json!({})));
let queries = [
("model.set.resolve", json!({"name": "root"})),
("solve.probe", json!({"step": "Load", "nodes": [1, 2]})),
("solve.probe", json!({"step": "Modes", "set": "tip", "mode": 1})),
("results.reactions", json!({"step": "Load"})),
];
for (id, params) in bare.chain(queries) {
let mut app = ModelApp::new(session.clone());
let r = app.run(id, params);
let Some(status) = app.status.clone() else { continue };
let text = status.text;
assert!(!text.contains('\n'), "{id}: {text}");
if r.is_ok() {
ran += 1;
assert!(!text.contains("{\"") && !text.contains("[{") && text.chars().count() <= 200, "{id}: {text}");
}
}
}
assert!(ran >= 20, "only {ran} commands ran");
}
/// Check Model and the Step Table open their readable view in the context panel, which
/// follows the model as it changes.
#[test]
fn check_model_opens_its_diagnostics_in_the_panel() {
let mut app = ModelApp::new(Session::default());
app.run("model.check", json!({})).unwrap();
let status = app.status.clone().unwrap();
assert_eq!(status.text, "Check Model: 2 errors, 1 warning (details in the panel)");
assert!(app.reply.as_ref().is_some_and(|r| r.command == "model.check"));
assert!(context::has_context(&mut app));
app.run("model.units", json!({"system": "mm-t-s-N-MPa"})).unwrap();
assert_eq!(app.reply.as_ref().map(|r| r.value["warnings"].clone()), Some(json!(0)), "the check follows the model");
let mut app = ModelApp::new(Session::default());
app.open_sample("cantilever");
app.run("model.activity", json!({})).unwrap();
assert_eq!(app.status.unwrap().text, "Step Table: 2 steps, 1 boundary condition, 1 load (details in the panel)");
}
/// Commands run from outside the ribbon bring their tab; queries and Tools commands leave it.
+377
View File
@@ -0,0 +1,377 @@
//! 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) }
}
/// 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}`");
}
}
}