feat: replace rfd Save As with native iced file chooser

Implements a full in-app file browser window for Save As, replacing the
rfd dialog that could not expose the selected filter index on Linux.
The new chooser includes directory navigation, a scrollable file list,
filename input, and a format drop-down (DWG/DXF × 2018/2013/2010/2007/2004/2000/R14).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-05-01 16:20:38 +03:00
commit 7e8fc8ef88
4 changed files with 224 additions and 146 deletions

View file

@ -154,14 +154,16 @@ pub(super) struct H7CAD {
unsaved_dialog_window: Option<window::Id>, unsaved_dialog_window: Option<window::Id>,
// ── Custom Save-As dialog ───────────────────────────────────────────── // ── 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<window::Id>, save_dialog_window: Option<window::Id>,
/// Currently selected format string, e.g. "DWG 2013". /// Currently selected format string, e.g. "DWG 2013".
save_dialog_format: String, save_dialog_format: String,
/// Editable filename (without path), e.g. "drawing.dwg". /// Editable filename (without path), e.g. "drawing.dwg".
save_dialog_filename: String, save_dialog_filename: String,
/// Target folder path (as a string for editing). /// Currently browsed folder (PathBuf for reliable fs ops).
save_dialog_folder: String, 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. /// True when triggered from the unsaved-changes flow.
save_dialog_for_unsaved: bool, save_dialog_for_unsaved: bool,
@ -212,13 +214,13 @@ pub enum Message {
FileOpened(Result<(String, PathBuf, CadDocument), String>), FileOpened(Result<(String, PathBuf, CadDocument), String>),
SaveFile, SaveFile,
SaveAs, SaveAs,
PickedSavePath(Option<PathBuf>),
// ── Custom Save-As dialog ───────────────────────────────────────────── // ── Custom Save-As dialog ─────────────────────────────────────────────
SaveDialogFormatChanged(String), SaveDialogFormatChanged(String),
SaveDialogFilenameChanged(String), SaveDialogFilenameChanged(String),
SaveDialogFolderChanged(String), /// Navigate the file-chooser to the given directory.
SaveDialogBrowse, SaveDialogNavigate(std::path::PathBuf),
SaveDialogFolderPicked(Option<std::path::PathBuf>), /// User clicked a file entry (fill filename) or directory entry (navigate).
SaveDialogEntryClicked(std::path::PathBuf, bool),
SaveDialogConfirm, SaveDialogConfirm,
SaveDialogCancel, SaveDialogCancel,
ClearScene, ClearScene,
@ -649,7 +651,9 @@ impl H7CAD {
save_dialog_filename: "drawing.dwg".to_string(), save_dialog_filename: "drawing.dwg".to_string(),
save_dialog_folder: std::env::var("HOME") save_dialog_folder: std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE")) .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, save_dialog_for_unsaved: false,
// Plot style // Plot style
active_plot_style: None, active_plot_style: None,

View file

@ -181,7 +181,11 @@ impl H7CAD {
let name = block_name.clone(); let name = block_name.clone();
Task::perform( Task::perform(
async move { 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)
}, },
|(name, path)| Message::WblockSaveResult(name, path), |(name, path)| Message::WblockSaveResult(name, path),
@ -425,7 +429,6 @@ impl H7CAD {
} }
Message::SaveDialogFormatChanged(fmt) => { Message::SaveDialogFormatChanged(fmt) => {
// Keep filename extension in sync with format choice.
let (ext, _) = crate::io::parse_save_format(&fmt); let (ext, _) = crate::io::parse_save_format(&fmt);
let stem = std::path::Path::new(&self.save_dialog_filename) let stem = std::path::Path::new(&self.save_dialog_filename)
.file_stem() .file_stem()
@ -441,64 +444,48 @@ impl H7CAD {
Task::none() Task::none()
} }
Message::SaveDialogFolderChanged(folder) => { Message::SaveDialogNavigate(path) => {
self.save_dialog_folder = folder; self.save_dialog_folder = path.clone();
self.save_dialog_entries = crate::io::read_dir_entries(&path);
Task::none() Task::none()
} }
Message::SaveDialogBrowse => { Message::SaveDialogEntryClicked(path, is_dir) => {
let folder = self.save_dialog_folder.clone(); if is_dir {
Task::perform( self.save_dialog_folder = path.clone();
async move { crate::io::pick_folder(&folder).await }, self.save_dialog_entries = crate::io::read_dir_entries(&path);
Message::SaveDialogFolderPicked, } else {
) // Fill filename from clicked file.
if let Some(name) = path.file_name() {
self.save_dialog_filename = name.to_string_lossy().into_owned();
}
} }
Message::SaveDialogFolderPicked(Some(p)) => {
self.save_dialog_folder = p.to_string_lossy().into_owned();
Task::none() Task::none()
} }
Message::SaveDialogFolderPicked(None) => Task::none(),
Message::SaveDialogConfirm => { Message::SaveDialogConfirm => {
let folder = std::path::Path::new(&self.save_dialog_folder); let path = self.save_dialog_folder.join(&self.save_dialog_filename);
let path = folder.join(&self.save_dialog_filename);
let (_, version) = crate::io::parse_save_format(&self.save_dialog_format); let (_, version) = crate::io::parse_save_format(&self.save_dialog_format);
let close = self.close_save_dialog_window(); 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; let i = self.active_tab;
match crate::io::save_as_version(&self.tabs[i].scene.document, &path, version) { match crate::io::save_as_version(&self.tabs[i].scene.document, &path, version) {
Ok(()) => { Ok(()) => {
self.command_line.push_output(&format!("Saved: {}", path.display())); self.command_line.push_output(&format!("Saved: {}", path.display()));
self.tabs[i].current_path = Some(path); self.tabs[i].current_path = Some(path.clone());
self.tabs[i].dirty = false; 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}")), Err(e) => self.command_line.push_error(&format!("Save failed: {e}")),
} }
close close
} }
}
Message::SaveDialogCancel => self.close_save_dialog_window(), Message::SaveDialogCancel => self.close_save_dialog_window(),
Message::PickedSavePath(Some(_)) | Message::PickedSavePath(None) => Task::none(),
Message::ClearScene => { Message::ClearScene => {
let i = self.active_tab; let i = self.active_tab;
@ -3922,23 +3909,24 @@ impl H7CAD {
if let Some(id) = self.save_dialog_window { if let Some(id) = self.save_dialog_window {
return window::gain_focus(id); return window::gain_focus(id);
} }
// Pre-fill filename from current path or tab name. // Pre-fill filename and folder from current path or defaults.
let (filename, folder) = if let Some(p) = &self.tabs[tab_idx].current_path { if let Some(p) = &self.tabs[tab_idx].current_path.clone() {
let name = p.file_name().map(|n| n.to_string_lossy().into_owned()) if let Some(name) = p.file_name() {
.unwrap_or_else(|| "drawing.dwg".to_string()); self.save_dialog_filename = name.to_string_lossy().into_owned();
let dir = p.parent().map(|d| d.to_string_lossy().into_owned()) }
.unwrap_or_else(|| self.save_dialog_folder.clone()); if let Some(dir) = p.parent() {
(name, dir) self.save_dialog_folder = dir.to_path_buf();
}
} else { } else {
let (ext, _) = crate::io::parse_save_format(&self.save_dialog_format); let (ext, _) = crate::io::parse_save_format(&self.save_dialog_format);
let name = format!("{}.{ext}", self.tabs[tab_idx].tab_display_name()); self.save_dialog_filename =
(name, self.save_dialog_folder.clone()) format!("{}.{ext}", self.tabs[tab_idx].tab_display_name());
}; }
self.save_dialog_filename = filename; self.save_dialog_entries =
self.save_dialog_folder = folder; crate::io::read_dir_entries(&self.save_dialog_folder.clone());
let (id, task) = window::open(window::Settings { let (id, task) = window::open(window::Settings {
size: iced::Size::new(500.0, 220.0), size: iced::Size::new(560.0, 480.0),
resizable: false, resizable: true,
..Default::default() ..Default::default()
}); });
self.save_dialog_window = Some(id); self.save_dialog_window = Some(id);

View file

@ -119,6 +119,7 @@ impl H7CAD {
return save_as_dialog_window( return save_as_dialog_window(
&self.save_dialog_filename, &self.save_dialog_filename,
&self.save_dialog_folder, &self.save_dialog_folder,
&self.save_dialog_entries,
&self.save_dialog_format, &self.save_dialog_format,
); );
} }
@ -856,95 +857,170 @@ const SAVE_FORMAT_OPTIONS: &[&str] = &[
fn save_as_dialog_window<'a>( fn save_as_dialog_window<'a>(
filename: &'a str, filename: &'a str,
folder: &'a str, folder: &'a std::path::Path,
entries: &'a [(String, bool, std::path::PathBuf)],
format: &'a str, format: &'a str,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
const BG: Color = Color { r: 0.18, g: 0.18, b: 0.20, a: 1.0 }; const BG: Color = Color { r: 0.15, g: 0.15, b: 0.17, a: 1.0 };
const BORDER_COL:Color = Color { r: 0.38, g: 0.38, b: 0.42, a: 1.0 }; const LIST_BG: Color = Color { r: 0.11, g: 0.11, b: 0.13, a: 1.0 };
const TEXT_COL: Color = Color { r: 0.90, g: 0.90, b: 0.90, a: 1.0 }; const BORDER: Color = Color { r: 0.32, g: 0.32, b: 0.36, a: 1.0 };
const INPUT_BG: Color = Color { r: 0.12, g: 0.12, b: 0.14, 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_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_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_GREY: Color = Color { r: 0.26, g: 0.26, b: 0.29, a: 1.0 };
const BTN_DHOV: Color = Color { r: 0.36, g: 0.36, b: 0.40, 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 { iced::widget::text_input::Style {
background: Background::Color(INPUT_BG), background: Background::Color(INPUT_BG),
border: Border { color: BORDER_COL, width: 1.0, radius: 4.0.into() }, border: Border { color: BORDER, width: 1.0, radius: 4.0.into() },
icon: TEXT_COL, icon: TEXT, placeholder: DIM, value: TEXT,
placeholder: Color { r: 0.5, g: 0.5, b: 0.5, a: 1.0 }, selection: Color { r: 0.20, g: 0.46, b: 0.80, a: 0.45 },
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| { let btn = |lbl: &'static str, msg: Message, base: Color, hov: Color| {
button(text(label).size(13).color(TEXT_COL)) button(text(lbl).size(12).color(TEXT))
.on_press(msg) .on_press(msg)
.style(move |_: &Theme, status| button::Style { .style(move |_: &Theme, st| button::Style {
background: Some(Background::Color(match status { background: Some(Background::Color(
button::Status::Hovered | button::Status::Pressed => hov, if matches!(st, button::Status::Hovered | button::Status::Pressed) { hov } else { base }
_ => base, )),
})), text_color: TEXT,
text_color: TEXT_COL, border: Border { color: BORDER, width: 1.0, radius: 4.0.into() },
border: Border { color: BORDER_COL, width: 1.0, radius: 4.0.into() }, ..Default::default()
shadow: iced::Shadow::default(),
snap: false,
}) })
.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<Element<'_, Message>> = 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( container(
column![ column![
label("File name"), path_bar,
iced::widget::Space::new().height(4), Space::new().height(8),
iced::widget::text_input("drawing.dwg", filename) file_list,
.on_input(Message::SaveDialogFilenameChanged) Space::new().height(10),
.style(input_style) bottom,
.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), .spacing(0),
) )
.style(move |_: &Theme| container::Style { .style(|_: &Theme| container::Style {
background: Some(Background::Color(BG)), background: Some(Background::Color(BG)),
..Default::default() ..Default::default()
}) })
.padding([20, 24]) .padding([14, 16])
.width(Fill) .width(Fill)
.height(Fill) .height(Fill)
.into() .into()

View file

@ -78,14 +78,33 @@ pub fn load_file(path: &Path) -> Result<CadDocument, String> {
} }
} }
// ── 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<PathBuf> { /// Read `dir` and return sorted entries: `(display_name, is_dir, full_path)`.
let mut dlg = rfd::AsyncFileDialog::new().set_title("Choose folder"); /// Directories come first, then files. Hidden entries (`.`) are skipped.
if !start.is_empty() { pub fn read_dir_entries(dir: &std::path::Path) -> Vec<(String, bool, PathBuf)> {
dlg = dlg.set_directory(start); 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 ────────────────────────────────────────────────────────────────── // ── Save ──────────────────────────────────────────────────────────────────
@ -166,15 +185,6 @@ pub fn save(doc: &CadDocument, path: &Path) -> Result<(), String> {
save_as_version(doc, path, doc.version) 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 ────────────────────────────────────────────────────── // ── Post-load fixups ──────────────────────────────────────────────────────