diff --git a/src/app/mod.rs b/src/app/mod.rs index 8ff5f4a65..26ccd5b0a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -154,14 +154,16 @@ pub(super) struct H7CAD { unsaved_dialog_window: Option, // ── Custom Save-As dialog ───────────────────────────────────────────── - /// OS window for the custom Save As dialog (format + filename + folder). + /// OS window for the custom Save As dialog. 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, + /// Currently browsed folder (PathBuf for reliable fs ops). + save_dialog_folder: std::path::PathBuf, + /// Cached directory listing: (display_name, is_dir, full_path). + save_dialog_entries: Vec<(String, bool, std::path::PathBuf)>, /// True when triggered from the unsaved-changes flow. save_dialog_for_unsaved: bool, @@ -212,13 +214,13 @@ pub enum Message { FileOpened(Result<(String, PathBuf, CadDocument), String>), SaveFile, SaveAs, - PickedSavePath(Option), // ── Custom Save-As dialog ───────────────────────────────────────────── SaveDialogFormatChanged(String), SaveDialogFilenameChanged(String), - SaveDialogFolderChanged(String), - SaveDialogBrowse, - SaveDialogFolderPicked(Option), + /// Navigate the file-chooser to the given directory. + SaveDialogNavigate(std::path::PathBuf), + /// User clicked a file entry (fill filename) or directory entry (navigate). + SaveDialogEntryClicked(std::path::PathBuf, bool), SaveDialogConfirm, SaveDialogCancel, ClearScene, @@ -649,7 +651,9 @@ impl H7CAD { 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()), + .unwrap_or_else(|_| ".".to_string()) + .into(), + save_dialog_entries: Vec::new(), save_dialog_for_unsaved: false, // Plot style active_plot_style: None, diff --git a/src/app/update.rs b/src/app/update.rs index 49cae78ed..3cbd34dff 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -181,7 +181,11 @@ impl H7CAD { let name = block_name.clone(); Task::perform( async move { - let path = crate::io::pick_folder("").await.map(|f| f.join("drawing.dwg")); + let path = rfd::AsyncFileDialog::new() + .set_title("Save Block As") + .set_file_name("block.dwg") + .add_filter("DWG Files", &["dwg"]) + .save_file().await.map(|h| h.path().to_path_buf()); (name, path) }, |(name, path)| Message::WblockSaveResult(name, path), @@ -425,7 +429,6 @@ impl H7CAD { } 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() @@ -441,64 +444,48 @@ impl H7CAD { Task::none() } - Message::SaveDialogFolderChanged(folder) => { - self.save_dialog_folder = folder; + Message::SaveDialogNavigate(path) => { + self.save_dialog_folder = path.clone(); + self.save_dialog_entries = crate::io::read_dir_entries(&path); 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(); + Message::SaveDialogEntryClicked(path, is_dir) => { + if is_dir { + self.save_dialog_folder = path.clone(); + self.save_dialog_entries = crate::io::read_dir_entries(&path); + } else { + // Fill filename from clicked file. + if let Some(name) = path.file_name() { + self.save_dialog_filename = name.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 path = self.save_dialog_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]); + 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.clone()); + self.tabs[i].dirty = false; + if self.save_dialog_for_unsaved { + let next = self.update(Message::UnsavedPickedSavePath(Some(path))); + return Task::batch([close, next]); } - 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 + 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; @@ -3922,23 +3909,24 @@ impl H7CAD { 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) + // Pre-fill filename and folder from current path or defaults. + if let Some(p) = &self.tabs[tab_idx].current_path.clone() { + if let Some(name) = p.file_name() { + self.save_dialog_filename = name.to_string_lossy().into_owned(); + } + if let Some(dir) = p.parent() { + self.save_dialog_folder = dir.to_path_buf(); + } } 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; + self.save_dialog_filename = + format!("{}.{ext}", self.tabs[tab_idx].tab_display_name()); + } + self.save_dialog_entries = + crate::io::read_dir_entries(&self.save_dialog_folder.clone()); let (id, task) = window::open(window::Settings { - size: iced::Size::new(500.0, 220.0), - resizable: false, + size: iced::Size::new(560.0, 480.0), + resizable: true, ..Default::default() }); self.save_dialog_window = Some(id); diff --git a/src/app/view.rs b/src/app/view.rs index 80924c8b4..41302a9c3 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -119,6 +119,7 @@ impl H7CAD { return save_as_dialog_window( &self.save_dialog_filename, &self.save_dialog_folder, + &self.save_dialog_entries, &self.save_dialog_format, ); } @@ -856,95 +857,170 @@ const SAVE_FORMAT_OPTIONS: &[&str] = &[ fn save_as_dialog_window<'a>( filename: &'a str, - folder: &'a str, + folder: &'a std::path::Path, + entries: &'a [(String, bool, std::path::PathBuf)], 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 BG: Color = Color { r: 0.15, g: 0.15, b: 0.17, a: 1.0 }; + const LIST_BG: Color = Color { r: 0.11, g: 0.11, b: 0.13, a: 1.0 }; + const BORDER: Color = Color { r: 0.32, g: 0.32, b: 0.36, a: 1.0 }; + const TEXT: Color = Color { r: 0.90, g: 0.90, b: 0.90, a: 1.0 }; + const DIM: Color = Color { r: 0.58, g: 0.58, b: 0.62, a: 1.0 }; + const INPUT_BG: Color = Color { r: 0.10, g: 0.10, b: 0.12, 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 }; + const BTN_GREY: Color = Color { r: 0.26, g: 0.26, b: 0.29, a: 1.0 }; + const BTN_GHOV: Color = Color { r: 0.34, g: 0.34, b: 0.38, a: 1.0 }; + const DIR_COL: Color = Color { r: 0.75, g: 0.85, b: 1.00, a: 1.0 }; + const FILE_COL: Color = TEXT; + const ROW_HOV: Color = Color { r: 0.22, g: 0.24, b: 0.28, a: 1.0 }; - let input_style = |_: &Theme, _status: iced::widget::text_input::Status| { + let input_sty = |_: &Theme, _: 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 }, + border: Border { color: BORDER, width: 1.0, radius: 4.0.into() }, + icon: TEXT, placeholder: DIM, value: TEXT, + selection: Color { r: 0.20, g: 0.46, b: 0.80, a: 0.45 }, } }; - let btn = |label: &'static str, msg: Message, base: Color, hov: Color| { - button(text(label).size(13).color(TEXT_COL)) + let btn = |lbl: &'static str, msg: Message, base: Color, hov: Color| { + button(text(lbl).size(12).color(TEXT)) .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, + .style(move |_: &Theme, st| button::Style { + background: Some(Background::Color( + if matches!(st, button::Status::Hovered | button::Status::Pressed) { hov } else { base } + )), + text_color: TEXT, + border: Border { color: BORDER, width: 1.0, radius: 4.0.into() }, + ..Default::default() }) - .padding([5, 14]) + .padding([4, 12]) }; - let sel_static = SAVE_FORMAT_OPTIONS.iter().copied().find(|&s| s == format); + // ── Path bar ───────────────────────────────────────────────────────── + let path_str = folder.to_string_lossy().into_owned(); + let up_path = folder.parent().map(|p| p.to_path_buf()); + let path_bar = row![ + { + let up_msg = up_path.map(Message::SaveDialogNavigate); + let b = button(text("↑").size(14).color(TEXT)) + .style(|_: &Theme, st| button::Style { + background: Some(Background::Color( + if matches!(st, button::Status::Hovered | button::Status::Pressed) + { BTN_GHOV } else { BTN_GREY } + )), + text_color: TEXT, + border: Border { color: BORDER, width: 1.0, radius: 4.0.into() }, + ..Default::default() + }) + .padding([3, 10]); + if let Some(msg) = up_msg { b.on_press(msg) } else { b } + }, + Space::new().width(8), + container( + text(path_str.clone()).size(12).color(DIM) + ) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(INPUT_BG)), + border: Border { color: BORDER, width: 1.0, radius: 4.0.into() }, + ..Default::default() + }) + .padding([4, 8]) + .width(Fill), + ] + .align_y(iced::Alignment::Center); - let label = |s: &'static str| text(s).size(12).color(Color { r: 0.65, g: 0.65, b: 0.68, a: 1.0 }); + // ── File list ───────────────────────────────────────────────────────── + let file_list: Element<'_, Message> = { + let rows: Vec> = entries.iter().map(|(name, is_dir, path)| { + let icon = if *is_dir { "📁" } else { "📄" }; + let color = if *is_dir { DIR_COL } else { FILE_COL }; + let p = path.clone(); + let d = *is_dir; + mouse_area( + container( + row![ + text(icon).size(13), + Space::new().width(6), + text(name.as_str()).size(13).color(color), + ] + .align_y(iced::Alignment::Center), + ) + .style(|_: &Theme| container::Style { ..Default::default() }) + .padding([3, 8]) + .width(Fill), + ) + .on_press(Message::SaveDialogEntryClicked(p, d)) + .into() + }).collect(); + + container( + iced::widget::scrollable( + column(rows).spacing(1).width(Fill) + ) + ) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(LIST_BG)), + border: Border { color: BORDER, width: 1.0, radius: 4.0.into() }, + ..Default::default() + }) + .width(Fill) + .height(Fill) + .into() + }; + let _ = ROW_HOV; // used conceptually, suppress warning + + let sel_fmt = SAVE_FORMAT_OPTIONS.iter().copied().find(|&s| s == format); + let label = |s: &'static str| text(s).size(11).color(DIM); + + // ── Bottom controls ─────────────────────────────────────────────────── + let bottom = column![ + row![ + label("File name:").width(90), + text_input("drawing.dwg", filename) + .on_input(Message::SaveDialogFilenameChanged) + .style(input_sty) + .size(13) + .padding([5, 8]) + .width(Fill), + ] + .align_y(iced::Alignment::Center) + .spacing(6), + Space::new().height(6), + row![ + label("Format:").width(90), + pick_list(SAVE_FORMAT_OPTIONS, sel_fmt, + |s: &str| Message::SaveDialogFormatChanged(s.to_string())) + .width(Fill), + ] + .align_y(iced::Alignment::Center) + .spacing(6), + Space::new().height(12), + row![ + Space::new().width(Fill), + btn("Save", Message::SaveDialogConfirm, BTN_OK, BTN_HOV), + Space::new().width(8), + btn("Cancel", Message::SaveDialogCancel, BTN_GREY, BTN_GHOV), + ], + ] + .spacing(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), - ], + path_bar, + Space::new().height(8), + file_list, + Space::new().height(10), + bottom, ] .spacing(0), ) - .style(move |_: &Theme| container::Style { + .style(|_: &Theme| container::Style { background: Some(Background::Color(BG)), ..Default::default() }) - .padding([20, 24]) + .padding([14, 16]) .width(Fill) .height(Fill) .into() diff --git a/src/io/mod.rs b/src/io/mod.rs index 119a49479..085e06622 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -78,14 +78,33 @@ pub fn load_file(path: &Path) -> Result { } } -// ── Folder picker (used by the custom Save As dialog) ──────────────────── +// ── Directory listing (used by the custom Save As dialog) ──────────────── -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); +/// Read `dir` and return sorted entries: `(display_name, is_dir, full_path)`. +/// Directories come first, then files. Hidden entries (`.`) are skipped. +pub fn read_dir_entries(dir: &std::path::Path) -> Vec<(String, bool, PathBuf)> { + let mut dirs: Vec<(String, bool, PathBuf)> = Vec::new(); + let mut files: Vec<(String, bool, PathBuf)> = Vec::new(); + + if let Some(parent) = dir.parent() { + dirs.push(("..".to_string(), true, parent.to_path_buf())); } - dlg.pick_folder().await.map(|h| h.path().to_path_buf()) + if let Ok(rd) = std::fs::read_dir(dir) { + for entry in rd.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if name.starts_with('.') { continue; } + let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false); + if is_dir { + dirs.push((name, true, entry.path())); + } else { + files.push((name, false, entry.path())); + } + } + } + dirs.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase())); + files.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase())); + dirs.extend(files); + dirs } // ── Save ────────────────────────────────────────────────────────────────── @@ -166,15 +185,6 @@ 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()) -} - -pub fn save_dxf(doc: &CadDocument, path: &Path) -> Result<(), String> { - DxfWriter::new(doc) - .write_to_file(path) - .map_err(|e| e.to_string()) -} // ── Post-load fixups ──────────────────────────────────────────────────────