//! The app SHELL: the toolbar's chrome buttons and the dock's panes.
//!
//! Everything else in this layer drives the document; these drive the window
//! around it. They exist so the toolbar is fully reachable without a pointer —
//! `hit_keys` records the command each button names, and a test refuses a
//! toolbar button that names none. A widget an agent can only click is a widget
//! it cannot use the moment something scrolls or covers the rect.
use crate::automation::command::{app_command, parse_args, CommandSpec, Ctx, NoArgs, Outcome};
use crate::automation::HELP_URL;
use crate::panels::dock::PaneKind;
use serde::Deserialize;
use serde_json::{json, Value};
#[derive(Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct OpenArgs {
/// Open (true) or close the window.
pub open: bool,
}
#[derive(Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct WorkbenchButtonArgs {
/// The button's `WorkbenchButton::id`, e.g. `sheetmetal.flat_pattern`.
/// `describe_workbenches` lists the ids the active workbench offers.
pub id: String,
}
#[derive(Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PaneArgs {
/// A dock pane name as `describe_panes` lists it, e.g. `History`.
pub pane: String,
}
#[derive(Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PickArgs {
/// Who asks: the file comes back only to this tag.
pub tag: String,
/// The chooser's heading. Default "Choose a file".
#[serde(default)]
pub title: Option<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PickedArgs {
pub tag: String,
}
// Shared envelopes follow the existing window/file-dialog producers.
fn window_result_schema() -> Value {
json!({"type":"object","properties":{"open":{"type":"boolean"}},"required":["open"],"additionalProperties":false})
}
fn file_pick_result_schema() -> Value {
json!({"type":"object","properties":{"tag":{"type":"string"},"open":{"const":true}},
"required":["tag","open"],"additionalProperties":false})
}
fn file_picked_result_schema() -> Value {
json!({"type":"object","oneOf":[
{"type":"object","properties":{"picked":{"const":false},"picking":{"type":"boolean"}},
"required":["picked","picking"],"additionalProperties":false},
{"type":"object","properties":{"picked":{"const":true},"name":{"type":"string"},
"path":{"type":["string","null"]},"size":{"type":"integer","minimum":0},"text":{"type":["string","null"]}},
"required":["picked","name","path","size","text"],"additionalProperties":false}
]})
}
fn help_open_result_schema() -> Value {
json!({"type":"object","properties":{"url":{"type":"string"}},"required":["url"],"additionalProperties":false})
}
fn diagnostics_result_schema() -> Value {
// Empty rows are omitted, notably the driver in browser builds.
let mut fields=serde_json::Map::new();
for name in ["Renderer","wgpu backend","Adapter","Device type","Driver","Max texture size","App version","Platform"] {
fields.insert(name.into(),json!({"type":"string"}));
}
json!({"type":"object","properties":fields,"additionalProperties":false})
}
fn describe_panes_result_schema() -> Value {
json!({"type":"object","properties":{"panes":{"type":"array","items":{"type":"string"}}},
"required":["panes"],"additionalProperties":false})
}
fn show_pane_result_schema() -> Value {
json!({"type":"object","properties":{"pane":{"type":"string"}},"required":["pane"],"additionalProperties":false})
}
fn file_pick(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
let a: PickArgs = parse_args(args)?;
let (file, _, store) = ctx.app.file_docs_store();
file.request_pick_file(store, &a.tag, a.title.as_deref().unwrap_or("Choose a file"))?;
Ok(Outcome::Done(json!({ "tag": a.tag, "open": true })))
}
fn file_picked(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
let a: PickedArgs = parse_args(args)?;
let (file, _, _) = ctx.app.file_docs_store();
let picking = file.picking(&a.tag);
Ok(Outcome::Done(match file.take_picked_file(&a.tag) {
Some(picked) => json!({
"picked": true, "name": picked.name, "path": picked.path, "size": picked.bytes.len(),
"text": std::str::from_utf8(&picked.bytes).ok().filter(|t| t.len() <= 4096),
}),
None => json!({ "picked": false, "picking": picking }),
}))
}
fn settings_window(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
let a: OpenArgs = parse_args(args)?;
ctx.app.set_settings_window_open(a.open);
Ok(Outcome::Done(json!({ "open": a.open })))
}
fn part_properties_window(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
let a: OpenArgs = parse_args(args)?;
ctx.app.set_part_properties_window_open(a.open);
Ok(Outcome::Done(json!({ "open": a.open })))
}
fn help_open(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
let _: NoArgs = parse_args(args)?;
// In a window this opens the help site in a browser tab; headless there is
// no browser to open it in, so the URL in the reply is the whole answer.
ctx.egui.open_url(egui::OpenUrl::new_tab(HELP_URL));
Ok(Outcome::Done(json!({ "url": HELP_URL })))
}
fn info_window(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
let a: OpenArgs = parse_args(args)?;
ctx.app.set_info_window_open(a.open);
Ok(Outcome::Done(json!({ "open": a.open })))
}
fn diagnostics(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
let _: NoArgs = parse_args(args)?;
// The app's ONE Diagnostics instance — the same rows the Info window draws
// and a problem report carries, not a fresh probe of the device (which
// could answer differently from what is actually rendering).
Ok(Outcome::Done(ctx.app.diagnostics().json()))
}
fn bug_report_open(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
let _: NoArgs = parse_args(args)?;
let egui = ctx.egui.clone();
ctx.app.begin_bug_report(&egui);
Ok(Outcome::Done(json!({})))
}
fn workbench_button(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
let a: WorkbenchButtonArgs = parse_args(args)?;
// A CONDITIONAL button is refused exactly when the row would not be showing
// it: Drawing's sheet buttons want paper on screen. Without this the command
// surface would do what no click can, and a dimension would be made on a
// sheet nobody is looking at.
//
// An offered button can still be DISABLED (an eCAD action its editor does
// not allow yet): that is refused with the reason, never run as a silent
// no-op. A MENU button is not pressed itself — its entries are. The gate
// is `workbench::press_refusal`, where it is tested against each kind.
let doc = ctx.app.docs.view();
let state = crate::workbench::ButtonState { engine: &doc.engine, ecad: Some(&doc.ecad) };
if let Some(refusal) = crate::workbench::press_refusal(&a.id, &state) {
return Err(refusal);
}
if !ctx.app.dispatch_workbench_button(&a.id) {
return Err(format!("no workbench button `{}` (see describe_workbenches)", a.id));
}
Ok(Outcome::Done(json!({})))
}
fn describe_workbenches_result_schema() -> Value {
use super::plugin_output_schema::{action_schema, panels_schema, annotations_schema};
use super::schema_dsl::{array, closed_object_all};
let text=json!({"type":"string"});
let texts=array(text.clone());
let button=closed_object_all(json!({"id":text,"tooltip":text}));
let builtin=closed_object_all(json!({"id":text,"label":text,"buttons":array(button.clone()),"panels":texts}));
let owned=closed_object_all(json!({"id":text,"label":text,"featureTypes":texts,"actions":texts,"panels":texts}));
closed_object_all(json!({"active":text,"saved":text,
"pluginActions":array(action_schema()),"pluginPanels":panels_schema(),
"pluginAnnotations":annotations_schema(),
"workbenches":array(json!({"oneOf":[builtin,owned]})),
"sharedButtons":array(button),"activeButtons":texts,
"disabledButtons":array(closed_object_all(json!({"id":text,"reason":text})))
}))
}
fn describe_workbenches(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
let _: NoArgs = parse_args(args)?;
let active = ctx.app.docs.engine().settings.workbench.clone();
let doc = ctx.app.docs.view();
let state = crate::workbench::ButtonState { engine: &doc.engine, ecad: Some(&doc.ecad) };
let offered: Vec<&crate::workbench::WorkbenchButton> = crate::workbench::offered_buttons(&active, &state)
.into_iter()
.flat_map(|b| std::iter::once(b).chain(b.menu.iter().filter(|e| e.offered(&state))))
.collect();
let mut workbenches: Vec<Value> = crate::workbench::WORKBENCHES
.iter()
.map(|w| {
json!({
"id": w.id,
"label": w.label,
// What this workbench DECLARES of its own. The buttons every
// workbench also carries are reported once, as `sharedButtons`
// below, rather than eleven times here — that is the shape the
// registry actually has (`workbench::shared_buttons`), and a
// reader asking "whose button is this?" gets the true answer.
"buttons": w.buttons.iter().map(|b| json!({ "id": b.id, "tooltip": b.tooltip })).collect::<Vec<_>>(),
"panels": w.panels,
})
})
.collect();
workbenches.extend(crate::workbench::owned_workbenches(&doc.engine).into_iter().map(|w| serde_json::to_value(w).unwrap()));
Ok(Outcome::Done(json!({
"active": crate::workbench::resolved_id(&doc.engine, &active),
"saved": active,
"pluginActions": crate::workbench::plugin_actions(&doc.engine, &active),
"pluginPanels": crate::workbench::plugin_panels(&doc.engine, &active),
"pluginAnnotations": doc.engine.plugin_annotation_catalogue(),
"workbenches": workbenches,
// The buttons EVERY workbench's row carries whatever is active — the
// sketch tools. Declared by no workbench, so they are listed here and
// not under one.
"sharedButtons": crate::workbench::shared_buttons().iter().map(|b| json!({ "id": b.id, "tooltip": b.tooltip })).collect::<Vec<_>>(),
// A MENU's offered entries are pressable by id, so they are listed
// after the menu; a DISABLED button is listed here AND under
// `disabledButtons` with the reason a press would be refused with.
"activeButtons": offered.iter().map(|b| b.id).collect::<Vec<_>>(),
"disabledButtons": offered
.iter()
.filter_map(|b| b.disabled_reason(&state).map(|why| json!({ "id": b.id, "reason": why })))
.collect::<Vec<_>>(),
})))
}
fn describe_panes(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
let _: NoArgs = parse_args(args)?;
let _ = ctx;
Ok(Outcome::Done(json!({ "panes": PaneKind::ALL.iter().map(|k| k.title()).collect::<Vec<_>>() })))
}
fn show_pane(ctx: &mut Ctx<'_>, args: Value) -> Result<Outcome, String> {
let a: PaneArgs = parse_args(args)?;
let kind = PaneKind::ALL
.iter()
.copied()
.find(|k| k.title().eq_ignore_ascii_case(&a.pane))
.ok_or_else(|| format!("no pane `{}` (see describe_panes)", a.pane))?;
// A workbench-CLAIMED pane is HIDDEN under a workbench that does not claim
// it (it keeps its place in the tree — see `panels::dock`), and activating a
// hidden tab is a silent no-op: egui_tiles moves the active tab straight
// back to a visible one. So say so instead of reporting success for nothing.
let workbench = ctx.app.docs.engine().settings.workbench.clone();
let panels = crate::workbench::ButtonState::of(ctx.app.docs.engine());
let forced = kind == crate::panels::dock::PaneKind::Bom && ctx.app.docs.work_part_in_place();
if !forced && !kind.visible_under_workbench(&workbench, &panels) {
// Two ways to be hidden, and they want different answers: a CLAIMED
// pane needs another workbench, a CONDITIONAL one needs a different
// document. Telling a caller to switch workbench for the Qualify pane
// would send them round every one of them.
return Err(match kind {
PaneKind::Qualify => format!(
"the {} pane is on screen only while the part declares connection points — press the `{}` workbench button first",
kind.title(),
crate::workbench::wire_harness::DECLARE_POINT_BUTTON_ID,
),
PaneKind::FamilyTable => format!(
"the {} pane is on screen only while the document is a family seed (.fbrep)",
kind.title(),
),
PaneKind::Plm => format!("the {} pane is on screen only while the session is on a PLM", kind.title()),
_ => format!(
"the {} pane is claimed by another workbench (active: `{}`) — switch with settings_set {{patch:{{workbench}}}}",
kind.title(),
crate::workbench::resolve(&workbench).id
),
});
}
ctx.app.show_pane(kind);
Ok(Outcome::Done(json!({ "pane": kind.title() })))
}
pub static COMMANDS: &[CommandSpec] = &[
app_command!("shell", "file_pick", "Open the file chooser for ANY file on this machine, on behalf of `tag` (what a panel's Add file… does). Natively it is the app's own explorer over the local files, even in a PLM session; drive it by its `pickfile:` hit keys. In the browser it is the page's file input. The chosen file is taken with `file_picked`.", PickArgs, file_pick, Mutate, MUTATE_NOWAIT, file_pick_result_schema),
app_command!("shell", "file_picked", "Take the file the chooser delivered for `tag`, once: `{picked, name, path, size, text}` (`text` when it is UTF-8 and at most 4 KB), or `{picked: false, picking}` while the chooser is still open or after a cancel.", PickedArgs, file_picked, Mutate, MUTATE_NOWAIT, file_picked_result_schema),
app_command!("shell", "settings_window", "Open or close the floating Settings window (the toolbar's gear). The settings themselves are read and written with `settings_get` / `settings_set`, no window needed.", OpenArgs, settings_window, Mutate, MUTATE_NOWAIT, window_result_schema),
app_command!("shell", "part_properties_window", "Open or close the floating Part Properties window (the toolbar's tag button), which edits the active document's own BOM attributes. The attributes themselves are read and written with `part_attributes_get` / `part_attribute_set`, no window needed.", OpenArgs, part_properties_window, Mutate, MUTATE_NOWAIT, window_result_schema),
app_command!("shell", "help_open", "Open the generated help site in a browser tab (the toolbar's Help button) and return its URL. Headless there is no browser, so the URL is the answer.", NoArgs, help_open, Mutate, MUTATE_NOWAIT, help_open_result_schema),
app_command!("shell", "info_window", "Open or close the floating Info window (the toolbar's \u{2139} button): the project + third-party licences, and this session's renderer diagnostics. Read the diagnostics themselves with `diagnostics`, no window needed.", OpenArgs, info_window, Mutate, MUTATE_NOWAIT, window_result_schema),
app_command!("shell", "diagnostics", "What this session is running on: the renderer actually in use (WebGPU or the WebGL2 fallback in a browser; Vulkan/Metal/Direct3D 12 natively), the adapter, the texture ceiling that bounds the viewport, the app version and the platform. Captured at startup from the adapter that is drawing, never re-probed \u{2014} the same rows the Info window shows and every problem report carries.", NoArgs, diagnostics, Read, READ, diagnostics_result_schema),
app_command!("shell", "bug_report_open", "Begin the in-app problem report (the toolbar's Submit Bug button): captures the current frame, then opens the description form.", NoArgs, bug_report_open, Mutate, MUTATE_NOWAIT),
app_command!("shell", "workbench_button", "Press one of the active workbench's toolbar buttons by id — the same dispatch a click runs (flat pattern, add component, interference, parts library, capture PMI view; while a sheet is open, PMI's Back to 3D and the five sheet-dimension constructions; and while a sketch is being edited, the ten draw tools and Auto-constrain, which EVERY workbench carries). A button whose condition is not met right now is refused rather than run; `describe_workbenches` lists what is offered as `activeButtons`. An offered button that is DISABLED is refused with the reason (`describe_workbenches` `disabledButtons`), and a MENU button is refused with its entries, which are pressed by their own ids.", WorkbenchButtonArgs, workbench_button, Mutate, MUTATE),
app_command!("shell", "describe_workbenches", "Every workbench with its label, its toolbar buttons and the panels it claims, plus which one is active. `buttons` is what that workbench DECLARES; `sharedButtons` is the list EVERY workbench's row also carries (the sketch draw tools), reported once because no workbench owns it. `activeButtons` is what the toolbar row is offering right NOW \u{2014} a button declared conditional on document state (Drawing's sheet buttons want an open sheet; the sketch tools want a sketch being edited) is listed under its workbench but absent from `activeButtons` until its condition holds. A MENU button's offered entries follow it in `activeButtons`; `disabledButtons` lists the offered buttons a press would refuse right now, each with the reason. Switch with `settings_set {patch:{workbench}}`.", NoArgs, describe_workbenches, Read, READ, describe_workbenches_result_schema),
app_command!("shell", "describe_panes", "Every dock pane name `show_pane` accepts.", NoArgs, describe_panes, Read, READ, describe_panes_result_schema),
app_command!("shell", "show_pane", "Bring a dock pane to the front (History, Scene, BOM, PMI, …) so its widgets are laid out and its hit-rects are publishable.", PaneArgs, show_pane, Mutate, MUTATE_NOWAIT, show_pane_result_schema),
];