From c87a38546cfa7b8400947cbb32e638aac11b3db6 Mon Sep 17 00:00:00 2001 From: by Truitt <133776756+Tru10@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:11:46 -0500 Subject: [PATCH] Default save format for new drawings (DXF or DWG) (#298) Save As suggested `.dxf` for every untitled drawing, so saving new work as DWG meant typing the extension each time. AutoCAD keeps this choice in Options > Open and Save > "Save as". - File > Default Save Format > DXF / DWG sets the format Save As suggests for drawings that have no file yet. Drawings opened from a file keep that file's format, as before. DXF stays the default. - The choice is saved across restarts with the other preferences (`saveFormat`), survives Reset Palettes, and is scriptable as `ui.saveformat {format}` or the `saveFormat` UiState field. - Ukrainian labels for the new menu entries. On macOS the suggested `.dwg` name needs DWG in the Save As filter list (#198) to survive the dialog. Claude-Session: https://claude.ai/code/session_019uMCkRAB8DMd8MEmoY9Vyr Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/i18n/uk.tsv | 3 ++ crates/ui-egui/src/lib.rs | 82 +++++++++++++++++++++++++++++++++- crates/ui-egui/src/menus.rs | 30 +++++++++++-- docs/control-protocol.md | 2 +- 4 files changed, 110 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index b442c499..a31d4094 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -141,8 +141,11 @@ Cut Вирізати Cyan Блакитний Cylinder Циліндр + DWG DWG + DXF DXF Dark Темна Default Типово + Default Save Format Типовий формат збереження Define Attributes Визначити атрибути Define Attributes... Визначити атрибути... Defined width Задана ширина diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index da19a0b4..56fcad67 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -69,6 +69,45 @@ pub struct UiState { pub history_lines: usize, /// Interface theme: "system" (follow the OS light/dark appearance), "light" or "dark". pub theme: theme::ThemePref, + /// The format Save As suggests for a drawing that has no file yet: "dxf" or "dwg". + pub save_format: SaveFormat, +} + +/// The default save format for new drawings, like AutoCAD's Options > Open and Save > "Save as". +/// A drawing opened from a file keeps that file's format. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SaveFormat { + /// CADCraft's native format, and the default. + #[default] + Dxf, + Dwg, +} + +impl SaveFormat { + pub const ALL: [SaveFormat; 2] = [SaveFormat::Dxf, SaveFormat::Dwg]; + + /// The file extension, which is also the saved name ("dxf" or "dwg"). + pub fn as_str(self) -> &'static str { + match self { + SaveFormat::Dxf => "dxf", + SaveFormat::Dwg => "dwg", + } + } + + pub fn parse(s: &str) -> Option { + SaveFormat::ALL.into_iter().find(|f| f.as_str().eq_ignore_ascii_case(s.trim().trim_start_matches('.'))) + } + + /// The name Save As suggests: a title that already has an extension (a drawing opened from a + /// file) keeps it; an untitled drawing gets this format's extension. + pub fn suggested_name(self, title: Option<&str>) -> String { + match title.map(str::trim).filter(|t| !t.is_empty()) { + Some(t) if std::path::Path::new(t).extension().is_some() => t.to_owned(), + Some(t) => format!("{t}.{}", self.as_str()), + None => format!("Drawing.{}", self.as_str()), + } + } } impl Default for UiState { @@ -93,6 +132,7 @@ impl Default for UiState { dialog: None, history_lines: 3, theme: theme::ThemePref::default(), + save_format: SaveFormat::default(), } } } @@ -226,7 +266,12 @@ impl CadApp { /// Preferences kept across restarts, as JSON for the host's storage ([`PREFS_KEY`]). pub fn prefs_json(&self) -> String { - json!({ "theme": self.ui.theme.as_str(), "interfaceLanguage": self.ui.interface_language.code() }).to_string() + json!({ + "theme": self.ui.theme.as_str(), + "interfaceLanguage": self.ui.interface_language.code(), + "saveFormat": self.ui.save_format.as_str(), + }) + .to_string() } /// Restore preferences saved by [`Self::prefs_json`]; unknown or malformed values are ignored. @@ -238,6 +283,9 @@ impl CadApp { if let Some(l) = v.get("interfaceLanguage").and_then(Value::as_str).and_then(i18n::Preference::parse) { self.ui.interface_language = l; } + if let Some(f) = v.get("saveFormat").and_then(Value::as_str).and_then(SaveFormat::parse) { + self.ui.save_format = f; + } } pub fn set_status(&mut self, s: impl Into) { @@ -524,6 +572,36 @@ mod tests { frame(&mut a, &ctx, None); assert_eq!(Tokens::get(), Tokens::of(theme::SYSTEM_FALLBACK), "no OS appearance: the documented fallback"); assert_eq!(a.ui.theme, ThemePref::System); - assert_eq!(a.prefs_json(), r#"{"interfaceLanguage":"auto","theme":"system"}"#); + assert_eq!(a.prefs_json(), r#"{"interfaceLanguage":"auto","saveFormat":"dxf","theme":"system"}"#); + } + + #[test] + fn default_save_format_persists_and_names_new_drawings() { + let mut a = app(); + assert_eq!(a.ui.save_format, SaveFormat::Dxf, "default keeps DXF, CADCraft's native format"); + assert!(a.run("ui.saveformat", json!({ "format": "pdf" })).is_err()); + assert_eq!(a.run("ui.saveformat", json!({ "format": "DWG" })).ok(), Some(json!({ "saveFormat": "dwg" }))); + assert_eq!(a.run("ui.saveformat", json!({})).ok(), Some(json!({ "saveFormat": "dwg" })), "no format: report it"); + // Saved and restored by the host; bad values are ignored. + let mut b = app(); + b.load_prefs(&a.prefs_json()); + assert_eq!(b.ui.save_format, SaveFormat::Dwg); + b.load_prefs(r#"{"saveFormat": "svg"}"#); + assert_eq!(b.ui.save_format, SaveFormat::Dwg); + // The menu items reach the same setting, and resetting the palettes keeps it. + b.start("ui.saveformat.dxf"); + assert_eq!(b.ui.save_format, SaveFormat::Dxf); + b.start("ui.saveformat.dwg"); + b.start("ui.resetpalettes"); + assert_eq!(b.ui.save_format, SaveFormat::Dwg); + // Untitled drawings get the format's extension; files keep their own. + assert_eq!(SaveFormat::Dwg.suggested_name(Some("Drawing1")), "Drawing1.dwg"); + assert_eq!(SaveFormat::Dxf.suggested_name(Some("Drawing1")), "Drawing1.dxf"); + assert_eq!(SaveFormat::Dwg.suggested_name(Some("Floorplan.dxf")), "Floorplan.dxf"); + assert_eq!(SaveFormat::Dxf.suggested_name(Some("Floorplan.DWG")), "Floorplan.DWG"); + assert_eq!(SaveFormat::Dwg.suggested_name(None), "Drawing.dwg"); + assert_eq!(SaveFormat::Dwg.suggested_name(Some(" ")), "Drawing.dwg"); + let ui: UiState = serde_json::from_value(json!({ "saveFormat": "dwg" })).unwrap_or_default(); + assert_eq!(ui.save_format, SaveFormat::Dwg); } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e41d4d8e..0c614e67 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -26,6 +26,9 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ ("ui.theme.light", "Light", &["View", "Interface Theme", "Light"], None), ("ui.theme.dark", "Dark", &["View", "Interface Theme", "Dark"], None), ("ui.theme", "Interface Theme", &[], None), + ("ui.saveformat.dxf", "DXF", &["File", "Default Save Format", "DXF"], None), + ("ui.saveformat.dwg", "DWG", &["File", "Default Save Format", "DWG"], None), + ("ui.saveformat", "Default Save Format", &[], None), ("ui.toggle.menubar", "In-window Menu Bar", &["Window", "In-window Menu Bar"], None), ("ui.hidepalettes", "Hide Palettes", &["Window", "Hide Palettes"], None), ("ui.resetpalettes", "Reset Palettes", &["Window", "Reset Palettes"], None), @@ -82,8 +85,8 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option { - let name = app.session.state().map(|s| s.title.clone()).unwrap_or_else(|_| "Drawing.dxf".into()); - let name = if name.contains('.') { name } else { format!("{name}.dxf") }; + let title = app.session.state().map(|s| s.title.clone()).ok(); + let name = app.ui.save_format.suggested_name(title.as_deref()); if let Some(p) = app.services.pick_save.as_ref().and_then(|f| f(&name)) { return Some(app.session.execute("saveas", &json!({ "path": p })).map_err(|e| e.to_string())); } @@ -164,14 +167,27 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option Ok(theme_json(app)), }, + "ui.saveformat.dxf" | "ui.saveformat.dwg" => { + let format = crate::SaveFormat::parse(id.trim_start_matches("ui.saveformat.")).unwrap_or_default(); + Ok(set_save_format(app, format)) + } + // `{"format": "dxf"|"dwg"}` sets the default save format for new drawings; without it, + // reports it. + "ui.saveformat" => match params.get("format").and_then(Value::as_str) { + Some(name) => match crate::SaveFormat::parse(name) { + Some(format) => Ok(set_save_format(app, format)), + None => Err(format!("unknown save format \"{name}\" (dxf or dwg)")), + }, + None => Ok(json!({ "saveFormat": app.ui.save_format.as_str() })), + }, "ui.hidepalettes" => { app.ui.show_palettes = false; app.ui.show_toolsets = false; Ok(Value::Null) } "ui.resetpalettes" => { - let (menu, theme, interface_language) = (app.ui.in_window_menu, app.ui.theme, app.ui.interface_language); - app.ui = crate::UiState { in_window_menu: menu, theme, interface_language, ..Default::default() }; + let (menu, theme, interface_language, save_format) = (app.ui.in_window_menu, app.ui.theme, app.ui.interface_language, app.ui.save_format); + app.ui = crate::UiState { in_window_menu: menu, theme, interface_language, save_format, ..Default::default() }; Ok(Value::Null) } "ui.start" => { @@ -256,6 +272,12 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option Value { + app.ui.save_format = format; + app.session.echo(format!("New drawings will be saved as {}", format.as_str().to_ascii_uppercase())); + json!({ "saveFormat": format.as_str() }) +} + fn set_theme(app: &mut CadApp, pref: crate::theme::ThemePref) -> Result { app.ui.theme = pref; Ok(theme_json(app)) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 51926822..38378efe 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -25,7 +25,7 @@ Errors come back as `{"id": …, "ok": false, "error": "message"}`. | `ui.click` / `ui.move` | `{x, y, button?, shift?}` | Real egui pointer events in screen points (reach every widget). | | `ui.drag` | `{x, y, to: [x, y], button?}` | Real press, move and release (drag the command line's top edge, the ViewCube ring…). | | `ui.key` / `ui.text` | `{key, cmd?, shift?, alt?}` / `{text}` | Synthetic keyboard input. | -| `ui.set` | UiState fields | Show/hide palettes, toolbars, command line, ViewCube…; `theme: "system"\|"light"\|"dark"` (also `engine.execute` with `ui.theme {theme}`); `ui.inspect` reports the theme shown (`theme`: light/dark). | +| `ui.set` | UiState fields | Show/hide palettes, toolbars, command line, ViewCube…; `theme: "system"\|"light"\|"dark"` (also `engine.execute` with `ui.theme {theme}`); `ui.inspect` reports the theme shown (`theme`: light/dark). `saveFormat: "dxf"\|"dwg"` (also `ui.saveformat {format}`) is the format Save As suggests for new drawings. | | `ui.resize` | `{width, height}` | Resize the window. | | `ui.screenshot` | `{path?}` | PNG of the window (needs a presented frame). | | `ui.render` | `{path?, width?, height?, fit?}` | Headless render. `fit` (default true) frames the whole drawing; `false` uses the current view. Replies `{pngBase64, width, height, bytes, path?}`. |