diff --git a/crates/ui-egui/src/appbar.rs b/crates/ui-egui/src/appbar.rs index 2391274..415df36 100644 --- a/crates/ui-egui/src/appbar.rs +++ b/crates/ui-egui/src/appbar.rs @@ -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)); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 712e152..ef7a6a6 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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::>(), @@ -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); diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index fda7b4a..725df12 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -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 { 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. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 84d8e9a..a410347 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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 { + 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()); diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index 3347360..27303a4 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -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); } } diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 9aed1de..a1770da 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -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), }) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0427298..980b727 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -26,7 +26,7 @@ checked and clamped (drag steps, wheel lines, window size); `ui.render` refuses |---|---|---| | `engine.execute` | `command`: command id, `params`: JSON object | the command's result | | `engine.script` | `commands`: `[{command, params}, …]` | stops at the first error; `journal`: commands that changed the model so far | -| `engine.commands` | — | every command: `id`, `label`, `params` (the parameter documentation) and `schema` (its parameters as JSON Schema) | +| `engine.commands` | — | every command: `id`, `label`, `params` (the parameter documentation), `schema` (its parameters as JSON Schema) and `requires` (what the session must hold before it runs: `design`, `geometry`, `nodes`, `mesh`, `analysable`, `material`, `step`, `result`, …; `engine.execute` of a command missing one fails without doing anything, naming each missing piece and its toolbar entry) | 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 @@ -41,11 +41,11 @@ commands (files, undo, `model.info`) leave the ribbon where it is. | Method | Params | Notes | |---|---|---| -| `ui.inspect` | — | view settings (`ui`), camera, viewport rect (points), renderer (`gpu`/`cpu`) and adapter, open command form, status bar, node and element counts, the results display (`results`: what the `results.*` commands reply: view, `stale`, contour range, modes), the probed node (`probe`), `busy` (the background job: a file opening, a solve, a mesher run from its form by a click on OK), `prompt` (`{"unsaved": path}`, `{"design": path}` (a design of another part opened over a meshed or defined model), `"recover"`, `"samples"`), `recent` (recent files), `selected` (the browser's selected definition), `nav` (the left-drag mode), `layout` (each region of the window drawn last frame as `[x, y, w, h]` in points: `app_bar`, `ribbon`, `workspace_switcher`, `document_tab`, `app_bar:`, `browser`, `viewport`, `nav_bar`, `axis_view`, `selection_bar`, `context`, `command_line`, `timeline`, `toolbar_dropdown`, `palette`), version | +| `ui.inspect` | — | view settings (`ui`), camera, viewport rect (points), renderer (`gpu`/`cpu`) and adapter, open command form, status bar, node and element counts, the results display (`results`: what the `results.*` commands reply: view, `stale`, contour range, modes), the probed node (`probe`), `solve` (the title bar's Solve: `enabled`, and `unavailable` as `ui.toolbar`'s entries give it, listing everything `solve.run` misses), `busy` (the background job: a file opening, a solve, a mesher run from its form by a click on OK), `prompt` (`{"unsaved": path}`, `{"design": path}` (a design of another part opened over a meshed or defined model), `"recover"`, `"samples"`), `recent` (recent files), `selected` (the browser's selected definition), `nav` (the left-drag mode), `layout` (each region of the window drawn last frame as `[x, y, w, h]` in points: `app_bar`, `ribbon`, `workspace_switcher`, `document_tab`, `app_bar:`, `browser`, `viewport`, `nav_bar`, `axis_view`, `selection_bar`, `context`, `command_line`, `timeline`, `toolbar_dropdown`, `palette`), version | | `ui.set` | any view setting: `style` (`shaded_edges`, `shaded`, `wireframe`), `show_nodes`, `show_geometry`, `show_mesh`, `show_loads` (boundary condition and load symbols), `show_interactions` (reference points, couplings, rigid walls), `perspective`, `dark`, `show_tree` (the browser), `show_timeline`, `workspace` (`prepare`, `model`, `analysis`, `results`), `tab` (the toolbar tab: `geometry`, `mesh`, `properties`, `assembly`, `steps`, `loads`, `interactions`, `solve`, `results`, `tools`; a tab of another workspace brings that workspace), `palette` (the command search open), `menu` (the open menu: `file`, `help`, `workspace`, `view`, `more`, or a panel's drop-down as `/` such as `mesh/Tet`; `null` closes it) | an unknown setting or value is an error; returns the settings | | `ui.view` | `view`: `front`, `back`, `left`, `right`, `top`, `bottom`, `iso`, `home` (iso and fit), `fit` | returns the camera | | `ui.camera` | `yaw`, `pitch` (degrees), `distance?` (model units) | turns the camera to any orientation about its target: `yaw` about Z (0 looks along +Y, the front view; −45 is the iso view's, −90 the right view's), `pitch` the elevation above the XY plane; `distance` moves the eye (the zoom of an orthographic view). Angles must be finite and the distance above 0: the yaw is wrapped, the pitch clamped to ±90 and the distance to 0.001–10⁹; anything else is an error. A sequence of calls orbits smoothly. Returns the camera (`ui.inspect`'s, angles in radians) | -| `ui.toolbar` | — | the ribbon: `workspace`, `workspaces`, `tab`, `tabs` (the workspace's), `menu` (the open menu), `panels` (each panel label's `menu` key and `rect`), and every catalogue entry (docs/features.md) with `id`, `label`, `tab`, `panel`, `command` (null while coming soon), `more` (its related commands), `coming_soon` (the milestone, or null), `fit` — `full` (a button), `collapsed` (its panel is one button), `more` (its panel is in the » menu) or `menu` (in its panel's drop-down) — and `rect` (screen points, last frame, on the shown tab; for `menu` entries the row while the drop-down is open) | +| `ui.toolbar` | — | the ribbon: `workspace`, `workspaces`, `tab`, `tabs` (the workspace's), `menu` (the open menu), `panels` (each panel label's `menu` key and `rect`), and every catalogue entry (docs/features.md) with `id`, `label`, `tab`, `panel`, `command` (null while coming soon), `more` (its related commands), `coming_soon` (the milestone, or null), `enabled` (false while coming soon or while its command misses what it needs), `unavailable` (null, or what its command misses: `reason`, the tooltip's lines such as "Needs a material — Properties › Material", and `missing`, each with `requirement`, `what`, `provider` (the command that provides it) and `place` (its toolbar entry)), `fit` — `full` (a button), `collapsed` (its panel is one button), `more` (its panel is in the » menu) or `menu` (in its panel's drop-down) — and `rect` (screen points, last frame, on the shown tab; for `menu` entries the row while the drop-down is open) | | `ui.start` | `command`: a command or toolbar entry id | like a click on the toolbar, a drop-down row or a search result: a command without parameters runs, one with parameters opens its form in the context panel; an entry that is coming soon is an error naming its milestone. Never opens a native file picker: file entries (open, save as, write deck) open their command's form | | `ui.select` | `kind` (`set`, `material`, `section`, `step`, `boundary_condition`, `load`, `reference_point`, `coupling`, `fluid_domain`), `name`; nothing to deselect | selects a definition as a browser click does; its properties (with Edit and Delete) show in the context panel | | `ui.select` (viewport) | any of `filter`, `box_mode`, `mode`, `clear`, `at`, `box`, `geometry`, `nodes`, `elements`, `faces` (and no `kind`) | changes the viewport's selection (see [Selection](#selection)); returns `ui.selection`'s reply |