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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 12:52:22 +00:00
1 parent 9bd7c8eb33
commit fec99468d2
6 files changed
+122

No files matched your search

+77
View File
@@ -36,6 +36,8 @@ pub struct TreeState {
pub picked_canvases: Vec<u64>,
/// The Edit Canvas / Calibrate panel.
pub canvas_panel: Option<CanvasPanel>,
/// What waits for the Capture Position question: a command id, or [`SAVE`].
pub capture_prompt: Option<String>,
/// The last row clicked (for Shift ranges).
anchor: Option<String>,
/// 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<solvecraft_engine::geom::Vec3> {
let o = app.session.doc.occurrences.iter().find(|o| o.id == occurrence)?;
+8
View File
@@ -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")) => {
+5
View File
@@ -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") {
+27
View File
@@ -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");
}
+4
View File
@@ -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 {
+1
View File
@@ -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` | — | |