From fc0aa8698bb0eb91b3d4787160f016abbd6fe953 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 1 May 2026 11:31:11 +0300 Subject: [PATCH] feat: show Save/Discard/Cancel dialog before closing unsaved tabs or quitting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opens a floating OS window when the user tries to close a dirty tab or quit with unsaved changes. The main window stays alive until the dialog is resolved — Cancel aborts the close entirely. For quit with multiple dirty tabs the dialog loops through each one individually. Co-Authored-By: Claude Sonnet 4.6 --- src/app/mod.rs | 34 +++++++- src/app/update.rs | 200 ++++++++++++++++++++++++++++++++++++++++++++++ src/app/view.rs | 68 ++++++++++++++++ 3 files changed, 300 insertions(+), 2 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 4ab55c042..e9995dda5 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -147,6 +147,12 @@ pub(super) struct H7CAD { ps_lineweight_buf: String, ps_screening_buf: String, + // ── Unsaved-changes dialog ──────────────────────────────────────────── + /// Set when the user tries to close a tab or quit while there are unsaved changes. + pending_close: Option, + /// OS window for the unsaved-changes confirmation dialog. + unsaved_dialog_window: Option, + // ── DimStyle Dialog ─────────────────────────────────────────────────── /// Name of the style currently shown in the dialog. dimstyle_selected: String, @@ -167,6 +173,15 @@ pub(super) struct H7CAD { ds_dimtdec: String, ds_dimtfac: String, } +/// What triggered the "unsaved changes" dialog. +#[derive(Debug, Clone)] +pub(super) enum PendingClose { + /// User tried to close the tab at this index. + Tab(usize), + /// User tried to quit the application. + Quit, +} + /// Identifies a DimStyle field that can be edited in the dialog. #[derive(Debug, Clone, PartialEq, Eq)] pub enum DsField { @@ -210,6 +225,15 @@ pub enum Message { TabSwitch(usize), /// Close the given tab index. TabClose(usize), + // ── Unsaved-changes confirmation dialog ─────────────────────────────── + /// User clicked "Save" in the unsaved-changes dialog. + UnsavedDialogSave, + /// User clicked "Discard" in the unsaved-changes dialog. + UnsavedDialogDiscard, + /// User clicked "Cancel" in the unsaved-changes dialog. + UnsavedDialogCancel, + /// Save-as path picked for the unsaved-changes → save → close flow. + UnsavedPickedSavePath(Option), // ───────────────────────────────────────────────────────────────────── CommandInput(String), CommandSubmit, @@ -398,7 +422,9 @@ pub enum Message { AboutCopyInfo, /// Close the viewport right-click context menu without performing any action. ViewportContextMenuClose, - /// A window was closed by the OS (e.g. the user clicked the title-bar ✕). + /// The user clicked the title-bar ✕ (fires before the window closes). + WindowCloseRequested(window::Id), + /// A window was fully closed (fires after `window::close()` is called). OsWindowClosed(window::Id), /// No-op — used as a fallback when a TabEvent has no host mapping. Noop, @@ -596,6 +622,8 @@ impl H7CAD { page_setup_offset_y: "0.0".to_string(), page_setup_rotation: "0".to_string(), page_setup_scale: "Fit".to_string(), + pending_close: None, + unsaved_dialog_window: None, // Plot style active_plot_style: None, // Color scheme (default: dark CAD-style) @@ -650,6 +678,7 @@ impl H7CAD { let (id, open_task) = window::open(window::Settings { maximized: true, icon: window::icon::from_rgba(build_window_icon(), 32, 32).ok(), + exit_on_close_request: false, ..Default::default() }); let mut s = state; @@ -674,7 +703,8 @@ pub fn run() -> iced::Result { if Some(window_id) == state.plotstyle_window { return "Plot Style Table Editor".into(); } if Some(window_id) == state.dimstyle_window { return "Dimension Style Manager".into(); } 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.about_window { return "About H7CAD".into(); } + if Some(window_id) == state.unsaved_dialog_window { return "Unsaved Changes".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 d3f296ad4..11f9653e8 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -532,6 +532,10 @@ impl H7CAD { } Message::TabClose(idx) => { + if self.tabs.get(idx).map_or(false, |t| t.dirty) { + self.pending_close = Some(super::PendingClose::Tab(idx)); + return self.open_unsaved_dialog_window(); + } if self.tabs.len() == 1 { self.tab_counter += 1; self.tabs[0] = super::document::DocumentTab::new_drawing(self.tab_counter); @@ -705,10 +709,28 @@ impl H7CAD { } } + Message::WindowCloseRequested(id) => { + if self.main_window == Some(id) { + if self.tabs.iter().any(|t| t.dirty) { + self.pending_close = Some(super::PendingClose::Quit); + return self.open_unsaved_dialog_window(); + } + return iced::exit(); + } + Task::none() + } + Message::OsWindowClosed(id) => { if self.main_window == Some(id) { + // Main window was explicitly closed by us — exit. return iced::exit(); } + if self.unsaved_dialog_window == Some(id) { + // User closed the dialog window via OS ✕ — treat as Cancel. + self.unsaved_dialog_window = None; + self.pending_close = 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; } @@ -2664,6 +2686,163 @@ impl H7CAD { Message::Noop => Task::none(), + // ── Unsaved-changes dialog ──────────────────────────────────── + Message::UnsavedDialogCancel => { + self.pending_close = None; + self.close_unsaved_dialog_window() + } + + Message::UnsavedDialogDiscard => { + match self.pending_close.take() { + Some(super::PendingClose::Tab(idx)) => { + let close_win = self.close_unsaved_dialog_window(); + if self.tabs.len() == 1 { + self.tab_counter += 1; + self.tabs[0] = super::document::DocumentTab::new_drawing(self.tab_counter); + self.active_tab = 0; + } else { + self.tabs.remove(idx); + if self.active_tab >= self.tabs.len() { + self.active_tab = self.tabs.len() - 1; + } + } + return close_win; + } + Some(super::PendingClose::Quit) => { + if let Some(idx) = self.tabs.iter().position(|t| t.dirty) { + self.tabs[idx].dirty = false; + } + if self.tabs.iter().any(|t| t.dirty) { + // More dirty tabs remain — keep window open. + self.pending_close = Some(super::PendingClose::Quit); + } else { + let close_win = self.close_unsaved_dialog_window(); + return Task::batch(vec![close_win, iced::exit()]); + } + } + None => {} + } + Task::none() + } + + Message::UnsavedDialogSave => { + match self.pending_close.take() { + Some(super::PendingClose::Tab(idx)) => { + if let Some(path) = self.tabs[idx].current_path.clone() { + match crate::io::save(&self.tabs[idx].scene.document, &path) { + Ok(()) => { + self.command_line.push_output(&format!("Saved: {}", path.display())); + self.tabs[idx].dirty = false; + let close_win = self.close_unsaved_dialog_window(); + let close_tab = self.update(Message::TabClose(idx)); + return Task::batch(vec![close_win, close_tab]); + } + Err(e) => { + // Keep dialog open for retry. + self.command_line.push_error(&format!("Save failed: {e}")); + self.pending_close = Some(super::PendingClose::Tab(idx)); + } + } + } else { + // No path — close dialog, show save-as, then come back. + self.pending_close = Some(super::PendingClose::Tab(idx)); + let close_win = self.close_unsaved_dialog_window(); + return Task::batch(vec![ + close_win, + Task::perform(crate::io::pick_save_path(), Message::UnsavedPickedSavePath), + ]); + } + } + Some(super::PendingClose::Quit) => { + if let Some(idx) = self.tabs.iter().position(|t| t.dirty) { + if let Some(path) = self.tabs[idx].current_path.clone() { + match crate::io::save(&self.tabs[idx].scene.document, &path) { + Ok(()) => { + self.command_line.push_output(&format!("Saved: {}", path.display())); + self.tabs[idx].dirty = false; + } + Err(e) => { + self.command_line.push_error(&format!("Save failed: {e}")); + self.pending_close = Some(super::PendingClose::Quit); + return Task::none(); + } + } + } else { + // No path — close dialog, show save-as, then come back. + self.active_tab = idx; + self.pending_close = Some(super::PendingClose::Quit); + let close_win = self.close_unsaved_dialog_window(); + return Task::batch(vec![ + close_win, + Task::perform(crate::io::pick_save_path(), Message::UnsavedPickedSavePath), + ]); + } + } + if self.tabs.iter().any(|t| t.dirty) { + // More dirty tabs — keep window open. + self.pending_close = Some(super::PendingClose::Quit); + } else { + let close_win = self.close_unsaved_dialog_window(); + return Task::batch(vec![close_win, iced::exit()]); + } + } + None => {} + } + Task::none() + } + + Message::UnsavedPickedSavePath(Some(path)) => { + match self.pending_close.take() { + Some(super::PendingClose::Tab(idx)) => { + match crate::io::save(&self.tabs[idx].scene.document, &path) { + Ok(()) => { + self.command_line.push_output(&format!("Saved: {}", path.display())); + self.tabs[idx].current_path = Some(path); + self.tabs[idx].dirty = false; + return self.update(Message::TabClose(idx)); + } + 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(); + } + } + } + Some(super::PendingClose::Quit) => { + 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; + if self.tabs.iter().any(|t| t.dirty) { + self.pending_close = Some(super::PendingClose::Quit); + return self.open_unsaved_dialog_window(); + } else { + return iced::exit(); + } + } + Err(e) => { + self.command_line.push_error(&format!("Save failed: {e}")); + self.pending_close = Some(super::PendingClose::Quit); + return self.open_unsaved_dialog_window(); + } + } + } + None => {} + } + Task::none() + } + + Message::UnsavedPickedSavePath(None) => { + // User cancelled the save-as dialog — re-open the confirmation dialog. + if self.pending_close.is_some() { + return self.open_unsaved_dialog_window(); + } + Task::none() + } + // ── Page Setup ──────────────────────────────────────────────── Message::PageSetupOpen => { let i = self.active_tab; @@ -3660,6 +3839,27 @@ impl H7CAD { } /// Populate edit buffers from the currently selected text style. + fn open_unsaved_dialog_window(&mut self) -> Task { + if let Some(id) = self.unsaved_dialog_window { + return window::gain_focus(id); + } + let (id, task) = window::open(window::Settings { + size: iced::Size::new(420.0, 155.0), + resizable: false, + ..Default::default() + }); + self.unsaved_dialog_window = Some(id); + task.map(|_| Message::Noop) + } + + fn close_unsaved_dialog_window(&mut self) -> Task { + if let Some(id) = self.unsaved_dialog_window.take() { + window::close(id) + } else { + Task::none() + } + } + fn load_textstyle_bufs(&mut self, tab: usize) { let doc = &self.tabs[tab].scene.document; if let Some(s) = doc.text_styles.get(&self.textstyle_selected) { diff --git a/src/app/view.rs b/src/app/view.rs index 643a05fdc..4fd33d663 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -101,6 +101,20 @@ impl H7CAD { if Some(window_id) == self.about_window { return crate::ui::about::view_window(); } + 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); + } let i = self.active_tab; let tab = &self.tabs[i]; @@ -382,6 +396,9 @@ impl H7CAD { event::listen_with(|ev, status, win_id| { use iced::event::Status; match ev { + iced::Event::Window(window::Event::CloseRequested) => { + Some(Message::WindowCloseRequested(win_id)) + } iced::Event::Window(window::Event::Closed) => { Some(Message::OsWindowClosed(win_id)) } @@ -812,3 +829,54 @@ fn layout_context_menu_overlay(name: &str) -> Element<'_, Message> { stack![catcher, positioned].into() } +/// Content for the floating "Unsaved Changes" OS window. +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 }; + 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!("Do you want to save changes to \"{}\"?", name); + + 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("Save", Message::UnsavedDialogSave, BTN_SAVE, BTN_HOVER), + iced::widget::Space::new().width(8), + btn("Discard", Message::UnsavedDialogDiscard, BTN_DISC, BTN_DHOV), + iced::widget::Space::new().width(8), + btn("Cancel", Message::UnsavedDialogCancel, BTN_DISC, BTN_DHOV), + ], + ] + .spacing(0), + ) + .style(move |_: &Theme| container::Style { + background: Some(Background::Color(BG)), + ..Default::default() + }) + .center(Fill) + .padding([24, 28]) + .into() +} +