Save prompt on quit: "Save changes to <name>?" for every unsaved design
The title bar's ×, File ▸ Quit, the OS closing the window (Alt+F4, Cmd+Q, the window manager:
close_requested is cancelled until the designs are answered) and app.quit / ui.window close all
go through documents::request_quit, which asks Save / Don't Save / Cancel once per design with
unsaved changes. Save saves (Save As for a never-saved design) and quits only if it saved; Don't
Save discards (no recovery copy is kept); Cancel or Esc stays open. Closing a tab asks the same
question (now titled "Save changes to <name>?"); New and Open never replace an unsaved design
(they open a tab). The web app asks through beforeunload while a design is unsaved. Control:
ui.documents {action: quit, answer: save|dont_save|cancel}, app.quit {force}. Tests: quit with
Save then Don't Save across two tabs, Cancel, a Save that saves nothing, Save As; scenario
s43_quit_asks_to_save.
Fixes #33
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
d2bc8e9847
commit
4562c8e5ba
10 files changed
+240
-30
No files matched your search
@@ -29,4 +29,5 @@ web-sys = { version = "0.3", features = [
|
||||
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
|
||||
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
|
||||
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
|
||||
"BeforeUnloadEvent", "Event", "EventTarget",
|
||||
] }
|
||||
@@ -27,6 +27,25 @@ mod web {
|
||||
/// Seconds between autosaves of unsaved work.
|
||||
const AUTOSAVE_S: f64 = 30.0;
|
||||
|
||||
thread_local! {
|
||||
/// An open design has unsaved changes: leaving the page asks first.
|
||||
static UNSAVED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// The browser's "leave site? changes you made may not be saved" prompt while a design has
|
||||
/// unsaved changes (the page can't show its own dialog when it is being closed).
|
||||
fn ask_before_unload(window: &web_sys::Window) {
|
||||
let on_unload = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::BeforeUnloadEvent)>::new(|e: web_sys::BeforeUnloadEvent| {
|
||||
if UNSAVED.with(std::cell::Cell::get) {
|
||||
e.prevent_default();
|
||||
e.set_return_value("unsaved");
|
||||
}
|
||||
});
|
||||
let _ = window.add_event_listener_with_callback("beforeunload", on_unload.as_ref().unchecked_ref());
|
||||
// The listener lives as long as the page.
|
||||
on_unload.forget();
|
||||
}
|
||||
|
||||
struct Shell {
|
||||
app: SolveApp,
|
||||
files: Rc<Files>,
|
||||
@@ -291,6 +310,7 @@ mod web {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.app.logic(ctx);
|
||||
self.files_tick(ctx);
|
||||
UNSAVED.with(|u| u.set(solvecraft_ui_egui::documents::any_dirty(&self.app)));
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
files::take_drops(&self.files, raw);
|
||||
@@ -317,7 +337,9 @@ mod web {
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
ask_before_unload(&window);
|
||||
let Some(document) = window.document() else { return };
|
||||
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
|
||||
@@ -21,6 +21,12 @@ struct App(SolveApp);
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
// The OS closing the window (Alt+F4, Cmd+Q, the window manager): ask about unsaved
|
||||
// designs first; the window closes once they are answered (`quit_requested`).
|
||||
if ctx.input(|i| i.viewport().close_requested()) && !self.0.quit_requested {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
solvecraft_ui_egui::documents::request_quit(&mut self.0);
|
||||
}
|
||||
self.0.logic(ctx);
|
||||
if self.0.quit_requested {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
|
||||
@@ -111,6 +111,8 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
"preview": {"active": app.preview.active, "busy": app.preview.busy, "error": app.preview.error, "ms": app.preview.ms, "replaced": app.preview.replaced},
|
||||
"home": app.home.open,
|
||||
"documents": app.docs.count(),
|
||||
"quit_requested": app.quit_requested,
|
||||
"quitting": app.docs.quitting.is_some(),
|
||||
})),
|
||||
"ui.documents" => ok(crate::documents::control(app, p)),
|
||||
"ui.home" => {
|
||||
@@ -459,9 +461,16 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
}
|
||||
}
|
||||
"app.quit" => {
|
||||
app.quit_requested = true;
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
ok(Value::Null)
|
||||
// `force`: quit without asking about unsaved designs.
|
||||
if p.get("force").and_then(Value::as_bool).unwrap_or(false) {
|
||||
app.quit_requested = true;
|
||||
} else {
|
||||
crate::documents::request_quit(app);
|
||||
}
|
||||
if app.quit_requested {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
ok(json!({"quitting": app.quit_requested, "asking": app.docs.closing.is_some()}))
|
||||
}
|
||||
other => err(format!("unknown method `{other}`")),
|
||||
}
|
||||
|
||||
+168
-20
@@ -1,7 +1,8 @@
|
||||
//! Several open designs, as tabs in the application bar. The active design lives in the app as
|
||||
//! always (`app.session`, `app.cam`…); the others wait here with their own session (undo,
|
||||
//! selection), camera and per-design view state, and are swapped in when their tab is picked.
|
||||
//! Closing a design with unsaved changes asks first.
|
||||
//! Closing a design with unsaved changes asks first, and so does quitting: one "save changes?"
|
||||
//! per design with unsaved changes ([`request_quit`]).
|
||||
|
||||
use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
@@ -48,6 +49,8 @@ pub struct Documents {
|
||||
pub active: usize,
|
||||
/// A tab waiting for the answer to "save changes?".
|
||||
pub closing: Option<usize>,
|
||||
/// Quitting: the tabs answered so far with Don't Save (`None`: not quitting).
|
||||
pub quitting: Option<Vec<usize>>,
|
||||
}
|
||||
|
||||
impl Documents {
|
||||
@@ -214,6 +217,44 @@ pub fn close(app: &mut SolveApp, i: usize, force: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Quit (the title bar's ×, File ▸ Quit, the window being closed): asks "save changes?" for each
|
||||
/// design with unsaved changes, then sets `app.quit_requested` for the host to close the window.
|
||||
/// Cancel, or a Save that didn't save, stays open.
|
||||
pub fn request_quit(app: &mut SolveApp) {
|
||||
if app.docs.quitting.is_none() {
|
||||
app.docs.quitting = Some(Vec::new());
|
||||
}
|
||||
quit_step(app);
|
||||
}
|
||||
|
||||
/// Ask about the next design with unsaved changes, or quit when none is left.
|
||||
fn quit_step(app: &mut SolveApp) {
|
||||
let Some(skip) = app.docs.quitting.clone() else { return };
|
||||
if app.docs.closing.is_some() {
|
||||
return;
|
||||
}
|
||||
match (0..app.docs.count()).find(|i| !skip.contains(i) && dirty(app, *i)) {
|
||||
Some(i) => {
|
||||
switch(app, i);
|
||||
app.docs.closing = Some(i);
|
||||
}
|
||||
None => {
|
||||
// Don't Save means discard: no recovery copy is kept for the next launch either.
|
||||
for i in skip {
|
||||
switch(app, i);
|
||||
app.session.mark_saved();
|
||||
}
|
||||
app.docs.quitting = None;
|
||||
app.quit_requested = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is any open design unsaved (the web page asks before it is left)?
|
||||
pub fn any_dirty(app: &SolveApp) -> bool {
|
||||
(0..app.docs.count()).any(|i| dirty(app, i))
|
||||
}
|
||||
|
||||
fn dirty(app: &SolveApp, i: usize) -> bool {
|
||||
if i == app.docs.active || app.docs.slots.is_empty() {
|
||||
return app.session.is_dirty();
|
||||
@@ -236,18 +277,28 @@ pub fn tabs_info(app: &SolveApp) -> Vec<(String, bool)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The control channel's `ui.documents`: list, `new`, `switch` or `close` (by index; `force`
|
||||
/// closes without asking).
|
||||
/// The control channel's `ui.documents`: list, `new`, `switch`, `close` (by index; `force`
|
||||
/// closes without asking) or `quit`; `answer`: save | dont_save | cancel answers "save changes?".
|
||||
pub fn control(app: &mut SolveApp, p: &Value) -> Value {
|
||||
let i = p.get("index").and_then(Value::as_u64).map(|x| x as usize);
|
||||
match p.get("action").and_then(Value::as_str) {
|
||||
Some("new") => new_design(app),
|
||||
Some("switch") => switch(app, i.unwrap_or(app.docs.active)),
|
||||
Some("close") => close(app, i.unwrap_or(app.docs.active), p.get("force").and_then(Value::as_bool).unwrap_or(false)),
|
||||
Some("quit") => request_quit(app),
|
||||
_ => {}
|
||||
}
|
||||
let answer = match p.get("answer").and_then(Value::as_str) {
|
||||
Some("save") => Some(Answer::Save),
|
||||
Some("dont_save") => Some(Answer::DontSave),
|
||||
Some("cancel") => Some(Answer::Cancel),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(a) = answer {
|
||||
answer_prompt(app, a);
|
||||
}
|
||||
let tabs: Vec<Value> = tabs_info(app).into_iter().map(|(name, dirty)| json!({"name": name, "dirty": dirty})).collect();
|
||||
json!({"documents": tabs, "active": app.docs.active, "closing": app.docs.closing})
|
||||
json!({"documents": tabs, "active": app.docs.active, "closing": app.docs.closing, "quitting": app.docs.quitting.is_some()})
|
||||
}
|
||||
|
||||
/// The document tabs in the application bar, from `x0` to `x1`: click to switch, × to close,
|
||||
@@ -375,50 +426,81 @@ pub fn layout(n: usize, active: usize, room: f32) -> (usize, usize, f32) {
|
||||
(first, fit, MIN_TAB)
|
||||
}
|
||||
|
||||
/// The "save changes?" prompt for a tab being closed.
|
||||
/// What "save changes?" was answered.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Answer {
|
||||
Save,
|
||||
DontSave,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
/// The "save changes?" prompt for a tab being closed, or for each unsaved design when quitting.
|
||||
pub fn prompt(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
quit_step(app);
|
||||
let Some(i) = app.docs.closing else { return };
|
||||
let name = tabs_info(app).get(i).map(|x| x.0.clone()).unwrap_or_default();
|
||||
let mut answer: Option<u8> = None;
|
||||
crate::frame::window(ctx, "Save changes?", crate::frame::Width::Normal)
|
||||
let mut answer: Option<Answer> = None;
|
||||
crate::frame::window(ctx, format!("Save changes to {name}?"), crate::frame::Width::Normal)
|
||||
.id(egui::Id::new("sc_close_prompt"))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0))
|
||||
.show(ctx, |ui| {
|
||||
ui.label(format!("{name} has unsaved changes."));
|
||||
ui.label(format!("{name} has unsaved changes. If you don't save, they are lost."));
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Save").clicked() {
|
||||
answer = Some(0);
|
||||
answer = Some(Answer::Save);
|
||||
}
|
||||
if ui.button("Don't Save").clicked() {
|
||||
answer = Some(1);
|
||||
answer = Some(Answer::DontSave);
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
answer = Some(2);
|
||||
answer = Some(Answer::Cancel);
|
||||
}
|
||||
});
|
||||
});
|
||||
if ctx.input(|x| x.key_pressed(egui::Key::Escape)) {
|
||||
answer = Some(2);
|
||||
answer = Some(Answer::Cancel);
|
||||
app.esc_handled = true;
|
||||
}
|
||||
if let Some(a) = answer {
|
||||
answer_prompt(app, a);
|
||||
}
|
||||
}
|
||||
|
||||
/// Act on the "save changes?" answer (also the control channel's `ui.documents {answer}`).
|
||||
pub fn answer_prompt(app: &mut SolveApp, answer: Answer) {
|
||||
let Some(i) = app.docs.closing else { return };
|
||||
match answer {
|
||||
Some(0) => {
|
||||
Answer::Save => {
|
||||
switch(app, i);
|
||||
crate::toolbar::save(app);
|
||||
// Saved (not cancelled in the file picker): close it.
|
||||
if !app.session.is_dirty() {
|
||||
let saved = !app.session.is_dirty();
|
||||
app.docs.closing = None;
|
||||
if app.docs.quitting.is_some() {
|
||||
// Quit only once it is saved; a cancelled picker or a failed save stays open.
|
||||
if !saved {
|
||||
app.docs.quitting = None;
|
||||
}
|
||||
} else if saved {
|
||||
close(app, app.docs.active, true);
|
||||
} else {
|
||||
app.docs.closing = None;
|
||||
}
|
||||
}
|
||||
Some(1) => close(app, i, true),
|
||||
Some(2) => app.docs.closing = None,
|
||||
_ => {}
|
||||
Answer::DontSave => {
|
||||
if let Some(skip) = app.docs.quitting.as_mut() {
|
||||
skip.push(i);
|
||||
app.docs.closing = None;
|
||||
} else {
|
||||
close(app, i, true);
|
||||
}
|
||||
}
|
||||
Answer::Cancel => {
|
||||
app.docs.closing = None;
|
||||
app.docs.quitting = None;
|
||||
}
|
||||
}
|
||||
quit_step(app);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -473,6 +555,72 @@ mod tests {
|
||||
assert!(active_is_blank(&a) && a.home.open);
|
||||
}
|
||||
|
||||
/// Quitting asks once per design with unsaved changes: Don't Save moves on (and keeps no
|
||||
/// recovery copy), Save saves and moves on, and the app quits after the last answer.
|
||||
#[test]
|
||||
fn quit_asks_about_each_unsaved_design_then_quits() {
|
||||
let mut a = app();
|
||||
request_quit(&mut a);
|
||||
assert!(a.quit_requested, "nothing unsaved: quits at once");
|
||||
|
||||
let mut a = app();
|
||||
let file = std::env::temp_dir().join(format!("solvecraft-quit-save-{}.solvecraft", std::process::id()));
|
||||
let _ = std::fs::remove_file(&file);
|
||||
a.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap();
|
||||
a.run("file.save_as", json!({"path": file.to_string_lossy()})).unwrap();
|
||||
a.run("solid.sphere", json!({"diameter": 4})).unwrap();
|
||||
new_design(&mut a);
|
||||
a.run("solid.sphere", json!({"diameter": 8})).unwrap();
|
||||
assert!(dirty(&a, 0) && dirty(&a, 1));
|
||||
|
||||
request_quit(&mut a);
|
||||
assert!(!a.quit_requested);
|
||||
assert_eq!(a.docs.closing, Some(0), "the first unsaved design is asked about first");
|
||||
assert_eq!(a.docs.active, 0, "and shown");
|
||||
answer_prompt(&mut a, Answer::Save);
|
||||
assert!(!dirty(&a, 0), "saved");
|
||||
assert_eq!(
|
||||
std::fs::read(&file).ok().and_then(|b| solvecraft_engine::io::read_design(&b).ok()).map(|d| d.features.len()),
|
||||
Some(2),
|
||||
"the file holds the change"
|
||||
);
|
||||
assert_eq!(a.docs.closing, Some(1), "then the next one");
|
||||
assert!(!a.quit_requested);
|
||||
answer_prompt(&mut a, Answer::DontSave);
|
||||
assert_eq!(a.docs.closing, None);
|
||||
assert!(a.quit_requested, "every design answered: quit");
|
||||
assert!(!any_dirty(&a), "Don't Save discards: nothing is kept for recovery");
|
||||
let _ = std::fs::remove_file(&file);
|
||||
}
|
||||
|
||||
/// Cancel, Esc's answer, or a Save that saves nothing (a never-saved design whose Save As
|
||||
/// picker is cancelled) stay open with the changes.
|
||||
#[test]
|
||||
fn quit_cancelled_or_unsaved_stays_open() {
|
||||
let mut a = app();
|
||||
a.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap();
|
||||
request_quit(&mut a);
|
||||
assert_eq!(a.docs.closing, Some(0));
|
||||
answer_prompt(&mut a, Answer::Cancel);
|
||||
assert!(!a.quit_requested && a.docs.closing.is_none() && a.docs.quitting.is_none());
|
||||
assert!(a.session.is_dirty(), "the changes are still there");
|
||||
|
||||
// No Save As picker answer (the default services have none): not saved, so no quit.
|
||||
request_quit(&mut a);
|
||||
answer_prompt(&mut a, Answer::Save);
|
||||
assert!(!a.quit_requested && a.docs.quitting.is_none() && a.session.is_dirty());
|
||||
|
||||
// A picked file: saved, then quit.
|
||||
let file = std::env::temp_dir().join(format!("solvecraft-quit-saveas-{}.solvecraft", std::process::id()));
|
||||
let _ = std::fs::remove_file(&file);
|
||||
let to = file.to_string_lossy().to_string();
|
||||
a.services.pick_save = Some(Box::new(move |_: &str, _: &[&str]| Some(to.clone())));
|
||||
request_quit(&mut a);
|
||||
answer_prompt(&mut a, Answer::Save);
|
||||
assert!(a.quit_requested && file.exists());
|
||||
let _ = std::fs::remove_file(&file);
|
||||
}
|
||||
|
||||
/// A design built elsewhere replaces a blank one and gets a tab otherwise.
|
||||
#[test]
|
||||
fn adopt_reuses_a_blank_design() {
|
||||
|
||||
@@ -738,6 +738,11 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> {
|
||||
return Err(format!("start page open: got {}, want {v}", ui["home"]));
|
||||
}
|
||||
}
|
||||
"quit_requested" | "quitting" => {
|
||||
if ui[k] != *v {
|
||||
return Err(format!("{k}: got {}, want {v}", ui[k]));
|
||||
}
|
||||
}
|
||||
"documents" => {
|
||||
if ui["documents"] != *v {
|
||||
return Err(format!("open designs: got {}, want {v}", ui["documents"]));
|
||||
|
||||
@@ -57,8 +57,11 @@ pub fn window_action(app: &mut SolveApp, ctx: &egui::Context, action: &str) -> R
|
||||
"restore" => ctx.send_viewport_cmd(ViewportCommand::Maximized(false)),
|
||||
"toggle" => toggle_maximize(ctx),
|
||||
"close" => {
|
||||
app.quit_requested = true;
|
||||
ctx.send_viewport_cmd(ViewportCommand::Close);
|
||||
// Unsaved designs are asked about first; the window closes once they are answered.
|
||||
crate::documents::request_quit(app);
|
||||
if app.quit_requested {
|
||||
ctx.send_viewport_cmd(ViewportCommand::Close);
|
||||
}
|
||||
}
|
||||
other => return Err(format!("unknown window action `{other}` (minimize, maximize, restore, toggle, close)")),
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
close = true;
|
||||
}
|
||||
if item(ui, "Quit", "") {
|
||||
app.quit_requested = true;
|
||||
crate::documents::request_quit(app);
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[
|
||||
{"note": "Issue #33: quitting with unsaved changes asks \"Save changes to <name>?\"; Cancel stays open."},
|
||||
{"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 20, "width": 10, "height": 5}}},
|
||||
{"call": "ui.window", "params": {"action": "close"}},
|
||||
{"expect": {"shown": "Don't Save", "quitting": true, "quit_requested": false}},
|
||||
{"widget": "Cancel"},
|
||||
{"expect": {"not_shown": "Don't Save", "quitting": false, "quit_requested": false, "bodies": 1}},
|
||||
{"note": "The File menu's Quit asks the same; Esc answers Cancel."},
|
||||
{"call": "app.quit"},
|
||||
{"expect": {"shown": "Don't Save", "quit_requested": false}},
|
||||
{"key": "Escape"},
|
||||
{"expect": {"not_shown": "Don't Save", "quitting": false, "quit_requested": false}},
|
||||
{"call": "app.quit"},
|
||||
{"widget": "Don't Save"},
|
||||
{"expect": {"quit_requested": true}}
|
||||
]
|
||||
@@ -45,15 +45,15 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints
|
||||
| `ui.screenshot` | `path?` | PNG of the window (needs a presented frame) |
|
||||
| `ui.render` | `path`, `width?`, `height?` | CPU render of the model with the current camera (no window needed) |
|
||||
| `ui.confirm` | `accept?`: bool | answers the Delete confirmation (shown when a delete takes other features with it or makes some fail); without `accept`, what it lists |
|
||||
| `ui.documents` | `action?`: `new`, `switch`, `close`; `index?`; `force?` (close without asking) | the open designs (tabs): names, unsaved flags, the active one, a pending "save changes?" prompt |
|
||||
| `ui.documents` | `action?`: `new`, `switch`, `close`, `quit`; `index?`; `force?` (close without asking); `answer?`: `save`, `dont_save`, `cancel` (answers "save changes?") | the open designs (tabs): names, unsaved flags, the active one, a pending "save changes?" prompt, whether a quit is asking |
|
||||
| `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs |
|
||||
| `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) |
|
||||
| `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (copies diagnostics) | the version, commit, build date and diagnostic text |
|
||||
| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`), `zoom_reverse`, `orbit_cursor`, `msaa`, `length_decimals`, `angle_decimals` | sets preferences (kept between runs), returns them all |
|
||||
| `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits |
|
||||
| `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar) |
|
||||
| `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first |
|
||||
| `ui.resize` | `width`, `height` | |
|
||||
| `app.quit` | — | |
|
||||
| `app.quit` | `force?` (quit without asking about unsaved designs) | `quitting` (the window closes), `asking` (a "save changes?" prompt is open) |
|
||||
|
||||
## MCP
|
||||
|
||||
|
||||
Reference in new issue
Block a user