diff --git a/libs/rttrConfig/src/files.h b/libs/rttrConfig/src/files.h index 02178f8012..b4c0b1de7c 100644 --- a/libs/rttrConfig/src/files.h +++ b/libs/rttrConfig/src/files.h @@ -42,6 +42,7 @@ namespace folders { constexpr auto mbob = "/DATA/MBOB"; // nation graphics constexpr auto music = "/MUSIC"; constexpr auto playlists = "/playlists"; + constexpr auto addonPresets = "/PRESETS"; constexpr auto replays = "/REPLAYS"; constexpr auto save = "/SAVE"; constexpr auto screenshots = "/screenshots"; diff --git a/libs/s25main/WindowManager.cpp b/libs/s25main/WindowManager.cpp index ae874f87bf..2fb179de02 100644 --- a/libs/s25main/WindowManager.cpp +++ b/libs/s25main/WindowManager.cpp @@ -175,10 +175,11 @@ IngameWindow& WindowManager::DoShow(std::unique_ptr window, bool m SetToolTip(nullptr, ""); - // All windows are inserted before the first modal window (shown behind) - auto itModal = helpers::find_if(windows, [](const auto& curWnd) { return curWnd->IsModal(); }); - // Note that if there is no other modal window it will be put at the back which is what we want - auto& result = **windows.emplace(itModal, std::move(window)); + // New modal window goes on top, non-modal window goes behind all modals + const auto itInsert = window->IsModal() ? + windows.end() : + helpers::find_if(windows, [](const auto& curWnd) { return curWnd->IsModal(); }); + auto& result = **windows.emplace(itInsert, std::move(window)); // Make the new window active (special cases handled in the function) SetActiveWindow(result); diff --git a/libs/s25main/controls/ctrlEdit.cpp b/libs/s25main/controls/ctrlEdit.cpp index 4a3c3c8d1f..33a4521ae3 100644 --- a/libs/s25main/controls/ctrlEdit.cpp +++ b/libs/s25main/controls/ctrlEdit.cpp @@ -1,9 +1,10 @@ -// Copyright (C) 2005 - 2021 Settlers Freaks (sf-team at siedler25.org) +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) // // SPDX-License-Identifier: GPL-2.0-or-later #include "ctrlEdit.h" #include "CollisionDetection.h" +#include "RTTR_Assert.h" #include "ctrlTextDeepening.h" #include "driver/MouseCoords.h" #include "drivers/VideoDriverWrapper.h" @@ -11,7 +12,9 @@ #include "ogl/FontStyle.h" #include "ogl/glFont.h" #include "s25util/StringConversion.h" +#include "s25util/fileFuncs.h" #include +#include #include #include @@ -33,8 +36,10 @@ ctrlEdit::ctrlEdit(Window* parent, unsigned id, const DrawPoint& pos, const Exte void ctrlEdit::SetText(const std::string& text) { text_ = s25util::utf8to32(text); - if(numberOnly_) + if(editType_ == EditType::Number) helpers::erase_if(text_, [](char32_t c) { return c < '0' || c > '9'; }); + if(editType_ == EditType::Filename) + helpers::erase_if(text_, [](char32_t c) { return !isValidFileNameChar(c); }); if(maxLength_ > 0 && text_.size() > maxLength_) text_.resize(maxLength_); @@ -54,6 +59,24 @@ std::string ctrlEdit::GetText() const return s25util::utf32to8(text_); } +GetFileNameResult ctrlEdit::GetFileName(const std::string& ext) const +{ + RTTR_Assert(editType_ == EditType::Filename); + + std::string name = GetText(); + const auto isSpace = [](char c) { return c == ' '; }; + boost::algorithm::trim_left_if(name, isSpace); + if(ext.empty()) + boost::algorithm::trim_right_if(name, isSpace); + if(name.empty()) + return {FileNameStatus::Empty, {}}; + if(!ext.empty()) + name += ext; + if(!isValidFileName(name)) + return {FileNameStatus::Invalid, {}}; + return {FileNameStatus::Valid, std::move(name)}; +} + void ctrlEdit::SetFocus(bool focus) { if(focus_ != focus) @@ -167,8 +190,9 @@ void ctrlEdit::Draw_() */ void ctrlEdit::AddChar(char32_t c) { - // Number-only text fields accept numbers only ;) - if(numberOnly_ && (c < '0' || c > '9')) + if(editType_ == EditType::Number && (c < '0' || c > '9')) + return; + if(editType_ == EditType::Filename && !isValidFileNameChar(c)) return; if(maxLength_ > 0 && text_.size() >= maxLength_) @@ -244,10 +268,6 @@ bool ctrlEdit::Msg_KeyDown(const KeyEvent& ke) switch(ke.kt) { default: return false; - // Wird bereits über Char geliefert !! - case KeyType::Space: // Leertaste - AddChar(0x20); - break; case KeyType::Left: // Cursor nach Links // Blockweise nach links, falls Strg gedrückt diff --git a/libs/s25main/controls/ctrlEdit.h b/libs/s25main/controls/ctrlEdit.h index 910f49037b..f7ad442970 100644 --- a/libs/s25main/controls/ctrlEdit.h +++ b/libs/s25main/controls/ctrlEdit.h @@ -5,12 +5,32 @@ #pragma once #include "Window.h" +#include struct MouseCoords; class glFont; class ctrlTextDeepening; struct KeyEvent; +enum class EditType +{ + Text, + Number, + Filename +}; + +enum class FileNameStatus +{ + Empty, + Invalid, + Valid +}; +struct GetFileNameResult +{ + FileNameStatus status; + std::string name; // only set when status == Valid +}; + class ctrlEdit : public Window { public: @@ -21,11 +41,14 @@ class ctrlEdit : public Window void SetText(unsigned text); std::string GetText() const; + /// Trims leading whitespace (and trailing whitespace only if ext is empty), appends a non-empty ext, + /// validates; returns Empty/Invalid/Valid with filename. Requires EditType::Filename. + GetFileNameResult GetFileName(const std::string& ext = "") const; void SetFocus(bool focus = true); bool HasFocus() const { return focus_; } void SetDisabled(bool disabled = true) { this->isDisabled_ = disabled; } void SetNotify(bool notify = true) { this->notify_ = notify; } - void SetNumberOnly(const bool activated) { this->numberOnly_ = activated; } + void SetType(EditType type) { this->editType_ = type; } void Resize(const Extent& newSize) override; @@ -58,5 +81,5 @@ class ctrlEdit : public Window unsigned cursorOffsetX_ = 0; unsigned viewStart_ = 0; - bool numberOnly_ = false; + EditType editType_ = EditType::Text; }; diff --git a/libs/s25main/desktops/dskOptions.cpp b/libs/s25main/desktops/dskOptions.cpp index 8e91dc898b..c175d8bc42 100644 --- a/libs/s25main/desktops/dskOptions.cpp +++ b/libs/s25main/desktops/dskOptions.cpp @@ -247,7 +247,7 @@ dskOptions::dskOptions() : Desktop(LOADER.GetImageN("setup013", 0)) groupCommon->AddText(ID_txtPort, curPos, _("Local Port:"), COLOR_YELLOW, FontStyle{}, NormalFont); ctrlEdit* edtPort = groupCommon->AddEdit(ID_edtPort, curPos + ctrlOffset, ctrlSize, TextureColor::Grey, NormalFont, 15); - edtPort->SetNumberOnly(true); + edtPort->SetType(EditType::Number); edtPort->SetText(SETTINGS.server.localPort); curPos.y += rowHeight; @@ -266,7 +266,7 @@ dskOptions::dskOptions() : Desktop(LOADER.GetImageN("setup013", 0)) proxy->SetText(SETTINGS.proxy.hostname); proxy = groupCommon->AddEdit(ID_edtProxyPort, curPos + ctrlOffset2, Extent(50, 22), TextureColor::Grey, NormalFont, 5); - proxy->SetNumberOnly(true); + proxy->SetType(EditType::Number); proxy->SetText(SETTINGS.proxy.port); curPos.y += rowHeight; diff --git a/libs/s25main/gameData/const_gui_ids.h b/libs/s25main/gameData/const_gui_ids.h index 2347ce9198..32a23e9fc8 100644 --- a/libs/s25main/gameData/const_gui_ids.h +++ b/libs/s25main/gameData/const_gui_ids.h @@ -12,6 +12,7 @@ enum GUI_ID : unsigned { CGI_ACTION, CGI_ADDONS, + CGI_ADDON_PRESETS, CGI_AI_DEBUG, CGI_BUILDINGS, CGI_BUILDINGSPRODUCTIVITY, diff --git a/libs/s25main/ingameWindows/iwAddonPresets.cpp b/libs/s25main/ingameWindows/iwAddonPresets.cpp new file mode 100644 index 0000000000..f8c26c422b --- /dev/null +++ b/libs/s25main/ingameWindows/iwAddonPresets.cpp @@ -0,0 +1,300 @@ +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) +// +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "iwAddonPresets.h" +#include "ListDir.h" +#include "Loader.h" +#include "RttrConfig.h" +#include "WindowManager.h" +#include "controls/ctrlEdit.h" +#include "controls/ctrlTable.h" +#include "controls/ctrlText.h" +#include "files.h" +#include "helpers/format.hpp" +#include "iwMsgbox.h" +#include "gameData/const_gui_ids.h" +#include "libsiedler2/ArchivItem_Ini.h" +#include "libsiedler2/ArchivItem_Text.h" +#include "libsiedler2/libsiedler2.h" +#include "s25util/Log.h" +#include "s25util/StringConversion.h" +#include +#include + +namespace bfs = boost::filesystem; + +static bfs::path GetPresetsDir() +{ + return RTTRCONFIG.ExpandPath(s25::folders::addonPresets); +} + +static std::optional> LoadPresetsFromFile(const bfs::path& filePath) +{ + libsiedler2::Archiv archive; + if(libsiedler2::Load(filePath, archive) != 0) + { + LOG.write("Failed to load addon preset from %1%\n") % filePath; + return std::nullopt; + } + + const auto* ini = dynamic_cast(archive.find("addons")); + if(!ini) + return std::nullopt; + + std::map states; + for(unsigned i = 0; i < ini->size(); ++i) + { + const auto* item = dynamic_cast(ini->get(i)); + if(!item) + { + LOG.write("Skipping addon preset %1%: entry #%2% is not a text entry\n") % filePath % i; + return std::nullopt; + } + unsigned id, status; + if(!s25util::tryFromStringClassic(item->getName(), id) + || !s25util::tryFromStringClassic(item->getText(), status)) + { + LOG.write("Failed to parse addon option #%1% ('%2%' = '%3%') in %4%\n") % i % item->getName() + % item->getText() % filePath; + return std::nullopt; + } + states[id] = status; + } + return states; +} + +// iwAddonPresetsBase +iwAddonPresetsBase::iwAddonPresetsBase(const std::string& title, const std::string& actionLabel) + : IngameWindow(CGI_ADDON_PRESETS, IngameWindow::posLastOrCenter, Extent(440, 330), title, + LOADER.GetImageN("resource", 41), true) +{ + const bfs::path presetsDir = GetPresetsDir(); + boost::system::error_code ec; + bfs::create_directories(presetsDir, ec); + if(ec) + { + LOG.write("Failed to create addon preset folder %1%: %2%\n") % presetsDir % ec.message(); + // Without the folder, saving/loading/deleting presets can't work. + WINDOWMANAGER.Show(std::make_unique( + _("Addon Presets Unavailable"), + _("The addon presets folder could not be created. Saving and loading addon presets is unavailable."), nullptr, + MsgboxButton::Ok, MsgboxIcon::ExclamationRed)); + Close(); + return; + } + + using SRT = ctrlTable::SortType; + AddTable(ID_tblPresets, DrawPoint(20, 30), Extent(400, 200), TextureColor::Green2, NormalFont, + ctrlTable::Columns{{_("Preset Name"), 400, SRT::String}, {}}); + + AddText(ID_txtFolder, DrawPoint(20, 236), presetsDir.string(), COLOR_YELLOW, FontStyle::TOP, SmallFont) + ->setMaxWidth(400); + + // maxLength 251 = 255 filename limit - 4 chars for ".ini"; just discourages absurdly long + // input, isValidFileName() may still reject it since it counts bytes, not codepoints. + AddEdit(ID_edtName, DrawPoint(20, 254), Extent(400, 22), TextureColor::Green2, NormalFont, 251); + GetCtrl(ID_edtName)->SetType(EditType::Filename); + + AddTextButton(ID_btAction, DrawPoint(20, 284), Extent(185, 22), TextureColor::Green2, actionLabel, NormalFont); + AddTextButton(ID_btDelete, DrawPoint(235, 284), Extent(185, 22), TextureColor::Red1, _("Delete"), NormalFont); + + RefreshTable(); +} + +void iwAddonPresetsBase::RefreshTable() +{ + auto& table = *GetCtrl(ID_tblPresets); + table.DeleteAllItems(); + + for(const auto& file : ListDir(GetPresetsDir(), "ini")) + table.AddRow({file.stem().string(), file.string()}); + + table.SortRows(0, TableSortDir::Ascending); +} + +bfs::path iwAddonPresetsBase::GetTargetFilePath() const +{ + const std::string name = GetCtrl(ID_edtName)->GetText(); + if(name.empty()) + return {}; + + const auto& table = *GetCtrl(ID_tblPresets); + for(unsigned short i = 0; i < table.GetNumRows(); ++i) + { + if(table.GetItemText(i, 0) == name) + return table.GetItemText(i, 1); + } + return {}; +} + +bfs::path iwAddonPresetsBase::GetTargetFileOrNotify() const +{ + bfs::path path = GetTargetFilePath(); + if(!path.empty()) + return path; + + const std::string name = GetCtrl(ID_edtName)->GetText(); + if(name.empty()) + return {}; + + WINDOWMANAGER.Show(std::make_unique(_("Preset Not Found"), + helpers::format(_("Preset '%1%' was not found."), name), nullptr, + MsgboxButton::Ok, MsgboxIcon::ExclamationRed)); + return {}; +} + +void iwAddonPresetsBase::Msg_EditEnter(const unsigned /*ctrl_id*/) +{ + DoAction(); +} + +void iwAddonPresetsBase::Msg_ButtonClick(const unsigned ctrl_id) +{ + switch(ctrl_id) + { + case ID_btAction: DoAction(); break; + case ID_btDelete: ConfirmDelete(); break; + default: break; + } +} + +void iwAddonPresetsBase::Msg_TableSelectItem(const unsigned /*ctrl_id*/, const std::optional& selection) +{ + const auto& table = *GetCtrl(ID_tblPresets); + GetCtrl(ID_edtName)->SetText(selection ? table.GetItemText(*selection, 0) : ""); +} + +void iwAddonPresetsBase::Msg_TableChooseItem(const unsigned /*ctrl_id*/, const unsigned /*selection*/) +{ + DoAction(); +} + +void iwAddonPresetsBase::ConfirmDelete() +{ + const bfs::path filePath = GetTargetFileOrNotify(); + if(filePath.empty()) + return; + WINDOWMANAGER.Show(std::make_unique( + _("Delete Preset"), helpers::format(_("Are you sure you want to delete preset '%1%'?"), filePath.stem().string()), + this, MsgboxButton::YesNo, MsgboxIcon::QuestionRed, ID_mbDelete)); +} + +void iwAddonPresetsBase::Msg_MsgBoxResult(const unsigned msgbox_id, const MsgboxResult mbr) +{ + if(msgbox_id != ID_mbDelete || mbr != MsgboxResult::Yes) + return; + + const bfs::path filePath = GetTargetFilePath(); + if(filePath.empty()) + return; + + boost::system::error_code ec; + bfs::remove(filePath, ec); + if(ec) + { + LOG.write("Failed to delete addon preset %1%: %2%\n") % filePath % ec.message(); + WINDOWMANAGER.Show(std::make_unique(_("Delete Failed"), _("Failed to delete the selected preset."), + this, MsgboxButton::Ok, MsgboxIcon::ExclamationRed)); + } + // Refresh in both cases so the list reflects the actual filesystem state + // (e.g. the file became a directory or was removed out from under us). + RefreshTable(); + GetCtrl(ID_edtName)->SetText(""); +} + +// iwSaveAddonPreset +iwSaveAddonPreset::iwSaveAddonPreset(std::map states) + : iwAddonPresetsBase(_("Save Addon Preset"), _("Save")), states_(std::move(states)) +{} + +void iwSaveAddonPreset::DoAction() +{ + const auto fileNameResult = GetCtrl(ID_edtName)->GetFileName(".ini"); + switch(fileNameResult.status) + { + case FileNameStatus::Empty: + WINDOWMANAGER.Show(std::make_unique(_("Invalid Name"), _("Please enter a preset name."), this, + MsgboxButton::Ok, MsgboxIcon::ExclamationRed)); + return; + case FileNameStatus::Invalid: + WINDOWMANAGER.Show(std::make_unique(_("Invalid Name"), _("Please enter a valid preset name."), + this, MsgboxButton::Ok, MsgboxIcon::ExclamationRed)); + return; + case FileNameStatus::Valid: break; + } + const bfs::path filePath = GetPresetsDir() / fileNameResult.name; + + if(bfs::exists(filePath)) + { + WINDOWMANAGER.Show(std::make_unique( + _("Overwrite Preset"), + helpers::format(_("A preset named '%1%' already exists. Do you want to overwrite it?"), + filePath.stem().string()), + this, MsgboxButton::YesNo, MsgboxIcon::QuestionRed, ID_mbOverwrite)); + return; + } + + SaveToPath(filePath); +} + +void iwSaveAddonPreset::SaveToPath(const bfs::path& filePath) +{ + auto iniItem = std::make_unique("addons"); + for(const auto& [id, status] : states_) + iniItem->setValue(s25util::toStringClassic(id), s25util::toStringClassic(status)); + + libsiedler2::Archiv archive; + archive.push(std::move(iniItem)); + + if(libsiedler2::Write(filePath, archive) == 0) + { + Close(); + return; + } + + LOG.write("Failed to save addon preset to %1%\n") % filePath; + WINDOWMANAGER.Show(std::make_unique( + _("Save Failed"), _("Failed to save the preset. Please check the filename and try again."), this, + MsgboxButton::Ok, MsgboxIcon::ExclamationRed)); + RefreshTable(); +} + +void iwSaveAddonPreset::Msg_MsgBoxResult(const unsigned msgbox_id, const MsgboxResult mbr) +{ + if(msgbox_id == ID_mbOverwrite) + { + if(mbr == MsgboxResult::Yes) + { + const auto fileNameResult = GetCtrl(ID_edtName)->GetFileName(".ini"); + if(fileNameResult.status == FileNameStatus::Valid) + SaveToPath(GetPresetsDir() / fileNameResult.name); + } + } else + iwAddonPresetsBase::Msg_MsgBoxResult(msgbox_id, mbr); +} + +// iwLoadAddonPreset +iwLoadAddonPreset::iwLoadAddonPreset(std::function&)> onLoad) + : iwAddonPresetsBase(_("Load Addon Preset"), _("Load")), onLoad_(std::move(onLoad)) +{} + +void iwLoadAddonPreset::DoAction() +{ + const bfs::path filePath = GetTargetFileOrNotify(); + if(filePath.empty()) + return; + + const auto states = LoadPresetsFromFile(filePath); + if(!states) + { + WINDOWMANAGER.Show(std::make_unique( + _("Load Failed"), + _("The selected preset could not be loaded. The file may be corrupted or have an invalid format."), this, + MsgboxButton::Ok, MsgboxIcon::ExclamationRed)); + return; + } + + onLoad_(*states); + Close(); +} diff --git a/libs/s25main/ingameWindows/iwAddonPresets.h b/libs/s25main/ingameWindows/iwAddonPresets.h new file mode 100644 index 0000000000..1e06bbf423 --- /dev/null +++ b/libs/s25main/ingameWindows/iwAddonPresets.h @@ -0,0 +1,71 @@ +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) +// +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include "IngameWindow.h" +#include +#include +#include +#include +#include + +/// Base class for the save/load addon preset windows +class iwAddonPresetsBase : public IngameWindow +{ +public: + explicit iwAddonPresetsBase(const std::string& title, const std::string& actionLabel); + + enum + { + ID_tblPresets, + ID_edtName, + ID_btAction, + ID_btDelete, + ID_txtFolder, + ID_mbDelete, + ID_mbOverwrite, + }; + +protected: + void RefreshTable(); + /// Resolves the preset file whose display name matches the currently entered name, or empty if + /// the name field is empty or no such preset exists. + boost::filesystem::path GetTargetFilePath() const; + /// Like GetTargetFilePath(), but if a name was entered that doesn't match any preset, informs the + /// user before returning empty. An empty name stays a silent no-op. + boost::filesystem::path GetTargetFileOrNotify() const; + + void Msg_EditEnter(unsigned ctrl_id) override; + void Msg_ButtonClick(unsigned ctrl_id) override; + void Msg_TableSelectItem(unsigned ctrl_id, const std::optional& selection) override; + void Msg_TableChooseItem(unsigned ctrl_id, unsigned selection) override; + void Msg_MsgBoxResult(unsigned msgbox_id, MsgboxResult mbr) override; + +private: + virtual void DoAction() = 0; + void ConfirmDelete(); +}; + +class iwSaveAddonPreset : public iwAddonPresetsBase +{ +public: + explicit iwSaveAddonPreset(std::map states); + +private: + const std::map states_; + void SaveToPath(const boost::filesystem::path& filePath); + void DoAction() override; + void Msg_MsgBoxResult(unsigned msgbox_id, MsgboxResult mbr) override; +}; + +class iwLoadAddonPreset : public iwAddonPresetsBase +{ +public: + explicit iwLoadAddonPreset(std::function&)> onLoad); + +private: + std::function&)> onLoad_; + void DoAction() override; +}; diff --git a/libs/s25main/ingameWindows/iwAddons.cpp b/libs/s25main/ingameWindows/iwAddons.cpp index df219d49bb..cdb2197d2d 100644 --- a/libs/s25main/ingameWindows/iwAddons.cpp +++ b/libs/s25main/ingameWindows/iwAddons.cpp @@ -5,10 +5,12 @@ #include "iwAddons.h" #include "GlobalGameSettings.h" #include "Loader.h" +#include "WindowManager.h" #include "addons/Addon.h" #include "controls/ctrlOptionGroup.h" #include "controls/ctrlScrollBar.h" #include "helpers/containerUtils.h" +#include "iwAddonPresets.h" #include "gameData/const_gui_ids.h" #include "s25util/colors.h" #include @@ -20,6 +22,8 @@ enum ID_btApply, ID_btAbort, ID_btS2Defaults, + ID_btSavePreset, + ID_btLoadPreset, ID_grpAddonGroup, ID_scroll, ID_grpAddonsStart @@ -32,7 +36,7 @@ constexpr unsigned AddonGuiLineHeight = 30; iwAddons::iwAddons(GlobalGameSettings& ggs, Window* parent, AddonChangeAllowed policy, std::vector whitelistedAddons) - : IngameWindow(CGI_ADDONS, IngameWindow::posLastOrCenter, Extent(700, 500), _("Addon Settings"), + : IngameWindow(CGI_ADDONS, IngameWindow::posLastOrCenter, Extent(700, 530), _("Addon Settings"), LOADER.GetImageN("resource", 41), true, CloseBehavior::Custom, parent), ggs(ggs), policy_(policy), whitelistedAddons_(std::move(whitelistedAddons)) { @@ -40,16 +44,20 @@ iwAddons::iwAddons(GlobalGameSettings& ggs, Window* parent, AddonChangeAllowed p Extent btSize(200, 22); if(policy != AddonChangeAllowed::None) + { + AddTextButton(ID_btSavePreset, DrawPoint(20, GetSize().y - 70), btSize, TextureColor::Green2, _("Save"), + NormalFont, _("Save Addon Preset")); + AddTextButton(ID_btLoadPreset, DrawPoint(250, GetSize().y - 70), btSize, TextureColor::Green2, _("Load"), + NormalFont, _("Load Addon Preset")); + AddTextButton(ID_btS2Defaults, DrawPoint(480, GetSize().y - 70), btSize, TextureColor::Grey, _("Default"), + NormalFont, _("Use S2 Defaults")); AddTextButton(ID_btApply, DrawPoint(20, GetSize().y - 40), btSize, TextureColor::Green2, _("Apply"), NormalFont, _("Apply Changes")); + } AddTextButton(ID_btAbort, DrawPoint(250, GetSize().y - 40), btSize, TextureColor::Red1, _("Abort"), NormalFont, _("Close Without Saving")); - if(policy != AddonChangeAllowed::None) - AddTextButton(ID_btS2Defaults, DrawPoint(480, GetSize().y - 40), btSize, TextureColor::Grey, _("Default"), - NormalFont, _("Use S2 Defaults")); - // Kategorien ctrlOptionGroup* optiongroup = AddOptionGroup(ID_grpAddonGroup, GroupSelectType::Check); btSize = Extent(120, 22); @@ -71,7 +79,7 @@ iwAddons::iwAddons(GlobalGameSettings& ggs, Window* parent, AddonChangeAllowed p ctrlScrollBar* scrollbar = AddScrollBar(ID_scroll, DrawPoint(GetSize().x - SCROLLBAR_WIDTH - 20, 90), - Extent(SCROLLBAR_WIDTH, GetSize().y - 140), SCROLLBAR_WIDTH, TextureColor::Green2, 1); + Extent(SCROLLBAR_WIDTH, GetSize().y - 170), SCROLLBAR_WIDTH, TextureColor::Green2, 1); scrollbar->SetPageSize(scrollbar->GetSize().y / AddonGuiLineHeight); for(unsigned i = 0; i < ggs.getNumAddons(); ++i) @@ -89,6 +97,14 @@ iwAddons::iwAddons(GlobalGameSettings& ggs, Window* parent, AddonChangeAllowed p iwAddons::~iwAddons() = default; +void iwAddons::Close() +{ + // Close an open save/load preset window: the load window holds a callback into this window, + // so it must not outlive it + WINDOWMANAGER.Close(CGI_ADDON_PRESETS); + IngameWindow::Close(); +} + void iwAddons::Msg_ButtonClick(const unsigned ctrl_id) { switch(ctrl_id) @@ -125,6 +141,23 @@ void iwAddons::Msg_ButtonClick(const unsigned ctrl_id) Close(); break; + case ID_btSavePreset: + { + std::map states; + for(unsigned i = 0; i < ggs.getNumAddons(); ++i) + { + const auto& group = *GetCtrl(ID_grpAddonsStart + i); + states[static_cast(ggs.getAddon(i)->getId())] = addonGuis_[i]->getStatus(group); + } + WINDOWMANAGER.Show(std::make_unique(std::move(states))); + } + break; + + case ID_btLoadPreset: + WINDOWMANAGER.Show(std::make_unique( + [this](const std::map& states) { applyAddonStates(states); })); + break; + case ID_btS2Defaults: // Load S2 Defaults // Standardeinstellungen aufs Fenster übertragen for(unsigned i = 0; i < ggs.getNumAddons(); ++i) @@ -165,6 +198,21 @@ void iwAddons::UpdateView(const AddonGroup selection) scrollbar->SetRange(numAddonsInCurCategory); } +void iwAddons::applyAddonStates(const std::map& states) +{ + for(unsigned i = 0; i < ggs.getNumAddons(); ++i) + { + const Addon* addon = ggs.getAddon(i); + if(!isReadOnly(addon->getId())) + { + const auto it = states.find(static_cast(addon->getId())); + const unsigned rawStatus = (it != states.end()) ? it->second : addon->getDefaultStatus(); + const unsigned status = (rawStatus < addon->getNumOptions()) ? rawStatus : addon->getDefaultStatus(); + addonGuis_[i]->setStatus(*GetCtrl(ID_grpAddonsStart + i), status); + } + } +} + bool iwAddons::isReadOnly(AddonId id) const { return policy_ == AddonChangeAllowed::None diff --git a/libs/s25main/ingameWindows/iwAddons.h b/libs/s25main/ingameWindows/iwAddons.h index 7fbbc94621..2e2c36e645 100644 --- a/libs/s25main/ingameWindows/iwAddons.h +++ b/libs/s25main/ingameWindows/iwAddons.h @@ -6,6 +6,7 @@ #include "IngameWindow.h" #include "addons/const_addons.h" +#include #include #include @@ -29,6 +30,8 @@ class iwAddons : public IngameWindow std::vector whitelistedAddons = {}); ~iwAddons() override; + void Close() override; + protected: void Msg_ButtonClick(unsigned ctrl_id) override; void Msg_OptionGroupChange(unsigned ctrl_id, unsigned selection) override; @@ -46,4 +49,5 @@ class iwAddons : public IngameWindow /// Aktualisiert die Addons, die angezeigt werden sollen void UpdateView(AddonGroup selection); bool isReadOnly(AddonId) const; + void applyAddonStates(const std::map& states); }; diff --git a/libs/s25main/ingameWindows/iwSave.cpp b/libs/s25main/ingameWindows/iwSave.cpp index 4cfb42a716..5889011e2e 100644 --- a/libs/s25main/ingameWindows/iwSave.cpp +++ b/libs/s25main/ingameWindows/iwSave.cpp @@ -19,6 +19,7 @@ #include "helpers/make_array.h" #include "helpers/toString.h" #include "iwConnecting.h" +#include "iwMsgbox.h" #include "network/GameClient.h" #include "gameData/GameConsts.h" #include "gameData/const_gui_ids.h" @@ -57,7 +58,9 @@ iwSaveLoad::iwSaveLoad(const std::string& window_title, ITexture* btImg, const u AddText(ID_txtSaveFolder, DrawPoint(20, 333), RTTRCONFIG.ExpandPath(s25::folders::save).string(), COLOR_YELLOW, FontStyle::TOP, SmallFont) ->setMaxWidth(510); - AddEdit(ID_edtFilename, DrawPoint(20, 350), Extent(510, 22), TextureColor::Green2, NormalFont); + // maxLength 251 = 255 filename limit - 4 chars for ".sav"; just discourages absurdly long + // input, isValidFileName() may still reject it since it counts bytes, not codepoints. + AddEdit(ID_edtFilename, DrawPoint(20, 350), Extent(510, 22), TextureColor::Green2, NormalFont, 251); AddImageButton(ID_btSaveOrLoad, DrawPoint(540, 341), Extent(40, 40), TextureColor::Green2, btImg); // Initially fill the table RefreshTable(); @@ -118,9 +121,20 @@ void iwSaveLoad::RefreshTable() void iwSave::SaveLoad() { - const boost::filesystem::path savePath = - RTTRCONFIG.ExpandPath(s25::folders::save) / (GetCtrl(ID_edtFilename)->GetText() + ".sav"); - GAMECLIENT.SaveToFile(savePath); + const auto fileNameResult = GetCtrl(ID_edtFilename)->GetFileName(".sav"); + switch(fileNameResult.status) + { + case FileNameStatus::Empty: + WINDOWMANAGER.Show(std::make_unique(_("Invalid Filename"), _("Please enter a filename."), this, + MsgboxButton::Ok, MsgboxIcon::ExclamationRed)); + return; + case FileNameStatus::Invalid: + WINDOWMANAGER.Show(std::make_unique(_("Invalid Filename"), _("Please enter a valid filename."), + this, MsgboxButton::Ok, MsgboxIcon::ExclamationRed)); + return; + case FileNameStatus::Valid: break; + } + GAMECLIENT.SaveToFile(RTTRCONFIG.ExpandPath(s25::folders::save) / fileNameResult.name); RefreshTable(); GetCtrl(ID_edtFilename)->SetText(""); @@ -128,7 +142,8 @@ void iwSave::SaveLoad() iwSave::iwSave() : iwSaveLoad(_("Save game!"), LOADER.GetTextureN("io", 47), 30) { - const auto* fileNameEdit = GetCtrl(ID_edtFilename); + auto* fileNameEdit = GetCtrl(ID_edtFilename); + fileNameEdit->SetType(EditType::Filename); DrawPoint pos(GetSize().x / 2, fileNameEdit->GetPos().y + fileNameEdit->GetSize().y + 10); ctrlComboBox* combo = diff --git a/libs/s25main/ingameWindows/iwTrade.cpp b/libs/s25main/ingameWindows/iwTrade.cpp index 0d6cbb0b2d..3a8a6f7f2e 100644 --- a/libs/s25main/ingameWindows/iwTrade.cpp +++ b/libs/s25main/ingameWindows/iwTrade.cpp @@ -64,7 +64,8 @@ iwTrade::iwTrade(const nobBaseWarehouse& wh, const GameWorldViewer& gwv, GameCom } AddImage(5, DrawPoint(left_column + 20, 130), static_cast(nullptr), _("Ware you like to trade")); - AddEdit(6, DrawPoint(left_column + 34, 120), Extent(39, 20), TextureColor::Grey, NormalFont)->SetNumberOnly(true); + AddEdit(6, DrawPoint(left_column + 34, 120), Extent(39, 20), TextureColor::Grey, NormalFont) + ->SetType(EditType::Number); AddText(7, DrawPoint(left_column + 75, 125), "/ 20", COLOR_YELLOW, FontStyle::LEFT, NormalFont); AddTextButton(8, DrawPoint(left_column, 150), Extent(150, 22), TextureColor::Green2, _("Send"), NormalFont); diff --git a/libs/s25main/network/GameClient.cpp b/libs/s25main/network/GameClient.cpp index 0b9bb7e018..e7abe4f324 100644 --- a/libs/s25main/network/GameClient.cpp +++ b/libs/s25main/network/GameClient.cpp @@ -919,8 +919,12 @@ bool GameClient::OnGameMessage(const GameMessage_Map_Info& msg) if(!VerifyState(ConnectState::QueryMapInfo)) return true; - // full path - const std::string portFilename = makePortableFileName(msg.filename); + // For local savegame loads the filename comes from our own server and is already a valid + // filesystem name — sanitizing it (spaces -> underscores) would create a renamed duplicate + // alongside the original save file. + const std::string portFilename = (clientconfig.servertyp == ServerType::Local && msg.mt == MapType::Savegame) ? + msg.filename : + makePortableFileName(msg.filename); if(portFilename.empty()) { LOG.write("Invalid filename received!\n"); diff --git a/tests/s25Main/UI/testControls.cpp b/tests/s25Main/UI/testControls.cpp index 723a48a655..3f875fd538 100644 --- a/tests/s25Main/UI/testControls.cpp +++ b/tests/s25Main/UI/testControls.cpp @@ -1,4 +1,4 @@ -// Copyright (C) 2005 - 2021 Settlers Freaks (sf-team at siedler25.org) +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) // // SPDX-License-Identifier: GPL-2.0-or-later @@ -239,6 +239,127 @@ BOOST_FIXTURE_TEST_CASE(EditShowsCorrectChars, uiHelper::Fixture) BOOST_TEST_REQUIRE(txt->GetText() == txtWithoutFirst); } +BOOST_FIXTURE_TEST_CASE(EditFileNameOnlyFiltersInvalidChars, uiHelper::Fixture) +{ + const auto font = createMockFont( + {'a', 'B', 'c', 'D', '1', '2', '3', '4', ' ', '-', '!', '<', '>', ':', '"', '/', '\\', '|', '?', '*'}); + ctrlEdit edt(nullptr, 0, DrawPoint(0, 0), Extent(200, 15), TextureColor::Green1, font.get()); + edt.SetType(EditType::Filename); + + MouseCoords mc(edt.GetPos()); + mc.ldown = true; + edt.Msg_LeftDown(mc); // give focus + + for(char32_t c : {U'a', U'B', U'1', U'2', U' '}) // valid chars + edt.Msg_KeyDown(KeyEvent(c)); + BOOST_TEST(edt.GetText() == "aB12 "); // all accepted + + for(char32_t c : {U'c', U'<', U'D', U'>', U'-', U':', U'"', U'3', U'/', U'\\', U'4', U'|', U'?', U'!', + U'*'}) // mixed valid and invalid chars + edt.Msg_KeyDown(KeyEvent(c)); + BOOST_TEST(edt.GetText() == "aB12 cD-34!"); // only valid accepted + + edt.SetText("a/B\\1"); + BOOST_TEST(edt.GetText() == "aB1"); +} + +BOOST_FIXTURE_TEST_CASE(EditGetFileName, uiHelper::Fixture) +{ + const auto font = createMockFont({'?', '.', ' ', 'a', 'b', 'c', 'd', 'e', 'f', 'g', + 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', + 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', static_cast(0xE9)}); + ctrlEdit edt(nullptr, 0, DrawPoint(0, 0), Extent(200, 15), TextureColor::Green1, font.get()); + edt.SetType(EditType::Filename); + + edt.SetText(""); + BOOST_TEST((edt.GetFileName(".ini").status == FileNameStatus::Empty)); + + edt.SetText(" "); // whitespace only + BOOST_TEST((edt.GetFileName(".ini").status == FileNameStatus::Empty)); + + // leading whitespace trimmed, trailing kept - not at the end of the filename + edt.SetText(" mypreset "); + auto r = edt.GetFileName(".ini"); + BOOST_TEST((r.status == FileNameStatus::Valid)); + BOOST_TEST(r.name == "mypreset .ini"); + + // trailing space right before the extension round-trips + edt.SetText("abc "); + r = edt.GetFileName(".ini"); + BOOST_TEST((r.status == FileNameStatus::Valid)); + BOOST_TEST(r.name == "abc .ini"); + + // valid name: extension appended + edt.SetText("mypreset"); + r = edt.GetFileName(".ini"); + BOOST_TEST((r.status == FileNameStatus::Valid)); + BOOST_TEST(r.name == "mypreset.ini"); + + // extension appended even when the input already ends in it + edt.SetText("mypreset.ini"); + r = edt.GetFileName(".ini"); + BOOST_TEST((r.status == FileNameStatus::Valid)); + BOOST_TEST(r.name == "mypreset.ini.ini"); + + // 125 chars × 2 bytes + ".ini" = 254 bytes - just fits isValidFileName's 255 byte limit + const std::string twoByteChar = "\xC3\xA9"; // 2-byte UTF-8 char (U+00E9 'é') + std::string justFitsName; + for(int i = 0; i < 125; ++i) + justFitsName += twoByteChar; + edt.SetText(justFitsName); + r = edt.GetFileName(".ini"); + BOOST_TEST((r.status == FileNameStatus::Valid)); + BOOST_TEST(r.name == justFitsName + ".ini"); + + // With one more character the name is rejected + const std::string oneOverName = justFitsName + twoByteChar; + edt.SetText(oneOverName); + BOOST_TEST((edt.GetFileName(".ini").status == FileNameStatus::Invalid)); + + // no ext: name returned as-is without appending + edt.SetText("mypreset"); + r = edt.GetFileName(); + BOOST_TEST((r.status == FileNameStatus::Valid)); + BOOST_TEST(r.name == "mypreset"); + + // no ext: leading and trailing space trimmed + edt.SetText(" mypreset "); + r = edt.GetFileName(); + BOOST_TEST((r.status == FileNameStatus::Valid)); + BOOST_TEST(r.name == "mypreset"); +} + +BOOST_FIXTURE_TEST_CASE(EditSpaceKeyDoesNotDuplicateChar, uiHelper::Fixture) +{ + const auto font = createMockFont({U' ', '?'}); // '?' is required as glFont's missing-glyph fallback + ctrlEdit edt(nullptr, 0, DrawPoint(0, 0), Extent(200, 15), TextureColor::Green1, font.get()); + MouseCoords mc(edt.GetPos()); + mc.ldown = true; + edt.Msg_LeftDown(mc); + + // A real space press fires both of these on SDL2 (SDL_KEYDOWN+SDL_TEXTINPUT) and WinAPI + // (WM_KEYDOWN+WM_CHAR) for one physical key press. + edt.Msg_KeyDown(KeyEvent(KeyType::Space)); // OS "key down" event - must not insert anything + edt.Msg_KeyDown(KeyEvent(U' ')); // OS "char/text-input" event - inserts the space + BOOST_TEST(edt.GetText() == " "); // exactly one space, not two +} + +BOOST_FIXTURE_TEST_CASE(EditMaxLengthTruncatesInput, uiHelper::Fixture) +{ + const auto font = createMockFont({'a', 'b', 'c', 'd', 'e', '?'}); // '?' is glFont's missing-glyph fallback + ctrlEdit edt(nullptr, 0, DrawPoint(0, 0), Extent(200, 15), TextureColor::Green1, font.get(), 3 /*maxlength*/); + MouseCoords mc(edt.GetPos()); + mc.ldown = true; + edt.Msg_LeftDown(mc); + + for(char32_t c : {U'a', U'b', U'c', U'd', U'e'}) // 5 chars typed, cap is 3 + edt.Msg_KeyDown(KeyEvent(c)); + BOOST_TEST(edt.GetText() == "abc"); // input beyond maxLength is dropped + + edt.SetText("abcde"); // input beyond maxLength is dropped + BOOST_TEST(edt.GetText() == "abc"); +} + BOOST_AUTO_TEST_CASE(AdjustWidthForMaxChars_SetsCorrectSize) { auto font = createMockFont({'?', 'a', 'z'}); diff --git a/tests/s25Main/UI/testWindowManager.cpp b/tests/s25Main/UI/testWindowManager.cpp index aa1af96c05..a7e17571b9 100644 --- a/tests/s25Main/UI/testWindowManager.cpp +++ b/tests/s25Main/UI/testWindowManager.cpp @@ -1,4 +1,4 @@ -// Copyright (C) 2005 - 2021 Settlers Freaks (sf-team at siedler25.org) +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) // // SPDX-License-Identifier: GPL-2.0-or-later @@ -238,6 +238,8 @@ MOCK_BASE_CLASS(TestIngameWnd, IngameWindow) ~TestIngameWnd() override { closed.push_back(this); } MOCK_METHOD(DrawContent, 0, void()) MOCK_METHOD(Msg_KeyDown, 1) + MOCK_METHOD(Msg_LeftDown, 1) + MOCK_METHOD(Msg_MiddleDown, 1) static std::vector closed; }; std::vector TestIngameWnd::closed; @@ -382,15 +384,15 @@ BOOST_FIXTURE_TEST_CASE(ReplaceIngameWnd, uiHelper::Fixture) wnd->Close(); wnd2->Close(); - // Modal windows are not replaced but placed behind existing ones + // Modal windows are not replaced but placed on top of existing ones wnd = &WINDOWMANAGER.ReplaceWindow(std::make_unique(CGI_SETTINGS, true)); wnd2 = &WINDOWMANAGER.ReplaceWindow(std::make_unique(CGI_SETTINGS, true)); BOOST_TEST_REQUIRE((wnd && wnd2)); MOCK_EXPECT(wnd->DrawContent).once(); MOCK_EXPECT(wnd2->DrawContent).once(); WINDOWMANAGER.Draw(); - REQUIRE_WINDOW_ACTIVE(wnd); - REQUIRE_WINDOW_ALIVE(wnd2); + REQUIRE_WINDOW_ACTIVE(wnd2); + REQUIRE_WINDOW_ALIVE(wnd); wnd->Close(); wnd2->Close(); mock::verify(); @@ -398,29 +400,33 @@ BOOST_FIXTURE_TEST_CASE(ReplaceIngameWnd, uiHelper::Fixture) BOOST_FIXTURE_TEST_CASE(ModalWindowPlacement, uiHelper::Fixture) { - // new modal windows get placed before older ones + // new modal windows get placed on top of older ones, non-modal ones behind all modal ones auto& wnd = WINDOWMANAGER.ReplaceWindow(std::make_unique(CGI_MSGBOX, true)); MOCK_EXPECT(wnd.DrawContent).once(); WINDOWMANAGER.Draw(); REQUIRE_WINDOW_ACTIVE(&wnd); + BOOST_TEST_REQUIRE(WINDOWMANAGER.GetTopMostWindow() == &wnd); auto& wnd2 = WINDOWMANAGER.ReplaceWindow(std::make_unique(CGI_MSGBOX, true)); MOCK_EXPECT(wnd.DrawContent).once(); MOCK_EXPECT(wnd2.DrawContent).once(); WINDOWMANAGER.Draw(); - REQUIRE_WINDOW_ACTIVE(&wnd); + REQUIRE_WINDOW_ACTIVE(&wnd2); + BOOST_TEST_REQUIRE(WINDOWMANAGER.GetTopMostWindow() == &wnd2); auto& wnd3 = WINDOWMANAGER.ReplaceWindow(std::make_unique(CGI_MISSION_STATEMENT, true)); MOCK_EXPECT(wnd.DrawContent).once(); MOCK_EXPECT(wnd2.DrawContent).once(); MOCK_EXPECT(wnd3.DrawContent).once(); WINDOWMANAGER.Draw(); - REQUIRE_WINDOW_ACTIVE(&wnd); + REQUIRE_WINDOW_ACTIVE(&wnd3); + BOOST_TEST_REQUIRE(WINDOWMANAGER.GetTopMostWindow() == &wnd3); auto& wnd4 = WINDOWMANAGER.ReplaceWindow(std::make_unique(CGI_MSGBOX)); MOCK_EXPECT(wnd.DrawContent).once(); MOCK_EXPECT(wnd2.DrawContent).once(); MOCK_EXPECT(wnd3.DrawContent).once(); MOCK_EXPECT(wnd4.DrawContent).once(); WINDOWMANAGER.Draw(); - REQUIRE_WINDOW_ACTIVE(&wnd); + REQUIRE_WINDOW_ACTIVE(&wnd3); + BOOST_TEST_REQUIRE(WINDOWMANAGER.GetTopMostWindow() == &wnd3); auto& wnd5 = WINDOWMANAGER.ReplaceWindow(std::make_unique(CGI_HELP, true)); MOCK_EXPECT(wnd.DrawContent).once(); MOCK_EXPECT(wnd2.DrawContent).once(); @@ -428,7 +434,8 @@ BOOST_FIXTURE_TEST_CASE(ModalWindowPlacement, uiHelper::Fixture) MOCK_EXPECT(wnd4.DrawContent).once(); MOCK_EXPECT(wnd5.DrawContent).once(); WINDOWMANAGER.Draw(); - REQUIRE_WINDOW_ACTIVE(&wnd); + REQUIRE_WINDOW_ACTIVE(&wnd5); + BOOST_TEST_REQUIRE(WINDOWMANAGER.GetTopMostWindow() == &wnd5); auto& wnd6 = WINDOWMANAGER.ReplaceWindow(std::make_unique(CGI_SETTINGS)); MOCK_EXPECT(wnd.DrawContent).once(); MOCK_EXPECT(wnd2.DrawContent).once(); @@ -437,9 +444,10 @@ BOOST_FIXTURE_TEST_CASE(ModalWindowPlacement, uiHelper::Fixture) MOCK_EXPECT(wnd5.DrawContent).once(); MOCK_EXPECT(wnd6.DrawContent).once(); WINDOWMANAGER.Draw(); - REQUIRE_WINDOW_ACTIVE(&wnd); + REQUIRE_WINDOW_ACTIVE(&wnd5); + BOOST_TEST_REQUIRE(WINDOWMANAGER.GetTopMostWindow() == &wnd5); // Now we have the following order - std::vector expectedOrder = {&wnd, &wnd2, &wnd3, &wnd5, &wnd6, &wnd4}; + std::vector expectedOrder = {&wnd5, &wnd3, &wnd2, &wnd, &wnd6, &wnd4}; // Only way to check the order is to simulate a key event, expect the top most one to handle it and close it, then // proceed mock::sequence s; @@ -460,6 +468,106 @@ BOOST_FIXTURE_TEST_CASE(ModalWindowPlacement, uiHelper::Fixture) mock::verify(); } +BOOST_FIXTURE_TEST_CASE(ModalBlocksMouseActivation, uiHelper::Fixture) +{ + auto* wndA = &WINDOWMANAGER.Show(std::make_unique(CGI_HELP)); + auto* wndB = &WINDOWMANAGER.Show(std::make_unique(CGI_SETTINGS, true)); + wndB->SetPos(DrawPoint(200, 200)); + MOCK_EXPECT(wndA->DrawContent).once(); + MOCK_EXPECT(wndB->DrawContent).once(); + WINDOWMANAGER.Draw(); + REQUIRE_WINDOW_ACTIVE(wndB); + BOOST_TEST_REQUIRE(WINDOWMANAGER.GetTopMostWindow() == wndB); + + // Click is over non-modal A, not modal B; B must still receive it instead of the window under the cursor. + const Position posInA = wndA->GetDrawPos() + Position(10, 10); + + MOCK_EXPECT(wndB->Msg_LeftDown).once().returns(true); + MouseCoords mcDown(posInA); + mcDown.ldown = true; + WINDOWMANAGER.Msg_LeftDown(mcDown); + WINDOWMANAGER.Msg_LeftUp(MouseCoords(posInA)); + BOOST_TEST_REQUIRE(WINDOWMANAGER.GetTopMostWindow() == wndB); + REQUIRE_WINDOW_ACTIVE(wndB); + BOOST_TEST(!wndA->IsActive()); + mock::verify(); + + MOCK_EXPECT(wndB->Msg_MiddleDown).once().returns(true); + MouseCoords mcMiddle(posInA); + mcMiddle.mdown = true; + WINDOWMANAGER.Msg_MiddleDown(mcMiddle); + BOOST_TEST_REQUIRE(WINDOWMANAGER.GetTopMostWindow() == wndB); + BOOST_TEST(!wndA->IsActive()); + mock::verify(); + + // Wheel events must respect modal blocking too; the stack/active state must stay unchanged regardless. + WINDOWMANAGER.Msg_WheelUp(MouseCoords(posInA)); + WINDOWMANAGER.Msg_WheelDown(MouseCoords(posInA)); + BOOST_TEST_REQUIRE(WINDOWMANAGER.GetTopMostWindow() == wndB); + REQUIRE_WINDOW_ACTIVE(wndB); + BOOST_TEST(!wndA->IsActive()); +} + +BOOST_FIXTURE_TEST_CASE(BuriedModalClosedByGame, uiHelper::Fixture) +{ + auto* wndA = &WINDOWMANAGER.Show(std::make_unique(CGI_HELP, true)); + auto* wndB = &WINDOWMANAGER.Show(std::make_unique(CGI_SETTINGS, true)); + auto* wndC = &WINDOWMANAGER.Show(std::make_unique(CGI_MISSION_STATEMENT, true)); + MOCK_EXPECT(wndA->DrawContent).once(); + MOCK_EXPECT(wndB->DrawContent).once(); + MOCK_EXPECT(wndC->DrawContent).once(); + WINDOWMANAGER.Draw(); + REQUIRE_WINDOW_ACTIVE(wndC); + BOOST_TEST_REQUIRE(WINDOWMANAGER.GetTopMostWindow() == wndC); + + // Topmost C must remain topmost after non-topmost B is closed + WINDOWMANAGER.Close(wndB->GetID()); + MOCK_EXPECT(wndA->DrawContent).once(); + MOCK_EXPECT(wndC->DrawContent).once(); + WINDOWMANAGER.Draw(); + + REQUIRE_WINDOW_DESTROYED(wndB); + BOOST_TEST_REQUIRE(WINDOWMANAGER.GetTopMostWindow() == wndC); + REQUIRE_WINDOW_ACTIVE(wndC); + REQUIRE_WINDOW_ALIVE(wndA); + mock::verify(); +} + +BOOST_FIXTURE_TEST_CASE(ClickActivatesBackgroundNonModal, uiHelper::Fixture) +{ + auto* wndA = &WINDOWMANAGER.Show(std::make_unique(CGI_HELP)); + auto* wndB = &WINDOWMANAGER.Show(std::make_unique(CGI_SETTINGS)); + wndB->SetPos(DrawPoint(200, 200)); + MOCK_EXPECT(wndA->DrawContent).once(); + MOCK_EXPECT(wndB->DrawContent).once(); + WINDOWMANAGER.Draw(); + BOOST_TEST_REQUIRE(WINDOWMANAGER.GetTopMostWindow() == wndB); + REQUIRE_WINDOW_ACTIVE(wndB); + BOOST_TEST(!wndA->IsActive()); + + // B is topmost, click is over A which must become topmost/active + MOCK_EXPECT(wndA->Msg_LeftDown).once().returns(true); + MouseCoords mcDownA(wndA->GetDrawPos() + Position(10, 10)); + mcDownA.ldown = true; + WINDOWMANAGER.Msg_LeftDown(mcDownA); + WINDOWMANAGER.Msg_LeftUp(MouseCoords(mcDownA.pos)); + BOOST_TEST_REQUIRE(WINDOWMANAGER.GetTopMostWindow() == wndA); + REQUIRE_WINDOW_ACTIVE(wndA); + BOOST_TEST(!wndB->IsActive()); + mock::verify(); + + // A is topmost, click is over B which must become topmost/active again + MOCK_EXPECT(wndB->Msg_LeftDown).once().returns(true); + MouseCoords mcDownB(wndB->GetDrawPos() + Position(10, 10)); + mcDownB.ldown = true; + WINDOWMANAGER.Msg_LeftDown(mcDownB); + WINDOWMANAGER.Msg_LeftUp(MouseCoords(mcDownB.pos)); + BOOST_TEST_REQUIRE(WINDOWMANAGER.GetTopMostWindow() == wndB); + REQUIRE_WINDOW_ACTIVE(wndB); + BOOST_TEST(!wndA->IsActive()); + mock::verify(); +} + BOOST_FIXTURE_TEST_CASE(EscClosesWindow, uiHelper::Fixture) { auto* wnd = &WINDOWMANAGER.Show(std::make_unique(CGI_HELP)); diff --git a/tests/s25Main/UI/testWindows.cpp b/tests/s25Main/UI/testWindows.cpp index 52b68834aa..c543d23c55 100644 --- a/tests/s25Main/UI/testWindows.cpp +++ b/tests/s25Main/UI/testWindows.cpp @@ -1,18 +1,24 @@ -// Copyright (C) 2005 - 2025 Settlers Freaks (sf-team at siedler25.org) +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) // // SPDX-License-Identifier: GPL-2.0-or-later #include "GlobalGameSettings.h" +#include "RttrConfig.h" #include "WindowManager.h" #include "controls/ctrlButton.h" #include "controls/ctrlCheck.h" #include "controls/ctrlComboBox.h" +#include "controls/ctrlEdit.h" #include "controls/ctrlGroup.h" #include "controls/ctrlImage.h" #include "controls/ctrlMultiline.h" +#include "controls/ctrlTable.h" #include "controls/ctrlTextButton.h" #include "desktops/Desktop.h" +#include "files.h" +#include "ingameWindows/iwAddonPresets.h" #include "ingameWindows/iwAddons.h" +#include "ingameWindows/iwMsgbox.h" #include "ingameWindows/iwSkipGFs.h" #include "ingameWindows/iwVictory.h" #include "uiHelper/uiHelpers.hpp" @@ -20,9 +26,14 @@ #include "worldFixtures/WorldFixture.h" #include "world/GameWorldView.h" #include "world/GameWorldViewer.h" +#include "rttr/test/ConfigOverride.hpp" +#include "rttr/test/TmpFolder.hpp" #include +#include #include +#include #include +#include //-V:MOCK_METHOD:813 //-V:MOCK_EXPECT:807 @@ -100,6 +111,251 @@ BOOST_FIXTURE_TEST_CASE(JumpWindow, SmallWorldFixture) BOOST_TEST(numIncBts >= 4); } +namespace { +struct AddonPresetFixture : uiHelper::Fixture +{ + rttr::test::TmpFolder tmp; + rttr::test::ConfigOverride userDataOverride{"USERDATA", tmp}; + + void save(const std::map& states, const std::string& name) + { + iwSaveAddonPreset wnd(states); + Window& base = wnd; + base.GetCtrls().at(0)->SetText(name); + base.Msg_EditEnter(0); + } + + // Returns the given preset's settings, or empty if the preset is missing or corrupt. + std::map load(const std::string& name) + { + std::map out; + iwLoadAddonPreset wnd([&](const std::map& s) { out = s; }); + Window& base = wnd; + base.GetCtrls().at(0)->SetText(name); + base.Msg_EditEnter(0); + return out; + } + + // Presets currently on disk, read via a fresh Load window. + unsigned numPresets() + { + iwLoadAddonPreset wnd([](const std::map&) noexcept {}); + return wnd.GetCtrls().at(0)->GetNumRows(); + } +}; +} // namespace + +BOOST_FIXTURE_TEST_CASE(AddonPresetSaveLoadAndOverwrite, AddonPresetFixture) +{ + const std::map states1{{1, 2}, {3, 0}}; + const std::map states2{{3, 4}}; + + // save -> load roundtrip + save(states1, "myPreset"); + BOOST_TEST(load("myPreset") == states1); + + // overwrite: No - file unchanged + { + iwSaveAddonPreset wnd(states2); + Window& base = wnd; + base.GetCtrls().at(0)->SetText("myPreset"); + base.Msg_EditEnter(0); + + const auto* msgbox = dynamic_cast(WINDOWMANAGER.GetTopMostWindow()); + BOOST_TEST_REQUIRE(msgbox); + BOOST_TEST(msgbox->GetTitle() == _("Overwrite Preset")); + bool namesPreset = false; + for(const auto* ml : msgbox->GetCtrls()) + { + for(unsigned i = 0; i < ml->GetNumLines(); ++i) + namesPreset |= ml->GetLine(i).find("myPreset") != std::string::npos; + } + BOOST_TEST(namesPreset); + + base.Msg_MsgBoxResult(iwSaveAddonPreset::ID_mbOverwrite, MsgboxResult::No); + WINDOWMANAGER.CloseNow(WINDOWMANAGER.GetTopMostWindow()); // free the overwrite prompt + } + BOOST_TEST(load("myPreset") == states1); // unchanged + + // overwrite: Yes - file updated + { + iwSaveAddonPreset wnd(states2); + Window& base = wnd; + base.GetCtrls().at(0)->SetText("myPreset"); + base.Msg_EditEnter(0); + base.Msg_MsgBoxResult(iwSaveAddonPreset::ID_mbOverwrite, MsgboxResult::Yes); + WINDOWMANAGER.CloseNow(WINDOWMANAGER.GetTopMostWindow()); // free the overwrite prompt + } + BOOST_TEST(load("myPreset") == states2); // updated +} + +// A name already ending in the extension is a distinct preset, independently loadable and deletable. +BOOST_FIXTURE_TEST_CASE(AddonPresetExtensionInNameIsDistinct, AddonPresetFixture) +{ + const std::map states{{1, 2}}; + const std::map statesDoubled{{3, 4}}; + save(states, "myPreset"); // -> myPreset.ini, listed "myPreset" + save(statesDoubled, "myPreset.ini"); // -> myPreset.ini.ini, listed "myPreset.ini" + BOOST_TEST_REQUIRE(numPresets() == 2u); + + BOOST_TEST(load("myPreset") == states); + BOOST_TEST(load("myPreset.ini") == statesDoubled); + + iwLoadAddonPreset wnd([](const std::map&) noexcept {}); + Window& base = wnd; + base.GetCtrls().at(0)->SetText("myPreset.ini"); + base.Msg_MsgBoxResult(iwAddonPresetsBase::ID_mbDelete, MsgboxResult::Yes); + BOOST_TEST(numPresets() == 1u); + BOOST_TEST(load("myPreset.ini").empty()); // doubled file gone + BOOST_TEST(load("myPreset") == states); // sibling preset untouched +} + +// The edit box is the source of truth: after selecting a preset, editing the name and acting +// must target the edited name, not the stale table selection. +BOOST_FIXTURE_TEST_CASE(AddonPresetEditOverridesSelection, AddonPresetFixture) +{ + const std::map statesA{{1, 2}}; + const std::map statesB{{3, 4}}; + save(statesA, "presetA"); + save(statesB, "presetB"); + + std::optional> loaded; + iwLoadAddonPreset wnd([&](const std::map& s) { loaded = s; }); + Window& base = wnd; + auto& edit = *wnd.GetCtrls().at(0); + auto& table = *wnd.GetCtrls().at(0); + // Selection drives the edit: each selected row's name lands in the edit (rows sorted ascending) + table.SetSelection(0u); + BOOST_TEST_REQUIRE(edit.GetText() == "presetA"); + table.SetSelection(1u); + BOOST_TEST(edit.GetText() == "presetB"); // correct name for a non-first row + table.SetSelection(std::nullopt); // deselect + BOOST_TEST(edit.GetText() == ""); + table.SetSelection(0u); + BOOST_TEST_REQUIRE(edit.GetText() == "presetA"); + // User now retypes a different existing preset + edit.SetText("presetB"); + base.Msg_EditEnter(0); + BOOST_TEST_REQUIRE(loaded.has_value()); + BOOST_TEST(*loaded == statesB); +} + +BOOST_FIXTURE_TEST_CASE(AddonPresetDelete, AddonPresetFixture) +{ + save({{1, 2}}, "toDelete"); + BOOST_TEST_REQUIRE(numPresets() == 1u); + + iwLoadAddonPreset wnd([](const std::map&) noexcept {}); + Window& base = wnd; + base.GetCtrls().at(0)->SetText("toDelete"); + base.Msg_MsgBoxResult(iwAddonPresetsBase::ID_mbDelete, MsgboxResult::Yes); + + BOOST_TEST(base.GetCtrls().at(0)->GetText() == ""); // edit cleared after delete + BOOST_TEST(numPresets() == 0u); // file removed +} + +BOOST_FIXTURE_TEST_CASE(AddonPresetDeleteConfirmationNamesPreset, AddonPresetFixture) +{ + save({{1, 2}}, "toDelete"); + + iwLoadAddonPreset wnd([](const std::map&) noexcept {}); + Window& base = wnd; + base.GetCtrls().at(0)->SetText("toDelete"); + base.Msg_ButtonClick(iwAddonPresetsBase::ID_btDelete); + + const auto* msgbox = dynamic_cast(WINDOWMANAGER.GetTopMostWindow()); + BOOST_TEST_REQUIRE(msgbox); + BOOST_TEST(msgbox->GetTitle() == _("Delete Preset")); + bool namesPreset = false; + for(const auto* ml : msgbox->GetCtrls()) + { + for(unsigned i = 0; i < ml->GetNumLines(); ++i) + namesPreset |= ml->GetLine(i).find("toDelete") != std::string::npos; + } + BOOST_TEST(namesPreset); + WINDOWMANAGER.CloseNow(const_cast(msgbox)); +} + +// Loading/deleting a name that doesn't exist informs the user and changes nothing. +BOOST_FIXTURE_TEST_CASE(AddonPresetTargetNotFound, AddonPresetFixture) +{ + save({{1, 2}}, "exists"); + + // Load a missing name -> callback not invoked, "Preset Not Found" shown + { + bool called = false; + iwLoadAddonPreset wnd([&](const std::map&) noexcept { called = true; }); + Window& base = wnd; + base.GetCtrls().at(0)->SetText("missing"); + base.Msg_EditEnter(0); + BOOST_TEST(!called); + const auto* msgbox = dynamic_cast(WINDOWMANAGER.GetTopMostWindow()); + BOOST_TEST_REQUIRE(msgbox); + BOOST_TEST(msgbox->GetTitle() == _("Preset Not Found")); + WINDOWMANAGER.CloseNow(const_cast(msgbox)); + } + + // Delete a missing name -> "Preset Not Found" shown (not the delete confirmation) + { + iwLoadAddonPreset wnd([](const std::map&) noexcept {}); + Window& base = wnd; + base.GetCtrls().at(0)->SetText("missing"); + base.Msg_ButtonClick(iwAddonPresetsBase::ID_btDelete); + const auto* msgbox = dynamic_cast(WINDOWMANAGER.GetTopMostWindow()); + BOOST_TEST_REQUIRE(msgbox); + BOOST_TEST(msgbox->GetTitle() == _("Preset Not Found")); + WINDOWMANAGER.CloseNow(const_cast(msgbox)); + } + + BOOST_TEST(numPresets() == 1u); // "exists" untouched +} + +BOOST_FIXTURE_TEST_CASE(AddonPresetEmptyNameNoOp, AddonPresetFixture) +{ + save({{1, 2}}, "exists"); + + // Load with empty edit -> callback not invoked, no message + { + bool called = false; + iwLoadAddonPreset wnd([&](const std::map&) noexcept { called = true; }); + Window& base = wnd; + base.Msg_EditEnter(0); + BOOST_TEST(!called); + BOOST_TEST(!dynamic_cast(WINDOWMANAGER.GetTopMostWindow())); + } + // Delete with empty edit -> no message + { + iwLoadAddonPreset wnd([](const std::map&) noexcept {}); + Window& base = wnd; + base.Msg_ButtonClick(iwAddonPresetsBase::ID_btDelete); + BOOST_TEST(!dynamic_cast(WINDOWMANAGER.GetTopMostWindow())); + } + + BOOST_TEST(numPresets() == 1u); // nothing deleted +} + +// When the presets folder can't be created, the window informs the user and closes itself. +BOOST_FIXTURE_TEST_CASE(AddonPresetFolderUnavailable, AddonPresetFixture) +{ + // Plant a file where the presets folder should be so create_directories() fails + const auto presetsDir = RTTRCONFIG.ExpandPath(s25::folders::addonPresets); + { + std::ofstream blocker(presetsDir.string()); + blocker << 'x'; + } + BOOST_TEST_REQUIRE(boost::filesystem::exists(presetsDir)); + BOOST_TEST_REQUIRE(!boost::filesystem::is_directory(presetsDir)); + + iwSaveAddonPreset wnd(std::map{{1, 2}}); + BOOST_TEST(wnd.ShouldBeClosed()); // window marked itself for closing + BOOST_TEST(wnd.GetCtrls().empty()); // no controls were built + + const auto* msgbox = dynamic_cast(WINDOWMANAGER.GetTopMostWindow()); + BOOST_TEST_REQUIRE(msgbox); + BOOST_TEST(msgbox->GetTitle() == _("Addon Presets Unavailable")); + WINDOWMANAGER.CloseNow(const_cast(msgbox)); +} + namespace { MOCK_BASE_CLASS(TestWindow, Window) { diff --git a/tests/s25Main/lua/testLuaGUI.cpp b/tests/s25Main/lua/testLuaGUI.cpp index 1eeb936be3..16626deecd 100644 --- a/tests/s25Main/lua/testLuaGUI.cpp +++ b/tests/s25Main/lua/testLuaGUI.cpp @@ -41,21 +41,21 @@ BOOST_AUTO_TEST_CASE(MissionStatement) BOOST_TEST(wnd->IsActive()); BOOST_TEST(wnd->GetTitle() == _("Title")); - // double windows stack + // double windows stack, newest modal goes on top executeLua("rttr:MissionStatement(1, 'Title2', 'Text')"); const auto* wnd2 = dynamic_cast(WINDOWMANAGER.GetTopMostWindow()); BOOST_TEST_REQUIRE(wnd2); - // Other window still on top - BOOST_TEST(wnd2 == wnd); - // Close first wnd - WINDOWMANAGER.CloseNow(const_cast(wnd)); - // 2nd shows - wnd2 = dynamic_cast(WINDOWMANAGER.GetTopMostWindow()); - BOOST_TEST_REQUIRE(wnd2); BOOST_TEST(wnd2 != wnd); BOOST_TEST(wnd2->GetTitle() == "Title2"); - // Close wnd + // Close 2nd (topmost) wnd WINDOWMANAGER.CloseNow(const_cast(wnd2)); + // 1st shows again + wnd2 = dynamic_cast(WINDOWMANAGER.GetTopMostWindow()); + BOOST_TEST_REQUIRE(wnd2); + BOOST_TEST(wnd2 == wnd); + BOOST_TEST(wnd2->GetTitle() == _("Title")); + // Close wnd + WINDOWMANAGER.CloseNow(const_cast(wnd)); BOOST_TEST_REQUIRE(!WINDOWMANAGER.GetTopMostWindow()); // No image diff --git a/tests/s25Main/network/testGameClient.cpp b/tests/s25Main/network/testGameClient.cpp index ede00d43b1..caec924539 100644 --- a/tests/s25Main/network/testGameClient.cpp +++ b/tests/s25Main/network/testGameClient.cpp @@ -30,6 +30,7 @@ #include namespace bfs = boost::filesystem; +namespace dataset = boost::unit_test::data; // LCOV_EXCL_START namespace boost::test_tools::tt_detail { @@ -274,6 +275,47 @@ BOOST_AUTO_TEST_CASE(ClientStoresReceivedSavegamesInSaveFolder) BOOST_TEST(client.GetMapPath() == expectedSavePath); } +static constexpr std::array allServerTypes{ServerType::Local, ServerType::LAN, ServerType::Direct, ServerType::Lobby}; +static constexpr std::array allMapTypes{MapType::Savegame, MapType::OldMap}; +#ifndef __INTELLISENSE__ +BOOST_DATA_TEST_CASE(ClientTreatsLocalSavegameFilenameVerbatim, + dataset::make(allServerTypes) * dataset::make(allMapTypes), serverType, mapType) +#else +void ClientTreatsLocalSavegameFilenameVerbatim(ServerType serverType, MapType mapType) +#endif +{ + rttr::test::TmpFolder testUserData; + rttr::test::ConfigOverride userDataOverride("USERDATA", testUserData); + + GameClient client; + GameMessageInterface& clientMsgInterface = client; + TestServer server; + const auto serverPort = server.tryListen(); + BOOST_TEST_REQUIRE(serverPort >= 0); + + BOOST_TEST_REQUIRE(client.Connect("localhost", rttr::test::randString(10), serverType, serverPort, false, false)); + clientMsgInterface.OnGameMessage(GameMessage_Player_Id(1)); + client.GetMainPlayer().sendQueue.clear(); + clientMsgInterface.OnGameMessage(GameMessage_Server_TypeOK(GameMessage_Server_TypeOK::StatusCode::Ok, "")); + client.GetMainPlayer().sendQueue.clear(); + clientMsgInterface.OnGameMessage(GameMessage_Server_Password("true")); + client.GetMainPlayer().sendQueue.clear(); + + // Only local games (client and server are the same process via loopback) read the savegame + // straight off disk, so the filename is already a valid name for the local filesystem and must + // not be re-sanitized - doing so anyway would create a renamed duplicate next to the original. + // The bypass requires *both* ServerType::Local and MapType::Savegame - any other combination + // (e.g. a normal map, which is always copied into a separate "played maps" cache folder) must + // still go through the usual sanitization. + const std::string rawName = (mapType == MapType::Savegame) ? "my save.sav" : "my map.swd"; + clientMsgInterface.OnGameMessage(GameMessage_Map_Info(rawName, mapType, 1, 1, 0, 0)); + + const bool expectVerbatim = (serverType == ServerType::Local && mapType == MapType::Savegame); + const std::string sanitizedName = (mapType == MapType::Savegame) ? "my_save.sav" : "my_map.swd"; + const auto targetFolder = (mapType == MapType::Savegame) ? s25::folders::save : s25::folders::mapsPlayed; + BOOST_TEST(client.GetMapPath() == RTTRCONFIG.ExpandPath(targetFolder) / (expectVerbatim ? rawName : sanitizedName)); +} + BOOST_AUTO_TEST_CASE(ClientDetectsMapBufferOverflow) { rttr::test::LogAccessor _suppressLogOutput;