diff --git a/crates/engine/src/catalogue.rs b/crates/engine/src/catalogue.rs index 3ac0515..360ce63 100644 --- a/crates/engine/src/catalogue.rs +++ b/crates/engine/src/catalogue.rs @@ -509,6 +509,15 @@ pub fn entry_of(command: &str) -> Option<&'static Capability> { CATALOGUE.iter().find(|c| c.command() == Some(command)).or_else(|| CATALOGUE.iter().find(|c| c.more.contains(&command))) } +/// The tab the ribbon follows to when `command` has run from anywhere but the ribbon (a script, +/// the control channel, MCP, the command search, the browser), as a person's click on that tab +/// would: its entry's tab. `None` for commands on the Tools tab (files, history, the model +/// summary: on every workspace and no stage of an analysis) and for unknown commands. A ribbon +/// click runs a command of the tab shown, so it never moves the ribbon (a test checks it). +pub fn stage_tab(command: &str) -> Option { + entry_of(command).map(|c| c.tab).filter(|t| *t != Tab::Tools) +} + /// The entries of a tab (one run of the catalogue). pub fn tab_entries(tab: Tab) -> &'static [Capability] { let start = CATALOGUE.iter().position(|c| c.tab == tab).unwrap_or(CATALOGUE.len()); @@ -570,6 +579,26 @@ mod tests { } } + #[test] + fn the_ribbon_follows_each_command_to_its_stage() { + assert_eq!(stage_tab("mesh.hex.sweep"), Some(Tab::Mesh)); + assert_eq!(stage_tab("model.material.create"), Some(Tab::Properties)); + assert_eq!(stage_tab("model.material.edit"), Some(Tab::Properties)); + assert_eq!(stage_tab("model.load.create"), Some(Tab::Loads)); + assert_eq!(stage_tab("solve.static"), Some(Tab::Solve)); + assert_eq!(stage_tab("results.contour"), Some(Tab::Results)); + assert_eq!(stage_tab("edit.undo"), None); + assert_eq!(stage_tab("file.save"), None); + assert_eq!(stage_tab("model.info"), None); + assert_eq!(stage_tab("no.such"), None); + // A ribbon click (an entry's command, or one of its drop-down's) stays on the tab shown. + for c in CATALOGUE { + for command in c.command().into_iter().chain(c.more.iter().copied()) { + assert!(stage_tab(command).is_none_or(|t| t == c.tab), "{}: `{command}` would move the ribbon from {:?}", c.id, c.tab); + } + } + } + #[test] fn every_command_is_claimed_and_every_ready_entry_runs_a_command() { let mut claimed: Vec<&str> = Vec::new(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 90c92f0..26c5e12 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -369,6 +369,9 @@ impl ModelApp { } // The results display commands reply with what is shown, which the viewport shows. let display = (id.starts_with("results.") && id != "results.reactions") || id == "solve.run"; + if r.is_ok() && (changed || display) { + self.follow_stage(id); + } match &r { Ok(v) if v.is_null() || changed || display => self.set_status(format!("{label} done"), false), Ok(v) => self.set_status(format!("{label}: {v}"), false), @@ -377,10 +380,17 @@ impl ModelApp { r } - /// Run a script (a JSON array of `{command, params}`), stopping at the first error. + /// Run a script (a JSON array of `{command, params}`), stopping at the first error. The + /// ribbon follows its last command that has a stage. pub fn run_script(&mut self, script: &Value) -> Result<(), String> { let r = self.session.run_script(script).map(|_| ()).map_err(|e| e.to_string()); self.model_changed(); + if r.is_ok() { + let commands = script.as_array().into_iter().flatten().filter_map(|step| step.get("command").and_then(Value::as_str)); + if let Some(id) = commands.rev().find(|id| catalogue::stage_tab(id).is_some()) { + self.follow_stage(id); + } + } match &r { Ok(()) => self.set_status("Script done", false), Err(e) => self.set_status(format!("Script: {e}"), true), @@ -388,6 +398,16 @@ impl ModelApp { r } + /// The ribbon turns to the tab where `id` lives ([`catalogue::stage_tab`]), as a person's click + /// there would, so commands run by scripts, the control channel and MCP show their stage. + /// Called after a command changed the model or the results display; queries leave the ribbon + /// alone, and a ribbon click never moves it. + fn follow_stage(&mut self, id: &str) { + if let Some(tab) = catalogue::stage_tab(id) { + self.ui.select_tab(tab); + } + } + /// What a click on a toolbar entry, a menu row or a palette result does: a command without /// parameters runs, one with parameters opens its form, and the file entries (open, save, /// write a deck, recover) go through the host's file pickers. `id` is a command or a @@ -872,6 +892,30 @@ mod tests { assert!(app.status.as_ref().is_some_and(|s| s.text.contains("\"nodes\":1"))); } + /// Commands run from outside the ribbon bring their tab; queries and Tools commands leave it. + #[test] + fn the_ribbon_follows_commands_to_their_stage() { + let mut app = ModelApp::new(Session::default()); + app.ui.select_tab(Tab::Results); + app.run("mesh.node.add", json!({"label": 1, "xyz": [0, 0, 0]})).unwrap(); + assert_eq!((app.ui.workspace, app.ui.tab), (Workspace::Prepare, Tab::Mesh)); + app.run("model.material.create", json!({"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}})).unwrap(); + assert_eq!((app.ui.workspace, app.ui.tab), (Workspace::Model, Tab::Properties)); + // A query and a failed command leave it; so does undo (Tools). + app.run("mesh.quality", json!({})).ok(); + let _ = app.run("model.load.create", json!({"name": "bad"})); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.ui.tab, Tab::Properties); + // A script follows its last command with a stage. + app.run_script(&json!([{"command": "mesh.node.add", "params": {"label": 2, "xyz": [1, 0, 0]}}, {"command": "model.info", "params": {}}])) + .unwrap(); + assert_eq!(app.ui.tab, Tab::Mesh); + // The ribbon's own entries never move it. + app.ui.select_tab(Tab::Properties); + app.start("model.units").unwrap(); + assert_eq!(app.ui.tab, Tab::Properties); + } + #[test] fn starting_a_command_opens_its_form_and_running_it_closes_the_form() { let mut app = ModelApp::new(Session::default()); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ff972d5..cddf6b2 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -30,7 +30,12 @@ checked and clamped (drag steps, wheel lines, window size); `ui.render` refuses Commands never open forms or file pickers when run this way. `fieldcraft-cli commands` prints the same registry. `file.sample {name}` opens a sample (`parts`, `cantilever`, `impact`) and -`solve.run {}` solves every step; the Results workspace comes up when a result appears. +`solve.run {}` solves every step; the Results workspace comes up when a result appears. The +ribbon follows the commands run here, as it does for scripts, MCP, the command search and the +browser: a command that changes the model or the results display brings the tab where its +toolbar entry lives (Mesh for `mesh.*`, Properties, Steps, Loads & BCs… for `model.*`, Solve, +Results), as a click there would. Queries (`model.check`, `mesh.quality`…) and the Tools tab's +commands (files, undo, `model.info`) leave the ribbon where it is. ## UI