From 78379f58c87607e9e9aa2a41d24207ee7f098bb1 Mon Sep 17 00:00:00 2001 From: Gary Talent Date: Sat, 24 May 2025 00:48:10 -0500 Subject: [PATCH] [studio] Add ability to remember recent projects in config --- src/olympic/studio/applib/src/app.cpp | 6 +- src/olympic/studio/applib/src/studioui.cpp | 172 +++++++++++++++--- src/olympic/studio/applib/src/studioui.hpp | 16 +- .../studio/modlib/include/studio/configio.hpp | 72 +++++--- 4 files changed, 207 insertions(+), 59 deletions(-) diff --git a/src/olympic/studio/applib/src/app.cpp b/src/olympic/studio/applib/src/app.cpp index 7f41ebad..c7f4d18d 100644 --- a/src/olympic/studio/applib/src/app.cpp +++ b/src/olympic/studio/applib/src/app.cpp @@ -71,9 +71,9 @@ static ox::Error run( } ox::Error run( - ox::StringView project, - ox::StringView appName, - ox::StringView projectDataDir, + ox::StringView const project, + ox::StringView const appName, + ox::StringView const projectDataDir, ox::SpanView args) noexcept { return studio::run(ox::sfmt("{} {}", project, appName), projectDataDir, args); } diff --git a/src/olympic/studio/applib/src/studioui.cpp b/src/olympic/studio/applib/src/studioui.cpp index a6d0d6e5..d0aea543 100644 --- a/src/olympic/studio/applib/src/studioui.cpp +++ b/src/olympic/studio/applib/src/studioui.cpp @@ -7,6 +7,8 @@ #include +#include + #include #include #include @@ -17,6 +19,8 @@ #include "font.hpp" #include "studioui.hpp" +#include + #ifdef OX_OS_Darwin #define STUDIO_CTRL "Cmd" #else @@ -54,7 +58,7 @@ void registerModule(Module const*mod) noexcept { } -struct StudioConfig { +struct StudioConfigV1 { static constexpr auto TypeName = "net.drinkingtea.studio.StudioConfig"; static constexpr auto TypeVersion = 1; ox::String projectPath; @@ -63,13 +67,65 @@ struct StudioConfig { bool showProjectExplorer = true; }; -OX_MODEL_BEGIN(StudioConfig) +OX_MODEL_BEGIN(StudioConfigV1) OX_MODEL_FIELD_RENAME(activeTabItemName, active_tab_item_name) OX_MODEL_FIELD_RENAME(projectPath, project_path) OX_MODEL_FIELD_RENAME(openFiles, open_files) OX_MODEL_FIELD_RENAME(showProjectExplorer, show_project_explorer) OX_MODEL_END() + +struct StudioConfigV2 { + static constexpr auto TypeName = "net.drinkingtea.studio.StudioConfig"; + static constexpr auto TypeVersion = 2; + struct ProjectConfig { + static constexpr auto TypeName = "net.drinkingtea.studio.ProjectConfig"; + static constexpr auto TypeVersion = 2; + ox::String projectPath; + ox::String activeTabItemName; + ox::Vector openFiles; + }; + ox::Vector projects; + bool showProjectExplorer = true; + + [[nodiscard]] + constexpr ProjectConfig const *project() const { + return projects.empty() ? nullptr : &projects[0]; + } + + [[nodiscard]] + constexpr ProjectConfig *project() { + return projects.empty() ? nullptr : &projects[0]; + } +}; + +OX_MODEL_BEGIN(StudioConfigV2::ProjectConfig) + OX_MODEL_FIELD_RENAME(activeTabItemName, active_tab_item_name) + OX_MODEL_FIELD_RENAME(projectPath, project_path) + OX_MODEL_FIELD_RENAME(openFiles, open_files) +OX_MODEL_END() + +OX_MODEL_BEGIN(StudioConfigV2) + OX_MODEL_FIELD(projects) + OX_MODEL_FIELD_RENAME(showProjectExplorer, show_project_explorer) +OX_MODEL_END() + +static ox::Error convertStudioConfigV1ToStudioConfigV2( + keel::Context&, + StudioConfigV1 &src, + StudioConfigV2 &dst) noexcept { + dst.projects.emplace_back(StudioConfigV2::ProjectConfig{ + .projectPath = std::move(src.projectPath), + .activeTabItemName = std::move(src.activeTabItemName), + .openFiles = std::move(src.openFiles), + }); + dst.showProjectExplorer = src.showProjectExplorer; + return {}; +} + +using StudioConfig = StudioConfigV2; + + StudioUI::StudioUI(turbine::Context &ctx, ox::StringParam projectDataDir) noexcept: m_sctx{*this, ctx}, m_tctx{ctx}, @@ -87,6 +143,9 @@ StudioUI::StudioUI(turbine::Context &ctx, ox::StringParam projectDataDir) noexce // and hopefully it will change at some point. io.Fonts->AddFontFromMemoryTTF(const_cast(font.data()), static_cast(font.size()), 13, &fontCfg); } + auto &kctx = keelCtx(m_tctx); + kctx.converters.emplace_back(keel::Converter::make()); + oxLogError(headerizeConfigFile(kctx)); turbine::setApplicationData(m_tctx, &m_sctx); turbine::setShutdownHandler(m_tctx, shutdownHandler); m_projectExplorer.fileChosen.connect(this, &StudioUI::openFile); @@ -102,21 +161,14 @@ StudioUI::StudioUI(turbine::Context &ctx, ox::StringParam projectDataDir) noexce m_newMenu.finished.connect(this, &StudioUI::openFile); m_closeAppConfirm.response.connect(this, &StudioUI::handleCloseAppResponse); m_closeFileConfirm.response.connect(this, &StudioUI::handleCloseFileResponse); + m_removeRecentProject.dlg.response.connect(this, &StudioUI::handleRemoveRecentProjectResponse); loadModules(); // open project and files auto const [config, err] = studio::readConfig(keelCtx(m_tctx)); m_showProjectExplorer = config.showProjectExplorer; if (!err) { - auto const openProjErr = openProjectPath(config.projectPath); - if (!openProjErr) { - for (auto const&f: config.openFiles) { - auto const openFileErr = openFileActiveTab(f, config.activeTabItemName == f); - if (openFileErr) { - oxErrorf("\nCould not open editor for file:\n\t{}\nReason:\n\t{}\n", f, toStr(openFileErr)); - continue; - } - m_activeEditor = m_editors.back().value->get(); - } + if (auto const pc = config.project()) { + oxLogError(openProjectPath(pc->projectPath)); } } else { if constexpr(!ox::defines::Debug) { @@ -142,7 +194,7 @@ void StudioUI::navigateTo(ox::StringParam path, ox::StringParam navArgs) noexcep void StudioUI::draw() noexcept { glutils::clearScreen(); drawMenu(); - auto const&viewport = *ImGui::GetMainViewport(); + auto const &viewport = *ImGui::GetMainViewport(); ImGui::SetNextWindowPos(viewport.WorkPos); ImGui::SetNextWindowSize(viewport.WorkSize); ImGui::SetNextWindowViewport(viewport.ID); @@ -194,6 +246,18 @@ void StudioUI::drawMenu() noexcept { if (ImGui::MenuItem("Open Project...", STUDIO_CTRL "+O")) { m_taskRunner.add(*ox::make(this, &StudioUI::openProjectPath)); } + if (ImGui::BeginMenu("Recent Projects", m_recentProjects.size() > 1)) { + for (size_t i = 1; i < m_recentProjects.size(); ++i) { + auto const &p = m_recentProjects[i]; + if (ImGui::MenuItem(p.c_str())) { + if (openProjectPath(p)) { + m_removeRecentProject.idx = i; + m_removeRecentProject.dlg.open(); + } + } + } + ImGui::EndMenu(); + } if (ImGui::MenuItem( "Save", STUDIO_CTRL "+S", @@ -270,7 +334,7 @@ void StudioUI::drawTabBar() noexcept { void StudioUI::drawTabs() noexcept { for (auto it = m_editors.begin(); it != m_editors.end();) { - auto const&e = *it; + auto const &e = *it; auto open = true; auto const unsavedChanges = e->unsavedChanges() ? ImGuiTabItemFlags_UnsavedDocument : 0; auto const selected = m_activeEditorUpdatePending == e.get() ? ImGuiTabItemFlags_SetSelected : 0; @@ -279,7 +343,9 @@ void StudioUI::drawTabs() noexcept { if (m_activeEditor != e.get()) [[unlikely]] { m_activeEditor = e.get(); studio::editConfig(keelCtx(m_tctx), [&](StudioConfig &config) { - config.activeTabItemName = m_activeEditor->itemPath(); + if (auto const pc = config.project()) { + pc->activeTabItemName = m_activeEditor->itemPath(); + } }); turbine::setRefreshWithin(m_tctx, 0); } else [[likely]] { @@ -337,14 +403,14 @@ void StudioUI::drawTabs() noexcept { } } -void StudioUI::loadEditorMaker(EditorMaker const&editorMaker) noexcept { - for (auto const&ext : editorMaker.fileTypes) { +void StudioUI::loadEditorMaker(EditorMaker const &editorMaker) noexcept { + for (auto const &ext : editorMaker.fileTypes) { m_editorMakers[ext] = editorMaker.make; } } -void StudioUI::loadModule(Module const&mod) noexcept { - for (auto const&editorMaker : mod.editors(m_sctx)) { +void StudioUI::loadModule(Module const &mod) noexcept { + for (auto const &editorMaker : mod.editors(m_sctx)) { loadEditorMaker(editorMaker); } for (auto &im : mod.itemMakers(m_sctx)) { @@ -464,14 +530,16 @@ ox::Error StudioUI::renameFile(ox::StringViewCR path) noexcept { return m_renameFile.openPath(path); } -ox::Error StudioUI::handleMoveFile(ox::StringViewCR oldPath, ox::StringViewCR newPath, ox::UUID const&) noexcept { +ox::Error StudioUI::handleMoveFile(ox::StringViewCR oldPath, ox::StringViewCR newPath, ox::UUID const &) noexcept { for (auto &f : m_openFiles) { if (f == oldPath) { f = newPath; editConfig(keelCtx(m_sctx), [&](StudioConfig &cfg) { - auto p = find(cfg.openFiles.begin(), cfg.openFiles.end(), oldPath); - *p = newPath; - cfg.activeTabItemName = newPath; + if (auto const pc = cfg.project()) { + auto p = find(pc->openFiles.begin(), pc->openFiles.end(), oldPath); + *p = newPath; + pc->activeTabItemName = newPath; + } }); break; } @@ -546,8 +614,36 @@ ox::Error StudioUI::openProjectPath(ox::StringParam path) noexcept { m_openFiles.clear(); m_editors.clear(); studio::editConfig(keelCtx(m_tctx), [&](StudioConfig &config) { - config.projectPath = ox::String(m_project->projectPath()); - config.openFiles.clear(); + auto const pcIt = std::find_if( + config.projects.begin(), config.projects.end(), + [this](StudioConfig::ProjectConfig const &pc) { + return pc.projectPath == m_project->projectPath(); + }); + if (pcIt != config.projects.end()) { + auto p = std::move(*pcIt); + std::ignore = config.projects.erase(pcIt); + auto &pc = *config.projects.emplace(0, std::move(p)); + for (auto const &f: pc.openFiles) { + auto const openFileErr = openFileActiveTab(f, pc.activeTabItemName == f); + if (openFileErr) { + oxErrorf("\nCould not open editor for file:\n\t{}\nReason:\n\t{}\n", f, toStr(openFileErr)); + continue; + } + m_activeEditor = m_editors.back().value->get(); + } + } else { + config.projects.emplace(0, StudioConfig::ProjectConfig{ + .projectPath = ox::String{m_project->projectPath()}, + .activeTabItemName = {}, + .openFiles = {}, + }); + } + config.projects.resize(ox::min(10, config.projects.size())); + m_recentProjects.clear(); + m_recentProjects.reserve(config.projects.size()); + for (auto const &p : config.projects) { + m_recentProjects.emplace_back(p.projectPath); + } }); return m_projectExplorer.refreshProjectTreeModel(); } @@ -593,8 +689,10 @@ ox::Error StudioUI::openFileActiveTab(ox::StringViewCR path, bool const makeActi } // save to config studio::editConfig(keelCtx(m_tctx), [&path](StudioConfig &config) { - if (!config.openFiles.contains(path)) { - config.openFiles.emplace_back(path); + if (auto const pc = config.project()) { + if (!pc->openFiles.contains(path)) { + pc->openFiles.emplace_back(path); + } } }); return {}; @@ -618,6 +716,22 @@ ox::Error StudioUI::handleCloseFileResponse(ig::PopupResponse const response) no return {}; } +ox::Error StudioUI::handleRemoveRecentProjectResponse(ig::PopupResponse const response) noexcept { + if (response == ig::PopupResponse::OK) { + auto const p = std::move(m_recentProjects[m_removeRecentProject.idx]); + studio::editConfig(keelCtx(m_tctx), [&p](StudioConfig &config) { + std::ignore = config.projects.erase( + std::remove_if( + config.projects.begin(), config.projects.end(), + [&p](StudioConfig::ProjectConfig const &pc) { + return pc.projectPath == p; + })); + }); + return m_recentProjects.erase(m_removeRecentProject.idx).error; + } + return {}; +} + ox::Error StudioUI::closeCurrentFile() noexcept { for (auto &e : m_editors) { if (m_activeEditor == e.get()) { @@ -636,7 +750,9 @@ ox::Error StudioUI::closeFile(ox::StringViewCR path) noexcept { std::ignore = m_openFiles.erase(std::remove(m_openFiles.begin(), m_openFiles.end(), path)); // save to config studio::editConfig(keelCtx(m_tctx), [&](StudioConfig &config) { - std::ignore = config.openFiles.erase(std::remove(config.openFiles.begin(), config.openFiles.end(), path)); + if (auto const pc = config.project()) { + std::ignore = pc->openFiles.erase(std::remove(pc->openFiles.begin(), pc->openFiles.end(), path)); + } }); return {}; } diff --git a/src/olympic/studio/applib/src/studioui.hpp b/src/olympic/studio/applib/src/studioui.hpp index ee9ee1bc..b56fe9a8 100644 --- a/src/olympic/studio/applib/src/studioui.hpp +++ b/src/olympic/studio/applib/src/studioui.hpp @@ -44,6 +44,7 @@ class StudioUI: public ox::SignalHandler { bool m_closeActiveTab{}; ox::Vector> m_queuedMoves; ox::Vector> m_queuedDirMoves; + ox::Vector m_recentProjects; NewMenu m_newMenu{keelCtx(m_tctx)}; AboutPopup m_aboutPopup{m_tctx}; DeleteConfirmation m_deleteConfirmation; @@ -57,7 +58,11 @@ class StudioUI: public ox::SignalHandler { MakeCopyPopup m_copyFilePopup; RenameFile m_renameFile; NewProject m_newProject{m_projectDataDir}; - ox::Array const m_widgets { + struct { + ig::QuestionPopup dlg{"Remove From Recents?", "Unable to load project. Remove from recent projects?"}; + size_t idx{}; + } m_removeRecentProject; + ox::Array const m_widgets { &m_closeFileConfirm, &m_closeAppConfirm, &m_copyFilePopup, @@ -68,6 +73,7 @@ class StudioUI: public ox::SignalHandler { &m_newDirDialog, &m_renameFile, &m_messagePopup, + &m_removeRecentProject.dlg, }; bool m_showProjectExplorer = true; struct NavAction { @@ -100,9 +106,9 @@ class StudioUI: public ox::SignalHandler { void drawTabs() noexcept; - void loadEditorMaker(EditorMaker const&editorMaker) noexcept; + void loadEditorMaker(EditorMaker const &editorMaker) noexcept; - void loadModule(Module const&mod) noexcept; + void loadModule(Module const &mod) noexcept; void loadModules() noexcept; @@ -124,7 +130,7 @@ class StudioUI: public ox::SignalHandler { ox::Error renameFile(ox::StringViewCR path) noexcept; - ox::Error handleMoveFile(ox::StringViewCR oldPath, ox::StringViewCR newPath, ox::UUID const&id) noexcept; + ox::Error handleMoveFile(ox::StringViewCR oldPath, ox::StringViewCR newPath, ox::UUID const &id) noexcept; ox::Error handleDeleteDir(ox::StringViewCR path) noexcept; @@ -144,6 +150,8 @@ class StudioUI: public ox::SignalHandler { ox::Error handleCloseFileResponse(ig::PopupResponse response) noexcept; + ox::Error handleRemoveRecentProjectResponse(ig::PopupResponse response) noexcept; + ox::Error closeCurrentFile() noexcept; ox::Error closeFile(ox::StringViewCR path) noexcept; diff --git a/src/olympic/studio/modlib/include/studio/configio.hpp b/src/olympic/studio/modlib/include/studio/configio.hpp index dae06f90..b155b3a6 100644 --- a/src/olympic/studio/modlib/include/studio/configio.hpp +++ b/src/olympic/studio/modlib/include/studio/configio.hpp @@ -4,21 +4,21 @@ #pragma once +#include #include #include -#include #include #include #include -#include #include +#include #include namespace studio { namespace detail { -inline ox::String slashesToPct(ox::StringView str) noexcept { +inline ox::String slashesToPct(ox::StringViewCR str) noexcept { auto out = ox::String{str}; for (auto&c: out) { if (c == '/' || c == '\\') { @@ -30,78 +30,102 @@ inline ox::String slashesToPct(ox::StringView str) noexcept { } [[nodiscard]] -ox::String configPath(keel::Context const&ctx) noexcept; +ox::String configPath(keel::Context const&kctx) noexcept; template -ox::Result readConfig(keel::Context &ctx, ox::StringViewCR name) noexcept { +ox::Result readConfig(keel::Context &kctx, ox::StringViewCR name) noexcept { oxAssert(name != "", "Config type has no TypeName"); auto const path = ox::sfmt("/{}.json", detail::slashesToPct(name)); - ox::PassThroughFS fs(configPath(ctx)); + ox::PassThroughFS fs(configPath(kctx)); auto const [buff, err] = fs.read(path); if (err) { //oxErrf("Could not read config file: {} - {}\n", path, toStr(err)); return err; } - return ox::readOC(buff); + OX_REQUIRE(id, ox::readClawTypeId(buff)); + ox::Result out; + if (id != ox::ModelTypeId_v) { + out = keel::convert(kctx, buff); + } else { + out = ox::readClaw(buff); + } + OX_RETURN_ERROR(out); + OX_RETURN_ERROR(keel::ensureValid(out.value)); + return out; } template -ox::Result readConfig(keel::Context &ctx) noexcept { +ox::Result readConfig(keel::Context &kctx) noexcept { constexpr auto TypeName = ox::requireModelTypeName(); - return readConfig(ctx, TypeName); + return readConfig(kctx, TypeName); } template -ox::Error writeConfig(keel::Context &ctx, ox::StringViewCR name, T const&data) noexcept { +ox::Error writeConfig(keel::Context &kctx, ox::StringViewCR name, T const&data) noexcept { oxAssert(name != "", "Config type has no TypeName"); auto const path = ox::sfmt("/{}.json", detail::slashesToPct(name)); - ox::PassThroughFS fs(configPath(ctx)); + ox::PassThroughFS fs(configPath(kctx)); if (auto const err = fs.mkdir("/", true)) { //oxErrf("Could not create config directory: {} - {}\n", path, toStr(err)); return err; } - OX_REQUIRE_M(buff, ox::writeOC(data)); + OX_REQUIRE_M(buff, ox::writeClaw(data, ox::ClawFormat::Organic)); *buff.back().value = '\n'; if (auto const err = fs.write(path, buff.data(), buff.size())) { //oxErrf("Could not read config file: {} - {}\n", path, toStr(err)); - return ox::Error(2, "Could not read config file"); + return ox::Error{2, "Could not read config file"}; } return {}; } template -ox::Error writeConfig(keel::Context &ctx, T const&data) noexcept { +ox::Error writeConfig(keel::Context &kctx, T const&data) noexcept { constexpr auto TypeName = ox::requireModelTypeName(); - return writeConfig(ctx, TypeName, data); + return writeConfig(kctx, TypeName, data); } template -void openConfig(keel::Context &ctx, ox::StringViewCR name, Func f) noexcept { +void openConfig(keel::Context &kctx, ox::StringViewCR name, Func f) noexcept { oxAssert(name != "", "Config type has no TypeName"); - auto const [c, err] = readConfig(ctx, name); + auto const [c, err] = readConfig(kctx, name); oxLogError(err); f(c); } template -void openConfig(keel::Context &ctx, Func f) noexcept { +void openConfig(keel::Context &kctx, Func f) noexcept { constexpr auto TypeName = ox::requireModelTypeName(); - openConfig(ctx, TypeName, f); + openConfig(kctx, TypeName, f); } template -void editConfig(keel::Context &ctx, ox::StringViewCR name, Func f) noexcept { +void editConfig(keel::Context &kctx, ox::StringViewCR name, Func f) noexcept { oxAssert(name != "", "Config type has no TypeName"); - auto [c, err] = readConfig(ctx, name); + auto [c, err] = readConfig(kctx, name); oxLogError(err); f(c); - oxLogError(writeConfig(ctx, name, c)); + oxLogError(writeConfig(kctx, name, c)); } template -void editConfig(keel::Context &ctx, Func f) noexcept { +void editConfig(keel::Context &kctx, Func f) noexcept { constexpr auto TypeName = ox::requireModelTypeName(); - editConfig(ctx, TypeName, f); + editConfig(kctx, TypeName, f); +} + +/** + * Older config files didn't use ClawHeaders, so they can't + * use the normal conversion system. + * Functions like this shouldn't be necessary moving forward. + */ +template +ox::Error headerizeConfigFile(keel::Context &kctx, ox::StringViewCR name = ox::ModelTypeName_v) noexcept { + auto const path = ox::sfmt("/{}.json", name); + ox::PassThroughFS fs(configPath(kctx)); + OX_REQUIRE_M(buff, fs.read(path)); + OX_REQUIRE_M(cv1, ox::readOC(buff)); + OX_RETURN_ERROR(ox::writeClaw(cv1, ox::ClawFormat::Organic).moveTo(buff)); + return fs.write(path, buff); } }