diff --git a/CMakeLists.txt b/CMakeLists.txt index 6920134..a1c16cd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -89,6 +89,8 @@ else() src/InputHandler.h src/InputHandler.cpp src/LongPressAction.h + src/MCMNavigator.h + src/MCMNavigator.cpp src/MenuUI.h src/MenuUI.cpp src/Plugin.cpp diff --git a/HoldFast.ini b/HoldFast.ini index 3349195..faa00f8 100644 --- a/HoldFast.ini +++ b/HoldFast.ini @@ -2,8 +2,16 @@ ; Duration in seconds a button must be held to trigger its long-press action (default: 0.5, max: 5.0) fHoldDuration=0.5 ; Long-press action for the Start (Menu) button. Short press performs the button's normal function. -; Valid values: Map, System, Quests, Stats, Inventory, Magic, Favorites, TweenMenu, Wait, NewSave, QuickSave, Bestiary, CharacterSheet, None (case-insensitive) +; Valid values: Map, System, Quests, Stats, Inventory, Magic, Favorites, TweenMenu, Wait, NewSave, QuickSave, Bestiary, CharacterSheet, MCM, None (case-insensitive) sButtonStartAction=Map +; Mod to open when sButtonStartAction=MCM. Leave as None to open the MCM mod list. +sButtonStartMCMModName=None +; Close the journal automatically when leaving a MCM mod page via Start. Only applies when sButtonStartAction=MCM. +bButtonStartMCMQuickexit=true ; Long-press action for the Back (View) button. Short press performs the button's normal function. -; Valid values: Map, System, Quests, Stats, Inventory, Magic, Favorites, TweenMenu, Wait, NewSave, QuickSave, Bestiary, CharacterSheet, None (case-insensitive) +; Valid values: Map, System, Quests, Stats, Inventory, Magic, Favorites, TweenMenu, Wait, NewSave, QuickSave, Bestiary, CharacterSheet, MCM, None (case-insensitive) sButtonBackAction=System +; Mod to open when sButtonBackAction=MCM. Leave as None to open the MCM mod list. +sButtonBackMCMModName=None +; Close the journal automatically when leaving a MCM mod page via Back. Only applies when sButtonBackAction=MCM. +bButtonBackMCMQuickexit=true diff --git a/README.md b/README.md index 3fb1d01..64b802d 100644 --- a/README.md +++ b/README.md @@ -42,11 +42,19 @@ Edit `Data\SKSE\Plugins\HoldFast.ini`: ; Duration in seconds a button must be held to trigger its long-press action (default: 0.5, max: 5.0) fHoldDuration=0.5 ; Long-press action for the Start (Menu) button. Short press performs the button's normal function. -; Valid values: Map, System, Quests, Stats, Inventory, Magic, Favorites, TweenMenu, Wait, NewSave, QuickSave, Bestiary, CharacterSheet, None (case-insensitive) +; Valid values: Map, System, Quests, Stats, Inventory, Magic, Favorites, TweenMenu, Wait, NewSave, QuickSave, Bestiary, CharacterSheet, MCM, None (case-insensitive) sButtonStartAction=Map +; Mod to open when sButtonStartAction=MCM. Leave as None to open the MCM mod list. +sButtonStartMCMModName=None +; Close the journal automatically when leaving a MCM mod page via Start. Only applies when sButtonStartAction=MCM. +bButtonStartMCMQuickexit=true ; Long-press action for the Back (View) button. Short press performs the button's normal function. -; Valid values: Map, System, Quests, Stats, Inventory, Magic, Favorites, TweenMenu, Wait, NewSave, QuickSave, Bestiary, CharacterSheet, None (case-insensitive) +; Valid values: Map, System, Quests, Stats, Inventory, Magic, Favorites, TweenMenu, Wait, NewSave, QuickSave, Bestiary, CharacterSheet, MCM, None (case-insensitive) sButtonBackAction=System +; Mod to open when sButtonBackAction=MCM. Leave as None to open the MCM mod list. +sButtonBackMCMModName=None +; Close the journal automatically when leaving a MCM mod page via Back. Only applies when sButtonBackAction=MCM. +bButtonBackMCMQuickexit=true ``` **In-game settings (optional):** @@ -56,22 +64,23 @@ Use **Save to config** to persist and apply changes, **Reload from config** to d **Valid actions:** -| Value | What it does | -| ---------------- | ---------------------------------------------- | -| `Map` | Opens the map | -| `System` | Opens the Journal on the System tab | -| `Quests` | Opens the Journal on the Quests tab | -| `Stats` | Opens the Journal on the Stats tab | -| `Inventory` | Opens the inventory | -| `Magic` | Opens the magic menu | -| `Favorites` | Opens the favourites menu | -| `TweenMenu` | Opens the tween menu (Items/Magic/Map/Skills) | -| `Wait` | Opens the sleep/wait menu | -| `NewSave` | Performs a new save | -| `QuickSave` | Performs a quicksave | -| `Bestiary` | Opens The Dragonborn's Bestiary (requires mod) | -| `CharacterSheet` | Opens Character Menu SE (requires mod) | -| `None` | Button not intercepted | +| Value | What it does | +| ---------------- | ------------------------------------------------------------- | +| `Map` | Opens the map | +| `System` | Opens the Journal on the System tab | +| `Quests` | Opens the Journal on the Quests tab | +| `Stats` | Opens the Journal on the Stats tab | +| `Inventory` | Opens the inventory | +| `Magic` | Opens the magic menu | +| `Favorites` | Opens the favourites menu | +| `TweenMenu` | Opens the tween menu (Items/Magic/Map/Skills) | +| `Wait` | Opens the sleep/wait menu | +| `NewSave` | Performs a new save | +| `QuickSave` | Performs a quicksave | +| `Bestiary` | Opens The Dragonborn's Bestiary (requires mod) | +| `CharacterSheet` | Opens Character Menu SE (requires mod) | +| `MCM` | Opens the MCM. Optionally navigates to a specific mod's page. | +| `None` | Button not intercepted | Logs are written to: diff --git a/src/Config.cpp b/src/Config.cpp index 9b44490..fce8934 100644 --- a/src/Config.cpp +++ b/src/Config.cpp @@ -32,6 +32,21 @@ namespace logger::warn("fHoldDuration ({:.2f}) exceeds maximum {:.1f} — capping", raw, HoldFast::kMaxHoldDuration); return duration; } + + [[nodiscard]] std::string GetMCMTarget(const CSimpleIniA& ini, const char* modKey) + { + const char* raw = ini.GetValue("General", modKey, nullptr); + if (!raw) { + return "None"; + } + const auto trimmed = HoldFast::TrimWhitespace(raw); + if (trimmed.empty()) { + return "None"; + } + std::string lower{ trimmed }; + std::ranges::transform(lower, lower.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + return lower == "none" ? "None" : std::string{ trimmed }; + } } HoldFast::Config::Settings HoldFast::Config::LoadSettings() @@ -56,6 +71,8 @@ HoldFast::Config::Settings HoldFast::Config::LoadSettings() return settings; } + constexpr auto kValidActions = "Map, System, Quests, Stats, Inventory, Magic, Favorites/Favourites, TweenMenu, Wait, NewSave, QuickSave, Bestiary, CharacterSheet, MCM, None"; + settings.startAction = hasStart ? ParseAction(rawStart) : LongPressAction::kNone; if (hasStart && settings.startAction == LongPressAction::kNone) { std::string lower{ HoldFast::TrimWhitespace(rawStart) }; @@ -63,7 +80,7 @@ HoldFast::Config::Settings HoldFast::Config::LoadSettings() c = static_cast(std::tolower(static_cast(c))); } if (lower != "none") { - logger::warn("sButtonStartAction='{}' is not a recognised action (valid: Map, System, Quests, Stats, Inventory, Magic, Favorites/Favourites, TweenMenu, Wait, NewSave, QuickSave, Bestiary, CharacterSheet, None) — disabling button", rawStart); + logger::warn("sButtonStartAction='{}' is not a recognised action (valid: {}) — disabling button", rawStart, kValidActions); } } settings.backAction = hasBack ? ParseAction(rawBack) : LongPressAction::kNone; @@ -73,9 +90,15 @@ HoldFast::Config::Settings HoldFast::Config::LoadSettings() c = static_cast(std::tolower(static_cast(c))); } if (lower != "none") { - logger::warn("sButtonBackAction='{}' is not a recognised action (valid: Map, System, Quests, Stats, Inventory, Magic, Favorites/Favourites, TweenMenu, Wait, NewSave, QuickSave, Bestiary, CharacterSheet, None) — disabling button", rawBack); + logger::warn("sButtonBackAction='{}' is not a recognised action (valid: {}) — disabling button", rawBack, kValidActions); } } + + settings.startMCMModName = GetMCMTarget(ini, "sButtonStartMCMModName"); + settings.backMCMModName = GetMCMTarget(ini, "sButtonBackMCMModName"); + settings.startMCMQuickexit = ini.GetBoolValue("General", "bButtonStartMCMQuickexit", true); + settings.backMCMQuickexit = ini.GetBoolValue("General", "bButtonBackMCMQuickexit", true); + return settings; } @@ -92,6 +115,10 @@ bool HoldFast::Config::SaveSettings(const Settings& settings) ini.SetValue("General", "fHoldDuration", holdDurationStr.c_str()); ini.SetValue("General", "sButtonStartAction", startActionName.c_str()); ini.SetValue("General", "sButtonBackAction", backActionName.c_str()); + ini.SetValue("General", "sButtonStartMCMModName", settings.startMCMModName.c_str()); + ini.SetValue("General", "sButtonBackMCMModName", settings.backMCMModName.c_str()); + ini.SetBoolValue("General", "bButtonStartMCMQuickexit", settings.startMCMQuickexit); + ini.SetBoolValue("General", "bButtonBackMCMQuickexit", settings.backMCMQuickexit); const auto rc = ini.SaveFile(kIniPath); if (rc < SI_OK) { @@ -107,10 +134,22 @@ std::vector HoldFast::Config::BuildButtons(const Settings& setting std::vector buttons; if (settings.startAction != LongPressAction::kNone) { - buttons.push_back({ .keyCode = static_cast(Key::kStart), .name = "Start", .action = settings.startAction }); + buttons.push_back({ + .keyCode = static_cast(Key::kStart), + .name = "Start", + .action = settings.startAction, + .mcmModName = settings.startMCMModName, + .mcmQuickexit = settings.startMCMQuickexit, + }); } if (settings.backAction != LongPressAction::kNone) { - buttons.push_back({ .keyCode = static_cast(Key::kBack), .name = "Back", .action = settings.backAction }); + buttons.push_back({ + .keyCode = static_cast(Key::kBack), + .name = "Back", + .action = settings.backAction, + .mcmModName = settings.backMCMModName, + .mcmQuickexit = settings.backMCMQuickexit, + }); } return buttons; } diff --git a/src/Config.h b/src/Config.h index 8349897..d582fe1 100644 --- a/src/Config.h +++ b/src/Config.h @@ -15,6 +15,10 @@ namespace HoldFast::Config float holdDuration{ HoldFast::kDefaultHoldDuration }; LongPressAction startAction{ LongPressAction::kMap }; LongPressAction backAction{ LongPressAction::kSystem }; + std::string startMCMModName{ "None" }; + std::string backMCMModName{ "None" }; + bool startMCMQuickexit{ true }; + bool backMCMQuickexit{ true }; }; struct ActionOption @@ -23,7 +27,7 @@ namespace HoldFast::Config LongPressAction action; }; - inline constexpr std::array kActionOptions{ { + inline constexpr std::array kActionOptions{ { { "Map", LongPressAction::kMap }, { "System", LongPressAction::kSystem }, { "Quests", LongPressAction::kQuests }, @@ -37,6 +41,7 @@ namespace HoldFast::Config { "QuickSave", LongPressAction::kQuickSave }, { "Bestiary", LongPressAction::kBestiary }, { "CharacterSheet", LongPressAction::kCharacterSheet }, + { "MCM", LongPressAction::kMCM }, { "None", LongPressAction::kNone }, } }; diff --git a/src/ConfigParsing.cpp b/src/ConfigParsing.cpp index 0dc7a55..d5392e0 100644 --- a/src/ConfigParsing.cpp +++ b/src/ConfigParsing.cpp @@ -21,6 +21,7 @@ LongPressAction HoldFast::Config::ParseAction(std::string_view raw) { "quicksave", LongPressAction::kQuickSave }, { "bestiary", LongPressAction::kBestiary }, { "charactersheet", LongPressAction::kCharacterSheet }, + { "mcm", LongPressAction::kMCM }, { "none", LongPressAction::kNone }, }; diff --git a/src/InputHandler.cpp b/src/InputHandler.cpp index b465a9d..f9bc5b9 100644 --- a/src/InputHandler.cpp +++ b/src/InputHandler.cpp @@ -1,12 +1,14 @@ #include "PCH.h" #include "InputHandler.h" +#include "MCMNavigator.h" #include "MenuUI.h" namespace { constexpr auto kGfxCurrentTab = "_root.QuestJournalFader.Menu_mc.iCurrentTab"; constexpr auto kGfxRestoreSavedSettings = "_root.QuestJournalFader.Menu_mc.RestoreSavedSettings"; + constexpr auto kGfxConfigPanelOpen = "_root.QuestJournalFader.Menu_mc.ConfigPanelOpen"; constexpr auto kGfxSwitchPageToFront = "_root.QuestJournalFader.Menu_mc.SwitchPageToFront"; constexpr auto kGfxQJOEndPage = "_root.QuestJournalFader.Menu_mc.QuestsFader.Page_mc.QJO_EndPage"; constexpr auto kBestiaryMenuName = "BestiaryMenu"; @@ -95,13 +97,11 @@ RE::BSEventNotifyControl InputHandler::ProcessEvent( return RE::BSEventNotifyControl::kContinue; } - // Journal closed. _lastKnownTab was last written by the input snapshot (GameIsPaused - // block) which runs on every input while the journal is open — including the button - // press that triggers close. No reliable data is available here (uiMovie is null and - // sJournalTabIdx is always kSystem due to QJO's Hook_ProcessMessage). + // Journal closed. if (_tabRestorePending) { RestoreJournalTab(); } + ResetMCMQuickexitState(); UpdateShortPressBinding(); return RE::BSEventNotifyControl::kContinue; } @@ -144,6 +144,8 @@ RE::BSEventNotifyControl InputHandler::ProcessEvent( if (ui && (ui->GameIsPaused() || ui->IsMenuOpen(kCharacterSheetMenuName))) { if (ui->IsMenuOpen(RE::JournalMenu::MENU_NAME)) { SnapshotJournalTab(ui); + MCMNavigator::TryCacheFromOpenMCM(); + HandleMCMQuickexit(); } for (auto& bs : _buttons) { bs.pressTime.reset(); @@ -222,12 +224,13 @@ void InputHandler::DispatchLongPress(const ButtonState& state) if (state.action == LongPressAction::kNewSave) { logger::info("{}: dispatching NewSave", logCtx); - if (auto* saveLoadManager = RE::BGSSaveLoadManager::GetSingleton()) { - RE::SendHUDMessage::ShowHUDMessage("Saving..."); - saveLoadManager->Save(nullptr); + auto* saveLoadManager = RE::BGSSaveLoadManager::GetSingleton(); + if (!saveLoadManager) { + logger::error("{}: BGSSaveLoadManager unavailable — action not dispatched", logCtx); return; } - logger::error("{}: BGSSaveLoadManager unavailable — action not dispatched", logCtx); + RE::SendHUDMessage::ShowHUDMessage("Saving..."); + saveLoadManager->Save(nullptr); return; } @@ -268,6 +271,7 @@ void InputHandler::DispatchLongPress(const ButtonState& state) case LongPressAction::kQuests: case LongPressAction::kSystem: case LongPressAction::kStats: + case LongPressAction::kMCM: { logger::info("{}: opening Journal", logCtx); JournalTab targetTab = JournalTab::kQuest; @@ -275,6 +279,12 @@ void InputHandler::DispatchLongPress(const ButtonState& state) targetTab = JournalTab::kSystem; } else if (state.action == LongPressAction::kStats) { targetTab = JournalTab::kStats; + } else if (state.action == LongPressAction::kMCM) { + targetTab = JournalTab::kMCM; + _pendingMCMModName = state.mcmModName; + _mcmQuickexit = state.mcmQuickexit; + _mcmWasOpen = false; + _mcmModPageSeen = false; } OpenJournalOnTab(targetTab, state.name); if (!DispatchViaMenuOpenHandler(userEvents->journal, state.keyCode, logCtx)) { @@ -283,8 +293,9 @@ void InputHandler::DispatchLongPress(const ButtonState& state) } // Re-write target tab after menuOpenHandler->ProcessButton() resets sJournalTabIdx internally. // AddMessage is queued for the next frame so the Journal will read our value. + // For kMCM, write kSystem (2) — MCM is accessed via the System tab. if (sJournalTabIdx.get()) { - *sJournalTabIdx = static_cast(targetTab); + *sJournalTabIdx = JournalTabToIndex(targetTab); } return; } @@ -373,10 +384,28 @@ bool InputHandler::DispatchViaHandler( return true; } +std::uint32_t InputHandler::JournalTabToIndex(JournalTab tab) +{ + return tab == JournalTab::kMCM ? + static_cast(JournalTab::kSystem) : + static_cast(tab); +} + +void InputHandler::CloseJournal() +{ + auto* uiQueue = RE::UIMessageQueue::GetSingleton(); + if (!uiQueue) { + return; + } + uiQueue->AddMessage(RE::JournalMenu::MENU_NAME, RE::UI_MESSAGE_TYPE::kHide, nullptr); +} + void InputHandler::OpenJournalOnTab(JournalTab tab, const std::string& buttonName) { _pendingTab = tab; + const auto sJournalValue = JournalTabToIndex(tab); + if (!sJournalTabIdx.get()) { // sJournalTabIdx unavailable — skip write/restore bookkeeping for the relocation, // but keep _pendingTab set so InvokeScaleformTab still fires on opening=true. @@ -390,7 +419,7 @@ void InputHandler::OpenJournalOnTab(JournalTab tab, const std::string& buttonNam _savedTabIdx = static_cast(*sJournalTabIdx); } _tabRestorePending = true; - *sJournalTabIdx = static_cast(tab); + *sJournalTabIdx = sJournalValue; } void InputHandler::RestoreJournalTab() @@ -400,6 +429,7 @@ void InputHandler::RestoreJournalTab() } _tabRestorePending = false; _pendingTab.reset(); + ResetMCMQuickexitState(); } void InputHandler::SnapshotJournalTab(RE::UI* ui) @@ -442,7 +472,7 @@ void InputHandler::SnapshotJournalTab(RE::UI* ui) void InputHandler::InvokeScaleformTab(JournalTab tab) { - const auto tabIdx = static_cast(tab); + const auto tabIdx = JournalTabToIndex(tab); auto* ui = RE::UI::GetSingleton(); if (!ui) { @@ -485,6 +515,29 @@ void InputHandler::InvokeScaleformTab(JournalTab tab) kGfxRestoreSavedSettings, nullptr, args.data(), static_cast(args.size())); logger::info("Journal long press: RestoreSavedSettings({}) {}", tabIdx, ok ? "ok" : "FAIL"); + + if (tab != JournalTab::kMCM) { + return; + } + + const bool cpOk = journal->uiMovie->Invoke(kGfxConfigPanelOpen, nullptr, nullptr, 0); + if (!cpOk) { + logger::info("Journal long press: ConfigPanelOpen not found — SkyUI may not be installed"); + return; + } + + if (_pendingMCMModName.empty() || _pendingMCMModName == "None") { + return; + } + const auto* taskIface = SKSE::GetTaskInterface(); + if (!taskIface) { + return; + } + std::string modName = std::move(_pendingMCMModName); + _pendingMCMModName.clear(); + taskIface->AddUITask([mn = std::move(modName)]() noexcept { + MCMNavigator::NavigateToTarget(mn); + }); } void InputHandler::InvokeRestoreTabIfNeeded(JournalTab tab) @@ -549,6 +602,46 @@ void InputHandler::DetectQJOIfNeeded(RE::GFxMovieView* movie) logger::info("QJO detection: {}", *_qjoInstalled ? "QJO installed" : "vanilla Journal"); } +void InputHandler::ResetMCMQuickexitState() +{ + _mcmQuickexit = false; + _mcmWasOpen = false; + _mcmModPageSeen = false; +} + +void InputHandler::HandleMCMQuickexit() +{ + if (!_mcmQuickexit) { + return; + } + + if (!_mcmWasOpen) { + _mcmWasOpen = MCMNavigator::IsMCMOpen(); + return; + } + + const bool modOpen = MCMNavigator::IsAnyModOpen(); + + if (!_mcmModPageSeen) { + if (modOpen) { + _mcmModPageSeen = true; + return; + } + if (MCMNavigator::IsMCMOpen()) { + return; + } + ResetMCMQuickexitState(); + CloseJournal(); + return; + } + + if (modOpen) { + return; + } + ResetMCMQuickexitState(); + CloseJournal(); +} + void InputHandler::DispatchShortPress(const ButtonState& state, float held) { // Best-effort guard against stale pressTime from process suspension (e.g. Alt-Tab). diff --git a/src/InputHandler.h b/src/InputHandler.h index 3a37dfb..e916e9a 100644 --- a/src/InputHandler.h +++ b/src/InputHandler.h @@ -55,6 +55,9 @@ class InputHandler : kQuest = 0, kStats = 1, kSystem = 2, + // Sentinel: opens Journal on the System tab then navigates to the MCM overlay. + // Not a real sJournalTabIdx value — kSystem (2) is written to the relocation. + kMCM = 3, }; static inline REL::Relocation sJournalTabIdx{ RELOCATION_ID(520167, 406697) }; @@ -65,20 +68,24 @@ class InputHandler : bool triggered{ false }; }; - bool ScanInputEvents(RE::InputEvent* const* a_events); - bool ProcessButton(const RE::ButtonEvent* btn, ButtonState& state); - static bool DispatchViaMenuOpenHandler(const RE::BSFixedString& userEvent, std::uint32_t keyCode, const std::string& logContext); - static bool DispatchViaQuickSaveLoadHandler(const RE::BSFixedString& userEvent, std::uint32_t keyCode, const std::string& logContext); - static bool DispatchViaFavoritesHandler(const RE::BSFixedString& userEvent, std::uint32_t keyCode, const std::string& logContext); - static bool DispatchViaHandler(RE::MenuEventHandler* handler, std::string_view handlerName, const RE::BSFixedString& userEvent, std::uint32_t keyCode, const std::string& logContext); - static void DispatchShortPress(const ButtonState& state, float held); - void DispatchLongPress(const ButtonState& state); - void OpenJournalOnTab(JournalTab tab, const std::string& buttonName); - void RestoreJournalTab(); - static void InvokeScaleformTab(JournalTab tab); - void InvokeRestoreTabIfNeeded(JournalTab tab); - void SnapshotJournalTab(RE::UI* ui); - void DetectQJOIfNeeded(RE::GFxMovieView* movie); + bool ScanInputEvents(RE::InputEvent* const* a_events); + bool ProcessButton(const RE::ButtonEvent* btn, ButtonState& state); + static bool DispatchViaMenuOpenHandler(const RE::BSFixedString& userEvent, std::uint32_t keyCode, const std::string& logContext); + static bool DispatchViaQuickSaveLoadHandler(const RE::BSFixedString& userEvent, std::uint32_t keyCode, const std::string& logContext); + static bool DispatchViaFavoritesHandler(const RE::BSFixedString& userEvent, std::uint32_t keyCode, const std::string& logContext); + static bool DispatchViaHandler(RE::MenuEventHandler* handler, std::string_view handlerName, const RE::BSFixedString& userEvent, std::uint32_t keyCode, const std::string& logContext); + static void DispatchShortPress(const ButtonState& state, float held); + static std::uint32_t JournalTabToIndex(JournalTab tab); + static void CloseJournal(); + void DispatchLongPress(const ButtonState& state); + void OpenJournalOnTab(JournalTab tab, const std::string& buttonName); + void RestoreJournalTab(); + void InvokeScaleformTab(JournalTab tab); + void InvokeRestoreTabIfNeeded(JournalTab tab); + void SnapshotJournalTab(RE::UI* ui); + void DetectQJOIfNeeded(RE::GFxMovieView* movie); + void HandleMCMQuickexit(); + void ResetMCMQuickexitState(); float holdDuration{ kDefaultHoldDuration }; std::vector _buttons; @@ -99,4 +106,8 @@ class InputHandler : std::optional _pendingTab{}; std::optional _lastKnownTab{}; std::optional _qjoInstalled{}; + std::string _pendingMCMModName; + bool _mcmQuickexit{ false }; + bool _mcmWasOpen{ false }; + bool _mcmModPageSeen{ false }; }; diff --git a/src/LongPressAction.h b/src/LongPressAction.h index 0692798..6dcf53f 100644 --- a/src/LongPressAction.h +++ b/src/LongPressAction.h @@ -26,6 +26,7 @@ enum class LongPressAction kQuickSave, kBestiary, kCharacterSheet, + kMCM, }; struct ButtonConfig @@ -33,4 +34,6 @@ struct ButtonConfig std::uint32_t keyCode{}; std::string name; LongPressAction action{ LongPressAction::kNone }; + std::string mcmModName{ "None" }; + bool mcmQuickexit{ true }; }; diff --git a/src/MCMNavigator.cpp b/src/MCMNavigator.cpp new file mode 100644 index 0000000..abf834b --- /dev/null +++ b/src/MCMNavigator.cpp @@ -0,0 +1,502 @@ +#include "PCH.h" + +#include "MCMNavigator.h" +#include "Utils.h" + +#include +#include +#include + +namespace MCMNavigator +{ + namespace + { + constexpr int kModRetryFrames = 3; + constexpr auto kNavTimeout = std::chrono::seconds(3); + + constexpr auto kConfigPanel = "_root.ConfigPanelFader.configPanel."; + constexpr auto kModListPanel = "_root.ConfigPanelFader.configPanel.contentHolder.modListPanel."; + constexpr auto kModList = "_root.ConfigPanelFader.configPanel.contentHolder.modListPanel.modListFader.list."; + + bool CaseInsensitiveLess(const std::string& a, const std::string& b) + { + return std::ranges::lexicographical_compare( + a, b, + [](unsigned char x, unsigned char y) { return std::tolower(x) < std::tolower(y); }); + } + + std::string_view StripModNamePrefix(std::string_view name) + { + const auto pos = name.find("::"); + return pos != std::string_view::npos ? name.substr(pos + 2) : name; + } + + struct NavigationTarget + { + std::string modName; + std::chrono::steady_clock::time_point deadline; + }; + + // Set once per dispatch, consumed by the async chain. + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + inline NavigationTarget g_target{}; + // Navigation in-flight guard. + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + inline std::atomic g_lock{ false }; + + // Protected by g_cacheMutex — render thread reads, game/UI thread writes. + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + inline std::mutex g_cacheMutex{}; + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + inline std::vector g_modCache{}; + // Owned by TryCacheFromOpenMCM — debounces its AddUITask so at most one is pending at a time. + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + inline std::atomic g_cachePending{ false }; + // Set when the Papyrus cache path is done — either successfully populated, SkyUI absent, + // or MCM Unlocked detected. Prevents further Papyrus lookups regardless of outcome. + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + inline std::atomic g_skyUICacheDone{ false }; + // Set once EnsureCachePopulated successfully schedules CacheModListFromPapyrus. + // Prevents the every-frame settings UI call from re-scheduling on each retry failure. + // TryCacheFromOpenMCM uses g_cachePending for its own debounce and handles retries. + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + inline std::atomic g_papyrusEagerScheduled{ false }; + // Set while a CacheModListFromPapyrus game-thread task is pending. + // Prevents TryCacheFromOpenMCM from flooding the game-task queue when + // SKI_ConfigManager is not yet bound. + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + inline std::atomic g_papyrusPending{ false }; + + RE::GFxMovieView* GetJournalView() + { + auto* ui = RE::UI::GetSingleton(); + if (!ui) { + return nullptr; + } + auto menu = ui->GetMenu(RE::JournalMenu::MENU_NAME); + return menu ? menu->uiMovie.get() : nullptr; + } + + bool AddUITask(std::function func) + { + const auto* taskIface = SKSE::GetTaskInterface(); + if (!taskIface) { + logger::warn("MCMNavigator: task interface unavailable"); + return false; + } + taskIface->AddUITask(std::move(func)); + return true; + } + + bool DelayCallForUI(std::function func, int gameFramesLeft) + { + if (gameFramesLeft <= 0) { + return AddUITask(std::move(func)); + } + const auto* taskIface = SKSE::GetTaskInterface(); + if (!taskIface) { + return false; + } + taskIface->AddTask([func = std::move(func), gameFramesLeft]() mutable { + if (!DelayCallForUI(std::move(func), gameFramesLeft - 1)) { + g_lock = false; + } + }); + return true; + } + + std::vector CollectEntryNames(RE::GFxMovieView* view, const std::string& listPath, const char* memberName) + { + std::vector names; + if (!view) { + return names; + } + + RE::GFxValue listObj; + view->GetVariable(&listObj, listPath.c_str()); + if (!listObj.IsObject()) { + return names; + } + + RE::GFxValue entryList; + listObj.GetMember("_entryList", &entryList); + if (!entryList.IsArray()) { + return names; + } + + const auto length = entryList.GetArraySize(); + names.reserve(length); + for (std::uint32_t i = 0; i < length; i++) { + RE::GFxValue entry; + entryList.GetElement(i, &entry); + RE::GFxValue nameVal; + entry.GetMember(memberName, &nameVal); + if (nameVal.IsString()) { + names.emplace_back(nameVal.GetString()); + } + } + return names; + } + + // Uses doSetSelectedIndex + onItemPress to select and click the entry. + bool SelectEntryByName(const std::string& listPath, const char* varName, std::string_view targetName) + { + auto* view = GetJournalView(); + if (!view) { + return false; + } + + RE::GFxValue listObj; + view->GetVariable(&listObj, listPath.c_str()); + if (!listObj.IsObject()) { + return false; + } + + RE::GFxValue entryList; + listObj.GetMember("_entryList", &entryList); + if (!entryList.IsArray()) { + return false; + } + + const auto length = entryList.GetArraySize(); + if (length == 0) { + logger::debug("MCMNavigator: {} list is empty — consider increasing delay", listPath); + return false; + } + + int index = -1; + for (std::uint32_t i = 0; i < length; i++) { + RE::GFxValue entry; + entryList.GetElement(i, &entry); + RE::GFxValue nameVal; + entry.GetMember(varName, &nameVal); + if (!nameVal.IsString()) { + continue; + } + if (HoldFast::CaseInsensitiveEqual(targetName, nameVal.GetString())) { + index = static_cast(i); + break; + } + } + + if (index < 0) { + logger::warn("MCMNavigator: '{}' not found in {}", targetName, listPath); + return false; + } + + std::array args{ static_cast(index), 0.0 }; + listObj.Invoke("doSetSelectedIndex", nullptr, args.data(), 2); + listObj.Invoke("onItemPress", nullptr, args.data(), 2); + logger::debug("MCMNavigator: selected '{}' at index {} in {}", targetName, index, listPath); + return true; + } + + void TransitionToModList() + { + auto* view = GetJournalView(); + if (!view) { + return; + } + RE::GFxValue arg{ 4.0 }; // TRANSITION_TO_LIST state + const std::string setStatePath = std::string{ kModListPanel } + "setState"; + view->Invoke(setStatePath.c_str(), nullptr, &arg, 1); + } + + void OpenMod() + { + if (std::chrono::steady_clock::now() > g_target.deadline) { + logger::warn("MCMNavigator: mod selection timed out waiting for MCM to be ready"); + g_lock = false; + return; + } + + auto* view = GetJournalView(); + if (!view) { + g_lock = false; + return; + } + + // Poll disableSelection — list is animating, not ready yet. + // Also retry if the mod list path doesn't resolve (MCM may still be fading in). + RE::GFxValue disabled; + const std::string disablePath = std::string{ kModList } + "disableSelection"; + view->GetVariable(&disabled, disablePath.c_str()); + if (!disabled.IsBool() || disabled.GetBool()) { + if (!DelayCallForUI(OpenMod, kModRetryFrames)) { + g_lock = false; + } + return; + } + + // List is ready — but _entryList may still be empty if SkyUI hasn't + // populated the data yet. Retry until we have at least one entry. + auto mods = CollectEntryNames(view, std::string{ kModList }, "text"); + if (mods.empty()) { + if (!DelayCallForUI(OpenMod, kModRetryFrames)) { + g_lock = false; + } + return; + } + + std::ranges::sort(mods, CaseInsensitiveLess); + { + std::scoped_lock lock(g_cacheMutex); + if (g_modCache.empty()) { + g_modCache = std::move(mods); + logger::debug("MCMNavigator: cached {} mod names", g_modCache.size()); + } + } + + if (!SelectEntryByName(kModList, "text", g_target.modName)) { + logger::warn("MCMNavigator: mod '{}' not found — MCM mod list shown", g_target.modName); + } + g_lock = false; + } + } + + bool IsMCMOpen() + { + auto* view = GetJournalView(); + if (!view) { + return false; + } + RE::GFxValue alpha; + const std::string alphaPath = std::string{ kConfigPanel } + "_alpha"; + view->GetVariable(&alpha, alphaPath.c_str()); + return alpha.IsNumber() && alpha.GetNumber() == 100.0; + } + + bool IsAnyModOpen() + { + auto* view = GetJournalView(); + if (!view) { + return false; + } + RE::GFxValue state; + const std::string statePath = std::string{ kModListPanel } + "_state"; + view->GetVariable(&state, statePath.c_str()); + return state.IsNumber() && state.GetNumber() == 2.0; + } + + bool IsModAlreadyOpen(std::string_view modName) + { + if (!IsAnyModOpen()) { + return false; + } + auto* view = GetJournalView(); + if (!view) { + return false; + } + RE::GFxValue titleText; + const std::string titlePath = std::string{ kModListPanel } + "_titleText"; + view->GetVariable(&titleText, titlePath.c_str()); + return titleText.IsString() && HoldFast::CaseInsensitiveEqual(modName, titleText.GetString()); + } + + void CacheModListFromGFx() + { + auto* view = GetJournalView(); + if (!view || !IsMCMOpen()) { + return; + } + auto names = CollectEntryNames(view, std::string{ kModList }, "text"); + std::ranges::sort(names, CaseInsensitiveLess); + if (names.empty()) { + return; + } + std::scoped_lock lock(g_cacheMutex); + g_modCache = std::move(names); + } + + void TryCacheFromOpenMCM() + { + if (!IsMCMOpen()) { + return; + } + const auto* taskIface = SKSE::GetTaskInterface(); + if (!taskIface) { + return; + } + // Debounce — only one pending AddUITask at a time. + if (g_cachePending.exchange(true)) { + return; + } + taskIface->AddUITask([]() { + if (!IsMCMOpen()) { + g_cachePending = false; + return; + } + CacheModListFromGFx(); + if (g_skyUICacheDone) { + g_cachePending = false; + return; + } + // CacheModListFromPapyrus uses Papyrus VM APIs — must run on the game thread. + const auto* inner = SKSE::GetTaskInterface(); + if (!inner) { + g_cachePending = false; + return; + } + if (g_papyrusPending.exchange(true)) { + g_cachePending = false; + return; + } + inner->AddTask(CacheModListFromPapyrus); + g_cachePending = false; + }); + } + + std::vector GetCachedModNames() + { + std::scoped_lock lock(g_cacheMutex); + return g_modCache; + } + + void NavigateToTargetImpl(const std::string& modName) + { + if (modName.empty() || modName == "None") { + return; + } + + if (g_lock.exchange(true)) { + logger::debug("MCMNavigator: navigation already in flight — skipping"); + return; + } + + if (IsAnyModOpen() && !IsModAlreadyOpen(modName)) { + logger::debug("MCMNavigator: a different mod is open — transitioning to mod list"); + TransitionToModList(); + } + + g_target.modName = modName; + g_target.deadline = std::chrono::steady_clock::now() + kNavTimeout; + + if (IsModAlreadyOpen(modName)) { + g_lock = false; + } else if (!DelayCallForUI(OpenMod, kModRetryFrames)) { + logger::warn("MCMNavigator: task interface unavailable — navigation cancelled"); + g_lock = false; + } + } + + void NavigateToTarget(const std::string& modName) noexcept + { + try { + NavigateToTargetImpl(modName); + } catch (...) { + g_lock = false; + } + } + + void ReadModArraysIntoCache(const RE::BSTSmartPointer& namesArr) + { + std::vector modNames; + modNames.reserve(namesArr->size()); + + for (RE::BSScript::Array::size_type i = 0; i < namesArr->size(); ++i) { + const auto& nameElem = (*namesArr)[i]; + if (!nameElem.IsString()) { + continue; + } + const auto modName = nameElem.GetString(); + if (modName.empty()) { + continue; + } + modNames.emplace_back(StripModNamePrefix(modName)); + } + + if (modNames.empty()) { + return; + } + + std::ranges::sort(modNames, CaseInsensitiveLess); + std::scoped_lock lock(g_cacheMutex); + g_modCache = std::move(modNames); + logger::info("MCMNavigator: cached {} mods from SKI_ConfigManager", g_modCache.size()); + } + + void CacheModListFromPapyrus() + { + const struct ClearPapyrusPending + { + ClearPapyrusPending() = default; + ClearPapyrusPending(const ClearPapyrusPending&) = default; + ClearPapyrusPending(ClearPapyrusPending&&) = default; + ClearPapyrusPending& operator=(const ClearPapyrusPending&) = default; + ClearPapyrusPending& operator=(ClearPapyrusPending&&) = default; + ~ClearPapyrusPending() { g_papyrusPending = false; } + } clearPapyrusPending; + + if (g_skyUICacheDone) { + return; + } + + auto* vm = RE::BSScript::Internal::VirtualMachine::GetSingleton(); + if (!vm) { + return; + } + + auto* quest = RE::TESForm::LookupByEditorID("SKI_ConfigManagerInstance"); + if (!quest) { + logger::info("MCMNavigator: SKI_ConfigManagerInstance not found — SkyUI not installed, skipping Papyrus cache"); + g_skyUICacheDone = true; + return; + } + + const auto* policy = vm->GetObjectHandlePolicy(); + if (!policy) { + return; + } + + const auto handle = policy->GetHandleForObject( + static_cast(RE::FormType::Quest), quest); + if (handle == policy->EmptyHandle()) { + logger::debug("MCMNavigator: could not get VM handle for SKI_ConfigManagerInstance"); + return; + } + + RE::BSTSmartPointer managerObj; + if (!vm->FindBoundObject(handle, "SKI_ConfigManager", managerObj) || !managerObj) { + logger::debug("MCMNavigator: SKI_ConfigManager script not yet bound — will retry when MCM opens"); + return; + } + + const auto* namesVar = managerObj->GetVariable("_modNames"); + if (!namesVar || !namesVar->IsArray()) { + // MCM Unlocked replaces SKI_ConfigManager and removes _modNames/_modConfigs. + // DispatchStaticCall only works for native Papyrus functions — MCMUnlocked's + // GetModName / GetMarkerScript are scripted functions and cannot be called this + // way. Fall back to the Flash cache (TryCacheFromOpenMCM), which populates mod + // names the first time the user opens the Journal with MCM Unlocked. + logger::info("MCMNavigator: MCM Unlocked detected — Flash cache will populate on first MCM open"); + g_skyUICacheDone = true; + return; + } + + const auto namesArr = namesVar->GetArray(); + if (!namesArr) { + return; + } + + ReadModArraysIntoCache(namesArr); + g_skyUICacheDone = true; + } + + void EnsureCachePopulated() + { + if (g_skyUICacheDone) { + return; + } + if (g_papyrusEagerScheduled.exchange(true)) { + return; + } + const auto* taskIface = SKSE::GetTaskInterface(); + if (!taskIface) { + g_papyrusEagerScheduled = false; + return; + } + if (!g_papyrusPending.exchange(true)) { + taskIface->AddTask(CacheModListFromPapyrus); + } + } +} diff --git a/src/MCMNavigator.h b/src/MCMNavigator.h new file mode 100644 index 0000000..286043f --- /dev/null +++ b/src/MCMNavigator.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include + +namespace MCMNavigator +{ + bool IsMCMOpen(); + + bool IsAnyModOpen(); + + void NavigateToTarget(const std::string& modName) noexcept; + + // Debounced — safe to call every frame. + void TryCacheFromOpenMCM(); + + std::vector GetCachedModNames(); + + // Reads SKI_ConfigManager's _modNames from SKI_ConfigManagerInstance. + // Must be called on the game thread. No-op if SkyUI is not installed or script not yet bound. + void CacheModListFromPapyrus(); + + // Async. Safe to call from any thread. Schedules CacheModListFromPapyrus once per session; + // retries only if the task interface was unavailable at the time of the call. + // TryCacheFromOpenMCM handles retries when the Journal is open. + void EnsureCachePopulated(); +} diff --git a/src/MenuUI.cpp b/src/MenuUI.cpp index 6ce247f..551d19c 100644 --- a/src/MenuUI.cpp +++ b/src/MenuUI.cpp @@ -2,6 +2,7 @@ #include "Config.h" #include "InputHandler.h" +#include "MCMNavigator.h" #include "MenuUI.h" #include "SKSEMCP/utils.hpp" #include "Utils.h" @@ -66,7 +67,11 @@ namespace { return lhs.holdDuration == rhs.holdDuration && lhs.startAction == rhs.startAction && - lhs.backAction == rhs.backAction; + lhs.backAction == rhs.backAction && + lhs.startMCMModName == rhs.startMCMModName && + lhs.backMCMModName == rhs.backMCMModName && + lhs.startMCMQuickexit == rhs.startMCMQuickexit && + lhs.backMCMQuickexit == rhs.backMCMQuickexit; } bool DrawActionCombo(const char* label, InputHandler::LongPressAction& value) @@ -133,13 +138,62 @@ namespace state.hasPendingChanges = !SettingsEqual(defaults, loaded); } + void DrawMCMTargetInputs(const char* modLabel, std::string& modName, bool& changed) + { + MCMNavigator::EnsureCachePopulated(); + + const char* preview = modName.empty() || modName == "None" ? "None" : modName.c_str(); + + if (!ImGuiMCP::BeginCombo(modLabel, preview)) { + return; + } + + const auto cachedMods = MCMNavigator::GetCachedModNames(); + + const bool noneSelected = modName.empty() || modName == "None"; + if (ImGuiMCP::Selectable("None", noneSelected)) { + modName = "None"; + changed = true; + } + + if (cachedMods.empty()) { + ImGuiMCP::Selectable("(Open MCM once to populate)", false, ImGuiMCP::ImGuiSelectableFlags_Disabled); + ImGuiMCP::EndCombo(); + return; + } + + for (const auto& opt : cachedMods) { + if (ImGuiMCP::Selectable(opt.c_str(), HoldFast::CaseInsensitiveEqual(opt, modName))) { + modName = opt; + changed = true; + break; + } + } + + ImGuiMCP::EndCombo(); + } + void __stdcall RenderSettings() { auto& state = GetMenuState(); bool changed = false; changed |= ImGuiMCP::SliderFloat("Hold duration", &state.stagedSettings.holdDuration, InputHandler::kMinHoldDuration, InputHandler::kMaxHoldDuration, "%.2fs"); changed |= DrawActionCombo("Start long-press action", state.stagedSettings.startAction); + if (state.stagedSettings.startAction == InputHandler::LongPressAction::kMCM) { + DrawMCMTargetInputs( + "Start MCM mod name", + state.stagedSettings.startMCMModName, + changed); + changed |= ImGuiMCP::Checkbox("Close journal after leaving MCM mod page##start", &state.stagedSettings.startMCMQuickexit); + } changed |= DrawActionCombo("Back long-press action", state.stagedSettings.backAction); + if (state.stagedSettings.backAction == InputHandler::LongPressAction::kMCM) { + DrawMCMTargetInputs( + "Back MCM mod name", + state.stagedSettings.backMCMModName, + changed); + changed |= ImGuiMCP::Checkbox("Close journal after leaving MCM mod page##back", &state.stagedSettings.backMCMQuickexit); + } if (changed) { state.hasPendingChanges = true; diff --git a/src/PCH.h b/src/PCH.h index 6b0b25a..1d1d983 100644 --- a/src/PCH.h +++ b/src/PCH.h @@ -13,6 +13,7 @@ #include #include +#include #include #include #include diff --git a/src/Plugin.cpp b/src/Plugin.cpp index 540970d..1dad1d4 100644 --- a/src/Plugin.cpp +++ b/src/Plugin.cpp @@ -2,6 +2,7 @@ #include "Config.h" #include "InputHandler.h" +#include "MCMNavigator.h" #include "MenuUI.h" void SetupLog() @@ -93,6 +94,7 @@ SKSEPluginLoad(const SKSE::LoadInterface* a_skse) case SKSE::MessagingInterface::kPostLoadGame: case SKSE::MessagingInterface::kNewGame: InputHandler::GetSingleton()->UpdateShortPressBinding(); + MCMNavigator::EnsureCachePopulated(); break; default: break; diff --git a/src/Utils.h b/src/Utils.h index e364b37..6bf45fb 100644 --- a/src/Utils.h +++ b/src/Utils.h @@ -1,6 +1,8 @@ #pragma once +#include #include +#include #include #include @@ -16,6 +18,13 @@ namespace HoldFast return s.substr(first, s.find_last_not_of(" \t\r\n") - first + 1); } + [[nodiscard]] inline bool CaseInsensitiveEqual(std::string_view a, std::string_view b) + { + return std::ranges::equal( + a, b, + [](unsigned char x, unsigned char y) { return std::tolower(x) == std::tolower(y); }); + } + [[nodiscard]] inline float ClampHoldDuration(float value, float defaultVal, float minVal, float maxVal) { assert(minVal > 0.0F && defaultVal >= minVal && defaultVal <= maxVal); diff --git a/test/PluginTests.cpp b/test/PluginTests.cpp index dde5c45..8475e56 100644 --- a/test/PluginTests.cpp +++ b/test/PluginTests.cpp @@ -75,11 +75,21 @@ TEST_CASE("ParseAction supports favourites alias and invalid fallback", "[config CHECK(ParseAction(" None ") == Action::kNone); } +TEST_CASE("ParseAction handles MCM action", "[config]") +{ + using Action = LongPressAction; + + CHECK(ParseAction("MCM") == Action::kMCM); + CHECK(ParseAction("mcm") == Action::kMCM); + CHECK(ParseAction(" Mcm ") == Action::kMCM); +} + TEST_CASE("ActionName maps enum values and falls back to None", "[config]") { using Action = LongPressAction; CHECK(ActionName(Action::kMap) == "Map"); + CHECK(ActionName(Action::kMCM) == "MCM"); CHECK(ActionName(Action::kQuickSave) == "QuickSave"); CHECK(ActionName(Action::kCharacterSheet) == "CharacterSheet"); CHECK(ActionName(static_cast(9999)) == "None");