diff --git a/src/frontend-common/CMakeLists.txt b/src/frontend-common/CMakeLists.txt index 8ae1a95a8..9104f2016 100644 --- a/src/frontend-common/CMakeLists.txt +++ b/src/frontend-common/CMakeLists.txt @@ -9,6 +9,8 @@ add_library(frontend-common imgui_styles.h ini_settings_interface.cpp ini_settings_interface.h + save_state_selector_ui.cpp + save_state_selector_ui.h ) if(SDL2_FOUND) diff --git a/src/frontend-common/common_host_interface.cpp b/src/frontend-common/common_host_interface.cpp index 0b3a8b632..e21bc3807 100644 --- a/src/frontend-common/common_host_interface.cpp +++ b/src/frontend-common/common_host_interface.cpp @@ -9,6 +9,7 @@ #include "core/game_list.h" #include "core/gpu.h" #include "core/system.h" +#include "save_state_selector_ui.h" #include "scmversion/scmversion.h" #ifdef WITH_SDL2 #include "sdl_audio_stream.h" @@ -32,6 +33,8 @@ bool CommonHostInterface::Initialize() if (!FileSystem::SetWorkingDirectory(m_user_directory.c_str())) Log_ErrorPrintf("Failed to set working directory to '%s'", m_user_directory.c_str()); + m_save_state_selector_ui = std::make_unique(this); + RegisterGeneralHotkeys(); RegisterGraphicsHotkeys(); RegisterSaveStateHotkeys(); @@ -360,6 +363,14 @@ void CommonHostInterface::OnControllerTypeChanged(u32 slot) UpdateInputMap(); } +void CommonHostInterface::DrawImGuiWindows() +{ + HostInterface::DrawImGuiWindows(); + + if (m_save_state_selector_ui->IsOpen()) + m_save_state_selector_ui->Draw(); +} + void CommonHostInterface::SetDefaultSettings(SettingsInterface& si) { HostInterface::SetDefaultSettings(si); @@ -773,6 +784,27 @@ void CommonHostInterface::RegisterGraphicsHotkeys() void CommonHostInterface::RegisterSaveStateHotkeys() { + RegisterHotkey(StaticString("Save States"), StaticString("LoadSelectedSaveState"), + StaticString("Load From Selected Slot"), [this](bool pressed) { + if (!pressed) + m_save_state_selector_ui->LoadCurrentSlot(); + }); + RegisterHotkey(StaticString("Save States"), StaticString("SaveSelectedSaveState"), + StaticString("Save To Selected Slot"), [this](bool pressed) { + if (!pressed) + m_save_state_selector_ui->SaveCurrentSlot(); + }); + RegisterHotkey(StaticString("Save States"), StaticString("SelectPreviousSaveStateSlot"), + StaticString("Select Previous Save Slot"), [this](bool pressed) { + if (!pressed) + m_save_state_selector_ui->SelectPreviousSlot(); + }); + RegisterHotkey(StaticString("Save States"), StaticString("SelectNextSaveStateSlot"), + StaticString("Select Next Save Slot"), [this](bool pressed) { + if (!pressed) + m_save_state_selector_ui->SelectNextSlot(); + }); + for (u32 global_i = 0; global_i < 2; global_i++) { const bool global = ConvertToBoolUnchecked(global_i); diff --git a/src/frontend-common/common_host_interface.h b/src/frontend-common/common_host_interface.h index e9916ba5c..4ff21b943 100644 --- a/src/frontend-common/common_host_interface.h +++ b/src/frontend-common/common_host_interface.h @@ -13,6 +13,10 @@ class ControllerInterface; +namespace FrontendCommon { +class SaveStateSelectorUI; +} + class CommonHostInterface : public HostInterface { public: @@ -72,6 +76,7 @@ protected: virtual void OnSystemPaused(bool paused) override; virtual void OnSystemDestroyed() override; virtual void OnControllerTypeChanged(u32 slot) override; + virtual void DrawImGuiWindows() override; virtual void SetDefaultSettings(SettingsInterface& si) override; @@ -115,6 +120,8 @@ private: HotkeyInfoList m_hotkeys; + std::unique_ptr m_save_state_selector_ui; + // input key maps std::map m_keyboard_input_handlers; diff --git a/src/frontend-common/frontend-common.vcxproj b/src/frontend-common/frontend-common.vcxproj index d7287d489..7ba1d303a 100644 --- a/src/frontend-common/frontend-common.vcxproj +++ b/src/frontend-common/frontend-common.vcxproj @@ -60,6 +60,7 @@ + @@ -70,6 +71,7 @@ + diff --git a/src/frontend-common/frontend-common.vcxproj.filters b/src/frontend-common/frontend-common.vcxproj.filters index 4d5930475..bbe227b9f 100644 --- a/src/frontend-common/frontend-common.vcxproj.filters +++ b/src/frontend-common/frontend-common.vcxproj.filters @@ -9,6 +9,7 @@ + @@ -19,6 +20,7 @@ + diff --git a/src/frontend-common/save_state_selector_ui.cpp b/src/frontend-common/save_state_selector_ui.cpp new file mode 100644 index 000000000..097eb77a1 --- /dev/null +++ b/src/frontend-common/save_state_selector_ui.cpp @@ -0,0 +1,257 @@ +#include "save_state_selector_ui.h" +#include "common/log.h" +#include "common/timestamp.h" +#include "core/host_display.h" +#include "core/system.h" +#include "icon.h" +#include "imgui.h" +Log_SetChannel(SaveStateSelectorUI); + +namespace FrontendCommon { + +SaveStateSelectorUI::SaveStateSelectorUI(CommonHostInterface* host_interface) : m_host_interface(host_interface) {} + +SaveStateSelectorUI::~SaveStateSelectorUI() = default; + +void SaveStateSelectorUI::Open(float open_time /* = DEFAULT_OPEN_TIME */) +{ + m_open_timer.Reset(); + m_open_time = open_time; + + if (m_open) + return; + + m_open = true; + RefreshList(); +} + +void SaveStateSelectorUI::Close() +{ + if (!m_open) + return; + + m_open = false; +} + +void SaveStateSelectorUI::ClearList() +{ + m_slots.clear(); +} + +void SaveStateSelectorUI::RefreshList() +{ + ClearList(); + + const System* system = m_host_interface->GetSystem(); + if (system && !system->GetRunningCode().empty()) + { + for (s32 i = 1; i <= HostInterface::GLOBAL_SAVE_STATE_SLOTS; i++) + { + std::optional ssi = + m_host_interface->GetExtendedSaveStateInfo(system->GetRunningCode().c_str(), i); + + ListEntry li; + if (ssi) + InitializeListEntry(&li, &ssi.value()); + else + InitializePlaceholderListEntry(&li, i, false); + + m_slots.push_back(std::move(li)); + } + } + + for (s32 i = 1; i <= HostInterface::GLOBAL_SAVE_STATE_SLOTS; i++) + { + std::optional ssi = m_host_interface->GetExtendedSaveStateInfo(nullptr, i); + + ListEntry li; + if (ssi) + InitializeListEntry(&li, &ssi.value()); + else + InitializePlaceholderListEntry(&li, i, true); + + m_slots.push_back(std::move(li)); + } + + if (m_slots.empty() || m_current_selection >= m_slots.size()) + m_current_selection = 0; +} + +const char* SaveStateSelectorUI::GetSelectedStatePath() const +{ + if (m_slots.empty() || m_slots[m_current_selection].path.empty()) + return nullptr; + + return m_slots[m_current_selection].path.c_str(); +} + +s32 SaveStateSelectorUI::GetSelectedStateSlot() const +{ + if (m_slots.empty()) + return 0; + + return m_slots[m_current_selection].slot; +} + +void SaveStateSelectorUI::SelectNextSlot() +{ + if (!m_open) + { + Open(); + return; + } + + ResetOpenTimer(); + m_current_selection = (m_current_selection == static_cast(m_slots.size() - 1)) ? 0 : (m_current_selection + 1); +} + +void SaveStateSelectorUI::SelectPreviousSlot() +{ + if (!m_open) + { + Open(); + return; + } + + ResetOpenTimer(); + m_current_selection = + (m_current_selection == 0) ? (static_cast(m_slots.size()) - 1u) : (m_current_selection - 1); +} + +void SaveStateSelectorUI::InitializeListEntry(ListEntry* li, HostInterface::ExtendedSaveStateInfo* ssi) +{ + li->title = std::move(ssi->title); + li->game_code = std::move(ssi->game_code); + li->path = std::move(ssi->path); + li->formatted_timestamp = Timestamp::FromUnixTimestamp(ssi->timestamp).ToString("%c"); + li->slot = ssi->slot; + li->global = ssi->global; + + li->preview_texture.reset(); + if (ssi && !ssi->screenshot_data.empty()) + { + li->preview_texture = m_host_interface->GetDisplay()->CreateTexture(ssi->screenshot_width, ssi->screenshot_height, + ssi->screenshot_data.data(), + sizeof(u32) * ssi->screenshot_width, false); + } + else + { + li->preview_texture = + m_host_interface->GetDisplay()->CreateTexture(PLACEHOLDER_ICON_WIDTH, PLACEHOLDER_ICON_HEIGHT, + PLACEHOLDER_ICON_DATA, sizeof(u32) * PLACEHOLDER_ICON_WIDTH, false); + } + + if (!li->preview_texture) + Log_ErrorPrintf("Failed to upload save state image to GPU"); +} + +void SaveStateSelectorUI::InitializePlaceholderListEntry(ListEntry* li, s32 slot, bool global) +{ + li->title = "No Save State"; + std::string().swap(li->game_code); + std::string().swap(li->path); + std::string().swap(li->formatted_timestamp); + li->slot = slot; + li->global = global; + + li->preview_texture = + m_host_interface->GetDisplay()->CreateTexture(PLACEHOLDER_ICON_WIDTH, PLACEHOLDER_ICON_HEIGHT, + PLACEHOLDER_ICON_DATA, sizeof(u32) * PLACEHOLDER_ICON_WIDTH, false); + + if (!li->preview_texture) + Log_ErrorPrintf("Failed to upload save state image to GPU"); +} + +void SaveStateSelectorUI::Draw() +{ + const float framebuffer_scale = ImGui::GetIO().DisplayFramebufferScale.x; + const float window_width = ImGui::GetIO().DisplaySize.x * (2.0f / 3.0f); + const float window_height = ImGui::GetIO().DisplaySize.y * 0.5f; + const float rounding = 4.0f * framebuffer_scale; + ImGui::SetNextWindowSize(ImVec2(window_width, window_height), ImGuiCond_Always); + ImGui::SetNextWindowPos(ImVec2(ImGui::GetIO().DisplaySize.x * 0.5f, ImGui::GetIO().DisplaySize.y * 0.5f), + ImGuiCond_Always, ImVec2(0.5f, 0.5f)); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.11f, 0.15f, 0.17f, 0.8f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, rounding); + + if (ImGui::Begin("##save_state_selector", nullptr, + ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoTitleBar)) + { + const float padding = 10.0f * framebuffer_scale; + const ImVec2 image_size = ImVec2(128.0f * framebuffer_scale, (128.0f / (4.0f / 3.0f)) * framebuffer_scale); + const float item_height = image_size.y + padding * 2.0f; + const float text_indent = image_size.x + padding + padding; + + for (size_t i = 0; i < m_slots.size(); i++) + { + const ListEntry& entry = m_slots[i]; + const float y_start = item_height * static_cast(i); + + if (i == m_current_selection) + { + ImGui::SetCursorPosY(y_start); + ImGui::SetScrollHereY(); + + const ImVec2 p_start(ImGui::GetCursorScreenPos()); + const ImVec2 p_end(p_start.x + window_width, p_start.y + item_height); + ImGui::GetWindowDrawList()->AddRectFilled(p_start, p_end, ImColor(0.22f, 0.30f, 0.34f, 0.9f), rounding); + } + + if (entry.preview_texture) + { + ImGui::SetCursorPosY(y_start + padding); + ImGui::SetCursorPosX(padding); + ImGui::Image(reinterpret_cast(entry.preview_texture->GetHandle()), image_size); + } + + ImGui::SetCursorPosY(y_start + padding); + + ImGui::Indent(text_indent); + + ImGui::Text("%s Slot %d", entry.global ? "Global" : (entry.game_code.empty() ? "Game" : entry.game_code.c_str()), + entry.slot); + ImGui::TextUnformatted(entry.title.c_str()); + ImGui::TextUnformatted(entry.formatted_timestamp.c_str()); + ImGui::TextUnformatted(entry.path.c_str()); + + ImGui::Unindent(text_indent); + } + } + + ImGui::PopStyleVar(2); + ImGui::PopStyleColor(); + ImGui::End(); + + // auto-close + if (m_open_timer.GetTimeSeconds() >= m_open_time) + Close(); +} + +void SaveStateSelectorUI::LoadCurrentSlot() +{ + if (m_current_selection >= m_slots.size()) + { + RefreshList(); + return; + } + + const ListEntry& le = m_slots.at(m_current_selection); + m_host_interface->LoadState(le.global, le.slot); + Close(); +} + +void SaveStateSelectorUI::SaveCurrentSlot() +{ + if (m_current_selection >= m_slots.size()) + { + RefreshList(); + return; + } + + const ListEntry& le = m_slots.at(m_current_selection); + m_host_interface->SaveState(le.global, le.slot); + Close(); +} + +} // namespace FrontendCommon \ No newline at end of file diff --git a/src/frontend-common/save_state_selector_ui.h b/src/frontend-common/save_state_selector_ui.h new file mode 100644 index 000000000..78c94f301 --- /dev/null +++ b/src/frontend-common/save_state_selector_ui.h @@ -0,0 +1,63 @@ +#pragma once +#include "common_host_interface.h" +#include "common/timer.h" +#include + +class HostDisplayTexture; + +namespace FrontendCommon { + +class SaveStateSelectorUI +{ +public: + static constexpr float DEFAULT_OPEN_TIME = 5.0f; + + SaveStateSelectorUI(CommonHostInterface* host_interface); + ~SaveStateSelectorUI(); + + ALWAYS_INLINE bool IsOpen() const { return m_open; } + ALWAYS_INLINE void ResetOpenTimer() { m_open_timer.Reset(); } + + void Open(float open_time = DEFAULT_OPEN_TIME); + void Close(); + + void ClearList(); + void RefreshList(); + + const char* GetSelectedStatePath() const; + s32 GetSelectedStateSlot() const; + + void SelectNextSlot(); + void SelectPreviousSlot(); + + void Draw(); + + void LoadCurrentSlot(); + void SaveCurrentSlot(); + +private: + struct ListEntry + { + std::string path; + std::string game_code; + std::string title; + std::string formatted_timestamp; + std::unique_ptr preview_texture; + s32 slot; + bool global; + }; + + void InitializePlaceholderListEntry(ListEntry* li, s32 slot, bool global); + void InitializeListEntry(ListEntry* li, HostInterface::ExtendedSaveStateInfo* ssi); + + CommonHostInterface* m_host_interface; + std::vector m_slots; + u32 m_current_selection = 0; + + Common::Timer m_open_timer; + float m_open_time = 0.0f; + + bool m_open = false; +}; + +} // namespace FrontendCommon \ No newline at end of file