Files
cadcraft/crates/ui-egui/src/options/mod.rs
T
a2b6c9ac4a Animation effects follow the operating system's setting; View ▸ Accessibility ▸ Animation Effects (#759)
With Windows set to show no animations (Settings ▸ Accessibility ▸ Visual
effects ▸ Animation effects), dialogs and menus still faded in and wheel
input glided. All of that motion is egui's default behaviour, and nothing
asked the operating system.

- crates/ui-egui/src/animation.rs: AnimationPref (System, On, Off). Off sets
  egui's animation_time to 0 and scroll_animation to none (window, menu,
  tooltip and popup fades, collapsing sections, scroll bars, scroll-to
  glides), hands each wheel notch to scroll areas whole, and lets
  Cmd/Ctrl+wheel zoom reach its zoom in one step.
- System, the default, follows the OS live: UISettings::AnimationsEnabled on
  Windows, NSWorkspace's Reduce motion on macOS, the XDG Desktop Portal's
  reduced-motion (then GNOME's enable-animations) on Linux and the BSDs,
  prefers-reduced-motion on the web.
- View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting / On / Off,
  the same choice in Options ▸ Display, and `ui.animations`, `options` and
  `ui.set` {"animations"}; `ui.inspect` reports whether animations show.

Drafting behaviour is untouched: tracking's acquisition pause, the
time-sensitive right-click, previews, the caret blink, realtime pan and
zoom. Plain wheel zoom already moved a whole notch per frame (#692).

macOS and Linux are compile-checked (clippy -D warnings for
aarch64-apple-darwin and x86_64-unknown-linux-gnu), not run.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 05:34:56 -04:00

365 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! OPTIONS: the user's preferences, in one dialog and one JSON form.
//!
//! Typed (OPTIONS, OP), chosen from Tools ▸ Options… or, on macOS, CADCraft ▸ Settings… (⌘,),
//! OPTIONS opens the Options dialog ([`dialog`]). A JSON call never opens it: `options {}` reports
//! every preference and `options {"PICKBOX": 5, "modelBackground": "#ffffff"}` changes some (all
//! or nothing; `"reset": true` restores the defaults first). Upper-case names are the engine's
//! profile variables (`cadcraft_engine::cmd::options`); camel-case names are the interface's own:
//!
//! | name | value |
//! |---|---|
//! | `theme` | `"system"`, `"light"` or `"dark"` |
//! | `animations` | `"system"`, `"on"` or `"off"`: dialog and menu fades and smoothed scrolling |
//! | `interfaceLanguage` | `"auto"`, `"en"`, `"uk"`, `"pt-BR"`, `"de"`, `"es"`, `"cs"`, `"ru"`, `"fr"`, `"zh"` or `"it"` |
//! | `saveFormat` | `"dxf"` or `"dwg"`: the format Save As suggests for new drawings |
//! | `saveVersion` | `"2000"` … `"2018"`: the version drawings with no file yet are saved in |
//! | `SAVETIME` | minutes between automatic saves, 0–600 (0: off) |
//! | `SAVEFILEPATH` | the folder automatic saves go to |
//! | `timeSensitiveRightClick` | while a command runs, a quick right-click is Enter and holding the button opens the shortcut menu (default); `false`: a right-click always opens the menu |
//! | `systemCursor` | keep the system cursor visible over the drawing area |
//! | `historyLines` | command-line history lines, 0–12 |
//! | `modelBackground`, `paperBackground` | `"#rrggbb"`, or `null` for the default |
//! | `snapMarkerSize` | object snap marker size in points, 5–31 |
//!
//! SAVETIME and SAVEFILEPATH are the engine's autosave settings, kept with the autosave state
//! rather than in the profile.
//!
//! The profile variables and the interface preferences survive restarts: the host saves
//! [`CadApp::profile_json`] under [`PROFILE_KEY`] next to the interface prefs and restores it with
//! [`CadApp::load_profile`].
mod dialog;
pub use dialog::dialog;
use egui::Color32;
use serde_json::{Map, Value, json};
use crate::CadApp;
use crate::animation::AnimationPref;
use crate::theme::{self, ThemePref};
/// The host storage key for [`CadApp::profile_json`].
pub const PROFILE_KEY: &str = "cadcraft.profile";
/// The largest saved profile [`CadApp::load_profile`] reads.
const MAX_PROFILE_BYTES: usize = 64 * 1024;
/// The longest SAVEFILEPATH the JSON form accepts.
const MAX_PATH_CHARS: usize = 1024;
/// Object snap marker sizes the Options dialog offers, in points.
pub const SNAP_MARKER_SIZES: std::ops::RangeInclusive<f32> = 5.0..=31.0;
/// The interface's own preferences (the drafting ones are engine settings).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct UiOptions {
/// Model-space background; `None` keeps the theme's.
pub model_background: Option<Color32>,
/// The sheet colour on layout tabs; `None` is white.
pub paper_background: Option<Color32>,
/// Object snap marker size in points.
pub snap_marker_size: f32,
/// While a command runs: a quick right-click is Enter and a held one opens the shortcut menu
/// (AutoCAD's time-sensitive right-click). Off: a right-click always opens the menu.
pub time_sensitive_right_click: bool,
}
impl Default for UiOptions {
fn default() -> Self {
UiOptions { model_background: None, paper_background: None, snap_marker_size: theme::SNAP_MARKER, time_sensitive_right_click: true }
}
}
impl UiOptions {
/// What the drawing area paints with.
pub fn canvas(&self) -> theme::Canvas {
theme::Canvas { model: self.model_background, paper: self.paper_background, snap_marker: self.snap_marker_size }
}
}
/// `"#rrggbb"`.
pub fn hex(c: Color32) -> String {
format!("#{:02x}{:02x}{:02x}", c.r(), c.g(), c.b())
}
/// Parse `"#rrggbb"` (the `#` is optional).
pub fn parse_hex(s: &str) -> Option<Color32> {
let h = s.trim().trim_start_matches('#');
if h.len() != 6 || !h.is_ascii() {
return None;
}
let byte = |i: usize| h.get(i..i + 2).and_then(|b| u8::from_str_radix(b, 16).ok());
Some(Color32::from_rgb(byte(0)?, byte(2)?, byte(4)?))
}
fn color_json(c: Option<Color32>) -> Value {
c.map_or(Value::Null, |c| json!(hex(c)))
}
/// Open the dialog for a typed or menu-invoked OPTIONS; run the JSON form for a call with
/// parameters. `None` if `id` isn't OPTIONS.
pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option<Result<Value, String>> {
if !matches!(id, "options" | "op" | "preferences") {
return None;
}
if params.is_null() {
app.ui.dialog = Some("options".into());
return Some(Ok(Value::Null));
}
Some(run_json(app, params))
}
/// The interface preferences a JSON call may set, parsed before anything changes.
#[derive(Default)]
struct UiChanges {
theme: Option<ThemePref>,
animations: Option<AnimationPref>,
language: Option<crate::i18n::Preference>,
save_format: Option<crate::SaveFormat>,
system_cursor: Option<bool>,
history_lines: Option<usize>,
model_background: Option<Option<Color32>>,
paper_background: Option<Option<Color32>>,
snap_marker_size: Option<f32>,
save_version: Option<crate::SaveVersion>,
savetime: Option<u32>,
savefilepath: Option<String>,
time_sensitive_right_click: Option<bool>,
}
fn color_param(key: &str, v: &Value) -> Result<Option<Color32>, String> {
match v {
Value::Null => Ok(None),
Value::String(s) => parse_hex(s).map(Some).ok_or_else(|| format!("{key} must be \"#rrggbb\" or null")),
_ => Err(format!("{key} must be \"#rrggbb\" or null")),
}
}
fn parse_ui(key: &str, v: &Value, c: &mut UiChanges) -> Result<bool, String> {
let text = || v.as_str().ok_or_else(|| format!("{key} must be text"));
match key {
"theme" => c.theme = Some(ThemePref::parse(text()?).ok_or("theme must be system, light or dark")?),
"animations" => c.animations = Some(AnimationPref::parse(text()?).ok_or("animations must be system, on or off")?),
"interfaceLanguage" => {
c.language = Some(
crate::i18n::Preference::parse(text()?).ok_or_else(|| format!("interfaceLanguage must be {}", crate::i18n::Preference::codes()))?,
)
}
"saveFormat" => c.save_format = Some(crate::SaveFormat::parse(text()?).ok_or("saveFormat must be dxf or dwg")?),
"systemCursor" => c.system_cursor = Some(v.as_bool().ok_or("systemCursor must be true or false")?),
"historyLines" => {
let n = v.as_u64().filter(|n| *n <= crate::cmdline::MAX_LINES as u64).ok_or("historyLines must be a whole number from 0 to 12")?;
c.history_lines = Some(n as usize);
}
"modelBackground" => c.model_background = Some(color_param(key, v)?),
"paperBackground" => c.paper_background = Some(color_param(key, v)?),
"snapMarkerSize" => {
let n = v.as_f64().filter(|n| SNAP_MARKER_SIZES.contains(&(*n as f32)) && n.is_finite()).ok_or("snapMarkerSize must be from 5 to 31")?;
c.snap_marker_size = Some(n as f32);
}
"saveVersion" => {
let name = v.as_str().map_or_else(|| v.to_string(), str::to_string);
c.save_version = Some(crate::SaveVersion::parse(name.trim()).ok_or("saveVersion must be 2000, 2004, 2007, 2010, 2013 or 2018")?);
}
"timeSensitiveRightClick" => c.time_sensitive_right_click = Some(v.as_bool().ok_or("timeSensitiveRightClick must be true or false")?),
k if k.eq_ignore_ascii_case("SAVETIME") => {
let max = cadcraft_engine::autosave::MAX_SAVETIME;
let n = v
.as_f64()
.filter(|n| n.is_finite() && (0.0..=f64::from(max)).contains(n))
.ok_or_else(|| format!("SAVETIME must be from 0 to {max} minutes"))?;
c.savetime = Some(n.round() as u32);
}
k if k.eq_ignore_ascii_case("SAVEFILEPATH") => {
let p = v.as_str().map(str::trim).filter(|p| p.chars().count() <= MAX_PATH_CHARS && !p.chars().any(char::is_control));
c.savefilepath = Some(p.ok_or("SAVEFILEPATH must be a folder path")?.to_string());
}
_ => return Ok(false),
}
Ok(true)
}
/// The interface preferences as the JSON form reports them.
fn ui_json(app: &CadApp) -> Map<String, Value> {
let o = &app.options;
let mut m = Map::new();
m.insert("theme".into(), json!(app.ui.theme.as_str()));
m.insert("animations".into(), json!(app.ui.animations.as_str()));
m.insert("interfaceLanguage".into(), json!(app.ui.interface_language.code()));
m.insert("saveFormat".into(), json!(app.ui.save_format.as_str()));
m.insert("systemCursor".into(), json!(app.ui.system_cursor));
m.insert("historyLines".into(), json!(app.ui.history_lines));
m.insert("modelBackground".into(), color_json(o.model_background));
m.insert("paperBackground".into(), color_json(o.paper_background));
m.insert("snapMarkerSize".into(), json!(o.snap_marker_size));
m.insert("saveVersion".into(), json!(app.ui.save_version));
m.insert("SAVETIME".into(), json!(cadcraft_engine::autosave::savetime()));
m.insert("SAVEFILEPATH".into(), json!(cadcraft_engine::autosave::savefilepath()));
m.insert("timeSensitiveRightClick".into(), json!(o.time_sensitive_right_click));
m
}
/// Every preference: the interface's and the engine's profile variables.
pub fn report(app: &CadApp) -> Value {
let mut m = ui_json(app);
if let Value::Object(engine) = cadcraft_engine::cmd::options::profile(&app.session) {
m.extend(engine);
}
Value::Object(m)
}
/// The OPTIONS JSON form: validate everything, then change it.
fn run_json(app: &mut CadApp, params: &Value) -> Result<Value, String> {
let Some(obj) = params.as_object() else { return Err("options: expected an object".into()) };
let mut ui = UiChanges::default();
let mut engine = Map::new();
for (k, v) in obj {
if !parse_ui(k, v, &mut ui)? {
engine.insert(k.clone(), v.clone());
}
}
let reset = engine.get("reset").and_then(Value::as_bool) == Some(true);
// The engine checks its part (and refuses unknown names) before anything here changes.
app.session.execute("options", &Value::Object(engine)).map_err(|e| e.to_string())?;
if reset {
reset_ui(app);
}
if let Some(t) = ui.theme {
app.ui.theme = t;
}
if let Some(a) = ui.animations {
app.ui.animations = a;
}
if let Some(l) = ui.language {
app.ui.interface_language = l;
}
if let Some(f) = ui.save_format {
app.ui.save_format = f;
}
if let Some(b) = ui.system_cursor {
app.ui.system_cursor = b;
}
if let Some(n) = ui.history_lines {
app.ui.history_lines = n;
}
if let Some(c) = ui.model_background {
app.options.model_background = c;
}
if let Some(c) = ui.paper_background {
app.options.paper_background = c;
}
if let Some(n) = ui.snap_marker_size {
app.options.snap_marker_size = n;
}
if let Some(v) = ui.save_version {
app.ui.save_version = v.year().to_string();
}
if let Some(m) = ui.savetime {
cadcraft_engine::autosave::set_savetime(m);
}
if let Some(p) = &ui.savefilepath {
cadcraft_engine::autosave::set_savefilepath(p);
}
if let Some(b) = ui.time_sensitive_right_click {
app.options.time_sensitive_right_click = b;
}
Ok(report(app))
}
/// The interface preferences back to their defaults (the interface language stays).
fn reset_ui(app: &mut CadApp) {
let d = crate::UiState::default();
app.ui.theme = d.theme;
app.ui.animations = d.animations;
app.ui.save_format = d.save_format;
app.ui.save_version = d.save_version;
app.ui.system_cursor = d.system_cursor;
app.ui.history_lines = d.history_lines;
app.options = UiOptions::default();
}
impl CadApp {
/// The user profile kept across restarts, as JSON for the host's storage ([`PROFILE_KEY`]):
/// the engine's profile variables and the interface preferences the interface prefs
/// ([`CadApp::prefs_json`]) and the desktop workspace file (panels, command line) do not
/// already keep.
pub fn profile_json(&self) -> String {
json!({
"version": 1,
"sysvars": cadcraft_engine::cmd::options::profile(&self.session),
"systemCursor": self.ui.system_cursor,
"modelBackground": color_json(self.options.model_background),
"paperBackground": color_json(self.options.paper_background),
"snapMarkerSize": self.options.snap_marker_size,
"timeSensitiveRightClick": self.options.time_sensitive_right_click,
})
.to_string()
}
/// Restore a profile saved by [`Self::profile_json`]. Unknown, malformed or out-of-range
/// entries are skipped; a profile from a newer version still restores what it can.
pub fn load_profile(&mut self, json: &str) {
if json.len() > MAX_PROFILE_BYTES {
return;
}
let Ok(v) = serde_json::from_str::<Value>(json) else { return };
if let Some(vars) = v.get("sysvars") {
cadcraft_engine::cmd::options::restore(&mut self.session, vars);
}
let mut c = UiChanges::default();
for key in ["systemCursor", "modelBackground", "paperBackground", "snapMarkerSize", "timeSensitiveRightClick"] {
if let Some(value) = v.get(key) {
let _ = parse_ui(key, value, &mut c);
}
}
// CADCRAFT_SYSTEM_CURSOR=1 turns the system cursor on whatever was saved.
if c.system_cursor == Some(true) {
self.ui.system_cursor = true;
}
if let Some(bg) = c.model_background {
self.options.model_background = bg;
}
if let Some(bg) = c.paper_background {
self.options.paper_background = bg;
}
if let Some(n) = c.snap_marker_size {
self.options.snap_marker_size = n;
}
if let Some(b) = c.time_sensitive_right_click {
self.options.time_sensitive_right_click = b;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hex_colours_round_trip_and_bad_ones_are_refused() {
let c = Color32::from_rgb(0x12, 0xab, 0xff);
assert_eq!(parse_hex(&hex(c)), Some(c));
assert_eq!(parse_hex("FFFFFF"), Some(Color32::WHITE));
for bad in ["", "#fff", "#gggggg", "#12345678", "#ééé", "#1234567"] {
assert_eq!(parse_hex(bad), None, "{bad}");
}
}
#[test]
fn dialog_labels_have_ukrainian_translations() {
let sources = [include_str!("dialog.rs")];
let mut missing: Vec<String> = dialog::TABS.iter().filter(|(_, l)| crate::i18n::tr("uk", l) == *l).map(|(_, l)| l.to_string()).collect();
for source in sources {
for rest in source.split("crate::tl!(").skip(1) {
let Some(text) = rest.strip_prefix('"').and_then(|r| r.split('"').next()) else { continue };
// File format names read the same in every language.
if crate::i18n::tr("uk", text) == text && !["DXF", "DWG"].contains(&text) {
missing.push(text.to_string());
}
}
}
assert!(missing.is_empty(), "missing translations: {missing:?}");
}
}