diff --git a/CMakeLists.txt b/CMakeLists.txt index 6bbb5019..0da27c69 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -128,6 +128,8 @@ add_executable(pyrite64 src/main.cpp src/renderer/texture.cpp src/renderer/texture.h src/project/project.cpp + src/editor/recentProjects.h + src/editor/recentProjects.cpp src/editor/actions.h src/editor/actions.cpp src/editor/undoRedo.h diff --git a/src/editor/globalActions.cpp b/src/editor/globalActions.cpp index d4d8765a..943dbf01 100644 --- a/src/editor/globalActions.cpp +++ b/src/editor/globalActions.cpp @@ -14,6 +14,7 @@ #include "../utils/json.h" #include "../utils/proc.h" #include "undoRedo.h" +#include "recentProjects.h" #include "pages/editorScene.h" //#include @@ -27,6 +28,7 @@ namespace Editor::Actions UndoRedo::getHistory().clear(); try { ctx.project = new Project::Project(path); + Editor::RecentProjects::setMostRecentPath(path); if(ctx.project && !ctx.project->getScenes().getEntries().empty()) { ctx.project->getScenes().loadScene(ctx.project->conf.sceneIdLastOpened); } @@ -96,6 +98,7 @@ namespace Editor::Actions configJSON["name"] = args["name"]; configJSON["romName"] = args["rom"]; Utils::FS::saveTextFile(configPath, configJSON.dump(2)); + Editor::RecentProjects::setMostRecentPath(configPath); return true; }); diff --git a/src/editor/pages/launcher.cpp b/src/editor/pages/launcher.cpp index cdc61ca2..9504bc9f 100644 --- a/src/editor/pages/launcher.cpp +++ b/src/editor/pages/launcher.cpp @@ -7,11 +7,16 @@ #include #include #include +#include +#include +#include +#include #include "imgui.h" #include "../imgui/theme.h" #include "../actions.h" #include "../../utils/filePicker.h" +#include "../recentProjects.h" #include "../../context.h" #include "backends/imgui_impl_sdlgpu3.h" #include "parts/createProjectOverlay.h" @@ -19,6 +24,8 @@ #include "SDL3/SDL_dialog.h" #include "../imgui/notification.h" +namespace fs = std::filesystem; + void ImDrawCallback_ImplSDLGPU3_SetSamplerRepeat(const ImDrawList* parent_list, const ImDrawCmd* cmd); namespace @@ -50,14 +57,33 @@ Editor::Launcher::Launcher(SDL_GPUDevice* device) texBG{device, "data/img/splashBG.png"} { ctx.toolchain.scan(); + updateProjectEntries(); } Editor::Launcher::~Launcher() { } +void Editor::Launcher::updateProjectEntries() { + Editor::RecentProjects::load(); + projectEntries = {}; + for(auto path : Editor::RecentProjects::recentPaths) { + auto json = Utils::JSON::loadFile(path); + if (json.empty()) continue; + Editor::ProjectEntry entry; + entry.name = json.value("name", ""); + entry.path = path; + entry.editorVersion = json.value("editorVersion", PYRITE_VERSION); + fs::path projPath{path}; + auto writeTime = fs::last_write_time(projPath); + entry.lastModified = std::format("{:%Y-%m-%d}", writeTime); + entry.expand = false; + projectEntries.push_back(entry); + } +} + void Editor::Launcher::draw() { - float BTN_SPACING = 300_px; + float BTN_SPACING = 160_px; const auto &toolState = ctx.toolchain.getState(); auto &io = ImGui::GetIO(); @@ -74,8 +100,8 @@ void Editor::Launcher::draw() // BG ImGui::GetWindowDrawList()->AddCallback(ImDrawCallback_ImplSDLGPU3_SetSamplerRepeat, nullptr); - float topBgHeight = 7_px; - float bottomBgHeight = 3_px; + float topBgHeight = 4.5_px; + float bottomBgHeight = 2.5_px; float bgRepeatsX = io.DisplaySize.x / texBG.getWidth(); ImGui::SetCursorPos({0,0}); ImGui::Image(ImTextureID(texBG.getGPUTex()), @@ -103,21 +129,18 @@ void Editor::Launcher::draw() ImGui::SetMouseCursor(ImGuiMouseCursor_Arrow); } - auto logoSize = texTitle.getSize(0.65 * ImGui::Theme::zoomFactor); - ImGui::SetCursorPos({ - centerPos.x - (logoSize.x/2) + 16_px, - 28_px - }); + auto logoSize = texTitle.getSize(0.4 * ImGui::Theme::zoomFactor); + ImGui::SetCursorPos({32_px, 24_px}); ImGui::Image(ImTextureID(texTitle.getGPUTex()),logoSize); auto renderButton = [&](Renderer::Texture &img, const char* text, bool& hover, int &posX) -> bool { - auto btnSizeAdd = img.getSize(hover ? 0.85f : 0.8f); + auto btnSizeAdd = img.getSize(hover ? 0.45f : 0.4f); btnSizeAdd *= ImGui::Theme::zoomFactor; ImVec2 btnPos{ posX - (btnSizeAdd.x/2), - midBgPointY - (btnSizeAdd.y/2), + 72_px - (btnSizeAdd.y/2), }; ImGui::SetCursorPos(btnPos); @@ -130,7 +153,7 @@ void Editor::Launcher::draw() renderSubText( btnPos.x + (btnSizeAdd.x / 2), - btnSizeAdd, midBgPointY, text + btnSizeAdd, 72_px, text ); posX += BTN_SPACING; @@ -145,10 +168,9 @@ void Editor::Launcher::draw() bool validToolchain = toolState.hasToolchain && toolState.hasLibdragon && toolState.hasTiny3d; int buttonCount = (validToolchain && toolState.upToDateLibs) ? 3 : 1; - // screen center - int posX = (int)centerPos.x - 6_px; + int posX = (int)io.DisplaySize.x - BTN_SPACING + 48_px; if(buttonCount == 3) { - posX -= (BTN_SPACING); + posX -= (BTN_SPACING * 2); } if(buttonCount == 3) @@ -206,6 +228,112 @@ void Editor::Launcher::draw() ImGui::PopStyleColor(3); + // recent files + ImGui::SetCursorPos({8_px, (float)texBG.getHeight() * topBgHeight + 8_px}); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0,0,0,0)); + if (ImGui::BeginTable("RecentProjects", 5, ImGuiTableFlags_NoBordersInBody)) { + + //header + ImGui::PushStyleColor(ImGuiCol_TableHeaderBg, ImVec4(0,0,0,0)); + const char* expandLabel = expandAll ? ICON_MDI_CHEVRON_DOWN : ICON_MDI_CHEVRON_RIGHT; + ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed, 32_px); + ImGui::TableSetupColumn("Project\nName"); + ImGui::TableSetupColumn("Last\nModified", ImGuiTableColumnFlags_WidthFixed, 120_px); + ImGui::TableSetupColumn("Editor\nVersion", ImGuiTableColumnFlags_WidthFixed, 80_px); + ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed, 32_px); + ImGui::PushFont(nullptr, 16_px); + ImGui::TableNextRow(ImGuiTableRowFlags_Headers, 50_px); + for (int column = 0; column < 5; column++) { + ImGui::TableSetColumnIndex(column); + //ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 8_px); + const char* columnName = ImGui::TableGetColumnName(column); + if (column == 0 && ImGui::Button(expandLabel, ImVec2(32_px, 32_px))) { + expandAll = !expandAll; + for(auto &entry : projectEntries) entry.expand = expandAll; + } else if (column == 4 && ImGui::Button(ICON_MDI_COG, ImVec2(32_px, 32_px))) { + ImGui::OpenPopup("HeaderContextMenu"); + } else ImGui::TextUnformatted(columnName); + } + ImGui::PopFont(); + ImGui::PopStyleColor(); + if (ImGui::BeginPopup("HeaderContextMenu")) { + Editor::Launcher::showHeaderContextMenu(); + ImGui::EndPopup(); + } + + //separator + float y = ImGui::GetCursorScreenPos().y; + y -= 16_px; + ImGui::SetCursorPosY(y); + ImGui::GetWindowDrawList()->AddLine( + ImVec2(8_px, y), + ImVec2(io.DisplaySize.x - 8_px, y), + ImGui::GetColorU32(ImGuiCol_Separator), + 1_px + ); + + auto paths = Editor::RecentProjects::recentPaths; + int index = 0; + for (auto& entry : projectEntries) { + //expand arrow + ImGui::PushID(index); + float rowHeight = entry.expand ? 48_px : 32_px; + ImGui::TableNextRow(ImGuiTableRowFlags_None, rowHeight); + ImGui::TableSetColumnIndex(0); + float y = ImGui::GetCursorPosY(); + ImGuiSelectableFlags selectableFlags = ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap; + if (ImGui::Selectable("##SelectableRow", false, selectableFlags, ImVec2(0, rowHeight))) { + Editor::Actions::call(Editor::Actions::Type::PROJECT_OPEN, entry.path); + } + ImGui::SetCursorPosY(y); + if (ImGui::Button(entry.expand ? ICON_MDI_CHEVRON_DOWN : ICON_MDI_CHEVRON_RIGHT)) { + entry.expand = !entry.expand; + } + + //project name + ImGui::TableSetColumnIndex(1); + ImGui::AlignTextToFramePadding(); + ImGui::BeginGroup(); + ImGui::PushFont(nullptr, 16_px); + ImGui::TextUnformatted(entry.name.c_str()); + ImGui::PopFont(); + if (entry.expand) { + ImGui::PushFont(ImGui::Theme::getFontMono(), 16_px); + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyle().Colors[ImGuiCol_TextDisabled]); + ImGui::TextUnformatted(entry.path.c_str()); + ImGui::PopStyleColor(); + ImGui::PopFont(); + } + ImGui::EndGroup(); + + //last modified + ImGui::TableSetColumnIndex(2); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(entry.lastModified.c_str()); + + //editor ersion + ImGui::TableSetColumnIndex(3); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(entry.editorVersion.c_str()); + + //open context menu + ImGui::TableSetColumnIndex(4); + ImGui::AlignTextToFramePadding(); + if (ImGui::Button(ICON_MDI_DOTS_HORIZONTAL)) { + ImGui::OpenPopup("ProjectContextMenu"); + } + + if (ImGui::BeginPopup("ProjectContextMenu")) { + Editor::Launcher::showProjectContextMenu(entry.path); + ImGui::EndPopup(); + } + ImGui::PopID(); + index++; + } + ImGui::EndTable(); + } + ImGui::PopStyleColor(); + // version + credits { float PADDING = 24_px; @@ -229,3 +357,33 @@ void Editor::Launcher::draw() ImGui::End(); } + +void Editor::Launcher::showHeaderContextMenu() { + if(ImGui::MenuItem(ICON_MDI_RELOAD " Reload List")) { + updateProjectEntries(); + } +} + +void Editor::Launcher::showProjectContextMenu(const std::string& path) { +#if defined(_WIN32) + std::string showPrompt = ICON_MDI_FOLDER_OPEN " Show in Explorer"; +#elif defined(__APPLE__) + std::string showPrompt = ICON_MDI_FOLDER_OPEN " Show in Finder"; +#else + std::string showPrompt = ICON_MDI_FOLDER_OPEN " Show in File Manager"; +#endif + if(ImGui::MenuItem(showPrompt.c_str())) { + if (!Utils::Proc::openInFileBrowser(path)) { + Editor::Noti::add(Editor::Noti::Type::ERROR, "Failed to open File Explorer. This may be due to WSL path conversion failure."); + } + } + + if(ImGui::MenuItem(ICON_MDI_CONTENT_COPY " Copy Path")) { + SDL_SetClipboardText(path.c_str()); + } + + if(ImGui::MenuItem(ICON_MDI_CLOSE_CIRCLE " Remove from List")) { + Editor::RecentProjects::removePath(path); + updateProjectEntries(); + } +} \ No newline at end of file diff --git a/src/editor/pages/launcher.h b/src/editor/pages/launcher.h index 37e7eb85..1782d9b6 100644 --- a/src/editor/pages/launcher.h +++ b/src/editor/pages/launcher.h @@ -3,11 +3,20 @@ * @license MIT */ #pragma once +#include #include "SDL3/SDL_gpu.h" #include "../../renderer/texture.h" namespace Editor { + struct ProjectEntry { + std::string name; + std::string path; + std::string editorVersion; + std::string lastModified; + bool expand; + }; + class Launcher { private: @@ -16,11 +25,17 @@ namespace Editor Renderer::Texture texBtnOpen; Renderer::Texture texBtnTool; Renderer::Texture texBG; + std::vector projectEntries; + bool expandAll; public: Launcher(SDL_GPUDevice* device); ~Launcher(); void draw(); + void updateProjectEntries(); + void showHeaderContextMenu(); + void showProjectContextMenu(const std::string& path); + }; } diff --git a/src/editor/recentProjects.cpp b/src/editor/recentProjects.cpp new file mode 100644 index 00000000..fa2382ff --- /dev/null +++ b/src/editor/recentProjects.cpp @@ -0,0 +1,56 @@ +/** +* @copyright 2026 - Nolan Baker +* @license MIT +*/ + +#include "recentProjects.h" +#include +#include "json.hpp" +#include "../utils/fs.h" +#include "../utils/proc.h" +#include "../utils/json.h" + +namespace Editor::RecentProjects { + std::vector recentPaths = {}; + + std::string getJsonPath() { + auto path = Utils::Proc::getAppDataPath() / "recent.json"; + return path.string(); + } + + std::string getMostRecentPath() { + if (recentPaths.empty()) load(); + if (recentPaths.empty()) return ""; + return recentPaths.front(); + } + + void setMostRecentPath(const std::string &path) { + recentPaths.erase(std::remove(recentPaths.begin(), recentPaths.end(), path), recentPaths.end()); + recentPaths.insert(recentPaths.begin(), path); + save(); + } + + void removePath(const std::string &path) { + recentPaths.erase(std::remove(recentPaths.begin(), recentPaths.end(), path), recentPaths.end()); + save(); + } + + void save() { + try { + nlohmann::json json = recentPaths; + Utils::FS::saveTextFile(getJsonPath(), json.dump(2)); + } catch (const std::exception& e) { + fprintf(stderr, "Error saving recent.json: %s\n", e.what()); + } + } + + void load() { + nlohmann::json json; + try { + json = Utils::JSON::loadFile(getJsonPath()); + if (json.is_array()) recentPaths = json.get>(); + } catch (const std::exception& e) { + fprintf(stderr, "Error loading recent.json: %s\n", e.what()); + } + } +} diff --git a/src/editor/recentProjects.h b/src/editor/recentProjects.h new file mode 100644 index 00000000..de30f01a --- /dev/null +++ b/src/editor/recentProjects.h @@ -0,0 +1,19 @@ +/** +* @copyright 2026 - Nolan Baker +* @license MIT +*/ +#pragma once + +#include +#include + +namespace Editor::RecentProjects { + extern std::vector recentPaths; + + std::string getJsonPath(); + std::string getMostRecentPath(); + void setMostRecentPath(const std::string &path); + void removePath(const std::string &path); + void save(); + void load(); +} diff --git a/src/project/project.cpp b/src/project/project.cpp index 2bd1ad0b..49a0588c 100644 --- a/src/project/project.cpp +++ b/src/project/project.cpp @@ -63,6 +63,7 @@ std::string Project::ProjectConf::serialize() const { .set("romName", romName) .set("pathEmu", pathEmu) .set("pathN64Inst", pathN64Inst) + .set("editorVersion", PYRITE_VERSION) .set("sceneIdOnBoot", sceneIdOnBoot) .set("sceneIdOnReset", sceneIdOnReset) .set("sceneIdLastOpened", sceneIdLastOpened) @@ -82,6 +83,7 @@ void Project::Project::deserialize(const nlohmann::json &doc) { conf.romName = doc.value("romName", "pyrite64"); conf.pathEmu = doc.value("pathEmu", "ares"); conf.pathN64Inst = doc.value("pathN64Inst", ""); + conf.editorVersion = doc.value("editorVersion", PYRITE_VERSION); conf.sceneIdOnBoot = doc.value("sceneIdOnBoot", 1); conf.sceneIdOnReset = doc.value("sceneIdOnReset", 1); conf.sceneIdLastOpened = doc.value("sceneIdLastOpened", 1); diff --git a/src/project/project.h b/src/project/project.h index 9db7f75e..853dd0ce 100644 --- a/src/project/project.h +++ b/src/project/project.h @@ -16,6 +16,7 @@ namespace Project std::string romName{}; std::string pathEmu{}; std::string pathN64Inst{}; + std::string editorVersion{}; uint32_t sceneIdOnBoot{1}; uint32_t sceneIdOnReset{1}; diff --git a/src/utils/json.h b/src/utils/json.h index 16ec970f..d817a82b 100644 --- a/src/utils/json.h +++ b/src/utils/json.h @@ -4,6 +4,7 @@ */ #pragma once #include "fs.h" +#include "prop.h" #include "glm/vec3.hpp" #include "glm/gtc/quaternion.hpp"