Skip to content

Commit

Permalink
Add named save states widget, disable menu entries for numbered/quick…
Browse files Browse the repository at this point in the history
…/global save states that don't exist
  • Loading branch information
Ken Murdock committed Nov 22, 2023
1 parent a072e38 commit f6d217a
Show file tree
Hide file tree
Showing 6 changed files with 281 additions and 15 deletions.
64 changes: 51 additions & 13 deletions src/gui/gui.cc
Original file line number Diff line number Diff line change
Expand Up @@ -1065,7 +1065,7 @@ void PCSX::GUI::endFrame() {
SaveStates::ProtoFile::dumpSchema(schema);
}

static constexpr char globalSaveState[] = "global.sstate";
const auto globalSaveStateName = fmt::format(f_("global{}"), getSaveStatePostfix());

if (ImGui::BeginMenu(_("Save state slots"))) {
if (ImGui::MenuItem(_("Quick-save slot"), "F1")) {
Expand All @@ -1079,25 +1079,44 @@ void PCSX::GUI::endFrame() {
}
}

ImGui::Separator();
ImGui::MenuItem(_("Show named save states"), nullptr, &m_namedSaveStates.m_show);

ImGui::EndMenu();
}

if (ImGui::MenuItem(_("Save global state"))) {
saveSaveState(globalSaveState);
}
if (ImGui::MenuItem(_("Save global state"))) saveSaveState(globalSaveStateName);

if (ImGui::BeginMenu(_("Load state slots"))) {
if (ImGui::MenuItem(_("Quick-save slot"), "F2")) loadSaveState(buildSaveStateFilename(0));
auto saveFilename = buildSaveStateFilename(0);
ImGui::BeginDisabled(!saveStateExists(saveFilename));
if (ImGui::MenuItem(_("Quick-load slot"), "F2")) loadSaveState(saveFilename);
ImGui::EndDisabled();

for (auto i = 1; i < 10; i++) {
const auto str = fmt::format(f_("Slot {}"), i);
if (ImGui::MenuItem(str.c_str())) loadSaveState(buildSaveStateFilename(i));
saveFilename = buildSaveStateFilename(i);
ImGui::BeginDisabled(!saveStateExists(saveFilename));
if (ImGui::MenuItem(str.c_str())) loadSaveState(saveFilename);
ImGui::EndDisabled();
}

auto namedSaves = m_namedSaveStates.getNamedSaveStates(this);
if (!namedSaves.empty()) {
ImGui::Separator();
for (auto filenamePair : namedSaves) {
if (ImGui::MenuItem(filenamePair.second.c_str())) {
loadSaveState(filenamePair.first);
}
}
}

ImGui::EndMenu();
}

if (ImGui::MenuItem(_("Load global state"))) loadSaveState(globalSaveState);
ImGui::BeginDisabled(!saveStateExists(globalSaveStateName));
if (ImGui::MenuItem(_("Load global state"))) loadSaveState(globalSaveStateName);
ImGui::EndDisabled();

ImGui::Separator();
if (ImGui::MenuItem(_("Open LID"))) {
Expand Down Expand Up @@ -1469,6 +1488,10 @@ in Configuration->Emulation, restart PCSX-Redux, then try again.)"));
m_breakpoints.draw(_("Breakpoints"));
}

if (m_namedSaveStates.m_show) {
m_namedSaveStates.draw(this, _("Named Save States"));
}

Check notice on line 1494 in src/gui/gui.cc

View check run for this annotation

CodeScene Delta Analysis / CodeScene Cloud Delta Analysis (main)

ℹ Getting worse: Complex Method

PCSX::GUI::endFrame increases in cyclomatic complexity from 188 to 192, threshold = 9. This function has many conditional statements (e.g. if, for, while), leading to lower code health. Avoid adding more conditionals and code to it without refactoring.

Check notice on line 1494 in src/gui/gui.cc

View check run for this annotation

CodeScene Delta Analysis / CodeScene Cloud Delta Analysis (main)

ℹ Getting worse: Bumpy Road Ahead

