From fec99468d214287c55347076af5bcfbb326ebec8 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 12:40:28 +0000 Subject: [PATCH] Capture Position question: starting a command or saving while components were moved without capturing asks Capture Position / Revert / Cancel, then goes ahead; ui.capture control method Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/ui-egui/src/browser.rs | 77 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/control.rs | 8 ++++ crates/ui-egui/src/lib.rs | 5 +++ crates/ui-egui/src/menu_tests.rs | 27 +++++++++++ crates/ui-egui/src/toolbar.rs | 4 ++ docs/control-protocol.md | 1 + 6 files changed, 122 insertions(+) diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index b5fb4ae0..2e46f54b 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -36,6 +36,8 @@ pub struct TreeState { pub picked_canvases: Vec, /// The Edit Canvas / Calibrate panel. pub canvas_panel: Option, + /// What waits for the Capture Position question: a command id, or [`SAVE`]. + pub capture_prompt: Option, /// The last row clicked (for Shift ranges). anchor: Option, /// Row keys in drawing order this frame (for Shift ranges). @@ -446,6 +448,7 @@ pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) { crate::context_menu::open_for(app, at, target); } occurrence_panel(app, ui.ctx()); + capture_prompt(app, ui.ctx()); canvas_panel(app, ui.ctx()); redefine_panel(app, ui.ctx()); } @@ -1430,6 +1433,80 @@ fn panel_pos(app: &SolveApp) -> Pos2 { app.viewport.rect.map(|r| r.left_top() + vec2(16.0, 16.0)).unwrap_or(pos2(270.0, 140.0)) } +/// The Capture Position question stands for saving. +pub const SAVE: &str = "__save"; + +/// Does starting `what` (a command id or [`SAVE`]) need the Capture Position question first? +/// Yes while components were moved without capturing their positions, except for the commands +/// that capture, revert or keep moving. +pub fn needs_capture(app: &SolveApp, what: &str) -> bool { + !app.session.pending_moves.is_empty() + && !matches!(what, "SnapshotCmd" | "AsBuiltPositionsCmd" | "occurrence.move" | "UndoCommand" | "RedoCommand") + && app.tree.capture_prompt.is_none() +} + +/// Answer the Capture Position question: `capture`, `revert` or `cancel`. The waiting command +/// (or save) then goes ahead, unless cancelled. +pub fn answer_capture(app: &mut SolveApp, action: &str) -> Result<(), String> { + let Some(what) = app.tree.capture_prompt.clone() else { return Err("no Capture Position question is open".into()) }; + match action { + "capture" => drop(app.run("SnapshotCmd", json!({}))), + "revert" => drop(app.run("AsBuiltPositionsCmd", json!({}))), + "cancel" => { + app.tree.capture_prompt = None; + return Ok(()); + } + other => return Err(format!("unknown answer `{other}` (capture, revert, cancel)")), + } + app.tree.capture_prompt = None; + app.tree.occurrence_move = None; + if what == SAVE { + crate::toolbar::save(app); + } else { + app.start(&what); + } + Ok(()) +} + +fn capture_prompt(app: &mut SolveApp, ctx: &egui::Context) { + if app.tree.capture_prompt.is_none() { + return; + } + let n = app.session.pending_moves.len(); + let mut answer: Option<&str> = None; + egui::Window::new("Capture Position") + .id(egui::Id::new("sc_capture_prompt")) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, vec2(0.0, -80.0)) + .show(ctx, |ui| { + ui.label(if n == 1 { + "A component was moved but its position isn't captured.".to_string() + } else { + format!("{n} components were moved but their positions aren't captured.") + }); + ui.label(RichText::new("Capture keeps the new position; Revert puts it back.").color(Tokens::get().text_dim)); + ui.add_space(6.0); + ui.horizontal(|ui| { + if ui.button("Capture Position").clicked() { + answer = Some("capture"); + } + if ui.button("Revert").clicked() { + answer = Some("revert"); + } + if ui.button("Cancel").clicked() { + answer = Some("cancel"); + } + }); + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + answer = Some("cancel"); + } + }); + if let Some(a) = answer { + let _ = answer_capture(app, a); + } +} + /// Where an occurrence's origin is now (with its pending move). fn occurrence_origin(app: &SolveApp, occurrence: u64) -> Option { let o = app.session.doc.occurrences.iter().find(|o| o.id == occurrence)?; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 529fa895..ed5805c6 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -26,6 +26,7 @@ //! - `ui.help {item?: about|shortcuts|report}`: the Help menu; returns the version and diagnostics //! - `ui.prefs {...}`: the preferences (default_units, nav, zoom_reverse, orbit_cursor, msaa, //! length_decimals, angle_decimals); returns them all +//! - `ui.capture {action?: capture|revert|cancel}`: the Capture Position question //! - `ui.resize {width, height}`, `app.quit` use std::sync::mpsc::Sender; @@ -378,6 +379,13 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> None => ok(json!({"command": id, "key": crate::keymap::effective(app, id)})), } } + "ui.capture" => match s("action") { + Some(a) => match crate::browser::answer_capture(app, a) { + Ok(()) => ok(json!({"pending_moves": app.session.pending_moves.len()})), + Err(e) => err(e), + }, + None => ok(json!({"question": app.tree.capture_prompt, "pending_moves": app.session.pending_moves.len()})), + }, "ui.prefs" => wrap(crate::prefs::set(app, p)), "ui.help" => match s("item") { Some(i @ ("about" | "shortcuts" | "report")) => { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f56ea553..c0b57aea 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -365,6 +365,11 @@ impl SolveApp { /// Start a command the way a toolbar click does: interactive tools and dialogs for commands /// that have them, otherwise run it with defaults. pub fn start(&mut self, id: &str) { + // A component moved without Capture Position: ask first (Capture, Revert or Cancel). + if browser::needs_capture(self, id) { + self.tree.capture_prompt = Some(id.to_string()); + return; + } self.tool = None; self.home.open = false; if matches!(id, "FusionImportCommandFromToolbar" | "ParaMeshInsertAlignCommand") { diff --git a/crates/ui-egui/src/menu_tests.rs b/crates/ui-egui/src/menu_tests.rs index 8660f962..b0061e61 100644 --- a/crates/ui-egui/src/menu_tests.rs +++ b/crates/ui-egui/src/menu_tests.rs @@ -416,3 +416,30 @@ fn units_menu_changes_the_design_units_and_new_designs_take_the_preference() { assert_eq!(app.session.doc.units, "cm"); assert!(!app.session.is_dirty(), "a fresh design stays unmodified"); } + +#[test] +fn an_uncaptured_move_asks_before_the_next_command() { + let mut app = sample_app(); + let b = body(&app); + app.run("FusionCreateComponentsFromBodiesCommand", json!({"bodies": [b]})).unwrap(); + let occ = app.session.doc.occurrences[0].id; + app.run("occurrence.move", json!({"occurrence": occ, "translate": [10, 0, 0], "capture": false})).unwrap(); + app.start("SketchCreate"); + assert_eq!(app.tree.capture_prompt.as_deref(), Some("SketchCreate"), "asks first"); + assert!(app.dialog.is_none()); + crate::browser::answer_capture(&mut app, "cancel").unwrap(); + assert!(app.tree.capture_prompt.is_none() && !app.session.pending_moves.is_empty()); + // Revert, then the command goes ahead. + app.start("SketchCreate"); + crate::browser::answer_capture(&mut app, "revert").unwrap(); + assert!(app.session.pending_moves.is_empty()); + assert!(app.dialog.is_some(), "Create Sketch started"); + assert_eq!(app.session.doc.occurrences[0].transform, solvecraft_engine::doc::IDENTITY); + // Capture keeps the move. + app.dialog = None; + app.run("occurrence.move", json!({"occurrence": occ, "translate": [10, 0, 0], "capture": false})).unwrap(); + app.start("Extrude"); + crate::browser::answer_capture(&mut app, "capture").unwrap(); + assert_eq!(app.session.doc.occurrences[0].transform[3][0], 10.0); + assert!(crate::browser::answer_capture(&mut app, "capture").is_err(), "nothing waits now"); +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index e48a8daf..12004472 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -206,6 +206,10 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) { } pub fn save(app: &mut SolveApp) { + if crate::browser::needs_capture(app, crate::browser::SAVE) { + app.tree.capture_prompt = Some(crate::browser::SAVE.into()); + return; + } if app.session.path.is_some() { let _ = app.run("SaveDocumentCommand", json!({})); } else { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4c62cd5f..24d3feb9 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -49,6 +49,7 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints | `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.resize` | `width`, `height` | | | `app.quit` | — | |