feat(ui): migrate the last dialogs to modals — fully single-window (Plan B)

Move the unsaved-changes prompt, the Save As browser and the
file-association prompt onto `active_modal` / `modal_content`, reusing
their existing open/close helpers (now just set/clear active_modal). The
daemon `view` no longer dispatches per window — every dialog is an
in-canvas modal, so the app is single-window on native and web alike.

`close_active_modal` handles their ✕: the unsaved prompt cancels the
pending close, the association prompt records that it was answered.

Native + web build; 75 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-06-16 23:59:38 +03:00
commit 94b906d95b
3 changed files with 56 additions and 81 deletions

View file

@ -606,6 +606,9 @@ pub enum ModalKind {
MlStyle, MlStyle,
MLeaderStyle, MLeaderStyle,
DimStyle, DimStyle,
Unsaved,
SaveDialog,
AssocPrompt,
} }
/// Identifies a DimStyle field that can be edited in the dialog. /// Identifies a DimStyle field that can be edited in the dialog.
@ -1687,18 +1690,10 @@ impl OpenCADStudio {
// One-time prompt offering to make Open CAD Studio the default app for // One-time prompt offering to make Open CAD Studio the default app for
// .dwg / .dxf. Shown only on the first launch that hasn't answered it // .dwg / .dxf. Shown only on the first launch that hasn't answered it
// yet; the flag is persisted so we never ask twice. // yet; the flag is persisted so we never ask twice.
let assoc_prompt: Task<Message> = if s.default_assoc_prompted { let assoc_prompt: Task<Message> = Task::none();
Task::none() if !s.default_assoc_prompted {
} else { s.active_modal = Some(ModalKind::AssocPrompt);
let (id, open) = window::open(window::Settings { }
size: iced::Size::new(440.0, 210.0),
resizable: false,
level: window::Level::AlwaysOnTop,
..Default::default()
});
s.assoc_prompt_window = Some(id);
open.map(|_| Message::Noop)
};
( (
s, s,
Task::batch([open_main, check_update, focus_cmd, cli_open, assoc_prompt]), Task::batch([open_main, check_update, focus_cmd, cli_open, assoc_prompt]),

View file

@ -117,6 +117,9 @@ impl OpenCADStudio {
self.ribbon.deactivate_tool_if("KEYBOARD"); self.ribbon.deactivate_tool_if("KEYBOARD");
} }
Some(About) => self.ribbon.deactivate_tool_if("ABOUT"), Some(About) => self.ribbon.deactivate_tool_if("ABOUT"),
// Dismissing these via ✕ is the cancel/decline path.
Some(Unsaved) => self.pending_close = None,
Some(AssocPrompt) => self.mark_assoc_prompted(),
_ => {} _ => {}
} }
self.active_modal = None; self.active_modal = None;
@ -5784,24 +5787,16 @@ impl OpenCADStudio {
} }
Message::AssocPromptYes => { Message::AssocPromptYes => {
self.mark_assoc_prompted(); self.mark_assoc_prompted();
let close = if let Some(id) = self.assoc_prompt_window.take() { self.active_modal = None;
window::close(id) Task::perform(
} else {
Task::none()
};
let run = Task::perform(
crate::io::file_association::set_default_app(), crate::io::file_association::set_default_app(),
Message::AssocResult, Message::AssocResult,
); )
Task::batch([close, run])
} }
Message::AssocPromptNo => { Message::AssocPromptNo => {
self.mark_assoc_prompted(); self.mark_assoc_prompted();
if let Some(id) = self.assoc_prompt_window.take() { self.active_modal = None;
window::close(id) Task::none()
} else {
Task::none()
}
} }
Message::AssocResult(result) => { Message::AssocResult(result) => {
match result { match result {
@ -8534,44 +8529,27 @@ impl OpenCADStudio {
self.save_dialog_filename = format!("{}.{ext}", self.tabs[tab_idx].tab_display_name()); 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.save_dialog_entries = crate::io::read_dir_entries(&self.save_dialog_folder.clone());
let (id, task) = window::open(window::Settings { self.active_modal = Some(super::ModalKind::SaveDialog);
size: iced::Size::new(560.0, 480.0), Task::none()
resizable: true,
level: window::Level::AlwaysOnTop,
..Default::default()
});
self.save_dialog_window = Some(id);
task.map(|_| Message::Noop)
} }
fn close_save_dialog_window(&mut self) -> Task<Message> { fn close_save_dialog_window(&mut self) -> Task<Message> {
if let Some(id) = self.save_dialog_window.take() { if self.active_modal == Some(super::ModalKind::SaveDialog) {
window::close(id) self.active_modal = None;
} else {
Task::none()
} }
Task::none()
} }
fn open_unsaved_dialog_window(&mut self) -> Task<Message> { fn open_unsaved_dialog_window(&mut self) -> Task<Message> {
if let Some(id) = self.unsaved_dialog_window { self.active_modal = Some(super::ModalKind::Unsaved);
return window::gain_focus(id); Task::none()
}
let (id, task) = window::open(window::Settings {
size: iced::Size::new(420.0, 155.0),
resizable: false,
level: window::Level::AlwaysOnTop,
..Default::default()
});
self.unsaved_dialog_window = Some(id);
task.map(|_| Message::Noop)
} }
fn close_unsaved_dialog_window(&mut self) -> Task<Message> { fn close_unsaved_dialog_window(&mut self) -> Task<Message> {
if let Some(id) = self.unsaved_dialog_window.take() { if self.active_modal == Some(super::ModalKind::Unsaved) {
window::close(id) self.active_modal = None;
} else {
Task::none()
} }
Task::none()
} }
pub(super) fn load_textstyle_bufs(&mut self, tab: usize) { pub(super) fn load_textstyle_bufs(&mut self, tab: usize) {

View file

@ -40,35 +40,9 @@ impl std::fmt::Display for RenderModeChoice {
impl OpenCADStudio { impl OpenCADStudio {
pub fn view(&self, window_id: window::Id) -> Element<'_, Message> { pub fn view(&self, window_id: window::Id) -> Element<'_, Message> {
// ── Floating panel windows ───────────────────────────────────────── // ── Floating panel windows ─────────────────────────────────────────
if Some(window_id) == self.assoc_prompt_window { // All dialogs are in-canvas modals now (Plan B); view_main stacks the
return default_assoc_dialog_window(); // active one. `window_id` is unused — there is only the main window.
} let _ = window_id;
if Some(window_id) == self.unsaved_dialog_window {
let tab_name = match &self.pending_close {
Some(super::PendingClose::Tab(idx)) => self
.tabs
.get(*idx)
.map(|t| t.tab_display_name())
.unwrap_or_default(),
Some(super::PendingClose::Quit) => self
.tabs
.iter()
.find(|t| t.dirty)
.map(|t| t.tab_display_name())
.unwrap_or_default(),
None => String::new(),
};
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_entries,
&self.save_dialog_format,
);
}
self.view_main() self.view_main()
} }
@ -1543,6 +1517,34 @@ impl OpenCADStudio {
&self.style_rename_buf, &self.style_rename_buf,
), 720, 560) ), 720, 560)
} }
super::ModalKind::AssocPrompt => sized(default_assoc_dialog_window(), 440, 210),
super::ModalKind::Unsaved => {
let tab_name = match &self.pending_close {
Some(super::PendingClose::Tab(idx)) => self
.tabs
.get(*idx)
.map(|t| t.tab_display_name())
.unwrap_or_default(),
Some(super::PendingClose::Quit) => self
.tabs
.iter()
.find(|t| t.dirty)
.map(|t| t.tab_display_name())
.unwrap_or_default(),
None => String::new(),
};
sized(unsaved_changes_dialog_window(&tab_name), 420, 160)
}
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,
),
}) })
} }