diff --git a/src/app/mod.rs b/src/app/mod.rs index e9995dda5..8ff5f4a65 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -153,6 +153,18 @@ pub(super) struct H7CAD { /// OS window for the unsaved-changes confirmation dialog. unsaved_dialog_window: Option, + // ── Custom Save-As dialog ───────────────────────────────────────────── + /// OS window for the custom Save As dialog (format + filename + folder). + save_dialog_window: Option, + /// Currently selected format string, e.g. "DWG 2013". + save_dialog_format: String, + /// Editable filename (without path), e.g. "drawing.dwg". + save_dialog_filename: String, + /// Target folder path (as a string for editing). + save_dialog_folder: String, + /// True when triggered from the unsaved-changes flow. + save_dialog_for_unsaved: bool, + // ── DimStyle Dialog ─────────────────────────────────────────────────── /// Name of the style currently shown in the dialog. dimstyle_selected: String, @@ -201,6 +213,14 @@ pub enum Message { SaveFile, SaveAs, PickedSavePath(Option), + // ── Custom Save-As dialog ───────────────────────────────────────────── + SaveDialogFormatChanged(String), + SaveDialogFilenameChanged(String), + SaveDialogFolderChanged(String), + SaveDialogBrowse, + SaveDialogFolderPicked(Option), + SaveDialogConfirm, + SaveDialogCancel, ClearScene, SetWireframe(bool), /// Switch camera projection: true = Orthographic, false = Perspective. @@ -624,6 +644,13 @@ impl H7CAD { page_setup_scale: "Fit".to_string(), pending_close: None, unsaved_dialog_window: None, + save_dialog_window: None, + save_dialog_format: "DWG 2018".to_string(), + save_dialog_filename: "drawing.dwg".to_string(), + save_dialog_folder: std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .unwrap_or_else(|_| ".".to_string()), + save_dialog_for_unsaved: false, // Plot style active_plot_style: None, // Color scheme (default: dark CAD-style) @@ -705,6 +732,7 @@ pub fn run() -> iced::Result { if Some(window_id) == state.shortcuts_window { return "Keyboard Shortcuts".into(); } if Some(window_id) == state.about_window { return "About H7CAD".into(); } if Some(window_id) == state.unsaved_dialog_window { return "Unsaved Changes".into(); } + if Some(window_id) == state.save_dialog_window { return "Save As".into(); } if let Some(tab) = state.tabs.get(state.active_tab) { let dot = if tab.dirty { "● " } else { "" }; let name = tab.tab_display_name(); diff --git a/src/app/update.rs b/src/app/update.rs index 016805318..49cae78ed 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -181,7 +181,7 @@ impl H7CAD { let name = block_name.clone(); Task::perform( async move { - let path = crate::io::pick_save_path().await; + let path = crate::io::pick_folder("").await.map(|f| f.join("drawing.dwg")); (name, path) }, |(name, path)| Message::WblockSaveResult(name, path), @@ -411,29 +411,94 @@ impl H7CAD { } Err(e) => self.command_line.push_error(&format!("Save failed: {e}")), } + Task::none() } else { - return Task::perform(crate::io::pick_save_path(), Message::PickedSavePath); + self.save_dialog_for_unsaved = false; + self.open_save_dialog_window(i) } - Task::none() } - Message::SaveAs => Task::perform(crate::io::pick_save_path(), Message::PickedSavePath), - - Message::PickedSavePath(Some(path)) => { + Message::SaveAs => { let i = self.active_tab; - match crate::io::save(&self.tabs[i].scene.document, &path) { - Ok(()) => { - self.command_line - .push_output(&format!("Saved: {}", path.display())); - self.tabs[i].current_path = Some(path); - self.tabs[i].dirty = false; - } - Err(e) => self.command_line.push_error(&format!("Save failed: {e}")), - } + self.save_dialog_for_unsaved = false; + self.open_save_dialog_window(i) + } + + Message::SaveDialogFormatChanged(fmt) => { + // Keep filename extension in sync with format choice. + let (ext, _) = crate::io::parse_save_format(&fmt); + let stem = std::path::Path::new(&self.save_dialog_filename) + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "drawing".to_string()); + self.save_dialog_filename = format!("{stem}.{ext}"); + self.save_dialog_format = fmt; Task::none() } - Message::PickedSavePath(None) => Task::none(), + Message::SaveDialogFilenameChanged(name) => { + self.save_dialog_filename = name; + Task::none() + } + + Message::SaveDialogFolderChanged(folder) => { + self.save_dialog_folder = folder; + Task::none() + } + + Message::SaveDialogBrowse => { + let folder = self.save_dialog_folder.clone(); + Task::perform( + async move { crate::io::pick_folder(&folder).await }, + Message::SaveDialogFolderPicked, + ) + } + + Message::SaveDialogFolderPicked(Some(p)) => { + self.save_dialog_folder = p.to_string_lossy().into_owned(); + Task::none() + } + + Message::SaveDialogFolderPicked(None) => Task::none(), + + Message::SaveDialogConfirm => { + let folder = std::path::Path::new(&self.save_dialog_folder); + let path = folder.join(&self.save_dialog_filename); + let (_, version) = crate::io::parse_save_format(&self.save_dialog_format); + let close = self.close_save_dialog_window(); + if self.save_dialog_for_unsaved { + let result = crate::io::save_as_version( + &self.tabs[self.active_tab].scene.document, &path, version, + ); + match result { + Ok(()) => { + let i = self.active_tab; + self.command_line.push_output(&format!("Saved: {}", path.display())); + self.tabs[i].current_path = Some(path.clone()); + self.tabs[i].dirty = false; + let close_tab_or_quit = self.update(Message::UnsavedPickedSavePath(Some(path))); + return Task::batch([close, close_tab_or_quit]); + } + Err(e) => self.command_line.push_error(&format!("Save failed: {e}")), + } + close + } else { + let i = self.active_tab; + match crate::io::save_as_version(&self.tabs[i].scene.document, &path, version) { + Ok(()) => { + self.command_line.push_output(&format!("Saved: {}", path.display())); + self.tabs[i].current_path = Some(path); + self.tabs[i].dirty = false; + } + Err(e) => self.command_line.push_error(&format!("Save failed: {e}")), + } + close + } + } + + Message::SaveDialogCancel => self.close_save_dialog_window(), + + Message::PickedSavePath(Some(_)) | Message::PickedSavePath(None) => Task::none(), Message::ClearScene => { let i = self.active_tab; @@ -731,6 +796,10 @@ impl H7CAD { self.pending_close = None; return Task::none(); } + if self.save_dialog_window == Some(id) { + self.save_dialog_window = None; + return Task::none(); + } if self.layer_window == Some(id) { self.layer_window = None; } if self.page_setup_window == Some(id) { self.page_setup_window = None; } if self.textstyle_window == Some(id) { self.textstyle_window = None; } @@ -2756,13 +2825,12 @@ impl H7CAD { } } } else { - // No path — close dialog, show save-as, then come back. + // No path — close unsaved dialog, open custom Save As dialog. self.pending_close = Some(super::PendingClose::Tab(idx)); + self.save_dialog_for_unsaved = true; let close_win = self.close_unsaved_dialog_window(); - return Task::batch(vec![ - close_win, - Task::perform(crate::io::pick_save_path(), Message::UnsavedPickedSavePath), - ]); + let open_save = self.open_save_dialog_window(idx); + return Task::batch([close_win, open_save]); } } Some(super::PendingClose::Quit) => { @@ -2780,14 +2848,13 @@ impl H7CAD { } } } else { - // No path — close dialog, show save-as, then come back. + // No path — close unsaved dialog, open custom Save As dialog. self.active_tab = idx; self.pending_close = Some(super::PendingClose::Quit); + self.save_dialog_for_unsaved = true; let close_win = self.close_unsaved_dialog_window(); - return Task::batch(vec![ - close_win, - Task::perform(crate::io::pick_save_path(), Message::UnsavedPickedSavePath), - ]); + let open_save = self.open_save_dialog_window(idx); + return Task::batch([close_win, open_save]); } } if self.tabs.iter().any(|t| t.dirty) { @@ -2804,9 +2871,10 @@ impl H7CAD { } Message::UnsavedPickedSavePath(Some(path)) => { + let (_, version) = crate::io::parse_save_format(&self.save_dialog_format); match self.pending_close.take() { Some(super::PendingClose::Tab(idx)) => { - match crate::io::save(&self.tabs[idx].scene.document, &path) { + match crate::io::save_as_version(&self.tabs[idx].scene.document, &path, version) { Ok(()) => { self.command_line.push_output(&format!("Saved: {}", path.display())); self.tabs[idx].current_path = Some(path); @@ -2815,7 +2883,6 @@ impl H7CAD { } Err(e) => { self.command_line.push_error(&format!("Save failed: {e}")); - // Re-open dialog for retry. self.pending_close = Some(super::PendingClose::Tab(idx)); return self.open_unsaved_dialog_window(); } @@ -2823,7 +2890,7 @@ impl H7CAD { } Some(super::PendingClose::Quit) => { let i = self.active_tab; - match crate::io::save(&self.tabs[i].scene.document, &path) { + match crate::io::save_as_version(&self.tabs[i].scene.document, &path, version) { Ok(()) => { self.command_line.push_output(&format!("Saved: {}", path.display())); self.tabs[i].current_path = Some(path); @@ -3851,6 +3918,41 @@ impl H7CAD { } /// Populate edit buffers from the currently selected text style. + fn open_save_dialog_window(&mut self, tab_idx: usize) -> Task { + if let Some(id) = self.save_dialog_window { + return window::gain_focus(id); + } + // Pre-fill filename from current path or tab name. + let (filename, folder) = if let Some(p) = &self.tabs[tab_idx].current_path { + let name = p.file_name().map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "drawing.dwg".to_string()); + let dir = p.parent().map(|d| d.to_string_lossy().into_owned()) + .unwrap_or_else(|| self.save_dialog_folder.clone()); + (name, dir) + } else { + let (ext, _) = crate::io::parse_save_format(&self.save_dialog_format); + let name = format!("{}.{ext}", self.tabs[tab_idx].tab_display_name()); + (name, self.save_dialog_folder.clone()) + }; + self.save_dialog_filename = filename; + self.save_dialog_folder = folder; + let (id, task) = window::open(window::Settings { + size: iced::Size::new(500.0, 220.0), + resizable: false, + ..Default::default() + }); + self.save_dialog_window = Some(id); + task.map(|_| Message::Noop) + } + + fn close_save_dialog_window(&mut self) -> Task { + if let Some(id) = self.save_dialog_window.take() { + window::close(id) + } else { + Task::none() + } + } + fn open_unsaved_dialog_window(&mut self) -> Task { if let Some(id) = self.unsaved_dialog_window { return window::gain_focus(id); diff --git a/src/app/view.rs b/src/app/view.rs index 9c703e8ae..80924c8b4 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -7,7 +7,7 @@ use crate::scene::grip::{grips_to_screen, grips_to_screen_paper}; use crate::scene::paper_canvas::PaperCanvas; use crate::scene::viewport_pane::{PaperViewportPane, ViewportPane}; use crate::ui::overlay; -use iced::widget::{button, canvas, column, container, mouse_area, row, shader, stack, text, Row, Space}; +use iced::widget::{button, canvas, column, container, mouse_area, pick_list, row, shader, stack, text, text_input, Row, Space}; use iced::window; use iced::{keyboard, Background, Border, Color, Element, Fill, Subscription, Task, Theme}; @@ -115,6 +115,13 @@ impl H7CAD { }; return unsaved_changes_dialog_window(&tab_name); } + if Some(window_id) == self.save_dialog_window { + return save_as_dialog_window( + &self.save_dialog_filename, + &self.save_dialog_folder, + &self.save_dialog_format, + ); + } let i = self.active_tab; let tab = &self.tabs[i]; @@ -842,6 +849,107 @@ fn layout_context_menu_overlay(name: &str) -> Element<'_, Message> { } /// Content for the floating "Unsaved Changes" OS window. +const SAVE_FORMAT_OPTIONS: &[&str] = &[ + "DWG 2018", "DWG 2013", "DWG 2010", "DWG 2007", "DWG 2004", "DWG 2000", "DWG R14", + "DXF 2018", "DXF 2013", "DXF 2010", "DXF 2007", "DXF 2004", "DXF 2000", "DXF R14", +]; + +fn save_as_dialog_window<'a>( + filename: &'a str, + folder: &'a str, + format: &'a str, +) -> Element<'a, Message> { + const BG: Color = Color { r: 0.18, g: 0.18, b: 0.20, a: 1.0 }; + const BORDER_COL:Color = Color { r: 0.38, g: 0.38, b: 0.42, a: 1.0 }; + const TEXT_COL: Color = Color { r: 0.90, g: 0.90, b: 0.90, a: 1.0 }; + const INPUT_BG: Color = Color { r: 0.12, g: 0.12, b: 0.14, a: 1.0 }; + const BTN_OK: Color = Color { r: 0.20, g: 0.46, b: 0.80, a: 1.0 }; + const BTN_HOV: Color = Color { r: 0.26, g: 0.55, b: 0.92, a: 1.0 }; + const BTN_DISC: Color = Color { r: 0.28, g: 0.28, b: 0.30, a: 1.0 }; + const BTN_DHOV: Color = Color { r: 0.36, g: 0.36, b: 0.40, a: 1.0 }; + + let input_style = |_: &Theme, _status: iced::widget::text_input::Status| { + iced::widget::text_input::Style { + background: Background::Color(INPUT_BG), + border: Border { color: BORDER_COL, width: 1.0, radius: 4.0.into() }, + icon: TEXT_COL, + placeholder: Color { r: 0.5, g: 0.5, b: 0.5, a: 1.0 }, + value: TEXT_COL, + selection: Color { r: 0.20, g: 0.46, b: 0.80, a: 0.5 }, + } + }; + + let btn = |label: &'static str, msg: Message, base: Color, hov: Color| { + button(text(label).size(13).color(TEXT_COL)) + .on_press(msg) + .style(move |_: &Theme, status| button::Style { + background: Some(Background::Color(match status { + button::Status::Hovered | button::Status::Pressed => hov, + _ => base, + })), + text_color: TEXT_COL, + border: Border { color: BORDER_COL, width: 1.0, radius: 4.0.into() }, + shadow: iced::Shadow::default(), + snap: false, + }) + .padding([5, 14]) + }; + + let sel_static = SAVE_FORMAT_OPTIONS.iter().copied().find(|&s| s == format); + + let label = |s: &'static str| text(s).size(12).color(Color { r: 0.65, g: 0.65, b: 0.68, a: 1.0 }); + + container( + column![ + label("File name"), + iced::widget::Space::new().height(4), + iced::widget::text_input("drawing.dwg", filename) + .on_input(Message::SaveDialogFilenameChanged) + .style(input_style) + .size(13) + .padding([6, 8]), + iced::widget::Space::new().height(10), + label("Save in"), + iced::widget::Space::new().height(4), + row![ + iced::widget::text_input("/home/...", folder) + .on_input(Message::SaveDialogFolderChanged) + .style(input_style) + .size(13) + .padding([6, 8]) + .width(Fill), + iced::widget::Space::new().width(6), + btn("Browse…", Message::SaveDialogBrowse, BTN_DISC, BTN_DHOV), + ], + iced::widget::Space::new().height(10), + label("Format"), + iced::widget::Space::new().height(4), + pick_list( + SAVE_FORMAT_OPTIONS, + sel_static, + |s: &str| Message::SaveDialogFormatChanged(s.to_string()), + ) + .width(Fill), + iced::widget::Space::new().height(16), + row![ + iced::widget::Space::new().width(Fill), + btn("Save", Message::SaveDialogConfirm, BTN_OK, BTN_HOV), + iced::widget::Space::new().width(8), + btn("Cancel", Message::SaveDialogCancel, BTN_DISC, BTN_DHOV), + ], + ] + .spacing(0), + ) + .style(move |_: &Theme| container::Style { + background: Some(Background::Color(BG)), + ..Default::default() + }) + .padding([20, 24]) + .width(Fill) + .height(Fill) + .into() +} + fn unsaved_changes_dialog_window(name: &str) -> Element<'static, Message> { const BG: Color = Color { r: 0.18, g: 0.18, b: 0.20, a: 1.0 }; const BORDER_COL: Color = Color { r: 0.38, g: 0.38, b: 0.42, a: 1.0 }; diff --git a/src/io/mod.rs b/src/io/mod.rs index db046eccf..119a49479 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -78,39 +78,33 @@ pub fn load_file(path: &Path) -> Result { } } -// ── Save dialog ─────────────────────────────────────────────────────────── +// ── Folder picker (used by the custom Save As dialog) ──────────────────── -/// Show a save-file dialog listing all DWG and DXF version filters. -/// DWG versions appear first; format is auto-detected from the returned extension. -pub async fn pick_save_path() -> Option { - let dwg_filters: &[(&str, &[&str])] = &[ - ("DWG Files (2018)", &["dwg"]), - ("DWG Files (2013)", &["dwg"]), - ("DWG Files (2010)", &["dwg"]), - ("DWG Files (2007)", &["dwg"]), - ("DWG Files (2004)", &["dwg"]), - ("DWG Files (2000)", &["dwg"]), - ("DWG Files (R14)", &["dwg"]), - ("DWG Files (R13)", &["dwg"]), - ]; - let dxf_filters: &[(&str, &[&str])] = &[ - ("DXF Files (2018)", &["dxf"]), - ("DXF Files (2013)", &["dxf"]), - ("DXF Files (2010)", &["dxf"]), - ("DXF Files (2007)", &["dxf"]), - ("DXF Files (2004)", &["dxf"]), - ("DXF Files (2000)", &["dxf"]), - ("DXF Files (R14)", &["dxf"]), - ("DXF Files (R13)", &["dxf"]), - ]; - - let mut dlg = rfd::AsyncFileDialog::new() - .set_title("Save As") - .set_file_name("drawing.dwg"); - for (label, exts) in dwg_filters.iter().chain(dxf_filters.iter()) { - dlg = dlg.add_filter(*label, *exts); +pub async fn pick_folder(start: &str) -> Option { + let mut dlg = rfd::AsyncFileDialog::new().set_title("Choose folder"); + if !start.is_empty() { + dlg = dlg.set_directory(start); } - dlg.save_file().await.map(|h| h.path().to_path_buf()) + dlg.pick_folder().await.map(|h| h.path().to_path_buf()) +} + +// ── Save ────────────────────────────────────────────────────────────────── + +/// Parse a format string like "DWG 2013" or "DXF 2007" into +/// `(extension, DxfVersion)`. Falls back to ("dwg", AC1032) for unknown strings. +pub fn parse_save_format(format: &str) -> (&'static str, acadrust::DxfVersion) { + use acadrust::DxfVersion; + let f = format.to_ascii_uppercase(); + let is_dxf = f.starts_with("DXF"); + let ext = if is_dxf { "dxf" } else { "dwg" }; + let version = if f.contains("2013") { DxfVersion::AC1027 } + else if f.contains("2010") { DxfVersion::AC1024 } + else if f.contains("2007") { DxfVersion::AC1021 } + else if f.contains("2004") { DxfVersion::AC1018 } + else if f.contains("2000") { DxfVersion::AC1015 } + else if f.contains("R14") { DxfVersion::AC1014 } + else { DxfVersion::AC1032 }; // 2018 + (ext, version) } // ── Plot Style Table ────────────────────────────────────────────────────── @@ -148,21 +142,30 @@ pub async fn pick_image_file() -> Result<(PathBuf, u32, u32), String> { Ok((path, w, h)) } -// ── Save ────────────────────────────────────────────────────────────────── - -/// Save the document to the given path. +/// Save `doc` to `path` with the given DXF version, overriding `doc.version`. /// Format is auto-detected from the extension (dwg / dxf). -pub fn save(doc: &CadDocument, path: &Path) -> Result<(), String> { +pub fn save_as_version( + doc: &CadDocument, + path: &Path, + version: acadrust::DxfVersion, +) -> Result<(), String> { + let mut doc = doc.clone(); + doc.version = version; let ext = path .extension() .map(|e| e.to_string_lossy().to_lowercase()) .unwrap_or_default(); match ext.as_str() { - "dxf" => save_dxf(doc, path), - _ => save_dwg(doc, path), + "dxf" => DxfWriter::new(&doc).write_to_file(path).map_err(|e| e.to_string()), + _ => DwgWriter::write_to_file(path, &doc).map_err(|e| e.to_string()), } } +/// Save using the document's existing version. +pub fn save(doc: &CadDocument, path: &Path) -> Result<(), String> { + save_as_version(doc, path, doc.version) +} + pub fn save_dwg(doc: &CadDocument, path: &Path) -> Result<(), String> { DwgWriter::write_to_file(path, doc).map_err(|e| e.to_string()) }