From a00fb8efc2070bdb232ecc4a2e105c9389697d54 Mon Sep 17 00:00:00 2001 From: Stanislav Denisov Date: Wed, 15 Oct 2025 17:39:15 +0200 Subject: [PATCH] Implement a dialog for unsaved changes (#1241) * Track unsaved scene changes * Implement a dialog for unsaved changes * Exclude all selection actions from scene change tracking --- Editor/ArmatureWindow.cpp | 2 +- Editor/ComponentsWindow.cpp | 2 +- Editor/Editor.cpp | 91 ++++++++++++++++++++++++++++++--- Editor/Editor.h | 5 +- Editor/HumanoidWindow.cpp | 2 +- Editor/MaterialPickerWindow.cpp | 2 +- WickedEngine/wiHelper.cpp | 87 +++++++++++++++++++++++++++++-- WickedEngine/wiHelper.h | 15 ++++++ 8 files changed, 190 insertions(+), 16 deletions(-) diff --git a/Editor/ArmatureWindow.cpp b/Editor/ArmatureWindow.cpp index 09e02501e..365a86bc8 100644 --- a/Editor/ArmatureWindow.cpp +++ b/Editor/ArmatureWindow.cpp @@ -177,7 +177,7 @@ void ArmatureWindow::Create(EditorComponent* _editor) if (args.iValue < 0) return; - wi::Archive& archive = editor->AdvanceHistory(); + wi::Archive& archive = editor->AdvanceHistory(true); archive << EditorComponent::HISTORYOP_SELECTION; // record PREVIOUS selection state... editor->RecordSelection(archive); diff --git a/Editor/ComponentsWindow.cpp b/Editor/ComponentsWindow.cpp index a1d71d73f..72fc279d3 100644 --- a/Editor/ComponentsWindow.cpp +++ b/Editor/ComponentsWindow.cpp @@ -91,7 +91,7 @@ void ComponentsWindow::Create(EditorComponent* _editor) if (args.iValue < 0) return; - wi::Archive& archive = editor->AdvanceHistory(); + wi::Archive& archive = editor->AdvanceHistory(true); archive << EditorComponent::HISTORYOP_SELECTION; // record PREVIOUS selection state... editor->RecordSelection(archive); diff --git a/Editor/Editor.cpp b/Editor/Editor.cpp index 11d732e87..c9bc08695 100644 --- a/Editor/Editor.cpp +++ b/Editor/Editor.cpp @@ -1294,7 +1294,15 @@ void EditorComponent::Load() exitButton.SetTooltip("Exit"); exitButton.SetColor(wi::Color(160, 50, 50, 180), wi::gui::WIDGETSTATE::IDLE); exitButton.SetColor(wi::Color(200, 50, 50, 255), wi::gui::WIDGETSTATE::FOCUS); - exitButton.OnClick([](wi::gui::EventArgs args) { + exitButton.OnClick([this](wi::gui::EventArgs args) { + // Check all scenes for unsaved changes + for (int i = 0; i < (int)scenes.size(); ++i) + { + if (!CheckUnsavedChanges(i)) + { + return; + } + } wi::platform::Exit(); }); topmenuWnd.AddWidget(&exitButton); @@ -2350,7 +2358,7 @@ void EditorComponent::Update(float dt) // Select... if (leftmouse_select || selectAll || clear_selected) { - wi::Archive& archive = AdvanceHistory(); + wi::Archive& archive = AdvanceHistory(true); archive << HISTORYOP_SELECTION; // record PREVIOUS selection state... RecordSelection(archive); @@ -4735,8 +4743,9 @@ void EditorComponent::ResetHistory() EditorScene& editorscene = GetCurrentEditorScene(); editorscene.historyPos = -1; editorscene.history.clear(); + editorscene.has_unsaved_changes = false; } -wi::Archive& EditorComponent::AdvanceHistory() +wi::Archive& EditorComponent::AdvanceHistory(const bool scene_unchanged) { EditorScene& editorscene = GetCurrentEditorScene(); editorscene.historyPos++; @@ -4749,6 +4758,12 @@ wi::Archive& EditorComponent::AdvanceHistory() editorscene.history.emplace_back(); editorscene.history.back().SetReadModeAndResetPos(false); + if (!scene_unchanged) + { + editorscene.has_unsaved_changes = true; + RefreshSceneList(); + } + return editorscene.history.back(); } void EditorComponent::ConsumeHistoryOperation(bool undo) @@ -5218,6 +5233,10 @@ void EditorComponent::Open(std::string filename) componentsWnd.weatherWnd.UpdateData(); componentsWnd.RefreshEntityTree(); + + GetCurrentEditorScene().has_unsaved_changes = false; + RefreshSceneList(); + wi::backlog::post("[Editor] finished loading model: " + filename); }); }); @@ -5282,6 +5301,7 @@ void EditorComponent::Save(const std::string& filename) } GetCurrentEditorScene().path = filename; + GetCurrentEditorScene().has_unsaved_changes = false; RefreshSceneList(); RegisterRecentlyUsed(filename); @@ -5994,28 +6014,42 @@ void EditorComponent::RefreshSceneList() for (int i = 0; i < int(scenes.size()); ++i) { auto& editorscene = scenes[i]; + std::string tabText; if (editorscene->path.empty()) { if (current_localization.Get((size_t)EditorLocalization::UntitledScene)) { - editorscene->tabSelectButton.SetText(current_localization.Get((size_t)EditorLocalization::UntitledScene)); + tabText = current_localization.Get((size_t)EditorLocalization::UntitledScene); } else { - editorscene->tabSelectButton.SetText("Untitled scene"); + tabText = "Untitled scene"; } editorscene->tabSelectButton.SetTooltip(""); } else { - editorscene->tabSelectButton.SetText(wi::helper::GetFileNameFromPath(editorscene->path)); + tabText = wi::helper::GetFileNameFromPath(editorscene->path); editorscene->tabSelectButton.SetTooltip(editorscene->path); } + if (editorscene->has_unsaved_changes) + { + tabText = tabText + " *"; + } + + editorscene->tabSelectButton.SetText(tabText); + editorscene->tabSelectButton.OnClick([this, i](wi::gui::EventArgs args) { SetCurrentScene(i); }); editorscene->tabCloseButton.OnClick([this, i](wi::gui::EventArgs args) { + // Check for unsaved changes before closing + if (!CheckUnsavedChanges(i)) + { + return; + } + wi::lua::KillProcesses(); translator.selected.clear(); @@ -6090,6 +6124,51 @@ void EditorComponent::NewScene() cameraWnd.ResetCam(); } +bool EditorComponent::CheckUnsavedChanges(int scene_index) +{ + if (scene_index < 0) + scene_index = current_scene; + + if (scene_index < 0 || scene_index >= (int)scenes.size()) + return true; + + EditorScene& editorscene = *scenes[scene_index]; + if (!editorscene.has_unsaved_changes) + return true; + + std::string sceneName = editorscene.path.empty() ? "" : wi::helper::GetFileNameFromPath(editorscene.path); + std::string message = sceneName.empty() ? "Do you want to save the untitled scene?" : "Do you want to save changes to \"" + sceneName + "\"?"; + wi::helper::MessageBoxResult result = wi::helper::messageBoxCustom(message, "Unsaved changes", "YesNoCancel"); + + if (result == wi::helper::MessageBoxResult::Yes) + { + // User wants to save + if (editorscene.path.empty()) + { + // Need to prompt for save location + SaveAs(); + // After SaveAs, check if the scene was actually saved + return !editorscene.has_unsaved_changes; + } + else + { + // Save to existing path + Save(editorscene.path); + return true; + } + } + else if (result == wi::helper::MessageBoxResult::No) + { + // User doesn't want to save, proceed + return true; + } + else // Cancel or closed dialog + { + // User cancelled, don't proceed + return false; + } +} + void EditorComponent::FocusCameraOnSelected() { Scene& scene = GetCurrentScene(); diff --git a/Editor/Editor.h b/Editor/Editor.h index 650364027..c06518376 100644 --- a/Editor/Editor.h +++ b/Editor/Editor.h @@ -174,7 +174,7 @@ public: void RecordEntity(wi::Archive& archive, const wi::vector& entities); void ResetHistory(); - wi::Archive& AdvanceHistory(); + wi::Archive& AdvanceHistory(bool scene_unchanged = false); void ConsumeHistoryOperation(bool undo); wi::vector recentFilenames; @@ -209,6 +209,7 @@ public: wi::scene::TransformComponent camera_target; wi::vector history; int historyPos = -1; + bool has_unsaved_changes = false; wi::gui::Button tabSelectButton; wi::gui::Button tabCloseButton; }; @@ -222,6 +223,8 @@ public: void RefreshSceneList(); void NewScene(); + bool CheckUnsavedChanges(int scene_index = -1); + void FocusCameraOnSelected(); wi::Localization default_localization; diff --git a/Editor/HumanoidWindow.cpp b/Editor/HumanoidWindow.cpp index a2123301f..3af041ce9 100644 --- a/Editor/HumanoidWindow.cpp +++ b/Editor/HumanoidWindow.cpp @@ -169,7 +169,7 @@ void HumanoidWindow::Create(EditorComponent* _editor) if (args.iValue < 0) return; - wi::Archive& archive = editor->AdvanceHistory(); + wi::Archive& archive = editor->AdvanceHistory(true); archive << EditorComponent::HISTORYOP_SELECTION; // record PREVIOUS selection state... editor->RecordSelection(archive); diff --git a/Editor/MaterialPickerWindow.cpp b/Editor/MaterialPickerWindow.cpp index 7d423981b..b7ac5a5e5 100644 --- a/Editor/MaterialPickerWindow.cpp +++ b/Editor/MaterialPickerWindow.cpp @@ -40,7 +40,7 @@ void MaterialPickerWindow::RecreateButtons() button.OnClick([entity, this](wi::gui::EventArgs args) { - wi::Archive& archive = editor->AdvanceHistory(); + wi::Archive& archive = editor->AdvanceHistory(true); archive << EditorComponent::HISTORYOP_SELECTION; // record PREVIOUS selection state... editor->RecordSelection(archive); diff --git a/WickedEngine/wiHelper.cpp b/WickedEngine/wiHelper.cpp index 95c5b9f5a..9a5971d95 100644 --- a/WickedEngine/wiHelper.cpp +++ b/WickedEngine/wiHelper.cpp @@ -81,6 +81,83 @@ namespace wi::helper #endif // SDL2 } + MessageBoxResult messageBoxCustom(const std::string& msg, const std::string& caption, const std::string& buttons) + { +#ifdef PLATFORM_WINDOWS_DESKTOP + std::wstring wmsg; + std::wstring wcaption; + StringConvert(msg, wmsg); + StringConvert(caption, wcaption); + + UINT type = MB_ICONQUESTION; + if (buttons == "YesNoCancel") + { + type |= MB_YESNOCANCEL; + } + else if (buttons == "YesNo") + { + type |= MB_YESNO; + } + else if (buttons == "OKCancel") + { + type |= MB_OKCANCEL; + } + else if (buttons == "AbortRetryIgnore") + { + type |= MB_ABORTRETRYIGNORE; + } + else // default to OK + { + type |= MB_OK; + } + + const int result = MessageBox(GetActiveWindow(), wmsg.c_str(), wcaption.c_str(), type); + + switch (result) + { + case IDOK: return MessageBoxResult::OK; + case IDCANCEL: return MessageBoxResult::Cancel; + case IDYES: return MessageBoxResult::Yes; + case IDNO: return MessageBoxResult::No; + case IDABORT: return MessageBoxResult::Abort; + case IDRETRY: return MessageBoxResult::Retry; + case IDIGNORE: return MessageBoxResult::Ignore; + default: return MessageBoxResult::Cancel; + } +#endif // PLATFORM_WINDOWS_DESKTOP + +#ifdef SDL2 + const SDL_MessageBoxButtonData buttons_data[] = { + { SDL_MESSAGEBOX_BUTTON_RETURNKEY_DEFAULT, 0, "Yes" }, + { 0, 1, "No" }, + { SDL_MESSAGEBOX_BUTTON_ESCAPEKEY_DEFAULT, 2, "Cancel" }, + }; + const SDL_MessageBoxData messageboxdata = { + SDL_MESSAGEBOX_INFORMATION, + NULL, + caption.c_str(), + msg.c_str(), + SDL_arraysize(buttons_data), + buttons_data, + NULL + }; + int buttonid; + if (SDL_ShowMessageBox(&messageboxdata, &buttonid) < 0) + { + return MessageBoxResult::Cancel; + } + switch (buttonid) + { + case 0: return MessageBoxResult::Yes; + case 1: return MessageBoxResult::No; + case 2: return MessageBoxResult::Cancel; + default: return MessageBoxResult::Cancel; + } +#endif // SDL2 + + return MessageBoxResult::Cancel; + } + std::string screenshot(const wi::graphics::SwapChain& swapchain, const std::string& name) { return screenshot(wi::graphics::GetDevice()->GetBackBuffer(&swapchain)); @@ -1294,7 +1371,7 @@ namespace wi::helper ofn.lStructSize = sizeof(ofn); ofn.hwndOwner = nullptr; ofn.lpstrFile = szFile; - // Set lpstrFile[0] to '\0' so that GetOpenFileName does not + // Set lpstrFile[0] to '\0' so that GetOpenFileName does not // use the contents of szFile to initialize itself. ofn.lpstrFile[0] = '\0'; ofn.nMaxFile = sizeof(szFile); @@ -1582,7 +1659,7 @@ namespace wi::helper } } } - + void StringConvert(const std::wstring& from, std::string& to) { to.clear(); @@ -1639,7 +1716,7 @@ namespace wi::helper } } } - + int StringConvert(const char* from, wchar_t* to, int dest_size_in_characters) { if (!from || !to || dest_size_in_characters <= 0) @@ -1709,7 +1786,7 @@ namespace wi::helper to[written] = 0; return written; } - + int StringConvert(const wchar_t* from, char* to, int dest_size_in_characters) { if (!from || !to || dest_size_in_characters <= 0) @@ -1817,7 +1894,7 @@ namespace wi::helper } #endif // _WIN32 } - + void Sleep(float milliseconds) { std::this_thread::sleep_for(std::chrono::milliseconds((int)milliseconds)); diff --git a/WickedEngine/wiHelper.h b/WickedEngine/wiHelper.h index 571b4b961..843c3afeb 100644 --- a/WickedEngine/wiHelper.h +++ b/WickedEngine/wiHelper.h @@ -45,6 +45,21 @@ namespace wi::helper void messageBox(const std::string& msg, const std::string& caption = "Warning!"); + enum class MessageBoxResult + { + OK, + Cancel, + Yes, + No, + Abort, + Retry, + Ignore + }; + + // Shows a message box with custom buttons and returns the user's choice + // buttons can be combinations like: "OK", "OKCancel", "YesNo", "YesNoCancel", etc. + MessageBoxResult messageBoxCustom(const std::string& msg, const std::string& caption = "Warning!", const std::string& buttons = "OK"); + // Returns file path if successful, empty string otherwise std::string screenshot(const wi::graphics::SwapChain& swapchain, const std::string& name = "");