Preferences: one Fusion-style dialog from the gear at the top right and Ctrl+, (Cmd+,)
Pages: General (theme Dark/Light/System, UI scale, units of new designs, autosave, display precision, the agent cursor), Navigation (Fusion, SolidWorks, Inventor, Alias and Tinkercad mouse presets, reverse zoom, zoom to the cursor, orbit centre, view-change animation on/off and speed), Keyboard Shortcuts (the old Shortcuts window as a page: search, rebind, conflicts), Design (default modelling orientation Y up / Z up for #3, and the offer to stand up a Y-up STEP or IGES file: a quarter turn about X as one undoable Move), Graphics, Sketch, Selection. Changes apply at once; Cancel or Esc puts back what was there when the dialog opened, Apply keeps them, OK closes, Restore Defaults resets the page. The theme, ground shadow and agent cursor settings are now kept between runs. File and Help › Keyboard Shortcuts open the page; the unused docked Preferences dialog is gone. Control: ui.prefs {open, button, stand_up, …}. Scenario s45_preferences; tests for Cancel/Apply/defaults and standing a Y-up file up. Fixes #36 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
This commit is contained in:
1 parent
132acc9863
commit
7318b98b8d
12 files changed
+724
-184
No files matched your search
@@ -433,7 +433,7 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
},
|
||||
None => ok(json!({"question": app.tree.capture_prompt, "pending_moves": app.session.pending_moves.len()})),
|
||||
},
|
||||
"ui.prefs" => wrap(crate::prefs::set(app, p)),
|
||||
"ui.prefs" => wrap(crate::prefs::control(app, p)),
|
||||
"ui.help" => match s("item") {
|
||||
Some(i @ ("about" | "shortcuts" | "report")) => {
|
||||
crate::help::run(app, ctx, i);
|
||||
|
||||
@@ -151,8 +151,6 @@ pub enum Kind {
|
||||
Stock {
|
||||
margin: String,
|
||||
},
|
||||
/// Application preferences (applied as they change; kept between runs).
|
||||
Preferences,
|
||||
/// Physical material (and with it the appearance) of bodies.
|
||||
Material {
|
||||
index: usize,
|
||||
@@ -502,10 +500,6 @@ impl Dialog {
|
||||
Some(d)
|
||||
}
|
||||
|
||||
pub fn preferences() -> Dialog {
|
||||
Dialog::new(Kind::Preferences, vec![])
|
||||
}
|
||||
|
||||
pub fn rename(feature: u64, name: &str) -> Dialog {
|
||||
Dialog::new(Kind::Rename { feature, name: name.to_string() }, vec![])
|
||||
}
|
||||
@@ -775,7 +769,6 @@ fn title(k: &Kind) -> &'static str {
|
||||
Kind::Mirror => "MIRROR",
|
||||
Kind::Measure { .. } => "MEASURE",
|
||||
Kind::Material { .. } => "PHYSICAL MATERIAL",
|
||||
Kind::Preferences => "PREFERENCES",
|
||||
Kind::OffsetPlane { .. } => "OFFSET PLANE",
|
||||
Kind::Rib { web: false, .. } => "RIB",
|
||||
Kind::Rib { web: true, .. } => "WEB",
|
||||
@@ -1136,30 +1129,6 @@ fn update_measure(app: &SolveApp, d: &mut Dialog) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The preferences, applied as they change.
|
||||
fn preferences_rows(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ui.label("Theme");
|
||||
let mut theme = usize::from(!app.ui.dark);
|
||||
combo(ui, "pref_theme", &["Dark", "Light"], &mut theme);
|
||||
app.ui.dark = theme == 0;
|
||||
ui.end_row();
|
||||
ui.label("Grid");
|
||||
ui.checkbox(&mut app.ui.show_grid, "");
|
||||
ui.end_row();
|
||||
ui.label("Perspective view");
|
||||
ui.checkbox(&mut app.ui.perspective, "");
|
||||
ui.end_row();
|
||||
ui.label("Project face edges into new sketches");
|
||||
let mut auto = app.session.auto_project;
|
||||
if ui.checkbox(&mut auto, "").changed() {
|
||||
let _ = app.run("sketch.auto_project", json!({ "value": auto }));
|
||||
}
|
||||
ui.end_row();
|
||||
ui.label("Click selects whole bodies");
|
||||
ui.checkbox(&mut app.ui.pick_bodies, "");
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
fn fmt_mm(v: &Value) -> String {
|
||||
v.as_f64().map(|x| format!("{x:.3} mm")).unwrap_or_default()
|
||||
}
|
||||
@@ -1251,7 +1220,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
.corner_radius(egui::CornerRadius { nw: 4, sw: 4, ne: 0, se: 0 })
|
||||
.shadow(egui::Shadow { offset: [-2, 2], blur: 8, spread: 0, color: Color32::from_black_alpha(40) });
|
||||
// OK is offered once the inputs make a feature.
|
||||
let has_ok = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } | Kind::Preferences);
|
||||
let has_ok = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. });
|
||||
let valid = !has_ok || !d.previews() || (apply_commands(app, &d).is_ok() && app.preview.error.is_none());
|
||||
let width = if wide { crate::frame::Width::Wide } else { crate::frame::Width::Normal };
|
||||
let wkey = egui::Id::new(("sc_dialog_w", wide));
|
||||
@@ -1490,7 +1459,6 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
ui.end_row();
|
||||
}
|
||||
Kind::Measure { result, .. } => measure_rows(ui, result.as_ref()),
|
||||
Kind::Preferences => preferences_rows(app, ui),
|
||||
Kind::OffsetPlane { offset } => {
|
||||
row_label(ui, "Distance");
|
||||
enter |= field(ui, offset);
|
||||
@@ -1811,7 +1779,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
ok = true;
|
||||
}
|
||||
}
|
||||
let close = if matches!(d.kind, Kind::Params { .. } | Kind::Measure { .. } | Kind::Preferences) { "Close" } else { "Cancel" };
|
||||
let close = if matches!(d.kind, Kind::Params { .. } | Kind::Measure { .. }) { "Close" } else { "Cancel" };
|
||||
if ui.add(egui::Button::new(close).min_size(vec2(64.0, 24.0))).clicked() {
|
||||
cancel = true;
|
||||
}
|
||||
@@ -1824,7 +1792,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let nothing_focused = ctx.memory(|m| m.focused().is_none());
|
||||
let canvas_enter = enter_free && ctx.memory(|m| m.had_focus_last_frame(egui::Id::new("sc_canvas_value")));
|
||||
// Tables (configurations, a study's keys) take Enter for their cells.
|
||||
let enter_applies = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } | Kind::ConfirmDelete { .. } | Kind::Preferences)
|
||||
let enter_applies = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } | Kind::ConfirmDelete { .. })
|
||||
&& !matches!(&d.kind, Kind::Motion(k) if k.wide());
|
||||
if enter_applies && (enter || canvas_enter || (enter_free && nothing_focused)) {
|
||||
ok = true;
|
||||
@@ -2533,7 +2501,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
Kind::EditParam { name, expr } => ("parameters.change", json!({"name": name, "expression": expr})),
|
||||
Kind::Rename { feature, name } => ("timeline.rename", json!({"feature": feature, "name": name})),
|
||||
Kind::ConfirmDelete { feature, .. } => ("timeline.delete", json!({ "features": [feature.to_string()] })),
|
||||
Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } | Kind::Preferences => return Ok(Vec::new()),
|
||||
Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } => return Ok(Vec::new()),
|
||||
Kind::Assembly(k) => return crate::dialogs_assembly::commands(app, k, &d.inputs),
|
||||
Kind::Sheet(k) => return crate::dialogs_sheet::commands(app, k, &d.inputs, &d.extra),
|
||||
Kind::Surface(k) => return crate::dialogs_surface::commands(app, k, &d.inputs, &d.extra),
|
||||
|
||||
@@ -13,13 +13,14 @@ use crate::SolveApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// Keys the application handles before any command shortcut.
|
||||
pub const RESERVED: &[&str] = &["Escape", "S", "V", "F2", "F6", "Delete", "Backspace", "Ctrl+Z", "Ctrl+Y", "Ctrl+S", "Ctrl+N", "Ctrl+O"];
|
||||
pub const RESERVED: &[&str] =
|
||||
&["Escape", "S", "V", "F2", "F6", "Delete", "Backspace", "Ctrl+Z", "Ctrl+Y", "Ctrl+S", "Ctrl+N", "Ctrl+O", "Ctrl+Comma"];
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Keymap {
|
||||
/// Command id → key (`""`: no key). Only the changed ones.
|
||||
pub custom: BTreeMap<String, String>,
|
||||
/// The Keyboard Shortcuts window.
|
||||
/// Asks for the Keyboard Shortcuts page of Preferences (File and Help menus).
|
||||
pub open: bool,
|
||||
/// Opened from Help: a list to read, not to edit.
|
||||
pub read_only: bool,
|
||||
@@ -113,11 +114,10 @@ pub fn reset(app: &mut SolveApp, id: Option<&str>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Keyboard Shortcuts window.
|
||||
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if !app.keymap.open {
|
||||
return;
|
||||
}
|
||||
/// The Keyboard Shortcuts page of Preferences: search, rebind (click, then press the key), clear,
|
||||
/// reset; a key another command uses is reported and taken over only on request.
|
||||
pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
// A key press while waiting for one binds it (Esc cancels).
|
||||
if let Some(id) = app.keymap.capturing.clone() {
|
||||
let press = ctx.input(|i| {
|
||||
@@ -142,13 +142,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let mut open = true;
|
||||
let mut action: Option<(String, &str)> = None;
|
||||
crate::frame::window(ctx, "Keyboard Shortcuts", crate::frame::Width::Wide)
|
||||
.id(egui::Id::new("sc_keymap"))
|
||||
.open(&mut open)
|
||||
.default_size([520.0, 480.0])
|
||||
.show(ctx, |ui| {
|
||||
{
|
||||
{
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(egui::TextEdit::singleline(&mut app.keymap.filter).hint_text("Filter commands or keys").desired_width(300.0));
|
||||
if !app.keymap.read_only && ui.button("Reset All").clicked() {
|
||||
@@ -170,7 +166,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
ui.separator();
|
||||
let q = app.keymap.filter.to_lowercase();
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
egui::ScrollArea::both().max_height(330.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
egui::Grid::new("sc_keymap_grid").num_columns(4).striped(true).spacing([10.0, 4.0]).show(ui, |ui| {
|
||||
for c in command_specs() {
|
||||
let key = effective(app, c.id).unwrap_or_default();
|
||||
@@ -193,8 +189,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
ui.end_row();
|
||||
continue;
|
||||
}
|
||||
let b =
|
||||
egui::Button::new(RichText::new(text).color(if changed { t.accent } else { t.text })).min_size(egui::vec2(110.0, 0.0));
|
||||
let b = egui::Button::new(RichText::new(text).color(if changed { t.accent } else { t.text })).min_size(egui::vec2(80.0, 0.0));
|
||||
if ui.add(b).on_hover_text("Click, then press the new key").clicked() {
|
||||
action = Some((c.id.to_string(), "capture"));
|
||||
}
|
||||
@@ -210,7 +205,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
match action {
|
||||
Some((id, "capture")) => {
|
||||
app.keymap.capturing = Some(id);
|
||||
@@ -233,15 +229,18 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if !open {
|
||||
app.keymap.open = false;
|
||||
app.keymap.capturing = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the window waiting for a key (so the key must not also run a command)?
|
||||
/// Stop waiting for a key (the dialog closed).
|
||||
pub fn stop_capture(app: &mut SolveApp) {
|
||||
app.keymap.capturing = None;
|
||||
app.keymap.conflict = None;
|
||||
app.keymap.message = None;
|
||||
}
|
||||
|
||||
/// Is the page waiting for a key (so the key must not also run a command)?
|
||||
pub fn capturing(app: &SolveApp) -> bool {
|
||||
app.keymap.open && app.keymap.capturing.is_some()
|
||||
app.prefs_window.open && app.prefs_window.section == crate::prefs::PAGE_SHORTCUTS && app.keymap.capturing.is_some()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -54,6 +54,7 @@ pub mod sketch_dims;
|
||||
mod sketch_edit_tests;
|
||||
pub mod sketch_palette;
|
||||
pub mod sketch_tools;
|
||||
pub mod stand_up;
|
||||
pub mod theme;
|
||||
pub mod timeline;
|
||||
pub mod titlebar;
|
||||
@@ -194,6 +195,8 @@ pub struct SolveApp {
|
||||
pub autosave: Option<solvecraft_engine::recovery::Autosaver>,
|
||||
/// Designs left by crashed apps, offered for recovery.
|
||||
pub recoverable: Vec<solvecraft_engine::recovery::Entry>,
|
||||
/// The bodies of a just-opened Y-up file, offered to be stood up (`stand_up`).
|
||||
pub stand_up: Option<Vec<String>>,
|
||||
/// The last command was a big operation: autosave after it.
|
||||
pub big_operation: bool,
|
||||
/// Autosave interval (minutes; 0 = only after big operations). A preference.
|
||||
@@ -250,6 +253,7 @@ impl SolveApp {
|
||||
quit_requested: false,
|
||||
autosave: None,
|
||||
recoverable: Vec::new(),
|
||||
stand_up: None,
|
||||
big_operation: false,
|
||||
autosave_minutes: 5.0,
|
||||
custom_titlebar: false,
|
||||
@@ -292,6 +296,8 @@ impl SolveApp {
|
||||
"shortcut_box": self.sbox.prefs(),
|
||||
"shortcuts": self.keymap.prefs(),
|
||||
"autosave_minutes": self.autosave_minutes,
|
||||
"ground_shadow": self.ui.ground_shadow,
|
||||
"agent_cursor": self.ui.agent_cursor,
|
||||
"preferences": self.preferences,
|
||||
})
|
||||
.to_string()
|
||||
@@ -345,6 +351,16 @@ impl SolveApp {
|
||||
if let Some(p) = v.get("preferences").and_then(|p| serde_json::from_value::<prefs::Prefs>(p.clone()).ok()) {
|
||||
self.preferences = p;
|
||||
}
|
||||
// Saved before the theme was a preference: the Dark/Light flag says which.
|
||||
if v.get("preferences").and_then(|p| p.get("theme")).is_none() {
|
||||
self.preferences.theme = if self.ui.dark { "dark" } else { "light" }.into();
|
||||
}
|
||||
if let Some(g) = flag("ground_shadow") {
|
||||
self.ui.ground_shadow = g;
|
||||
}
|
||||
if let Some(a) = v.get("agent_cursor").and_then(|a| serde_json::from_value(a.clone()).ok()) {
|
||||
self.ui.agent_cursor = a;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> Self {
|
||||
@@ -564,6 +580,7 @@ impl SolveApp {
|
||||
documents::prepare_open(self);
|
||||
if self.run("doc.open", json!({ "path": path })).is_ok() {
|
||||
self.fit_view();
|
||||
stand_up::offer(self, path, &[]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,8 +588,10 @@ impl SolveApp {
|
||||
/// current design.
|
||||
pub fn insert_path(&mut self, path: &str) {
|
||||
let cmd = if solvecraft_engine::io::is_mesh_path(path) { "file.insert_mesh" } else { "file.insert_step" };
|
||||
let before: Vec<String> = self.session.world_state().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
if let Ok(r) = self.run(cmd, json!({ "path": path })) {
|
||||
self.fit_view();
|
||||
stand_up::offer(self, path, &before);
|
||||
if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) {
|
||||
self.set_status(format!("imported with {} warning(s): {}", w.len(), w.first().and_then(Value::as_str).unwrap_or("")), false);
|
||||
}
|
||||
@@ -623,7 +642,18 @@ impl SolveApp {
|
||||
if !to.is_valid() {
|
||||
return;
|
||||
}
|
||||
self.cam_anim = Some(CameraAnim::new(self.cam, to, self.now));
|
||||
// Preferences › Navigation: animated (at a chosen speed) or immediate.
|
||||
if !self.preferences.look_at_anim {
|
||||
self.cam = to;
|
||||
self.cam_anim = None;
|
||||
return;
|
||||
}
|
||||
let mut anim = CameraAnim::new(self.cam, to, self.now);
|
||||
let speed = f64::from(self.preferences.look_at_speed);
|
||||
if speed.is_finite() && speed > 0.0 {
|
||||
anim.duration /= speed;
|
||||
}
|
||||
self.cam_anim = Some(anim);
|
||||
}
|
||||
|
||||
/// Animated view changes for the view cube and navigation bar.
|
||||
@@ -666,6 +696,7 @@ impl SolveApp {
|
||||
/// Per-frame logic before layout.
|
||||
pub fn logic(&mut self, ctx: &egui::Context) {
|
||||
self.esc_handled = false;
|
||||
prefs::apply(self, ctx);
|
||||
if !self.styled || theme::is_dark() != self.ui.dark {
|
||||
theme::set_dark(self.ui.dark);
|
||||
theme::apply(ctx);
|
||||
@@ -753,13 +784,13 @@ impl SolveApp {
|
||||
sketch_palette::show(self, ui.ctx());
|
||||
params_dialog::show(self, ui.ctx());
|
||||
recovery_ui::show(self, ui.ctx());
|
||||
stand_up::show(self, ui.ctx());
|
||||
context_menu::show(self, ui.ctx());
|
||||
delete::show(self, ui.ctx());
|
||||
if self.custom_titlebar {
|
||||
titlebar::resize_zones(ui);
|
||||
}
|
||||
shortcut_box::show(self, ui.ctx());
|
||||
keymap::show(self, ui.ctx());
|
||||
prefs::show(self, ui.ctx());
|
||||
agent_cursor::paint(self, ui.ctx());
|
||||
self.frame_ms = now_ms() - t0;
|
||||
|
||||
+541
-118
@@ -1,7 +1,12 @@
|
||||
//! Preferences (File › Preferences), in the spirit of Fusion's: General (theme, units of new
|
||||
//! designs, autosave interval), Navigation (mouse mapping preset, zoom direction, orbit centre), Display
|
||||
//! (grid, perspective, visual style with or without edges, display precision, anti-aliasing), Sketch and Selection. Everything
|
||||
//! applies at once and is kept between runs, except anti-aliasing, which needs a restart.
|
||||
//! Preferences, one dialog in the spirit of Fusion's, opened from the gear at the top right of the
|
||||
//! window, File › Preferences or Ctrl+, (Cmd+, on macOS): General (theme, UI scale, units of new
|
||||
//! designs, autosave, display precision, the agent cursor), Navigation (mouse presets, zoom
|
||||
//! direction, zoom to the cursor, orbit centre, Look At animation), Keyboard Shortcuts (search,
|
||||
//! rebind, conflicts; `keymap`), Design (default modelling orientation, Y up or Z up), Graphics
|
||||
//! (visual style, grid, shadow, perspective, anti-aliasing), Sketch and Selection. Changes apply
|
||||
//! at once; Cancel puts back what was there when the dialog opened, OK keeps them, Apply keeps
|
||||
//! them and stays open, and Restore Defaults resets the page shown. Everything is kept between
|
||||
//! runs, except anti-aliasing, which needs a restart.
|
||||
|
||||
use egui::{Modifiers, Pos2, RichText};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -14,7 +19,13 @@ use crate::viewport::NavMode;
|
||||
|
||||
pub const UNITS: [&str; 5] = ["mm", "cm", "m", "in", "ft"];
|
||||
/// Mouse mappings: (id, label).
|
||||
pub const NAV_PRESETS: [(&str, &str); 3] = [("fusion", "Fusion"), ("solidworks", "SolidWorks"), ("inventor", "Inventor")];
|
||||
pub const NAV_PRESETS: [(&str, &str); 5] =
|
||||
[("fusion", "Fusion"), ("solidworks", "SolidWorks"), ("inventor", "Inventor"), ("alias", "Alias"), ("tinkercad", "Tinkercad")];
|
||||
/// The dialog's pages, in order.
|
||||
pub const PAGES: [&str; 7] = ["General", "Navigation", "Keyboard Shortcuts", "Design", "Graphics", "Sketch", "Selection"];
|
||||
pub const PAGE_SHORTCUTS: usize = 2;
|
||||
pub const PAGE_DESIGN: usize = 3;
|
||||
pub const THEMES: [(&str, &str); 3] = [("dark", "Dark"), ("light", "Light"), ("system", "System")];
|
||||
pub const MSAA: [u32; 4] = [1, 2, 4, 8];
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -33,6 +44,19 @@ pub struct Prefs {
|
||||
/// Decimals shown for lengths and angles.
|
||||
pub length_decimals: u8,
|
||||
pub angle_decimals: u8,
|
||||
/// `dark`, `light` or `system` (follow the operating system).
|
||||
pub theme: String,
|
||||
/// Interface scale (1 = 100 %).
|
||||
pub ui_scale: f32,
|
||||
/// The wheel zooms toward the point under the cursor (else toward the view's centre).
|
||||
pub zoom_to_cursor: bool,
|
||||
/// View changes (view cube, Look At, Home) animate; `look_at_speed` scales how fast.
|
||||
pub look_at_anim: bool,
|
||||
pub look_at_speed: f32,
|
||||
/// Default modelling orientation: `z` (Z up) or `y` (Y up) (#3).
|
||||
pub up_axis: String,
|
||||
/// After a STEP or IGES file opens, offer to stand a Y-up part up (`stand_up`).
|
||||
pub offer_stand_up: bool,
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
@@ -45,6 +69,13 @@ impl Default for Prefs {
|
||||
msaa: 4,
|
||||
length_decimals: 2,
|
||||
angle_decimals: 1,
|
||||
theme: "dark".into(),
|
||||
ui_scale: 1.0,
|
||||
zoom_to_cursor: true,
|
||||
look_at_anim: true,
|
||||
look_at_speed: 1.0,
|
||||
up_axis: "z".into(),
|
||||
offer_stand_up: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,7 +84,127 @@ impl Default for Prefs {
|
||||
#[derive(Default)]
|
||||
pub struct PrefsWindow {
|
||||
pub open: bool,
|
||||
section: usize,
|
||||
/// The page shown (index into [`PAGES`]).
|
||||
pub section: usize,
|
||||
/// Everything as it was when the dialog opened (or at the last Apply), for Cancel.
|
||||
snapshot: Option<Snapshot>,
|
||||
}
|
||||
|
||||
/// Open the dialog at a page.
|
||||
pub fn open_at(app: &mut SolveApp, page: usize) {
|
||||
app.prefs_window.open = true;
|
||||
app.prefs_window.section = page.min(PAGES.len() - 1);
|
||||
}
|
||||
|
||||
/// Every setting the dialog changes, for Cancel and Restore Defaults.
|
||||
#[derive(Clone)]
|
||||
struct Snapshot {
|
||||
prefs: Prefs,
|
||||
dark: bool,
|
||||
grid: bool,
|
||||
shadow: bool,
|
||||
perspective: bool,
|
||||
style: u8,
|
||||
pick_bodies: bool,
|
||||
agent: crate::agent_cursor::Settings,
|
||||
autosave: f64,
|
||||
auto_project: bool,
|
||||
keys: std::collections::BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
fn take(app: &SolveApp) -> Snapshot {
|
||||
Snapshot {
|
||||
prefs: app.preferences.clone(),
|
||||
dark: app.ui.dark,
|
||||
grid: app.ui.show_grid,
|
||||
shadow: app.ui.ground_shadow,
|
||||
perspective: app.ui.perspective,
|
||||
style: app.ui.visual_style,
|
||||
pick_bodies: app.ui.pick_bodies,
|
||||
agent: app.ui.agent_cursor,
|
||||
autosave: app.autosave_minutes,
|
||||
auto_project: app.session.auto_project,
|
||||
keys: app.keymap.custom.clone(),
|
||||
}
|
||||
}
|
||||
fn restore(self, app: &mut SolveApp) {
|
||||
let msaa = app.preferences.msaa;
|
||||
app.preferences = self.prefs;
|
||||
app.ui.dark = self.dark;
|
||||
app.ui.show_grid = self.grid;
|
||||
app.ui.ground_shadow = self.shadow;
|
||||
app.ui.perspective = self.perspective;
|
||||
app.ui.visual_style = self.style;
|
||||
app.ui.pick_bodies = self.pick_bodies;
|
||||
app.ui.agent_cursor = self.agent;
|
||||
app.autosave_minutes = self.autosave;
|
||||
set_autosave(app);
|
||||
app.session.auto_project = self.auto_project;
|
||||
app.keymap.custom = self.keys;
|
||||
if app.preferences.msaa != msaa {
|
||||
save_graphics(app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore Defaults for one page.
|
||||
fn defaults(app: &mut SolveApp, page: usize) {
|
||||
let d = Prefs::default();
|
||||
let p = &mut app.preferences;
|
||||
match page {
|
||||
0 => {
|
||||
p.theme = d.theme;
|
||||
p.ui_scale = d.ui_scale;
|
||||
p.default_units = d.default_units;
|
||||
p.length_decimals = d.length_decimals;
|
||||
p.angle_decimals = d.angle_decimals;
|
||||
app.ui.dark = true;
|
||||
app.ui.agent_cursor = Default::default();
|
||||
app.autosave_minutes = 5.0;
|
||||
set_autosave(app);
|
||||
}
|
||||
1 => {
|
||||
p.nav = d.nav;
|
||||
p.zoom_reverse = d.zoom_reverse;
|
||||
p.zoom_to_cursor = d.zoom_to_cursor;
|
||||
p.orbit_cursor = d.orbit_cursor;
|
||||
p.look_at_anim = d.look_at_anim;
|
||||
p.look_at_speed = d.look_at_speed;
|
||||
}
|
||||
PAGE_SHORTCUTS => crate::keymap::reset(app, None),
|
||||
PAGE_DESIGN => {
|
||||
p.up_axis = d.up_axis;
|
||||
p.offer_stand_up = d.offer_stand_up;
|
||||
}
|
||||
4 => {
|
||||
let msaa = p.msaa != d.msaa;
|
||||
p.msaa = d.msaa;
|
||||
app.ui.visual_style = 0;
|
||||
app.ui.show_grid = true;
|
||||
app.ui.ground_shadow = true;
|
||||
app.ui.perspective = false;
|
||||
if msaa {
|
||||
save_graphics(app);
|
||||
}
|
||||
}
|
||||
5 => app.session.auto_project = true,
|
||||
_ => app.ui.pick_bodies = false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings that follow the preferences every frame: the theme when it follows the system, and
|
||||
/// the interface scale.
|
||||
pub fn apply(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if app.preferences.theme == "system"
|
||||
&& let Some(t) = ctx.system_theme()
|
||||
{
|
||||
app.ui.dark = t == egui::Theme::Dark;
|
||||
}
|
||||
let z = app.preferences.ui_scale.clamp(0.5, 2.5);
|
||||
if z.is_finite() && (ctx.zoom_factor() - z).abs() > 1e-3 {
|
||||
ctx.set_zoom_factor(z);
|
||||
}
|
||||
}
|
||||
|
||||
/// A new, empty design takes the preferred units (not an undo step, not a change to save).
|
||||
@@ -80,7 +231,8 @@ pub fn set_autosave(app: &mut SolveApp) {
|
||||
/// Which navigation a mouse drag does under the preset.
|
||||
pub fn nav_mode(app: &SolveApp, middle: bool, secondary: bool, mods: Modifiers) -> Option<NavMode> {
|
||||
if secondary && !middle {
|
||||
return Some(NavMode::Orbit);
|
||||
// Tinkercad: Shift+right pans; everywhere else right drag orbits.
|
||||
return Some(if app.preferences.nav == "tinkercad" && mods.shift { NavMode::Pan } else { NavMode::Orbit });
|
||||
}
|
||||
if !middle {
|
||||
return None;
|
||||
@@ -103,6 +255,10 @@ pub fn nav_mode(app: &SolveApp, middle: bool, secondary: bool, mods: Modifiers)
|
||||
} else {
|
||||
NavMode::Pan
|
||||
}),
|
||||
// Alias: middle pans, Ctrl+middle zooms (right drag orbits).
|
||||
"alias" => Some(if ctrl { NavMode::Zoom } else { NavMode::Pan }),
|
||||
// Tinkercad: middle pans.
|
||||
"tinkercad" => Some(NavMode::Pan),
|
||||
// Fusion: middle pans, Shift+middle orbits.
|
||||
_ => Some(if mods.shift { NavMode::Orbit } else { NavMode::Pan }),
|
||||
}
|
||||
@@ -198,141 +354,315 @@ fn save_graphics(app: &SolveApp) {
|
||||
}
|
||||
|
||||
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
// File › Keyboard Shortcuts and Help › Keyboard Shortcuts open this dialog at that page.
|
||||
if app.keymap.open {
|
||||
app.keymap.open = false;
|
||||
open_at(app, PAGE_SHORTCUTS);
|
||||
}
|
||||
if !app.prefs_window.open {
|
||||
app.prefs_window.snapshot = None;
|
||||
return;
|
||||
}
|
||||
if app.prefs_window.snapshot.is_none() {
|
||||
app.prefs_window.snapshot = Some(Snapshot::take(app));
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let mut open = true;
|
||||
let sections = ["General", "Navigation", "Display", "Sketch", "Selection"];
|
||||
let mut button: Option<&str> = None;
|
||||
let before = app.preferences.clone();
|
||||
crate::frame::window(ctx, "Preferences", crate::frame::Width::Wide)
|
||||
.id(egui::Id::new("sc_prefs"))
|
||||
.open(&mut open)
|
||||
.default_size([560.0, 360.0])
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.show(ctx, |ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
// A fixed page area: the vertical separator fills the height it is given, so an
|
||||
// auto-sized one would grow every frame.
|
||||
ui.allocate_ui_with_layout(egui::vec2(700.0, 400.0), egui::Layout::left_to_right(egui::Align::Min), |ui| {
|
||||
ui.set_min_size(egui::vec2(700.0, 400.0));
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(120.0);
|
||||
for (i, s) in sections.iter().enumerate() {
|
||||
ui.set_width(140.0);
|
||||
for (i, s) in PAGES.iter().enumerate() {
|
||||
if ui.selectable_label(app.prefs_window.section == i, *s).clicked() {
|
||||
app.prefs_window.section = i;
|
||||
app.keymap.read_only = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
ui.vertical(|ui| {
|
||||
egui::Grid::new("sc_prefs_grid").num_columns(2).spacing([16.0, 8.0]).show(ui, |ui| match app.prefs_window.section {
|
||||
0 => {
|
||||
ui.label("Theme");
|
||||
ui.horizontal(|ui| {
|
||||
ui.radio_value(&mut app.ui.dark, true, "Dark");
|
||||
ui.radio_value(&mut app.ui.dark, false, "Light");
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Units of new designs");
|
||||
egui::ComboBox::from_id_salt("pref_units").selected_text(app.preferences.default_units.clone()).show_ui(ui, |ui| {
|
||||
for u in UNITS {
|
||||
ui.selectable_value(&mut app.preferences.default_units, u.to_string(), u);
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Autosave every");
|
||||
if ui.add(egui::DragValue::new(&mut app.autosave_minutes).range(0.0..=120.0).speed(0.5).suffix(" min")).changed() {
|
||||
set_autosave(app);
|
||||
}
|
||||
ui.end_row();
|
||||
ui.label("");
|
||||
ui.label(RichText::new("0 turns it off").size(11.0).color(t.text_dim));
|
||||
ui.end_row();
|
||||
}
|
||||
1 => {
|
||||
ui.label("Mouse");
|
||||
egui::ComboBox::from_id_salt("pref_nav")
|
||||
.selected_text(NAV_PRESETS.iter().find(|(id, _)| *id == app.preferences.nav).map_or("Fusion", |x| x.1))
|
||||
.show_ui(ui, |ui| {
|
||||
for (id, label) in NAV_PRESETS {
|
||||
if ui.selectable_value(&mut app.preferences.nav, id.to_string(), label).clicked() {
|
||||
app.preferences.zoom_reverse = id == "solidworks";
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("");
|
||||
let how = match app.preferences.nav.as_str() {
|
||||
"solidworks" => "Middle drag orbits, Ctrl+middle pans, Shift+middle zooms",
|
||||
"inventor" => "Middle drag pans, Shift+middle orbits, Ctrl+middle zooms",
|
||||
_ => "Middle drag pans, Shift+middle orbits",
|
||||
};
|
||||
ui.label(RichText::new(format!("{how}; right drag orbits; the wheel zooms at the cursor")).size(11.0).color(t.text_dim));
|
||||
ui.end_row();
|
||||
ui.label("Reverse zoom direction");
|
||||
ui.checkbox(&mut app.preferences.zoom_reverse, "");
|
||||
ui.end_row();
|
||||
ui.label("Orbit around");
|
||||
ui.horizontal(|ui| {
|
||||
ui.radio_value(&mut app.preferences.orbit_cursor, false, "View centre");
|
||||
ui.radio_value(&mut app.preferences.orbit_cursor, true, "Point under the cursor");
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
2 => {
|
||||
ui.label("Grid");
|
||||
ui.checkbox(&mut app.ui.show_grid, "");
|
||||
ui.end_row();
|
||||
ui.label("Perspective view");
|
||||
ui.checkbox(&mut app.ui.perspective, "");
|
||||
ui.end_row();
|
||||
ui.label("Visual style");
|
||||
let styles = ["Shaded with edges", "Shaded", "Wireframe", "Shaded with hidden edges"];
|
||||
egui::ComboBox::from_id_salt("pref_style").selected_text(styles[usize::from(app.ui.visual_style.min(3))]).show_ui(
|
||||
ui,
|
||||
|ui| {
|
||||
for (i, s) in styles.iter().enumerate() {
|
||||
ui.selectable_value(&mut app.ui.visual_style, i as u8, *s);
|
||||
}
|
||||
},
|
||||
);
|
||||
ui.end_row();
|
||||
ui.label("Length decimals");
|
||||
ui.add(egui::DragValue::new(&mut app.preferences.length_decimals).range(0..=6));
|
||||
ui.end_row();
|
||||
ui.label("Angle decimals");
|
||||
ui.add(egui::DragValue::new(&mut app.preferences.angle_decimals).range(0..=6));
|
||||
ui.end_row();
|
||||
ui.label("Anti-aliasing");
|
||||
egui::ComboBox::from_id_salt("pref_msaa").selected_text(format!("{}×", app.preferences.msaa)).show_ui(ui, |ui| {
|
||||
for m in MSAA {
|
||||
ui.selectable_value(&mut app.preferences.msaa, m, format!("{m}×"));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("");
|
||||
ui.label(RichText::new("Anti-aliasing applies the next time SolveCraft starts").size(11.0).color(t.text_dim));
|
||||
ui.end_row();
|
||||
}
|
||||
3 => {
|
||||
ui.label("Project face edges into new sketches");
|
||||
let mut auto = app.session.auto_project;
|
||||
if ui.checkbox(&mut auto, "").changed() {
|
||||
let _ = app.run("sketch.auto_project", json!({ "value": auto }));
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
_ => {
|
||||
ui.label("Click selects whole bodies");
|
||||
ui.checkbox(&mut app.ui.pick_bodies, "");
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
// A fixed width, so the window keeps its size from page to page.
|
||||
ui.set_width(540.0);
|
||||
ui.label(RichText::new(PAGES.get(app.prefs_window.section).copied().unwrap_or_default()).strong().size(14.0));
|
||||
ui.add_space(6.0);
|
||||
page(app, ui, &t);
|
||||
});
|
||||
});
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Restore Defaults").on_hover_text("Reset the settings on this page").clicked() {
|
||||
button = Some("defaults");
|
||||
}
|
||||
crate::frame::right_aligned(ui, 700.0, 170.0, |ui| {
|
||||
if ui.button("OK").clicked() {
|
||||
button = Some("ok");
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
button = Some("cancel");
|
||||
}
|
||||
if ui.button("Apply").clicked() {
|
||||
button = Some("apply");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if app.preferences.msaa != before.msaa {
|
||||
save_graphics(app);
|
||||
}
|
||||
if !open {
|
||||
app.prefs_window.open = false;
|
||||
if app.preferences.up_axis != before.up_axis {
|
||||
up_axis_changed(app);
|
||||
}
|
||||
let esc = ctx.input(|i| i.key_pressed(egui::Key::Escape)) && !crate::keymap::capturing(app);
|
||||
match button {
|
||||
Some("defaults") => defaults(app, app.prefs_window.section),
|
||||
Some("apply") => app.prefs_window.snapshot = Some(Snapshot::take(app)),
|
||||
Some("ok") => close(app, false),
|
||||
Some("cancel") => close(app, true),
|
||||
_ if !open || esc => close(app, !open || esc),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the dialog; `revert`: put back what was there when it opened (Cancel, Esc, ×).
|
||||
fn close(app: &mut SolveApp, revert: bool) {
|
||||
if let Some(s) = app.prefs_window.snapshot.take()
|
||||
&& revert
|
||||
{
|
||||
let up = app.preferences.up_axis.clone();
|
||||
s.restore(app);
|
||||
if app.preferences.up_axis != up {
|
||||
up_axis_changed(app);
|
||||
}
|
||||
}
|
||||
app.prefs_window.open = false;
|
||||
app.keymap.read_only = false;
|
||||
crate::keymap::stop_capture(app);
|
||||
}
|
||||
|
||||
/// The default orientation changed: the view turns to match.
|
||||
fn up_axis_changed(app: &mut SolveApp) {
|
||||
app.animate_view("home");
|
||||
}
|
||||
|
||||
/// One page of the dialog.
|
||||
fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let note = |ui: &mut egui::Ui, s: &str| {
|
||||
ui.label("");
|
||||
ui.label(RichText::new(s).size(11.0).color(t.text_dim));
|
||||
ui.end_row();
|
||||
};
|
||||
if app.prefs_window.section == PAGE_SHORTCUTS {
|
||||
crate::keymap::page(app, ui);
|
||||
return;
|
||||
}
|
||||
egui::Grid::new(("sc_prefs_grid", app.prefs_window.section)).num_columns(2).spacing([16.0, 8.0]).show(ui, |ui| match app.prefs_window.section {
|
||||
0 => {
|
||||
ui.label("Theme");
|
||||
ui.horizontal(|ui| {
|
||||
for (id, label) in THEMES {
|
||||
if ui.radio(app.preferences.theme == id, label).clicked() {
|
||||
app.preferences.theme = id.to_string();
|
||||
if id != "system" {
|
||||
app.ui.dark = id == "dark";
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("UI scale");
|
||||
ui.add(egui::Slider::new(&mut app.preferences.ui_scale, 0.75..=2.0).step_by(0.05).custom_formatter(|v, _| format!("{:.0} %", v * 100.0)));
|
||||
ui.end_row();
|
||||
ui.label("Units of new designs");
|
||||
egui::ComboBox::from_id_salt("pref_units").selected_text(app.preferences.default_units.clone()).show_ui(ui, |ui| {
|
||||
for u in UNITS {
|
||||
ui.selectable_value(&mut app.preferences.default_units, u.to_string(), u);
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Autosave every");
|
||||
if ui.add(egui::DragValue::new(&mut app.autosave_minutes).range(0.0..=120.0).speed(0.5).suffix(" min")).changed() {
|
||||
set_autosave(app);
|
||||
}
|
||||
ui.end_row();
|
||||
note(ui, "0 turns it off");
|
||||
ui.label("Length decimals");
|
||||
ui.add(egui::DragValue::new(&mut app.preferences.length_decimals).range(0..=6));
|
||||
ui.end_row();
|
||||
ui.label("Angle decimals");
|
||||
ui.add(egui::DragValue::new(&mut app.preferences.angle_decimals).range(0..=6));
|
||||
ui.end_row();
|
||||
ui.label("Agent cursor");
|
||||
let s = &mut app.ui.agent_cursor;
|
||||
ui.checkbox(&mut s.show, "Show the pointer an AI agent moves (#35)");
|
||||
ui.end_row();
|
||||
ui.label("Agent cursor speed");
|
||||
ui.add_enabled_ui(s.show, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
for sp in crate::agent_cursor::Speed::ALL {
|
||||
ui.selectable_value(&mut s.speed, sp, sp.label());
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("");
|
||||
ui.add_enabled(s.show, egui::Checkbox::new(&mut s.follow_camera, "Follow the camera (Look At sketches)"));
|
||||
ui.end_row();
|
||||
}
|
||||
1 => {
|
||||
ui.label("Pan, zoom, orbit");
|
||||
egui::ComboBox::from_id_salt("pref_nav")
|
||||
.selected_text(NAV_PRESETS.iter().find(|(id, _)| *id == app.preferences.nav).map_or("Fusion", |x| x.1))
|
||||
.show_ui(ui, |ui| {
|
||||
for (id, label) in NAV_PRESETS {
|
||||
if ui.selectable_value(&mut app.preferences.nav, id.to_string(), label).clicked() {
|
||||
app.preferences.zoom_reverse = id == "solidworks";
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
note(ui, &format!("{}; the wheel zooms", nav_help(&app.preferences.nav)));
|
||||
ui.label("Reverse zoom direction");
|
||||
ui.checkbox(&mut app.preferences.zoom_reverse, "");
|
||||
ui.end_row();
|
||||
ui.label("Zoom to the cursor");
|
||||
ui.checkbox(&mut app.preferences.zoom_to_cursor, "")
|
||||
.on_hover_text("The wheel zooms toward the point under the cursor (off: toward the view's centre)");
|
||||
ui.end_row();
|
||||
ui.label("Orbit around");
|
||||
ui.horizontal(|ui| {
|
||||
ui.radio_value(&mut app.preferences.orbit_cursor, false, "View centre");
|
||||
ui.radio_value(&mut app.preferences.orbit_cursor, true, "Point under the cursor");
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Animate view changes");
|
||||
ui.checkbox(&mut app.preferences.look_at_anim, "View cube, Look At, Home and Fit");
|
||||
ui.end_row();
|
||||
ui.label("Animation speed");
|
||||
ui.add_enabled(
|
||||
app.preferences.look_at_anim,
|
||||
egui::Slider::new(&mut app.preferences.look_at_speed, 0.5..=3.0).step_by(0.25).custom_formatter(|v, _| format!("{v:.2}×")),
|
||||
);
|
||||
ui.end_row();
|
||||
}
|
||||
PAGE_DESIGN => {
|
||||
ui.label("Default modelling orientation");
|
||||
ui.horizontal(|ui| {
|
||||
ui.radio_value(&mut app.preferences.up_axis, "z".to_string(), "Z up");
|
||||
ui.radio_value(&mut app.preferences.up_axis, "y".to_string(), "Y up");
|
||||
});
|
||||
ui.end_row();
|
||||
note(ui, "Which axis points up in the views (view cube, Home, Look At). Designs are unchanged.");
|
||||
ui.label("Offer to stand up Y-up files");
|
||||
ui.checkbox(&mut app.preferences.offer_stand_up, "")
|
||||
.on_hover_text("After a STEP or IGES file opens with Z up, offer a quarter turn about X");
|
||||
ui.end_row();
|
||||
}
|
||||
4 => {
|
||||
ui.label("Visual style");
|
||||
let styles = ["Shaded with edges", "Shaded", "Wireframe", "Shaded with hidden edges"];
|
||||
egui::ComboBox::from_id_salt("pref_style").selected_text(styles[usize::from(app.ui.visual_style.min(3))]).show_ui(ui, |ui| {
|
||||
for (i, s) in styles.iter().enumerate() {
|
||||
ui.selectable_value(&mut app.ui.visual_style, i as u8, *s);
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Grid");
|
||||
ui.checkbox(&mut app.ui.show_grid, "");
|
||||
ui.end_row();
|
||||
ui.label("Ground shadow");
|
||||
ui.checkbox(&mut app.ui.ground_shadow, "");
|
||||
ui.end_row();
|
||||
ui.label("Perspective view");
|
||||
ui.checkbox(&mut app.ui.perspective, "");
|
||||
ui.end_row();
|
||||
ui.label("Anti-aliasing");
|
||||
egui::ComboBox::from_id_salt("pref_msaa").selected_text(format!("{}×", app.preferences.msaa)).show_ui(ui, |ui| {
|
||||
for m in MSAA {
|
||||
ui.selectable_value(&mut app.preferences.msaa, m, format!("{m}×"));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
note(ui, "Anti-aliasing applies the next time SolveCraft starts");
|
||||
ui.label("Renderer");
|
||||
ui.label(match (&app.viewport.gpu, &app.help.gpu) {
|
||||
(Some(_), Some(g)) => format!("GPU: {g}"),
|
||||
(Some(_), None) => "GPU".to_string(),
|
||||
_ => "CPU (software)".to_string(),
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
5 => {
|
||||
ui.label("Project face edges into new sketches");
|
||||
let mut auto = app.session.auto_project;
|
||||
if ui.checkbox(&mut auto, "").changed() {
|
||||
let _ = app.run("sketch.auto_project", json!({ "value": auto }));
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
_ => {
|
||||
ui.label("Click selects whole bodies");
|
||||
ui.checkbox(&mut app.ui.pick_bodies, "");
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// What the mouse does under a preset.
|
||||
pub fn nav_help(preset: &str) -> &'static str {
|
||||
match preset {
|
||||
"solidworks" => "Middle drag orbits, Ctrl+middle pans, Shift+middle zooms, right drag orbits",
|
||||
"inventor" => "Middle drag pans, Shift+middle orbits, Ctrl+middle zooms, right drag orbits",
|
||||
"alias" => "Alt+right or right drag orbits, middle drag pans, Ctrl+middle zooms",
|
||||
"tinkercad" => "Right drag orbits, middle or Shift+right drag pans",
|
||||
_ => "Middle drag pans, Shift+middle orbits, right drag orbits",
|
||||
}
|
||||
}
|
||||
|
||||
/// The control channel's `ui.prefs`: `open` (a page name) opens the dialog there, `button`
|
||||
/// presses OK, Cancel, Apply or Restore Defaults, `stand_up: true` answers the stand-up offer;
|
||||
/// any other keys set preferences. Returns them all, the open page and the offer.
|
||||
pub fn control(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::Value, String> {
|
||||
let mut rest = p.as_object().cloned().unwrap_or_default();
|
||||
if let Some(page) = rest.remove("open") {
|
||||
let name = page.as_str().unwrap_or("General");
|
||||
let i = PAGES.iter().position(|x| x.eq_ignore_ascii_case(name)).ok_or_else(|| format!("no page `{name}` ({})", PAGES.join(", ")))?;
|
||||
open_at(app, i);
|
||||
if app.prefs_window.snapshot.is_none() {
|
||||
app.prefs_window.snapshot = Some(Snapshot::take(app));
|
||||
}
|
||||
}
|
||||
if rest.remove("stand_up").and_then(|v| v.as_bool()) == Some(true) {
|
||||
crate::stand_up::stand_up(app)?;
|
||||
}
|
||||
let button = rest.remove("button");
|
||||
let mut out = set(app, &serde_json::Value::Object(rest))?;
|
||||
match button.as_ref().and_then(|b| b.as_str()) {
|
||||
Some("ok") => close(app, false),
|
||||
Some("cancel") => close(app, true),
|
||||
Some("apply") => app.prefs_window.snapshot = Some(Snapshot::take(app)),
|
||||
Some("defaults") => defaults(app, app.prefs_window.section),
|
||||
Some(b) => return Err(format!("unknown button `{b}` (ok, cancel, apply, defaults)")),
|
||||
None => {}
|
||||
}
|
||||
if button.is_some() {
|
||||
out = serde_json::to_value(&app.preferences).map_err(|e| e.to_string())?;
|
||||
}
|
||||
if let Some(o) = out.as_object_mut() {
|
||||
o.insert("dialog".into(), json!(app.prefs_window.open.then(|| PAGES.get(app.prefs_window.section).copied().unwrap_or_default())));
|
||||
o.insert("stand_up_offer".into(), json!(app.stand_up));
|
||||
o.insert("dark".into(), json!(app.ui.dark));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Set preferences from JSON (control channel); returns them all.
|
||||
@@ -351,13 +681,26 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::Valu
|
||||
return Err(format!("units must be one of {}", UNITS.join(", ")));
|
||||
}
|
||||
if !NAV_PRESETS.iter().any(|(id, _)| *id == new.nav) || !MSAA.contains(&new.msaa) {
|
||||
return Err("`nav` must be fusion, solidworks or inventor and `msaa` 1, 2, 4 or 8".into());
|
||||
return Err("`nav` must be fusion, solidworks, inventor, alias or tinkercad and `msaa` 1, 2, 4 or 8".into());
|
||||
}
|
||||
if !THEMES.iter().any(|(id, _)| *id == new.theme) || !["z", "y"].contains(&new.up_axis.as_str()) {
|
||||
return Err("`theme` must be dark, light or system and `up_axis` z or y".into());
|
||||
}
|
||||
if !(0.5..=2.5).contains(&new.ui_scale) || !(0.25..=4.0).contains(&new.look_at_speed) {
|
||||
return Err("`ui_scale` must be 0.5…2.5 and `look_at_speed` 0.25…4".into());
|
||||
}
|
||||
if new.theme != app.preferences.theme && new.theme != "system" {
|
||||
app.ui.dark = new.theme == "dark";
|
||||
}
|
||||
let up_changed = new.up_axis != app.preferences.up_axis;
|
||||
let msaa_changed = new.msaa != app.preferences.msaa;
|
||||
app.preferences = new;
|
||||
if msaa_changed {
|
||||
save_graphics(app);
|
||||
}
|
||||
if up_changed {
|
||||
up_axis_changed(app);
|
||||
}
|
||||
serde_json::to_value(&app.preferences).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -390,6 +733,86 @@ mod tests {
|
||||
assert_eq!(format_length(25.4, "in", 3), "1.000 in");
|
||||
}
|
||||
|
||||
/// The dialog: changes apply at once, Cancel puts back what was there when it opened, Apply
|
||||
/// keeps them, Restore Defaults resets one page, and the shortcuts menus open its page.
|
||||
#[test]
|
||||
fn preferences_dialog_cancel_apply_and_defaults() {
|
||||
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
|
||||
control(&mut app, &json!({"open": "general"})).unwrap();
|
||||
assert!(app.prefs_window.open && app.prefs_window.section == 0);
|
||||
control(&mut app, &json!({"theme": "light", "ui_scale": 1.25, "zoom_to_cursor": false})).unwrap();
|
||||
assert!(!app.ui.dark, "light applies at once");
|
||||
app.keymap.custom.insert("solid.extrude".into(), "Shift+E".into());
|
||||
let v = control(&mut app, &json!({"button": "cancel"})).unwrap();
|
||||
assert_eq!(v["dialog"], serde_json::Value::Null);
|
||||
assert!(app.ui.dark && app.preferences.theme == "dark" && app.preferences.ui_scale == 1.0 && app.preferences.zoom_to_cursor);
|
||||
assert!(app.keymap.custom.is_empty(), "a rebinding made while the dialog was open is undone too");
|
||||
|
||||
control(&mut app, &json!({"open": "Navigation", "nav": "alias", "look_at_anim": false})).unwrap();
|
||||
control(&mut app, &json!({"button": "apply"})).unwrap();
|
||||
control(&mut app, &json!({"nav": "tinkercad"})).unwrap();
|
||||
control(&mut app, &json!({"button": "cancel"})).unwrap();
|
||||
assert_eq!(app.preferences.nav, "alias", "Cancel goes back to the last Apply");
|
||||
let to = {
|
||||
let mut c = app.cam;
|
||||
c.set_view(solvecraft_engine::render::StandardView::Top);
|
||||
c
|
||||
};
|
||||
app.animate_to(to);
|
||||
assert!(app.cam_anim.is_none() && app.cam == to, "no animation: the view jumps");
|
||||
|
||||
control(&mut app, &json!({"open": "Navigation"})).unwrap();
|
||||
control(&mut app, &json!({"button": "defaults"})).unwrap();
|
||||
assert_eq!(app.preferences.nav, "fusion");
|
||||
assert!(app.preferences.look_at_anim);
|
||||
control(&mut app, &json!({"button": "ok"})).unwrap();
|
||||
assert!(!app.prefs_window.open);
|
||||
|
||||
// File and Help › Keyboard Shortcuts open the same dialog at its page.
|
||||
app.keymap.open = true;
|
||||
let ctx = egui::Context::default();
|
||||
let _ = ctx.run_ui(Default::default(), |ui| show(&mut app, ui.ctx()));
|
||||
assert!(app.prefs_window.open && app.prefs_window.section == PAGE_SHORTCUTS && !app.keymap.open);
|
||||
assert!(control(&mut app, &json!({"open": "Plugins"})).is_err());
|
||||
assert!(set(&mut app, &json!({"up_axis": "x"})).is_err());
|
||||
assert!(set(&mut app, &json!({"theme": "sepia"})).is_err());
|
||||
}
|
||||
|
||||
/// A STEP file opened Z-up is offered to be stood up: a quarter turn about X, undoable.
|
||||
#[test]
|
||||
fn a_y_up_step_file_can_be_stood_up() {
|
||||
let dir = std::env::temp_dir().join(format!("solvecraft-standup-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let path = dir.join("tall_in_y.step").to_string_lossy().to_string();
|
||||
let mut src = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
|
||||
src.run("solid.box", json!({"length": 10, "width": 40, "height": 5})).unwrap();
|
||||
src.run("file.export", json!({"path": path})).unwrap();
|
||||
|
||||
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
|
||||
app.open_path(&path);
|
||||
let offered = app.stand_up.clone().unwrap_or_default();
|
||||
assert_eq!(offered.len(), 1, "the file's body is offered");
|
||||
let size = |app: &SolveApp| solvecraft_engine::view::bounds(&app.session);
|
||||
assert!((size(&app).max.y - size(&app).min.y - 40.0).abs() < 1e-6);
|
||||
control(&mut app, &json!({"stand_up": true})).unwrap();
|
||||
let b = size(&app);
|
||||
assert!((b.max.z - b.min.z - 40.0).abs() < 1e-6, "Y became Z: {b:?}");
|
||||
assert!(app.stand_up.is_none());
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert!((size(&app).max.y - size(&app).min.y - 40.0).abs() < 1e-6, "one undo step");
|
||||
|
||||
// Not offered when turned off, or with Y up as the default orientation.
|
||||
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
|
||||
app.preferences.offer_stand_up = false;
|
||||
app.open_path(&path);
|
||||
assert!(app.stand_up.is_none());
|
||||
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
|
||||
app.preferences.up_axis = "y".into();
|
||||
app.open_path(&path);
|
||||
assert!(app.stand_up.is_none());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orbiting_around_the_cursor_keeps_that_point_still() {
|
||||
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
|
||||
|
||||
@@ -831,6 +831,15 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> {
|
||||
return Err(format!("start page open: got {}, want {v}", ui["home"]));
|
||||
}
|
||||
}
|
||||
"prefs" => {
|
||||
// {key: value…}: those entries of `ui.prefs` (preferences, `dialog`, `dark`…).
|
||||
let got = h.call("ui.prefs", json!({}))["result"].clone();
|
||||
for (pk, pv) in v.as_object().into_iter().flatten() {
|
||||
if got[pk.as_str()] != *pv {
|
||||
return Err(format!("preference {pk}: got {}, want {pv}", got[pk.as_str()]));
|
||||
}
|
||||
}
|
||||
}
|
||||
"quit_requested" | "quitting" => {
|
||||
if ui[k] != *v {
|
||||
return Err(format!("{k}: got {}, want {v}", ui[k]));
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Standing up a Y-up file (#3). Many CAD programs save parts with Y up, so a STEP or IGES file
|
||||
//! from them opens lying on its back in a Z-up design. After such a file opens, SolveCraft
|
||||
//! offers to stand it up: a quarter turn about X (Y → Z) of the bodies it brought, as one
|
||||
//! Move feature that can be undone or edited. Preferences › Design › "Offer to stand up Y-up
|
||||
//! files" turns the offer off; with Y up as the default orientation it isn't needed.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::SolveApp;
|
||||
|
||||
/// The bodies a just-opened file brought (`before`: body names before it opened, none for a
|
||||
/// new design): offer to stand them up.
|
||||
pub fn offer(app: &mut SolveApp, path: &str, before: &[String]) {
|
||||
let io = &solvecraft_engine::io::is_step_path;
|
||||
if !(io(path) || solvecraft_engine::io::is_iges_path(path)) || !app.preferences.offer_stand_up || app.preferences.up_axis != "z" {
|
||||
return;
|
||||
}
|
||||
let bodies: Vec<String> = app.session.world_state().bodies.iter().map(|b| b.name.clone()).filter(|n| !before.contains(n)).collect();
|
||||
if !bodies.is_empty() {
|
||||
app.stand_up = Some(bodies);
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn the offered bodies a quarter turn about X (Y up becomes Z up).
|
||||
pub fn stand_up(app: &mut SolveApp) -> Result<(), String> {
|
||||
let Some(bodies) = app.stand_up.take() else { return Err("no file is waiting to be stood up".into()) };
|
||||
app.run("solid.move", json!({ "bodies": bodies, "axis": [1, 0, 0], "angle": "90 deg" })).map_err(|e| e.to_string())?;
|
||||
app.animate_view("home");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The offer, under the toolbar.
|
||||
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if app.stand_up.is_none() {
|
||||
return;
|
||||
}
|
||||
let mut act: Option<&str> = None;
|
||||
crate::frame::window(ctx, "Stand this part up?", crate::frame::Width::Normal)
|
||||
.id(egui::Id::new("sc_stand_up"))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 140.0))
|
||||
.show(ctx, |ui| {
|
||||
ui.label("Files from many CAD programs have Y up, so they open lying on their back here (Z up).");
|
||||
ui.label("Stand it up: a quarter turn about X, as one Move feature you can undo.");
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Stand it up").clicked() {
|
||||
act = Some("stand");
|
||||
}
|
||||
if ui.button("Leave it").clicked() {
|
||||
act = Some("leave");
|
||||
}
|
||||
if ui.button("Don't ask again").on_hover_text("Preferences › Design turns the offer back on").clicked() {
|
||||
act = Some("never");
|
||||
}
|
||||
});
|
||||
});
|
||||
match act {
|
||||
Some("stand") => {
|
||||
if let Err(e) = stand_up(app) {
|
||||
app.set_status(e, true);
|
||||
}
|
||||
}
|
||||
Some("leave") => app.stand_up = None,
|
||||
Some("never") => {
|
||||
app.stand_up = None;
|
||||
app.preferences.offer_stand_up = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let theme_tip = if app.ui.dark { "Light theme" } else { "Dark theme" };
|
||||
if click(ui, if app.ui.dark { "sun" } else { "moon" }, theme_tip) {
|
||||
app.ui.dark = !app.ui.dark;
|
||||
app.preferences.theme = if app.ui.dark { "dark" } else { "light" }.into();
|
||||
}
|
||||
if click(ui, "help", "Help") {
|
||||
app.help.menu = !app.help.menu;
|
||||
@@ -108,7 +109,18 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
// Document tabs.
|
||||
let captions = if app.custom_titlebar { crate::titlebar::WIDTH } else { 0.0 };
|
||||
crate::documents::tabs(app, ui, r, x + 16.0, r.right() - captions - 60.0);
|
||||
crate::documents::tabs(app, ui, r, x + 16.0, r.right() - captions - 90.0);
|
||||
// Preferences, at the top right like Fusion's profile menu (also Ctrl+,).
|
||||
let gear = Rect::from_center_size(pos2(r.right() - captions - 70.0, r.center().y), vec2(24.0, 24.0));
|
||||
let gresp = ui.interact(gear, ui.id().with("sc_prefs_button"), Sense::click());
|
||||
if gresp.hovered() {
|
||||
ui.painter().rect_filled(gear, 4.0, Color32::from_white_alpha(30));
|
||||
}
|
||||
icons::paint(ui.painter(), gear.shrink(4.0), "settings", t.app_bar_text, t.app_bar_text, t.app_bar);
|
||||
crate::scenario::publish_handle("titlebar:preferences", gear.center());
|
||||
if gresp.on_hover_text(if cfg!(target_os = "macos") { "Preferences (Cmd+,)" } else { "Preferences (Ctrl+,)" }).clicked() {
|
||||
crate::prefs::open_at(app, 0);
|
||||
}
|
||||
ui.painter().text(
|
||||
pos2(r.right() - 12.0 - captions, r.center().y),
|
||||
Align2::RIGHT_CENTER,
|
||||
@@ -436,6 +448,7 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
Key::Y => drop(app.run("edit.redo", json!({}))),
|
||||
Key::S => save(app),
|
||||
Key::N => crate::documents::new_design(app),
|
||||
Key::Comma => crate::prefs::open_at(app, 0),
|
||||
Key::O => {
|
||||
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
|
||||
app.open_path(&p);
|
||||
|
||||
@@ -1292,7 +1292,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let h = rect.height().max(1.0) as f64;
|
||||
if inside && scroll != 0.0 {
|
||||
let f = (-scroll as f64 * 0.0015 * crate::prefs::zoom_sign(app)).exp();
|
||||
let anchor = hover.map(|p| {
|
||||
// Preferences › Navigation › Zoom to the cursor (off: toward the view's centre).
|
||||
let anchor = hover.filter(|_| app.preferences.zoom_to_cursor).map(|p| {
|
||||
let proj = projection(app, rect);
|
||||
// Toward the model's surface under the cursor, else the target's depth.
|
||||
if let Some(q) = pick_cached(app, &proj, p).iter().find_map(|h| if let Hit::Face { point, .. } = h { Some(*point) } else { None }) {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
[
|
||||
{"note": "#36: one Preferences dialog, from the gear at the top right (and Ctrl+,). Changes apply at once; Cancel puts them back, OK keeps them."},
|
||||
{"click": {"handle": "titlebar:preferences"}},
|
||||
{"expect": {"shown": "Restore Defaults", "prefs": {"dialog": "General", "theme": "dark", "dark": true}}},
|
||||
{"widget": "Light"},
|
||||
{"expect": {"prefs": {"theme": "light", "dark": false}}},
|
||||
{"widget": "Cancel"},
|
||||
{"expect": {"not_shown": "Restore Defaults", "prefs": {"dialog": null, "theme": "dark", "dark": true}}},
|
||||
{"note": "Ctrl+, opens it again; the shortcuts page is in the same dialog."},
|
||||
{"key": "Comma", "cmd": true},
|
||||
{"expect": {"prefs": {"dialog": "General"}}},
|
||||
{"widget": "Keyboard Shortcuts"},
|
||||
{"expect": {"shown": "Reset All", "prefs": {"dialog": "Keyboard Shortcuts"}}},
|
||||
{"widget": "Navigation"},
|
||||
{"widget": "Fusion"},
|
||||
{"widget": "Tinkercad"},
|
||||
{"expect": {"prefs": {"nav": "tinkercad"}}},
|
||||
{"widget": "Design"},
|
||||
{"widget": "Y up"},
|
||||
{"expect": {"prefs": {"up_axis": "y", "dialog": "Design"}}},
|
||||
{"widget": "OK"},
|
||||
{"expect": {"not_shown": "Restore Defaults", "prefs": {"up_axis": "y", "dialog": null}}}
|
||||
]
|
||||
@@ -85,7 +85,8 @@ fn floating_windows_stay_within_limits() {
|
||||
solvecraft_ui_egui::params_dialog::open(&mut h.app);
|
||||
h.frames(6);
|
||||
let ws = window_widths(&h);
|
||||
assert!(ws.len() >= 4, "windows on screen: {ws:?}");
|
||||
// Keyboard Shortcuts is a page of Preferences (one window, #36).
|
||||
assert!(ws.len() >= 3, "windows on screen: {ws:?}");
|
||||
for (layer, w) in ws {
|
||||
assert!(w <= WINDOW_WIDE_MAX_W + 4.0, "{layer} is {w} px wide");
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints
|
||||
| `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.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`), `zoom_reverse`, `zoom_to_cursor`, `orbit_cursor`, `look_at_anim`, `look_at_speed`, `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`; `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` |
|
||||
| `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); close asks about unsaved designs first |
|
||||
| `ui.agent_cursor` | `show` (bool), `speed`: `instant`, `normal`, `slow`, `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it |
|
||||
|
||||
Reference in new issue
Block a user