PCSX::GUI::endFrame increases from 39 to 40 logical blocks with deeply nested code, threshold is one single block per function. The Bumpy Road code smell is a function that contains multiple chunks of nested conditional logic. The deeper the nesting and the more bumps, the lower the code health.
if (m_memoryObserver.m_show) {
m_memoryObserver.draw(_("Memory Observer"));
}
Expand Down Expand Up @@ -2431,24 +2454,31 @@ void PCSX::GUI::magicOpen(const char* pathStr) {
g_emulator->m_cdrom->check();
}

std::string PCSX::GUI::buildSaveStateFilename(int i) {
std::string PCSX::GUI::getSaveStatePrefix(bool includeSeparator) {
// the ID of the game. Every savestate is marked with the ID of the game it's from.
const auto gameID = g_emulator->m_cdrom->getCDRomID();

// Check if the game has a non-NULL ID or a game hasn't been loaded. Some stuff like PS-X
// EXEs don't have proper IDs
if (!gameID.empty()) {
// For games with an ID of SLUS00213 for example, this will generate a state named
// SLUS00213.sstate
return fmt::format("{}.sstate{}", gameID, i);
return fmt::format("{}{}", gameID, includeSeparator ? "_" : "");
} else {
// For games without IDs, identify them via filename
const auto& iso = PCSX::g_emulator->m_cdrom->getIso()->getIsoPath().filename();
const auto& iso = g_emulator->m_cdrom->getIso()->getIsoPath().filename();
const auto lastFile = iso.empty() ? "BIOS" : iso.string();
return fmt::format("{}.sstate{}", lastFile, i);
return fmt::format("{}{}", lastFile, includeSeparator ? "_" : "");
}
}

std::string PCSX::GUI::getSaveStatePostfix() {
return ".sstate";
}

std::string PCSX::GUI::buildSaveStateFilename(int i) {
return fmt::format("{}{}{}", getSaveStatePrefix(false), getSaveStatePostfix(), i);
}

void PCSX::GUI::saveSaveState(std::filesystem::path filename) {
if (filename.is_relative()) {
filename = g_system->getPersistentDir() / filename;
Expand Down Expand Up @@ -2484,7 +2514,15 @@ void PCSX::GUI::loadSaveState(std::filesystem::path filename) {
delete[] buff;

if (!error) SaveStates::load(os.str());
};
}

bool PCSX::GUI::saveStateExists(std::filesystem::path filename) {
if (filename.is_relative()) {
filename = g_system->getPersistentDir() / filename;
}
ZReader save(new PosixFile(filename));
return !save.failed();
}

void PCSX::GUI::byteRateToString(float rate, std::string& str) {
if (rate >= 1000000000) {
Expand Down
13 changes: 11 additions & 2 deletions src/gui/gui.h
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
#include "gui/widgets/luaeditor.h"
#include "gui/widgets/luainspector.h"
#include "gui/widgets/memcard_manager.h"
#include "gui/widgets/named_savestates.h"
#include "gui/widgets/pio-cart.h"
#include "gui/widgets/registers.h"
#include "gui/widgets/shader-editor.h"
Expand Down Expand Up @@ -98,6 +99,7 @@ class GUI final : public UI {
typedef Setting<bool, TYPESTRING("ShowAssembly")> ShowAssembly;
typedef Setting<bool, TYPESTRING("ShowDisassembly")> ShowDisassembly;
typedef Setting<bool, TYPESTRING("ShowBreakpoints")> ShowBreakpoints;
typedef Setting<bool, TYPESTRING("ShowNamedSaveStates")> ShowNamedSaveStates;
typedef Setting<bool, TYPESTRING("ShowEvents")> ShowEvents;
typedef Setting<bool, TYPESTRING("ShowHandlers")> ShowHandlers;
typedef Setting<bool, TYPESTRING("ShowKernelLog")> ShowKernelLog;
Expand Down Expand Up @@ -146,8 +148,8 @@ class GUI final : public UI {
Settings<Fullscreen, FullWindowRender, ShowMenu, ShowLog, WindowPosX, WindowPosY, WindowSizeX, WindowSizeY,
IdleSwapInterval, ShowLuaConsole, ShowLuaInspector, ShowLuaEditor, ShowMainVRAMViewer, ShowCLUTVRAMViewer,
ShowVRAMViewer1, ShowVRAMViewer2, ShowVRAMViewer3, ShowVRAMViewer4, ShowMemoryObserver, ShowTypedDebugger,
ShowMemcardManager, ShowRegisters, ShowAssembly, ShowDisassembly, ShowBreakpoints, ShowEvents,
ShowHandlers, ShowKernelLog, ShowCallstacks, ShowSIO1, ShowIsoBrowser, ShowGPULogger, MainFontSize,
ShowMemcardManager, ShowRegisters, ShowAssembly, ShowDisassembly, ShowBreakpoints, ShowNamedSaveStates,
ShowEvents, ShowHandlers, ShowKernelLog, ShowCallstacks, ShowSIO1, ShowIsoBrowser, ShowGPULogger, MainFontSize,
MonoFontSize, GUITheme, AllowMouseCaptureToggle, EnableRawMouseMotion, WidescreenRatio, ShowPIOCartConfig,
ShowMemoryEditor1, ShowMemoryEditor2, ShowMemoryEditor3, ShowMemoryEditor4, ShowMemoryEditor5,
ShowMemoryEditor6, ShowMemoryEditor7, ShowMemoryEditor8, ShowParallelPortEditor, ShowScratchpadEditor,
Expand Down Expand Up @@ -375,6 +377,7 @@ class GUI final : public UI {
Widgets::FileDialog<> m_openBinaryDialog = {[]() { return _("Open Binary"); }};
Widgets::FileDialog<> m_selectBiosDialog = {[]() { return _("Select BIOS"); }};
Widgets::FileDialog<> m_selectEXP1Dialog = {[]() { return _("Select EXP1"); }};
Widgets::NamedSaveStates m_namedSaveStates = {settings.get<ShowNamedSaveStates>().value};
Widgets::Breakpoints m_breakpoints = {settings.get<ShowBreakpoints>().value};
Widgets::IsoBrowser m_isoBrowser = {settings.get<ShowIsoBrowser>().value};

Expand Down Expand Up @@ -405,9 +408,15 @@ class GUI final : public UI {
EventBus::Listener m_listener;

std::string buildSaveStateFilename(int i);
bool saveStateExists(std::filesystem::path filename);

public:
void saveSaveState(std::filesystem::path filename);
void loadSaveState(std::filesystem::path filename);
std::string getSaveStatePrefix(bool includeSeparator);
static std::string getSaveStatePostfix();

private:
void applyTheme(int theme);
void cherryTheme();
void monoTheme();
Expand Down
180 changes: 180 additions & 0 deletions src/gui/widgets/named_savestates.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@

#include "gui/widgets/named_savestates.h"

#include "core/cdrom.h"
#include "core/debug.h"
#include "gui/gui.h"
#include "imgui.h"
#include "imgui_internal.h"

void PCSX::Widgets::NamedSaveStates::draw(GUI* gui, const char* title) {
ImGui::SetNextWindowPos(ImVec2(520, 30), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(600, 500), ImGuiCond_FirstUseEver);
if (!ImGui::Begin(title, &m_show)) {
ImGui::End();
return;
}

ImGuiStyle& style = ImGui::GetStyle();
const float heightSeparator = style.ItemSpacing.y;
const float footerHeight = (heightSeparator * 2 + ImGui::GetTextLineHeightWithSpacing()) * 4;
const float glyphWidth = ImGui::GetFontSize();

// Any ImGui::Text() preceeding a ImGui::InputText() will be incorrectly aligned, use this value to awkwardly account for this
// Luckily this value is consistent no matter what the UI main font size is set to
const float verticalAlignAdjust = 3.0f;

// Create a button for each named save state
ImGui::BeginChild("SaveStatesList", ImVec2(0, -footerHeight), true);
auto saveStates = getNamedSaveStates(gui);
for (const auto& saveStatePair : saveStates) {
// The button is invisible so we can adjust the highlight separately
if (ImGui::InvisibleButton(saveStatePair.second.c_str(),
ImVec2(-FLT_MIN, glyphWidth + style.FramePadding.y * 2),
ImGuiButtonFlags_PressedOnClick | ImGuiButtonFlags_PressedOnDoubleClick)) {
// Copy the name to the input box
strcpy_s(m_namedSaveNameString, 128, saveStatePair.second.c_str());
// Load the save state if it was a double-click
if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) {
loadSaveState(gui, saveStatePair.first);
}
}
// Add a base coloured rect - highlight it if hovering over the button or the current InputText below matches it
bool matches = strcmpi(m_namedSaveNameString, saveStatePair.second.c_str()) == 0;
bool hovered = ImGui::IsItemHovered();
ImGui::GetWindowDrawList()->AddRectFilled(
ImGui::GetCurrentContext()->LastItemData.Rect.Min, ImGui::GetCurrentContext()->LastItemData.Rect.Max,
ImGui::ColorConvertFloat4ToU32(
ImGui::GetStyle()
.Colors[matches ? ImGuiCol_HeaderActive : (hovered ? ImGuiCol_HeaderHovered : ImGuiCol_Header)]));
// Finally the butotn text
ImGui::GetWindowDrawList()->AddText(
ImVec2(ImGui::GetCurrentContext()->LastItemData.Rect.Min.x + style.FramePadding.y,
ImGui::GetCurrentContext()->LastItemData.Rect.Min.y + style.FramePadding.y),
ImGui::GetColorU32(ImGuiCol_Text), saveStatePair.second.c_str());
}
ImGui::EndChild();

// Move the leading text down to align with the following InputText()
float posY = ImGui::GetCursorPosY();
ImGui::SetCursorPosY(posY + verticalAlignAdjust);

ImGui::Text(_("Filename: "));
ImGui::SameLine();
ImGui::Text(gui->getSaveStatePrefix(true).c_str());
ImGui::SameLine(0.0f, 0.0f);

// Restore the vertical value
ImGui::SetCursorPosY(posY);

// Ensure that we don't add invalid characters to the filename
// This also filters on pasted text
struct TextFilters {
static int FilterNonPathCharacters(ImGuiInputTextCallbackData* data) {
// Filter the core problematic characters for Windows and Linux
// Anything remaining outside of [a-zA-Z0-9._-] is also allowed
switch (data->EventChar) {
case '\\':
case '/':
case '<':
case '>':
case '|':
case '\"':
case ':':
case '?':
case '*':
case 0:
return 1;
}
return 0;
}
};

ImGui::InputTextWithHint("##SaveNameInput", "Enter the name of your save state here", m_namedSaveNameString, 128,
ImGuiInputTextFlags_CallbackCharFilter, TextFilters::FilterNonPathCharacters);
ImGui::SameLine(0.0f, 0.0f);

// Trailing text alignment also needs adjusting, but in the opposite direction
ImGui::SetCursorPosY(ImGui::GetCursorPosY() - verticalAlignAdjust);
ImGui::Text(gui->getSaveStatePostfix().c_str());

ImGui::Separator();

// Add various buttons based on whether a save state exists with that name
auto found = std::find_if(saveStates.begin(), saveStates.end(), [=](auto saveStatePair) {
return strlen(m_namedSaveNameString) > 0 && strcmpi(m_namedSaveNameString, saveStatePair.second.c_str()) == 0;
});
bool exists = found != saveStates.end();

float width = ImGui::GetCurrentContext()->LastItemData.Rect.GetWidth();
ImVec2 buttonDims = ImVec2(-FLT_MIN, glyphWidth + style.FramePadding.y * 2);
ImVec2 halfDims = ImVec2((width - (style.FramePadding.x * 6.0f)) * 0.5f, buttonDims.y);

if (!exists) {
if (strlen(m_namedSaveNameString) > 0) {
// The save state doesn't exist, and the name is valid
std::string pathStr = fmt::format("{}{}{}", gui->getSaveStatePrefix(true), m_namedSaveNameString, gui->getSaveStatePostfix());
std::filesystem::path newPath = pathStr;
if (ImGui::Button(_("Create save"), buttonDims)) {
saveSaveState(gui, newPath);
}
}
} else {
// The save state exists
if (ImGui::Button(_("Overwrite save"), halfDims)) {
saveSaveState(gui, found->first);
}
ImGui::SameLine();
if (ImGui::Button(_("Load save"), halfDims)) {
loadSaveState(gui, found->first);
}
// Add a deliberate spacer before the delete button
// There is no delete confirmation, and this makes a mis-click less likely to hit it
ImGui::Dummy(buttonDims);
if (ImGui::Button(_("Delete save"), buttonDims)) {
deleteSaveState(found->first);
}
}

ImGui::End();
}

Check warning on line 140 in src/gui/widgets/named_savestates.cc

View check run for this annotation

CodeScene Delta Analysis / CodeScene Cloud Delta Analysis (main)

❌ New issue: Complex Method

PCSX::Widgets::NamedSaveStates::draw has a cyclomatic complexity of 25, threshold = 9. This function has many conditional statements (e.g. if, for, while), leading to lower code health. Avoid adding more conditionals and code to it without refactoring.

Check warning on line 140 in src/gui/widgets/named_savestates.cc

View check run for this annotation

CodeScene Delta Analysis / CodeScene Cloud Delta Analysis (main)

❌ New issue: Bumpy Road Ahead

PCSX::Widgets::NamedSaveStates::draw has 3 blocks with nested conditional logic. Any nesting of 2 or deeper is considered. Threshold is one single, nested block per function. The Bumpy Road code smell is a function that contains multiple chunks of nested conditional logic. The deeper the nesting and the more bumps, the lower the code health.

std::vector<std::pair<std::filesystem::path, std::string>> PCSX::Widgets::NamedSaveStates::getNamedSaveStates(GUI* gui) {
std::vector<std::pair<std::filesystem::path, std::string>> names;

// Get the filename prefix to use, which follows the typical save state format, with a separator between gameID and name
std::string prefix = gui->getSaveStatePrefix(true);
std::string postfix = gui->getSaveStatePostfix();

// Loop the root directory
std::error_code ec;
if (std::filesystem::exists(std::filesystem::current_path(), ec)) {
for (const auto& entry : std::filesystem::directory_iterator(std::filesystem::current_path(), ec)) {
if (entry.is_regular_file()) {
std::string filename = entry.path().filename().string();
if (filename.find(prefix) == 0 &&
filename.rfind(postfix) == filename.length() - postfix.length()) {
std::string niceName = filename.substr(prefix.length(), filename.length() - (prefix.length() + postfix.length()));
names.emplace_back(entry.path(), niceName);
}
}
}
}

return names;
}

Check warning on line 165 in src/gui/widgets/named_savestates.cc

View check run for this annotation

CodeScene Delta Analysis / CodeScene Cloud Delta Analysis (main)

❌ New issue: Deep, Nested Complexity

PCSX::Widgets::NamedSaveStates::getNamedSaveStates has a nested complexity depth of 4, threshold = 4. This function contains deeply nested logic such as if statements and/or loops. The deeper the nesting, the lower the code health.

void PCSX::Widgets::NamedSaveStates::saveSaveState(GUI* gui, std::filesystem::path saveStatePath) {
g_system->log(LogClass::UI, "Saving named save state: %s\n", saveStatePath.filename().string().c_str());
gui->saveSaveState(saveStatePath);
}

void PCSX::Widgets::NamedSaveStates::loadSaveState(GUI* gui, std::filesystem::path saveStatePath) {
g_system->log(LogClass::UI, "Loading named save state: %s\n", saveStatePath.filename().string().c_str());
gui->loadSaveState(saveStatePath);
}

void PCSX::Widgets::NamedSaveStates::deleteSaveState(std::filesystem::path saveStatePath) {
g_system->log(LogClass::UI, "Deleting named save state: %s\n", saveStatePath.filename().string().c_str());
std::remove(saveStatePath.string().c_str());
}
31 changes: 31 additions & 0 deletions src/gui/widgets/named_savestates.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@

#pragma once

#include <string>
#include <vector>
#include <filesystem>

namespace PCSX {

class GUI;
namespace Widgets {

class NamedSaveStates {
public:
NamedSaveStates(bool& show) : m_show(show) {}
void draw(GUI* gui, const char* title);
bool& m_show;

std::vector<std::pair<std::filesystem::path, std::string>> getNamedSaveStates(GUI* gui);

private:
void saveSaveState(GUI* gui, std::filesystem::path saveStatePath);
void loadSaveState(GUI* gui, std::filesystem::path saveStatePath);
void deleteSaveState(std::filesystem::path saveStatePath);

char m_namedSaveNameString[128] = "";
};

} // namespace Widgets

} // namespace PCSX
Loading

0 comments on commit f6d217a

Please sign in to comment.