diff --git a/crates/ui-egui/src/context.rs b/crates/ui-egui/src/context.rs index 4541ec6..da68a72 100644 --- a/crates/ui-egui/src/context.rs +++ b/crates/ui-egui/src/context.rs @@ -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; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6ae3dd9..f06420c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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, /// The reaction sums asked for last in the results panel. pub reactions: Option, + /// The last command reply with more to it than its status line, shown in the context panel. + pub reply: Option, /// 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. diff --git a/crates/ui-egui/src/reply.rs b/crates/ui-egui/src/reply.rs new file mode 100644 index 0000000..5aca102 --- /dev/null +++ b/crates/ui-egui/src/reply.rs @@ -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 { + 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 = 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::>().join(" "); + if s.chars().count() > MAX_LINE { format!("{}…", s.chars().take(MAX_LINE).collect::()) } 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) { + 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 { + 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::>().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}`"); + } + } +}