feat(save): native OS dialog for Save As, keep in-app format picker

Replace the custom in-app file-browser save dialog with the native OS
save dialog for choosing the destination path — folder browsing and
overwrite confirmation now come from the OS. A compact in-app modal
still selects the DWG/DXF format and version (a native dialog cannot
carry the version year) and appears only for Save As. Plain Save on an
unsaved drawing defaults to DWG 2018 with no picker. The file-name field
is shown only on web, where there is no native dialog.

Remove the hand-rolled directory listing, folder navigation, and
overwrite modal.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-11 09:12:36 +03:00
1 parent f3482e8b3e
commit 6032a024d2
8 files changed
+212 -526

No files matched your search

+4 -23
View File
@@ -435,8 +435,6 @@ pub(super) struct OpenCADStudio {
aec_drop_acknowledged: bool,
/// Number of unsupported objects shown in the AEC-drop warning modal.
aec_drop_count: usize,
/// Set once the user confirms overwriting an existing file on Save-As.
overwrite_acknowledged: bool,
/// Layers awaiting a "delete non-empty layer(s)" confirmation: `(names,
/// total object count)`. Set when the user deletes one or more layers that
/// still have objects; the warning modal reads it, and confirming erases
@@ -677,12 +675,9 @@ pub(super) struct OpenCADStudio {
/// OS window for the custom Save As dialog.
/// Currently selected format string, e.g. "DWG 2013".
save_dialog_format: String,
/// Editable filename (without path), e.g. "drawing.dwg".
/// Editable filename (without path), e.g. "drawing.dwg" — seeds the native
/// OS save dialog's default name (native) or the download name (web).
save_dialog_filename: 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,
@@ -1097,7 +1092,6 @@ pub enum ModalKind {
Unsaved,
SaveDialog,
AecDropWarning,
OverwriteWarning,
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
AssocPrompt,
PointStyle,
@@ -1234,12 +1228,10 @@ pub enum Message {
// ── Custom Save-As dialog ─────────────────────────────────────────────
SaveDialogFormatChanged(String),
SaveDialogFilenameChanged(String),
/// 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,
/// Destination picked in the native OS save dialog (`None` = cancelled).
SaveDialogPathPicked(Option<std::path::PathBuf>),
ClearScene,
SetWireframe(bool),
/// Set the active tab's render mode (one of acadrust's seven visual
@@ -1285,11 +1277,6 @@ pub enum Message {
AecDropProceed,
/// Go back to the Save dialog from the AEC-drop warning.
AecDropBack,
// ── Overwrite confirmation (Save-As over an existing file) ────────────
/// Replace the existing file and save.
OverwriteConfirm,
/// Go back to the Save dialog from the overwrite warning.
OverwriteCancel,
/// Periodic autosave tick — write `.sv$` recovery files for dirty tabs.
AutoSave,
/// Save-as path picked for the unsaved-changes → save → close flow.
@@ -2170,7 +2157,6 @@ impl OpenCADStudio {
active_modal: None,
aec_drop_acknowledged: false,
aec_drop_count: 0,
overwrite_acknowledged: false,
layer_delete_pending: None,
modal_offset: iced::Vector::ZERO,
modal_drag_last: None,
@@ -2218,11 +2204,6 @@ impl OpenCADStudio {
pending_close: 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())
.into(),
save_dialog_entries: Vec::new(),
save_dialog_for_unsaved: false,
// Plot style
active_plot_style: None,
+10 -11
View File
@@ -36,21 +36,17 @@ impl OpenCADStudio {
self.save_dialog_format =
crate::io::format_for_version(acadrust::DxfVersion::AC1032, is_dxf);
// Pre-fill filename and folder from current path or defaults.
// Pre-fill the default file name from the current path or the tab name;
// the destination folder comes from the native OS dialog that follows.
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);
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());
self.aec_drop_acknowledged = false;
self.overwrite_acknowledged = false;
self.active_modal = Some(crate::app::ModalKind::SaveDialog);
Task::none()
}
@@ -58,7 +54,6 @@ impl OpenCADStudio {
pub(in crate::app) fn close_save_dialog_window(&mut self) -> Task<Message> {
self.aec_drop_acknowledged = false;
self.overwrite_acknowledged = false;
if self.active_modal == Some(crate::app::ModalKind::SaveDialog) {
self.active_modal = None;
}
@@ -221,11 +216,13 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven
}
}
} else {
// No path — close unsaved dialog, open custom Save As dialog.
// No path — close unsaved dialog and save a default
// DWG 2018 via the native destination dialog (no
// version picker; that is reserved for Save As).
self.pending_close = Some(crate::app::PendingClose::Tab(idx));
self.save_dialog_for_unsaved = true;
let close_win = self.close_unsaved_dialog_window();
let open_save = self.open_save_dialog_window(idx);
let open_save = self.save_default_dwg2018(idx);
return Task::batch([close_win, open_save]);
}
}
@@ -245,12 +242,14 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven
}
}
} else {
// No path — close unsaved dialog, open custom Save As dialog.
// No path — close unsaved dialog and save a
// default DWG 2018 via the native destination
// dialog (version picker is Save-As only).
self.active_tab = idx;
self.pending_close = Some(crate::app::PendingClose::Quit);
self.save_dialog_for_unsaved = true;
let close_win = self.close_unsaved_dialog_window();
let open_save = self.open_save_dialog_window(idx);
let open_save = self.save_default_dwg2018(idx);
return Task::batch([close_win, open_save]);
}
}
+98 -46
View File
@@ -723,7 +723,24 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
return Task::none();
}
self.save_dialog_for_unsaved = false;
self.open_save_dialog_window(i)
self.save_default_dwg2018(i)
}
/// Save without the version picker: default to DWG 2018 and go straight to
/// the native destination dialog (native) or the browser download (web).
/// Used by plain Save (QSAVE) on an as-yet-unsaved drawing and by the
/// save-before-close flow — the version picker is reserved for Save As.
pub(in crate::app) fn save_default_dwg2018(&mut self, tab_idx: usize) -> Task<Message> {
self.active_tab = tab_idx;
self.save_dialog_format = "DWG 2018".to_string();
self.save_dialog_filename = self.tabs[tab_idx]
.current_path
.as_ref()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| format!("{}.dwg", self.tabs[tab_idx].tab_display_name()));
self.aec_drop_acknowledged = false;
self.on_save_dialog_confirm()
}
pub(super) fn on_save_dialog_confirm(&mut self) -> Task<Message> {
@@ -756,52 +773,49 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
name.to_string()
};
self.save_dialog_filename = filename.clone();
// Confirm before overwriting an existing file in the folder.
#[cfg(not(target_arch = "wasm32"))]
if !self.overwrite_acknowledged
&& self.save_dialog_folder.join(&filename).exists()
{
self.active_modal = Some(crate::app::ModalKind::OverwriteWarning);
return Task::none();
}
let close = self.close_save_dialog_window();
let i = self.active_tab;
sync_annotation_scale_header(&mut self.tabs[i].scene);
// Persist Ortho / running OSNAP into the header (Save-As path).
self.stamp_header_sysvars(i);
self.sync_truck_solids_to_acis(i);
self.stamp_thumbnail(i);
// Native: write to the chosen path. Web: download the bytes
// under the chosen name (no filesystem).
// Native: hand the destination choice to the OS save dialog —
// it provides folder browsing and overwrite confirmation. The
// chosen version rides in `save_dialog_format` and the write
// happens in `on_save_dialog_path_picked` once a path returns.
#[cfg(not(target_arch = "wasm32"))]
{
let path = self.save_dialog_folder.join(&filename);
if self.backup_on_save {
crate::io::write_backup(&path);
}
match crate::io::save_as_version(&self.tabs[i].scene.document, &path, version) {
Ok(()) => {
self.command_line
.push_output(&format!("Saved: {}", path.display()));
// Drop any prior autosave copy — including the temp
// one used while the drawing was still unsaved —
// before the tab takes on its new path.
let _ = std::fs::remove_file(self.autosave_target(i));
self.tabs[i].current_path = Some(path.clone());
self.tabs[i].dirty = false;
let _ = std::fs::remove_file(path.with_extension("sv$"));
if self.save_dialog_for_unsaved {
let next = self.update(Message::UnsavedPickedSavePath(Some(path)));
return Task::batch([close, next]);
let seed_dir = self.tabs[i]
.current_path
.as_ref()
.and_then(|p| p.parent().map(|d| d.to_path_buf()));
let default_name = filename;
let (filter_label, filter_ext): (&str, &str) =
if ext.eq_ignore_ascii_case("dxf") {
("DXF Files", "dxf")
} else {
("DWG Files", "dwg")
};
let close = self.close_save_dialog_window();
let pick = Task::perform(
async move {
let mut dlg = rfd::AsyncFileDialog::new()
.set_title("Save Drawing As")
.set_file_name(default_name)
.add_filter(filter_label, &[filter_ext]);
if let Some(dir) = seed_dir {
dlg = dlg.set_directory(dir);
}
}
Err(e) => self.command_line.push_error(&format!("Save failed: {e}")),
}
close
dlg.save_file().await.map(|h| crate::sys::handle_path(&h))
},
Message::SaveDialogPathPicked,
);
Task::batch([close, pick])
}
// Web: no filesystem — serialize and hand the browser a download.
#[cfg(target_arch = "wasm32")]
{
let close = self.close_save_dialog_window();
sync_annotation_scale_header(&mut self.tabs[i].scene);
self.stamp_header_sysvars(i);
self.sync_truck_solids_to_acis(i);
self.stamp_thumbnail(i);
match crate::io::save_to_bytes(&self.tabs[i].scene.document, ext, version) {
Ok(bytes) => {
crate::sys::download_bytes(&filename, &bytes);
@@ -821,6 +835,51 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
}
}
/// Native: a destination path came back from the OS save dialog — write the
/// file there in the chosen version. `None` means the user cancelled.
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn on_save_dialog_path_picked(
&mut self,
picked: Option<std::path::PathBuf>,
) -> Task<Message> {
let Some(path) = picked else {
// Cancelled the OS dialog — if this came from the unsaved-changes →
// save → close flow, re-show the confirmation so the tab isn't lost.
if self.save_dialog_for_unsaved && self.pending_close.is_some() {
return self.open_unsaved_dialog_window();
}
return Task::none();
};
let (_ext, version) = crate::io::parse_save_format(&self.save_dialog_format);
let i = self.active_tab;
sync_annotation_scale_header(&mut self.tabs[i].scene);
// Persist Ortho / running OSNAP into the header (Save-As path).
self.stamp_header_sysvars(i);
self.sync_truck_solids_to_acis(i);
self.stamp_thumbnail(i);
if self.backup_on_save {
crate::io::write_backup(&path);
}
match crate::io::save_as_version(&self.tabs[i].scene.document, &path, version) {
Ok(()) => {
self.command_line
.push_output(&format!("Saved: {}", path.display()));
// Drop any prior autosave copy — including the temp one used
// while the drawing was still unsaved — before the tab takes on
// its new path.
let _ = std::fs::remove_file(self.autosave_target(i));
self.tabs[i].current_path = Some(path.clone());
self.tabs[i].dirty = false;
let _ = std::fs::remove_file(path.with_extension("sv$"));
if self.save_dialog_for_unsaved {
return self.update(Message::UnsavedPickedSavePath(Some(path)));
}
}
Err(e) => self.command_line.push_error(&format!("Save failed: {e}")),
}
Task::none()
}
/// AEC-drop warning → "Save anyway": accept the loss and proceed with the
/// format the user already chose.
pub(super) fn on_aec_drop_proceed(&mut self) -> Task<Message> {
@@ -846,13 +905,6 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
self.on_save_dialog_confirm()
}
/// Overwrite warning → "Replace": save over the existing file.
pub(super) fn on_overwrite_confirm(&mut self) -> Task<Message> {
self.overwrite_acknowledged = true;
self.active_modal = Some(crate::app::ModalKind::SaveDialog);
self.on_save_dialog_confirm()
}
/// Where the autosave recovery copy for tab `i` lives: beside a saved
/// drawing as `<file>.sv$`, or — for an unsaved drawing with no path yet —
/// under the system temp dir keyed by the tab's display name.
+6 -24
View File
@@ -558,29 +558,16 @@ impl OpenCADStudio {
Task::none()
}
Message::SaveDialogNavigate(path) => {
self.save_dialog_folder = path.clone();
self.save_dialog_entries = crate::io::read_dir_entries(&path);
Task::none()
}
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::SaveDialogConfirm => self.on_save_dialog_confirm(),
Message::SaveDialogCancel => self.close_save_dialog_window(),
#[cfg(not(target_arch = "wasm32"))]
Message::SaveDialogPathPicked(picked) => self.on_save_dialog_path_picked(picked),
#[cfg(target_arch = "wasm32")]
Message::SaveDialogPathPicked(_) => Task::none(),
Message::ClearScene => {
let i = self.active_tab;
self.push_undo_snapshot(i, "CLEAR");
@@ -3139,11 +3126,6 @@ impl OpenCADStudio {
Task::none()
}
Message::OverwriteConfirm => self.on_overwrite_confirm(),
Message::OverwriteCancel => {
self.active_modal = Some(crate::app::ModalKind::SaveDialog);
Task::none()
}
Message::AutoSave => self.on_autosave(),
Message::UnsavedPickedSavePath(Some(path)) => {
+4 -2
View File
@@ -1565,8 +1565,10 @@ impl OpenCADStudio {
AssocPrompt => (440, 210),
Unsaved => (420, 160),
AecDropWarning => (480, 230),
OverwriteWarning => (420, 180),
SaveDialog => (560, 480),
#[cfg(target_arch = "wasm32")]
SaveDialog => (420, 200),
#[cfg(not(target_arch = "wasm32"))]
SaveDialog => (420, 150),
PointStyle => (360, 470),
AttributeEditor => (640, 500),
LayerDeleteWarning => (440, 200),
+90 -261
View File
@@ -1,8 +1,5 @@
use super::super::{Message, OpenCADStudio};
use iced::widget::{
button, column, container, mouse_area, pick_list, row, text, text_input,
Space,
};
use iced::widget::{button, column, container, pick_list, row, text, Space};
use iced::{Background, Border, Color, Element, Fill, Theme};
impl OpenCADStudio {
@@ -474,9 +471,6 @@ impl OpenCADStudio {
230,
)
}
super::super::ModalKind::OverwriteWarning => {
sized(overwrite_dialog_window(&self.save_dialog_filename), 420, 180)
}
super::super::ModalKind::LayerDeleteWarning => {
let (names, count) = self
.layer_delete_pending
@@ -540,16 +534,19 @@ impl OpenCADStudio {
500,
)
}
super::super::ModalKind::SaveDialog => sized(
save_as_dialog_window(
&self.save_dialog_filename,
&self.save_dialog_folder,
&self.save_dialog_entries,
&self.save_dialog_format,
),
560,
480,
),
super::super::ModalKind::SaveDialog => {
// Native omits the file-name field (the OS dialog collects it),
// so it stands shorter than the web build.
#[cfg(target_arch = "wasm32")]
let h = 200;
#[cfg(not(target_arch = "wasm32"))]
let h = 150;
sized(
save_as_dialog_window(&self.save_dialog_filename, &self.save_dialog_format),
420,
h,
)
}
})
}
}
@@ -559,24 +556,16 @@ const SAVE_FORMAT_OPTIONS: &[&str] = &[
"DXF 2013", "DXF 2010", "DXF 2007", "DXF 2004", "DXF 2000", "DXF R14",
];
fn save_as_dialog_window<'a>(
filename: &'a str,
folder: &'a std::path::Path,
entries: &'a [(String, bool, std::path::PathBuf)],
format: &'a str,
) -> Element<'a, Message> {
/// Compact Save-As options dialog: pick the format/version and a default file
/// name. The destination folder and overwrite confirmation come from the
/// native OS save dialog (native) or the browser download (web) that follows.
fn save_as_dialog_window<'a>(filename: &'a str, format: &'a str) -> Element<'a, Message> {
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,
@@ -595,12 +584,6 @@ fn save_as_dialog_window<'a>(
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,
@@ -625,38 +608,6 @@ fn save_as_dialog_window<'a>(
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_sty =
|_: &Theme, _: iced::widget::text_input::Status| iced::widget::text_input::Style {
background: Background::Color(INPUT_BG),
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 = |lbl: &'static str, msg: Message, base: Color, hov: Color| {
button(text(lbl).size(12).color(TEXT))
@@ -680,163 +631,94 @@ fn save_as_dialog_window<'a>(
.padding([4, 12])
};
// ── Path bar ─────────────────────────────────────────────────────────
let path_str = if crate::io::is_drives_root(folder) {
crate::io::drives_root_label().to_string()
} else {
folder.to_string_lossy().into_owned()
};
let up_path = crate::io::parent_folder(folder);
let path_bar = row![
{
let up_msg = up_path.map(Message::SaveDialogNavigate);
let b = button(crate::ui::icons::tinted(crate::ui::icons::UP, 14.0, 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);
// ── File list ─────────────────────────────────────────────────────────
let file_list: Element<'_, Message> = {
let rows: Vec<Element<'_, Message>> = entries
.iter()
.map(|(name, is_dir, path)| {
let icon_bytes = if *is_dir {
crate::ui::icons::FOLDER
} else {
crate::ui::icons::DOC
};
let color = if *is_dir { DIR_COL } else { FILE_COL };
let p = path.clone();
let d = *is_dir;
mouse_area(
container(
row![
crate::ui::icons::tinted(icon_bytes, 13.0, color),
Space::new().width(6),
text(crate::ui::text_util::elide(name.as_str(), 48))
.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![
let mut items: Vec<Element<'a, Message>> = Vec::new();
items.push(text("Save Drawing As").size(14).color(TEXT).into());
items.push(Space::new().height(12).into());
// Web has no native file dialog, so the file name is typed here. On native
// the OS save dialog collects the name, so this field is omitted.
#[cfg(target_arch = "wasm32")]
{
const INPUT_BG: Color = Color {
r: 0.10,
g: 0.10,
b: 0.12,
a: 1.0,
};
let input_sty =
|_: &Theme, _: iced::widget::text_input::Status| iced::widget::text_input::Style {
background: Background::Color(INPUT_BG),
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,
},
};
items.push(
row![
label("File name:").width(70),
iced::widget::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)
.into(),
);
items.push(Space::new().height(8).into());
}
#[cfg(not(target_arch = "wasm32"))]
let _ = filename;
items.push(
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),
label("Format:").width(70),
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),
.spacing(6)
.into(),
);
items.push(Space::new().height(16).into());
items.push(
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![
path_bar,
Space::new().height(8),
file_list,
Space::new().height(10),
bottom,
]
.spacing(0),
)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(BG)),
..Default::default()
})
.padding([14, 16])
.width(Fill)
.height(Fill)
.into()
.into(),
);
let body = column(items).spacing(0);
container(body)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(BG)),
..Default::default()
})
.padding([14, 16])
.width(Fill)
.height(Fill)
.into()
}
fn unsaved_changes_dialog_window(name: &str) -> Element<'static, Message> {
@@ -992,59 +874,6 @@ fn aec_drop_dialog_window(count: usize, target: &str, src_version: &str) -> Elem
/// Confirmation shown when the chosen Save-As filename already exists in the
/// target folder. "Replace" overwrites; "Cancel" returns to the Save dialog.
fn overwrite_dialog_window(filename: &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 };
const TEXT_COL: Color = Color { r: 0.90, g: 0.90, b: 0.90, a: 1.0 };
const BTN_SAVE: Color = Color { r: 0.20, g: 0.46, b: 0.80, a: 1.0 };
const BTN_HOVER: 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 body_text =
format!("\"{filename}\" already exists in this folder.\n\nDo you want to replace it?");
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([6, 18])
};
container(
column![
text(body_text).size(13).color(TEXT_COL),
iced::widget::Space::new().height(20),
row![
btn("Replace", Message::OverwriteConfirm, BTN_SAVE, BTN_HOVER),
iced::widget::Space::new().width(8),
btn("Cancel", Message::OverwriteCancel, BTN_DISC, BTN_DHOV),
],
]
.spacing(0),
)
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(BG)),
..Default::default()
})
.center(Fill)
.padding([24, 28])
.into()
}
/// Confirm deleting layer(s) that still have objects on them. "Delete Objects"
/// erases them and removes the layers; "Cancel" leaves everything.
fn layer_delete_warning_window(names: &[String], count: usize) -> Element<'static, Message> {
-156
View File
@@ -266,162 +266,6 @@ fn resolve_image_file(raw: &str, base_dir: Option<&Path>) -> Option<String> {
None
}
// ── Directory listing (used by the custom Save As dialog) ────────────────
/// Sentinel path standing in for the volumes root — the pseudo-folder above
/// every drive/volume, whose children are the mounted volumes. On Windows this
/// is "This PC" (above the drive letters); on macOS/Linux it is "Computer"
/// (above `/` and the removable mounts). A real filesystem path is never empty,
/// so an empty path is a safe marker on every platform.
pub fn drives_root() -> PathBuf {
PathBuf::new()
}
/// True if `p` is the drives-root sentinel (see [`drives_root`]).
pub fn is_drives_root(p: &Path) -> bool {
p.as_os_str().is_empty()
}
/// Human label for the drives-root, matching each platform's file manager.
pub fn drives_root_label() -> &'static str {
#[cfg(windows)]
{
"This PC"
}
#[cfg(not(windows))]
{
"Computer"
}
}
/// The folder one level above `dir`, or `None` if already at the top.
///
/// The chain reaches past a filesystem root into the volumes list, so the Save
/// As dialog can switch volumes on every platform:
/// `…\sub` → `C:\` → This PC → `None` (Windows), or
/// `/home/me` → `/home` → `/` → Computer → `None` (macOS/Linux).
/// Without this, `Path::parent()` returns `None` at the root and navigation
/// dead-ends on the starting volume (issue #170).
pub fn parent_folder(dir: &Path) -> Option<PathBuf> {
if is_drives_root(dir) {
return None; // already at the top
}
match dir.parent() {
// A real parent exists (and is not the empty sentinel).
Some(parent) if !parent.as_os_str().is_empty() => Some(parent.to_path_buf()),
// No real parent: we're at a filesystem root (`C:\` or `/`) — step up
// into the volumes list rather than dead-ending.
_ => Some(drives_root()),
}
}
/// Enumerate the accessible volumes as directory entries.
///
/// Windows probes `A:\`–`Z:\` by stat (no Win32 binding, no extra dependency);
/// drives with no inserted media fail the stat and are skipped. macOS lists `/`
/// plus the mounts under `/Volumes`. Linux lists `/` plus removable mounts under
/// the usual desktop locations. Other Unix falls back to just `/`.
fn available_drives() -> Vec<(String, bool, PathBuf)> {
#[cfg(windows)]
{
let mut out: Vec<(String, bool, PathBuf)> = Vec::new();
for letter in b'A'..=b'Z' {
let root = format!("{}:\\", letter as char);
let path = PathBuf::from(&root);
if path.is_dir() {
out.push((root, true, path));
}
}
out
}
#[cfg(target_os = "macos")]
{
let mut out: Vec<(String, bool, PathBuf)> = vec![("/".to_string(), true, PathBuf::from("/"))];
if let Ok(rd) = std::fs::read_dir("/Volumes") {
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
out.push((
entry.file_name().to_string_lossy().into_owned(),
true,
path,
));
}
}
}
out
}
#[cfg(target_os = "linux")]
{
let mut out: Vec<(String, bool, PathBuf)> = vec![("/".to_string(), true, PathBuf::from("/"))];
let user = std::env::var("USER").unwrap_or_default();
// Per-user removable mounts first (udisks2 / older udisks), then /mnt.
let bases = [
format!("/run/media/{user}"),
format!("/media/{user}"),
"/mnt".to_string(),
];
let mut seen: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
for base in bases {
if let Ok(rd) = std::fs::read_dir(&base) {
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() && seen.insert(path.clone()) {
out.push((
entry.file_name().to_string_lossy().into_owned(),
true,
path,
));
}
}
}
}
out
}
#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))]
{
vec![("/".to_string(), true, PathBuf::from("/"))]
}
}
/// 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)> {
// Volumes root ("This PC" / "Computer"): list the mounted volumes instead
// of reading a directory.
if is_drives_root(dir) {
return available_drives();
}
let mut dirs: Vec<(String, bool, PathBuf)> = Vec::new();
let mut files: Vec<(String, bool, PathBuf)> = Vec::new();
if let Some(parent) = parent_folder(dir) {
dirs.push(("..".to_string(), true, parent));
}
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 ──────────────────────────────────────────────────────────────────
/// Parse a format string like "DWG 2013" or "DXF 2007" into
-3
View File
@@ -57,10 +57,7 @@ pub const SPLIT_V: &[u8] = include_bytes!("../../assets/icons/ui/split_v.svg");
pub const SPLIT_H: &[u8] = include_bytes!("../../assets/icons/ui/split_h.svg");
pub const GRID: &[u8] = include_bytes!("../../assets/icons/ui/grid.svg");
pub const SNAP: &[u8] = include_bytes!("../../assets/icons/ui/snap.svg");
pub const UP: &[u8] = include_bytes!("../../assets/icons/ui/up.svg");
pub const DOC_NEW: &[u8] = include_bytes!("../../assets/icons/ui/doc_new.svg");
pub const DOC: &[u8] = include_bytes!("../../assets/icons/ui/doc.svg");
pub const FOLDER: &[u8] = include_bytes!("../../assets/icons/ui/folder.svg");
pub const FOLDER_OPEN: &[u8] = include_bytes!("../../assets/icons/ui/folder_open.svg");
pub const SAVE: &[u8] = include_bytes!("../../assets/icons/ui/save.svg");
pub const FILE_EXPORT: &[u8] = include_bytes!("../../assets/icons/ui/file_export.svg");