From a85a246ab889550e555a326758de787feed2940b Mon Sep 17 00:00:00 2001 From: Stenzek Date: Thu, 14 Dec 2023 21:44:32 +1000 Subject: [PATCH] ImGui: Add save state selector UI --- bin/resources/fullscreenui/no-save.png | Bin 0 -> 5849 bytes pcsx2-qt/QtHost.cpp | 5 +- pcsx2/CMakeLists.txt | 1 + pcsx2/Hotkeys.cpp | 65 +--- pcsx2/ImGui/ImGuiAnimated.h | 127 ++++++++ pcsx2/ImGui/ImGuiManager.cpp | 1 + pcsx2/ImGui/ImGuiOverlays.cpp | 402 ++++++++++++++++++++++++- pcsx2/ImGui/ImGuiOverlays.h | 22 +- pcsx2/VMManager.cpp | 4 +- pcsx2/VMManager.h | 3 - pcsx2/pcsx2.vcxproj | 3 +- pcsx2/pcsx2.vcxproj.filters | 2 + 12 files changed, 569 insertions(+), 66 deletions(-) create mode 100644 bin/resources/fullscreenui/no-save.png create mode 100644 pcsx2/ImGui/ImGuiAnimated.h diff --git a/bin/resources/fullscreenui/no-save.png b/bin/resources/fullscreenui/no-save.png new file mode 100644 index 0000000000000000000000000000000000000000..e0baabc2b31a981b773ecdcfd387521bc2b32430 GIT binary patch literal 5849 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 MUHx3vIVCg!0HAnkS^xk5 literal 0 HcmV?d00001 diff --git a/pcsx2-qt/QtHost.cpp b/pcsx2-qt/QtHost.cpp index 01a7ca6cfc..b44a27830f 100644 --- a/pcsx2-qt/QtHost.cpp +++ b/pcsx2-qt/QtHost.cpp @@ -37,6 +37,7 @@ #include "pcsx2/INISettingsInterface.h" #include "pcsx2/ImGui/FullscreenUI.h" #include "pcsx2/ImGui/ImGuiManager.h" +#include "pcsx2/ImGui/ImGuiOverlays.h" #include "pcsx2/Input/InputManager.h" #include "pcsx2/LogSink.h" #include "pcsx2/MTGS.h" @@ -999,7 +1000,7 @@ void EmuThread::updatePerformanceMetrics(bool force) if (THREAD_VU1) { gs_stat = tr("Slot: %1 | %2 | EE: %3% | VU: %4% | GS: %5%") - .arg(VMManager::GetCurrentActiveSaveStateSlot()) + .arg(SaveStateSelectorUI::GetCurrentSlot()) .arg(gs_stat_str.c_str()) .arg(PerformanceMetrics::GetCPUThreadUsage(), 0, 'f', 0) .arg(PerformanceMetrics::GetVUThreadUsage(), 0, 'f', 0) @@ -1008,7 +1009,7 @@ void EmuThread::updatePerformanceMetrics(bool force) else { gs_stat = tr("Slot: %1 | %2 | EE: %3% | GS: %4%") - .arg(VMManager::GetCurrentActiveSaveStateSlot()) + .arg(SaveStateSelectorUI::GetCurrentSlot()) .arg(gs_stat_str.c_str()) .arg(PerformanceMetrics::GetCPUThreadUsage(), 0, 'f', 0) .arg(PerformanceMetrics::GetGSThreadUsage(), 0, 'f', 0); diff --git a/pcsx2/CMakeLists.txt b/pcsx2/CMakeLists.txt index f5d3f93ea5..525f556a5a 100644 --- a/pcsx2/CMakeLists.txt +++ b/pcsx2/CMakeLists.txt @@ -828,6 +828,7 @@ set(pcsx2ImGuiSources set(pcsx2ImGuiHeaders ImGui/FullscreenUI.h + ImGui/ImGuiAnimated.h ImGui/ImGuiFullscreen.h ImGui/ImGuiManager.h ImGui/ImGuiOverlays.h diff --git a/pcsx2/Hotkeys.cpp b/pcsx2/Hotkeys.cpp index 9ff13d927d..cb1ec647aa 100644 --- a/pcsx2/Hotkeys.cpp +++ b/pcsx2/Hotkeys.cpp @@ -20,6 +20,7 @@ #include "Host.h" #include "IconsFontAwesome5.h" #include "ImGui/FullscreenUI.h" +#include "ImGui/ImGuiOverlays.h" #include "Input/InputManager.h" #include "Recording/InputRecording.h" #include "SPU2/spu2.h" @@ -30,16 +31,10 @@ #include "common/Path.h" #include "common/Timer.h" -static s32 s_current_save_slot = 1; static std::optional s_limiter_mode_prior_to_hold_interaction; -s32 VMManager::GetCurrentActiveSaveStateSlot() -{ - return s_current_save_slot; -} void VMManager::Internal::ResetVMHotkeyState() { - s_current_save_slot = 1; s_limiter_mode_prior_to_hold_interaction.reset(); } @@ -80,48 +75,6 @@ static void HotkeyAdjustVolume(s32 fixed, s32 delta) } } -static constexpr s32 CYCLE_SAVE_STATE_SLOTS = 10; - -static void HotkeyCycleSaveSlot(s32 delta) -{ - // 1..10 - s_current_save_slot = ((s_current_save_slot - 1) + delta); - if (s_current_save_slot < 0) - s_current_save_slot = CYCLE_SAVE_STATE_SLOTS; - else - s_current_save_slot = (s_current_save_slot % CYCLE_SAVE_STATE_SLOTS) + 1; - - const u32 crc = VMManager::GetDiscCRC(); - const std::string serial = VMManager::GetDiscSerial(); - const std::string filename = VMManager::GetSaveStateFileName(serial.c_str(), crc, s_current_save_slot); - FILESYSTEM_STAT_DATA sd; - if (!filename.empty() && FileSystem::StatFile(filename.c_str(), &sd)) - { - char date_buf[128] = {}; -#ifdef _WIN32 - ctime_s(date_buf, std::size(date_buf), &sd.ModificationTime); -#else - ctime_r(&sd.ModificationTime, date_buf); -#endif - - // remove terminating \n - size_t len = std::strlen(date_buf); - if (len > 0 && date_buf[len - 1] == '\n') - date_buf[len - 1] = 0; - - Host::AddIconOSDMessage("CycleSaveSlot", ICON_FA_SEARCH, - fmt::format( - TRANSLATE_FS("Hotkeys", "Save slot {0} selected (last save: {1})."), s_current_save_slot, date_buf), - Host::OSD_QUICK_DURATION); - } - else - { - Host::AddIconOSDMessage("CycleSaveSlot", ICON_FA_SEARCH, - fmt::format(TRANSLATE_FS("Hotkeys", "Save slot {} selected (no save yet)."), s_current_save_slot), - Host::OSD_QUICK_DURATION); - } -} - static void HotkeyLoadStateSlot(s32 slot) { // Can reapply settings and thus binds, therefore must be deferred. @@ -284,22 +237,30 @@ DEFINE_HOTKEY("InputRecToggleMode", TRANSLATE_NOOP("Hotkeys", "System"), DEFINE_HOTKEY("PreviousSaveStateSlot", TRANSLATE_NOOP("Hotkeys", "Save States"), TRANSLATE_NOOP("Hotkeys", "Select Previous Save Slot"), [](s32 pressed) { if (!pressed && VMManager::HasValidVM()) - HotkeyCycleSaveSlot(-1); + SaveStateSelectorUI::SelectPreviousSlot(true); }) DEFINE_HOTKEY("NextSaveStateSlot", TRANSLATE_NOOP("Hotkeys", "Save States"), TRANSLATE_NOOP("Hotkeys", "Select Next Save Slot"), [](s32 pressed) { if (!pressed && VMManager::HasValidVM()) - HotkeyCycleSaveSlot(1); + SaveStateSelectorUI::SelectNextSlot(true); }) DEFINE_HOTKEY("SaveStateToSlot", TRANSLATE_NOOP("Hotkeys", "Save States"), TRANSLATE_NOOP("Hotkeys", "Save State To Selected Slot"), [](s32 pressed) { if (!pressed && VMManager::HasValidVM()) - VMManager::SaveStateToSlot(s_current_save_slot); + SaveStateSelectorUI::SaveCurrentSlot(); }) DEFINE_HOTKEY("LoadStateFromSlot", TRANSLATE_NOOP("Hotkeys", "Save States"), TRANSLATE_NOOP("Hotkeys", "Load State From Selected Slot"), [](s32 pressed) { if (!pressed && VMManager::HasValidVM()) - HotkeyLoadStateSlot(s_current_save_slot); + SaveStateSelectorUI::LoadCurrentSlot(); + }) +DEFINE_HOTKEY("SaveStateAndSelectNextSlot", TRANSLATE_NOOP("Hotkeys", "Save States"), + TRANSLATE_NOOP("Hotkeys", "Save State and Select Next Slot"), [](s32 pressed) { + if (!pressed && VMManager::HasValidVM()) + { + SaveStateSelectorUI::SaveCurrentSlot(); + SaveStateSelectorUI::SelectNextSlot(false); + } }) #define DEFINE_HOTKEY_SAVESTATE_X(slotnum, title) \ diff --git a/pcsx2/ImGui/ImGuiAnimated.h b/pcsx2/ImGui/ImGuiAnimated.h new file mode 100644 index 0000000000..df227a408b --- /dev/null +++ b/pcsx2/ImGui/ImGuiAnimated.h @@ -0,0 +1,127 @@ +/* PCSX2 - PS2 Emulator for PCs + * Copyright (C) 2002-2023 PCSX2 Dev Team + * + * PCSX2 is free software: you can redistribute it and/or modify it under the terms + * of the GNU Lesser General Public License as published by the Free Software Found- + * ation, either version 3 of the License, or (at your option) any later version. + * + * PCSX2 is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR + * PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with PCSX2. + * If not, see . + */ + +#pragma once + +#ifndef IMGUI_DEFINE_MATH_OPERATORS +#define IMGUI_DEFINE_MATH_OPERATORS +#endif + +#include "common/Easing.h" + +#include "imgui.h" +#include "imgui_internal.h" + +#include + +class ImAnimatedFloat +{ +public: + ImAnimatedFloat() = default; + + bool IsActive() const { return (m_current_value != m_end_value); } + float GetCurrentValue() const { return m_current_value; } + float GetStartValue() const { return m_start_value; } + float GetEndValue() const { return m_end_value; } + + void Stop() { m_end_value = m_current_value; } + void SetEndValue(float end_value) { m_end_value = end_value; } + + void Reset(float value) + { + m_current_value = value; + m_start_value = value; + m_end_value = value; + } + + float UpdateAndGetValue() + { + if (m_current_value == m_end_value) + return m_current_value; + + m_elapsed_time += ImGui::GetIO().DeltaTime; + + const float frac = std::min(0.05f + Easing::OutExpo(m_elapsed_time / m_duration), 1.0f); + m_current_value = std::clamp(m_start_value + ((m_end_value - m_start_value) * frac), + std::min(m_start_value, m_end_value), std::max(m_start_value, m_end_value)); + return m_current_value; + } + + void Start(float start_value, float end_value, float duration) + { + m_current_value = start_value; + m_start_value = start_value; + m_end_value = end_value; + m_elapsed_time = 0.0f; + m_duration = duration; + } + +private: + float m_current_value = 0.0f; + float m_start_value = 0.0f; + float m_end_value = 0.0f; + float m_elapsed_time = 0.0f; + float m_duration = 1.0f; +}; + +class ImAnimatedVec2 +{ +public: + ImAnimatedVec2() = default; + + bool IsActive() const { return (m_current_value.x != m_end_value.x || m_current_value.y != m_end_value.y); } + const ImVec2& GetCurrentValue() const { return m_current_value; } + const ImVec2& GetStartValue() const { return m_start_value; } + const ImVec2& GetEndValue() const { return m_end_value; } + + void Stop() { m_end_value = m_current_value; } + void SetEndValue(const ImVec2& end_value) { m_end_value = end_value; } + + void Reset(const ImVec2& value) + { + m_current_value = value; + m_start_value = value; + m_end_value = value; + } + + const ImVec2& UpdateAndGetValue() + { + if (m_current_value.x == m_end_value.x && m_current_value.y == m_end_value.y) + return m_current_value; + + m_elapsed_time += ImGui::GetIO().DeltaTime; + + const float frac = std::min(0.05f + Easing::OutExpo(m_elapsed_time / m_duration), 1.0f); + m_current_value = ImClamp(ImLerp(m_start_value, m_end_value, frac), ImMin(m_start_value, m_end_value), + ImMax(m_start_value, m_end_value)); + return m_current_value; + } + + void Start(const ImVec2& start_value, const ImVec2& end_value, float duration) + { + m_current_value = start_value; + m_start_value = start_value; + m_end_value = end_value; + m_elapsed_time = 0.0f; + m_duration = duration; + } + +private: + ImVec2 m_current_value = {}; + ImVec2 m_start_value = {}; + ImVec2 m_end_value = {}; + float m_elapsed_time = 0.0f; + float m_duration = 1.0f; +}; diff --git a/pcsx2/ImGui/ImGuiManager.cpp b/pcsx2/ImGui/ImGuiManager.cpp index 7bf793ef8d..77cdd81a6b 100644 --- a/pcsx2/ImGui/ImGuiManager.cpp +++ b/pcsx2/ImGui/ImGuiManager.cpp @@ -204,6 +204,7 @@ void ImGuiManager::Shutdown(bool clear_state) FullscreenUI::Shutdown(clear_state); ImGuiFullscreen::SetFonts(nullptr, nullptr, nullptr); + SaveStateSelectorUI::DestroyTextures(); if (clear_state) s_fullscreen_ui_was_initialized = false; diff --git a/pcsx2/ImGui/ImGuiOverlays.cpp b/pcsx2/ImGui/ImGuiOverlays.cpp index df41b5692e..8aaafc0563 100644 --- a/pcsx2/ImGui/ImGuiOverlays.cpp +++ b/pcsx2/ImGui/ImGuiOverlays.cpp @@ -1,5 +1,5 @@ /* PCSX2 - PS2 Emulator for PCs - * Copyright (C) 2002-2022 PCSX2 Dev Team + * Copyright (C) 2002-2023 PCSX2 Dev Team * * PCSX2 is free software: you can redistribute it and/or modify it under the terms * of the GNU Lesser General Public License as published by the Free Software Found- @@ -21,13 +21,16 @@ #include "GS/GS.h" #include "GS/GSCapture.h" #include "GS/GSVector.h" +#include "GS/Renderers/Common/GSDevice.h" #include "Host.h" #include "IconsFontAwesome5.h" #include "ImGui/FullscreenUI.h" +#include "ImGui/ImGuiAnimated.h" #include "ImGui/ImGuiFullscreen.h" #include "ImGui/ImGuiManager.h" #include "ImGui/ImGuiOverlays.h" #include "Input/InputManager.h" +#include "MTGS.h" #include "PerformanceMetrics.h" #include "Recording/InputRecording.h" #include "SIO/Pad/Pad.h" @@ -36,10 +39,13 @@ #include "VMManager.h" #include "common/BitUtils.h" +#include "common/FileSystem.h" +#include "common/Path.h" #include "common/StringUtil.h" #include "common/Timer.h" -#include "fmt/core.h" +#include "fmt/chrono.h" +#include "fmt/format.h" #include "imgui.h" #include @@ -421,7 +427,7 @@ void ImGuiManager::DrawSettingsOverlay() APPEND("FBC "); if (GSConfig.UserHacks_ReadTCOnClose) APPEND("FTC "); - if(GSConfig.UserHacks_DisableDepthSupport) + if (GSConfig.UserHacks_DisableDepthSupport) APPEND("DDE "); if (GSConfig.UserHacks_DisablePartialInvalidation) APPEND("DPIV "); @@ -503,7 +509,7 @@ void ImGuiManager::DrawInputsOverlay() const Pad::ControllerType ctype = pad->GetType(); if (ctype == Pad::ControllerType::NotConnected) continue; - + const Pad::ControllerInfo& cinfo = pad->GetInfo(); if (cinfo.icon_name) text.fmt("{} {}", cinfo.icon_name, slot + 1u); @@ -654,6 +660,392 @@ void ImGuiManager::DrawInputRecordingOverlay(float& position_y) #undef DRAW_LINE } +namespace SaveStateSelectorUI +{ + namespace + { + struct ListEntry + { + std::string title; + std::string summary; + std::string filename; + std::unique_ptr preview_texture; + }; + } // namespace + + static void InitializePlaceholderListEntry(ListEntry* li, std::string path, s32 slot); + static void InitializeListEntry(const std::string& serial, u32 crc, ListEntry* li, s32 slot); + + static void RefreshHotkeyLegend(); + static void Draw(); + static void ShowSlotOSDMessage(); + + static constexpr const char* DATE_TIME_FORMAT = TRANSLATE_NOOP("ImGuiOverlays", "Saved at {0:%H:%M} on {0:%a} {0:%Y/%m/%d}."); + + static std::shared_ptr s_placeholder_texture; + static std::string s_load_legend; + static std::string s_save_legend; + static std::string s_prev_legend; + static std::string s_next_legend; + + static std::array s_slots; + static std::atomic_int32_t s_current_slot{0}; + + static float s_open_time = 0.0f; + static float s_close_time = 0.0f; + + static ImAnimatedFloat s_scroll_animated; + static ImAnimatedFloat s_background_animated; + + static bool s_open = false; +} // namespace SaveStateSelectorUI + +void SaveStateSelectorUI::Open(float open_time /* = DEFAULT_OPEN_TIME */) +{ + const std::string serial = VMManager::GetDiscSerial(); + if (serial.empty()) + { + Host::AddIconOSDMessage("SaveStateSelectorUIUnavailable", ICON_FA_SD_CARD, + TRANSLATE_SV("ImGuiOverlays", "Save state selector is unavailable without a valid game serial.")); + return; + } + + s_open_time = 0.0f; + s_close_time = open_time; + + if (s_open) + return; + + + if (!s_placeholder_texture) + s_placeholder_texture = ImGuiFullscreen::LoadTexture("fullscreenui/no-save.png"); + + s_scroll_animated.Reset(0.0f); + s_background_animated.Reset(0.0f); + s_open = true; + RefreshList(serial, VMManager::GetDiscCRC()); + RefreshHotkeyLegend(); +} + +void SaveStateSelectorUI::Close() +{ + s_open = false; + s_load_legend = {}; + s_save_legend = {}; + s_prev_legend = {}; + s_next_legend = {}; +} + +void SaveStateSelectorUI::RefreshList(const std::string& serial, u32 crc) +{ + for (ListEntry& entry : s_slots) + { + if (entry.preview_texture) + g_gs_device->Recycle(entry.preview_texture.release()); + } + + for (u32 i = 0; i < VMManager::NUM_SAVE_STATE_SLOTS; i++) + InitializeListEntry(serial, crc, &s_slots[i], static_cast(i + 1)); +} + +void SaveStateSelectorUI::Clear() +{ + // called on CPU thread at shutdown, textures should already be deleted, unless running + // big picture UI, in which case we have to delete them here... + for (ListEntry& li : s_slots) + { + if (li.preview_texture) + { + MTGS::RunOnGSThread([tex = li.preview_texture.release()]() { + g_gs_device->Recycle(tex); + }); + } + + li = {}; + } + + s_current_slot.store(0, std::memory_order_release); +} + +void SaveStateSelectorUI::DestroyTextures() +{ + Close(); + + for (ListEntry& entry : s_slots) + { + if (entry.preview_texture) + g_gs_device->Recycle(entry.preview_texture.release()); + } + + s_placeholder_texture.reset(); +} + +void SaveStateSelectorUI::RefreshHotkeyLegend() +{ + auto format_legend_entry = [](std::string binding, std::string_view caption) { + InputManager::PrettifyInputBinding(binding); + return fmt::format("{} - {}", binding, caption); + }; + + s_load_legend = format_legend_entry(Host::GetStringSettingValue("Hotkeys", "LoadStateFromSlot"), + TRANSLATE_STR("ImGuiOverlays", "Load")); + s_save_legend = format_legend_entry(Host::GetStringSettingValue("Hotkeys", "SaveStateToSlot"), + TRANSLATE_STR("ImGuiOverlays", "Save")); + s_prev_legend = format_legend_entry(Host::GetStringSettingValue("Hotkeys", "PreviousSaveStateSlot"), + TRANSLATE_STR("ImGuiOverlays", "Select Previous")); + s_next_legend = format_legend_entry(Host::GetStringSettingValue("Hotkeys", "NextSaveStateSlot"), + TRANSLATE_STR("ImGuiOverlays", "Select Next")); +} + +void SaveStateSelectorUI::SelectNextSlot(bool open_selector) +{ + const s32 current_slot = s_current_slot.load(std::memory_order_acquire); + s_current_slot.store((current_slot == (VMManager::NUM_SAVE_STATE_SLOTS - 1)) ? 0 : (current_slot + 1), std::memory_order_release); + + if (open_selector) + { + MTGS::RunOnGSThread([]() { + if (!s_open) + Open(); + + s_open_time = 0.0f; + }); + } + else + { + ShowSlotOSDMessage(); + } +} + +void SaveStateSelectorUI::SelectPreviousSlot(bool open_selector) +{ + const s32 current_slot = s_current_slot.load(std::memory_order_acquire); + s_current_slot.store((current_slot == 0) ? (VMManager::NUM_SAVE_STATE_SLOTS - 1) : (current_slot - 1), std::memory_order_release); + + if (open_selector) + { + MTGS::RunOnGSThread([]() { + if (!s_open) + Open(); + + s_open_time = 0.0f; + }); + } + else + { + ShowSlotOSDMessage(); + } +} + +void SaveStateSelectorUI::InitializeListEntry(const std::string& serial, u32 crc, ListEntry* li, s32 slot) +{ + std::string path = VMManager::GetSaveStateFileName(serial.c_str(), crc, slot); + FILESYSTEM_STAT_DATA sd; + if (!FileSystem::StatFile(path.c_str(), &sd)) + { + InitializePlaceholderListEntry(li, std::move(path), slot); + return; + } + + li->title = fmt::format(TRANSLATE_FS("ImGuiOverlays", "Save Slot {0}"), slot); + li->summary = fmt::format(TRANSLATE_FS("ImGuiOverlays", DATE_TIME_FORMAT), fmt::localtime(sd.ModificationTime)); + li->filename = Path::GetFileName(path); + + u32 screenshot_width, screenshot_height; + std::vector screenshot_pixels; + if (SaveState_ReadScreenshot(path, &screenshot_width, &screenshot_height, &screenshot_pixels)) + { + li->preview_texture = + std::unique_ptr(g_gs_device->CreateTexture(screenshot_width, screenshot_height, 1, GSTexture::Format::Color)); + if (!li->preview_texture || !li->preview_texture->Update(GSVector4i(0, 0, screenshot_width, screenshot_height), + screenshot_pixels.data(), sizeof(u32) * screenshot_width)) + { + Console.Error("Failed to upload save state image to GPU"); + if (li->preview_texture) + g_gs_device->Recycle(li->preview_texture.release()); + } + } +} + +void SaveStateSelectorUI::InitializePlaceholderListEntry(ListEntry* li, std::string path, s32 slot) +{ + li->title = fmt::format(TRANSLATE_FS("ImGuiOverlays", "Save Slot {0}"), slot); + li->summary = TRANSLATE_STR("ImGuiOverlays", "No save present in this slot."); + li->filename = Path::GetFileName(path); +} + +void SaveStateSelectorUI::Draw() +{ + static constexpr float SCROLL_ANIMATION_TIME = 0.25f; + static constexpr float BG_ANIMATION_TIME = 0.15f; + + const auto& io = ImGui::GetIO(); + const float scale = ImGuiManager::GetGlobalScale(); + const float width = (600.0f * scale); + const float height = (420.0f * scale); + + const float padding_and_rounding = 15.0f * scale; + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, padding_and_rounding); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(padding_and_rounding, padding_and_rounding)); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.11f, 0.15f, 0.17f, 0.8f)); + ImGui::SetNextWindowSize(ImVec2(width, height), ImGuiCond_Always); + ImGui::SetNextWindowPos(ImVec2(io.DisplaySize.x * 0.5f, io.DisplaySize.y * 0.5f), ImGuiCond_Always, + ImVec2(0.5f, 0.5f)); + + if (ImGui::Begin("##save_state_selector", nullptr, + ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar)) + { + // Leave 2 lines for the legend + const float legend_margin = ImGui::GetFontSize() * 2.0f + ImGui::GetStyle().ItemSpacing.y * 3.0f; + const float padding = 10.0f * scale; + const s32 current_slot = s_current_slot.load(std::memory_order_acquire); + + ImGui::BeginChild("##item_list", ImVec2(0, -legend_margin), false, + ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoBackground); + { + const ImVec2 image_size = ImVec2(128.0f * scale, (128.0f / (4.0f / 3.0f)) * scale); + const float item_width = std::floor(width - (padding_and_rounding * 2.0f) - ImGui::GetStyle().ScrollbarSize); + const float item_height = std::floor(image_size.y + padding * 2.0f); + const float text_indent = image_size.x + padding + padding; + + for (size_t i = 0; i < s_slots.size(); i++) + { + const ListEntry& entry = s_slots[i]; + const float y_start = item_height * static_cast(i); + + if (i == current_slot) + { + ImGui::SetCursorPosY(y_start); + + const ImVec2 p_start(ImGui::GetCursorScreenPos()); + const ImVec2 p_end(p_start.x + item_width, p_start.y + item_height); + const ImRect item_rect(p_start, p_end); + const ImRect& window_rect = ImGui::GetCurrentWindow()->ClipRect; + if (!window_rect.Contains(item_rect)) + { + float scroll_target = ImGui::GetScrollY(); + if (item_rect.Min.y < window_rect.Min.y) + scroll_target = (ImGui::GetScrollY() - (window_rect.Min.y - item_rect.Min.y)); + else if (item_rect.Max.y > window_rect.Max.y) + scroll_target = (ImGui::GetScrollY() + (item_rect.Max.y - window_rect.Max.y)); + + if (scroll_target != s_scroll_animated.GetEndValue()) + s_scroll_animated.Start(ImGui::GetScrollY(), scroll_target, SCROLL_ANIMATION_TIME); + } + + if (s_scroll_animated.IsActive()) + ImGui::SetScrollY(s_scroll_animated.UpdateAndGetValue()); + + if (s_background_animated.GetEndValue() != p_start.y) + s_background_animated.Start(s_background_animated.UpdateAndGetValue(), p_start.y, BG_ANIMATION_TIME); + + ImVec2 highlight_pos; + if (s_background_animated.IsActive()) + highlight_pos = ImVec2(p_start.x, s_background_animated.UpdateAndGetValue()); + else + highlight_pos = p_start; + + ImGui::GetWindowDrawList()->AddRectFilled(highlight_pos, + ImVec2(highlight_pos.x + item_width, highlight_pos.y + item_height), + ImColor(0.22f, 0.30f, 0.34f, 0.9f), padding_and_rounding); + } + + if (GSTexture* preview_texture = entry.preview_texture ? entry.preview_texture.get() : s_placeholder_texture.get()) + { + ImGui::SetCursorPosY(y_start + padding); + ImGui::SetCursorPosX(padding); + ImGui::Image(preview_texture, image_size); + } + + ImGui::SetCursorPosY(y_start + padding); + + ImGui::Indent(text_indent); + + ImGui::TextUnformatted(entry.title.c_str(), entry.title.c_str() + entry.title.length()); + ImGui::TextUnformatted(entry.summary.c_str(), entry.summary.c_str() + entry.summary.length()); + ImGui::PushFont(ImGuiManager::GetFixedFont()); + ImGui::TextUnformatted(entry.filename.c_str(), entry.filename.c_str() + entry.filename.length()); + ImGui::PopFont(); + + ImGui::Unindent(text_indent); + ImGui::SetCursorPosY(y_start); + ImGui::ItemSize(ImVec2(item_width, item_height)); + } + } + ImGui::EndChild(); + + ImGui::BeginChild("##legend", ImVec2(0, 0), false, + ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoBackground); + { + ImGui::SetCursorPosX(padding); + ImGui::BeginTable("table", 2); + + ImGui::TableNextColumn(); + ImGui::TextUnformatted(s_load_legend.c_str()); + ImGui::TableNextColumn(); + ImGui::TextUnformatted(s_prev_legend.c_str()); + ImGui::TableNextColumn(); + ImGui::TextUnformatted(s_save_legend.c_str()); + ImGui::TableNextColumn(); + ImGui::TextUnformatted(s_next_legend.c_str()); + + ImGui::EndTable(); + } + ImGui::EndChild(); + } + ImGui::End(); + + ImGui::PopStyleVar(2); + ImGui::PopStyleColor(); + + // auto-close + s_open_time += io.DeltaTime; + if (s_open_time >= s_close_time) + Close(); +} + +s32 SaveStateSelectorUI::GetCurrentSlot() +{ + return s_current_slot.load(std::memory_order_acquire) + 1; +} + +void SaveStateSelectorUI::LoadCurrentSlot() +{ + Host::RunOnCPUThread([slot = GetCurrentSlot()]() { + VMManager::LoadStateFromSlot(slot); + }); + Close(); +} + +void SaveStateSelectorUI::SaveCurrentSlot() +{ + Host::RunOnCPUThread([slot = GetCurrentSlot()]() { + VMManager::SaveStateToSlot(slot); + }); + Close(); +} + +void SaveStateSelectorUI::ShowSlotOSDMessage() +{ + const s32 slot = GetCurrentSlot(); + const u32 crc = VMManager::GetDiscCRC(); + const std::string serial = VMManager::GetDiscSerial(); + const std::string filename = VMManager::GetSaveStateFileName(serial.c_str(), crc, slot); + FILESYSTEM_STAT_DATA sd; + std::string date; + if (!filename.empty() && FileSystem::StatFile(filename.c_str(), &sd)) + date = fmt::format(TRANSLATE_FS("ImGuiOverlays", DATE_TIME_FORMAT), fmt::localtime(sd.ModificationTime)); + else + date = TRANSLATE_STR("ImGuiOverlays", "no save yet"); + + Host::AddIconOSDMessage("ShowSlotOSDMessage", ICON_FA_SEARCH, + fmt::format(TRANSLATE_FS("Hotkeys", "Save slot {0} selected ({1})."), slot, date), + Host::OSD_QUICK_DURATION); +} + void ImGuiManager::RenderOverlays() { float position_y = 0; @@ -661,4 +1053,6 @@ void ImGuiManager::RenderOverlays() DrawPerformanceOverlay(position_y); DrawSettingsOverlay(); DrawInputsOverlay(); + if (SaveStateSelectorUI::s_open) + SaveStateSelectorUI::Draw(); } diff --git a/pcsx2/ImGui/ImGuiOverlays.h b/pcsx2/ImGui/ImGuiOverlays.h index 39736d3e59..aa976030b9 100644 --- a/pcsx2/ImGui/ImGuiOverlays.h +++ b/pcsx2/ImGui/ImGuiOverlays.h @@ -1,5 +1,5 @@ /* PCSX2 - PS2 Emulator for PCs - * Copyright (C) 2002-2022 PCSX2 Dev Team + * Copyright (C) 2002-2023 PCSX2 Dev Team * * PCSX2 is free software: you can redistribute it and/or modify it under the terms * of the GNU Lesser General Public License as published by the Free Software Found- @@ -15,11 +15,27 @@ #pragma once -#pragma once - #include "ImGuiManager.h" namespace ImGuiManager { void RenderOverlays(); } + +namespace SaveStateSelectorUI +{ + static constexpr float DEFAULT_OPEN_TIME = 7.5f; + + void Open(float open_time = DEFAULT_OPEN_TIME); + void RefreshList(const std::string& serial, u32 crc); + void DestroyTextures(); + void Clear(); + void Close(); + + void SelectNextSlot(bool open_selector); + void SelectPreviousSlot(bool open_selector); + + s32 GetCurrentSlot(); + void LoadCurrentSlot(); + void SaveCurrentSlot(); +} // namespace SaveStateSelectorUI diff --git a/pcsx2/VMManager.cpp b/pcsx2/VMManager.cpp index 3ebb2d6de7..d4027578ed 100644 --- a/pcsx2/VMManager.cpp +++ b/pcsx2/VMManager.cpp @@ -32,6 +32,7 @@ #include "Host.h" #include "INISettingsInterface.h" #include "ImGui/FullscreenUI.h" +#include "ImGui/ImGuiOverlays.h" #include "Input/InputManager.h" #include "IopBios.h" #include "LogSink.h" @@ -1446,6 +1447,7 @@ void VMManager::Shutdown(bool save_resume_state) s_state.store(VMState::Shutdown, std::memory_order_release); FullscreenUI::OnVMDestroyed(); + SaveStateSelectorUI::Clear(); UpdateInhibitScreensaver(false); Host::OnVMDestroyed(); @@ -1764,7 +1766,7 @@ bool VMManager::LoadState(const char* filename) bool VMManager::LoadStateFromSlot(s32 slot) { const std::string filename = GetCurrentSaveStateFileName(slot); - if (filename.empty()) + if (filename.empty() || !FileSystem::FileExists(filename.c_str())) { Host::AddIconOSDMessage("LoadStateFromSlot", ICON_FA_EXCLAMATION_TRIANGLE, fmt::format(TRANSLATE_FS("VMManager", "There is no save state in slot {}."), slot), diff --git a/pcsx2/VMManager.h b/pcsx2/VMManager.h index 68b961be7d..eadc572cc5 100644 --- a/pcsx2/VMManager.h +++ b/pcsx2/VMManager.h @@ -55,9 +55,6 @@ namespace VMManager /// The number of usable save state slots. static constexpr s32 NUM_SAVE_STATE_SLOTS = 10; - /// Returns the currently active savestate slot. - s32 GetCurrentActiveSaveStateSlot(); - /// The stack size to use for threads running recompilers static constexpr std::size_t EMU_THREAD_STACK_SIZE = 2 * 1024 * 1024; // µVU likes recursion diff --git a/pcsx2/pcsx2.vcxproj b/pcsx2/pcsx2.vcxproj index abe2e7c4c6..4f520f1685 100644 --- a/pcsx2/pcsx2.vcxproj +++ b/pcsx2/pcsx2.vcxproj @@ -559,6 +559,7 @@ + @@ -873,4 +874,4 @@ - + \ No newline at end of file diff --git a/pcsx2/pcsx2.vcxproj.filters b/pcsx2/pcsx2.vcxproj.filters index a555125fee..8c8739dcfd 100644 --- a/pcsx2/pcsx2.vcxproj.filters +++ b/pcsx2/pcsx2.vcxproj.filters @@ -1424,6 +1424,7 @@ System\Ps2\Iop\SIO\PAD + @@ -2354,6 +2355,7 @@ System\Ps2\Iop\SIO\PAD +