From 6984ae83bff4114a3ae33a1b77ced261c0aa8371 Mon Sep 17 00:00:00 2001 From: CraftyBoss Date: Mon, 22 Jun 2026 03:25:01 -0700 Subject: [PATCH] add UI to selecting/creating archipelago seed Next step will be to associate connection info to each save slot instead of overwriting the same config values for every slot. With these changes though, it should be possible to connect to an archipelago lobby without needing to use the ImGui debug window. --- files.cmake | 2 + include/d/d_file_select.h | 4 + src/d/d_file_select.cpp | 122 +++++++++++--- src/dusk/archipelago/archipelago_context.cpp | 44 +++-- src/dusk/archipelago/archipelago_context.hpp | 8 +- src/dusk/randomizer/randomizer.cmake | 2 +- src/dusk/ui/archi_connect_modal.cpp | 166 +++++++++++++++++++ src/dusk/ui/archi_connect_modal.hpp | 29 ++++ src/dusk/ui/modal.cpp | 63 +++++++ src/dusk/ui/modal.hpp | 23 +++ 10 files changed, 424 insertions(+), 39 deletions(-) create mode 100644 src/dusk/ui/archi_connect_modal.cpp create mode 100644 src/dusk/ui/archi_connect_modal.hpp diff --git a/files.cmake b/files.cmake index 97333f9acf..d8e0f21ae9 100644 --- a/files.cmake +++ b/files.cmake @@ -1540,6 +1540,8 @@ set(DUSK_FILES src/dusk/ui/rando_config.hpp src/dusk/ui/rando_seed_generation.cpp src/dusk/ui/rando_seed_generation.hpp + src/dusk/ui/archi_connect_modal.cpp + src/dusk/ui/archi_connect_modal.hpp src/dusk/achievements.cpp src/dusk/iso_validate.cpp src/dusk/livesplit.cpp diff --git a/include/d/d_file_select.h b/include/d/d_file_select.h index eff7604f10..87a8952e2a 100644 --- a/include/d/d_file_select.h +++ b/include/d/d_file_select.h @@ -205,6 +205,7 @@ public: DATASELPROC_SELECT_DATA_NAME_MOVE, #if TARGET_PC DATASELPROC_SELECT_DATA_PLAY_MOVE, // Select between vanilla or randomizer play + DATASELPROC_MENU_ARCHIPELAGO_CONNECT, // Wait for archipelago to connect #endif DATASELPROC_SELECT_DATA_OPENERASE_MOVE, DATASELPROC_MENU_SELECT, @@ -336,6 +337,7 @@ public: void selectDataNameMove(); #if TARGET_PC void selectDataPlayTypeMove(); + void menuArchipelagoConnect(); #endif void selectDataOpenEraseMove(); void menuSelect(); @@ -742,6 +744,8 @@ public: dDlst_FileSelFade_c mFadeDlst; bool mStartNameAnm; bool mBackToFileSelect; + bool mArchipelagoBeginConnect; + bool mArchiStartCloseFile; int mPendingRmlCloseFrames{0}; } mDusk; #endif diff --git a/src/d/d_file_select.cpp b/src/d/d_file_select.cpp index 6424e2ab7f..4a3cb4aa65 100644 --- a/src/d/d_file_select.cpp +++ b/src/d/d_file_select.cpp @@ -24,6 +24,7 @@ #include #include "dusk/archipelago/archipelago_context.hpp" +#include "dusk/ui/archi_connect_modal.hpp" #if TARGET_PC #include "dusk/config.hpp" @@ -301,6 +302,7 @@ static DataSelProcFunc DataSelProc[] = { &dFile_select_c::selectDataNameMove, #if TARGET_PC &dFile_select_c::selectDataPlayTypeMove, + &dFile_select_c::menuArchipelagoConnect, #endif &dFile_select_c::selectDataOpenEraseMove, &dFile_select_c::menuSelect, @@ -1032,34 +1034,75 @@ void dFile_select_c::dataSelectStart() { ketteiTxtDispAnmInit(0); #endif +#if TARGET_PC + headerTxtSet(msgTbl[mSelectNum], 1, 0); + makeRecInfo(mSelectNum); + + // Load the randomizer seed if one is tied to this file + auto curFileSeedHash = dusk::getSettings().randomizer.seedHashes.at(mSelectNum).getValue(); + // If this is a vanilla file, clear rando data structures + if (curFileSeedHash.empty()) { + g_randomizerState = RandomizerState(); + randomizer_GetContext() = RandomizerContext(); + mDataSelProc = DATASELPROC_SELECT_DATA_OPEN_MOVE; + + // ensure archipelago is not connected either + if (dusk::archi::ArchipelagoContext::IsConnected()) + dusk::archi::ArchipelagoContext::DisconnectFromServer(); + + selectDataMoveAnmInitSet(SelOpenStartFrameTbl[mSelectNum], SelOpenEndFrameTbl[mSelectNum]); + menuMoveAnmInitSet(799, 809); + selectWakuAlpahAnmInit(mSelectNum, 0xff, 0, g_fsHIO.select_box_appear_frames); + } + else if (dusk::archi::ArchipelagoContext::IsSeedHashArchipelago(curFileSeedHash)) { + if (dusk::archi::ArchipelagoContext::IsConnected()) { + // if connected, we need to check if we're already connected to the right slot/seed, + // if so, we can continue straight to file start, otherwise a disconnect must happen first. + if (!dusk::archi::ArchipelagoContext::IsCurrentSeedHash(curFileSeedHash)) { + mDataSelProc = DATASELPROC_MENU_ARCHIPELAGO_CONNECT; + mDusk.mArchipelagoBeginConnect = true; + mDusk.mArchiStartCloseFile = false; + }else { + mDataSelProc = DATASELPROC_SELECT_DATA_OPEN_MOVE; + + selectDataMoveAnmInitSet(SelOpenStartFrameTbl[mSelectNum], SelOpenEndFrameTbl[mSelectNum]); + menuMoveAnmInitSet(799, 809); + selectWakuAlpahAnmInit(mSelectNum, 0xff, 0, g_fsHIO.select_box_appear_frames); + } + }else { + mDataSelProc = DATASELPROC_MENU_ARCHIPELAGO_CONNECT; + mDusk.mArchipelagoBeginConnect = true; + mDusk.mArchiStartCloseFile = false; + } + }else { + // Reset randomizer state if we're switching to a different file + if (curFileSeedHash != randomizer_GetContext().mHash || g_randomizerState.mFileNum != mSelectNum) { + g_randomizerState = RandomizerState(); + randomizer_GetContext() = RandomizerContext(); + randomizer_GetContext().LoadFromHash(curFileSeedHash); + } + + // disconnect from archipelago if applicable + if (dusk::archi::ArchipelagoContext::IsConnected()) + dusk::archi::ArchipelagoContext::DisconnectFromServer(); + + selectDataMoveAnmInitSet(SelOpenStartFrameTbl[mSelectNum], SelOpenEndFrameTbl[mSelectNum]); + menuMoveAnmInitSet(799, 809); + selectWakuAlpahAnmInit(mSelectNum, 0xff, 0, g_fsHIO.select_box_appear_frames); + + mDataSelProc = DATASELPROC_SELECT_DATA_OPEN_MOVE; + } +#else + headerTxtSet(msgTbl[mSelectNum], 1, 0); selectDataMoveAnmInitSet(SelOpenStartFrameTbl[mSelectNum], SelOpenEndFrameTbl[mSelectNum]); menuMoveAnmInitSet(799, 809); selectWakuAlpahAnmInit(mSelectNum, 0xff, 0, g_fsHIO.select_box_appear_frames); makeRecInfo(mSelectNum); -#if TARGET_PC - // TODO: not this please D: - if (dusk::archi::ArchipelagoContext::IsConnected()) { - dusk::archi::ArchipelagoContext::GenerateLocalWorldData(); - }else { - // Load the randomizer seed if one is tied to this file - auto curFileSeedHash = dusk::getSettings().randomizer.seedHashes.at(mSelectNum).getValue(); - // If this is a vanilla file, clear rando data structures - if (curFileSeedHash.empty()) { - g_randomizerState = RandomizerState(); - randomizer_GetContext() = RandomizerContext(); - } - // Reset randomizer state if we're switching to a different file - else if (curFileSeedHash != randomizer_GetContext().mHash || g_randomizerState.mFileNum != mSelectNum) { - g_randomizerState = RandomizerState(); - randomizer_GetContext() = RandomizerContext(); - randomizer_GetContext().LoadFromHash(curFileSeedHash); - } - } + mDataSelProc = DATASELPROC_SELECT_DATA_OPEN_MOVE; #endif - mDataSelProc = DATASELPROC_SELECT_DATA_OPEN_MOVE; } modoruTxtDispAnmInit(1); @@ -1352,10 +1395,6 @@ void dFile_select_c::selectDataNameMove() { // Custom Proc to allow the player to select the play type and a randomizer seed // Initially copied and expanded upon from dFile_select_c::selectDataNameMove void dFile_select_c::selectDataPlayTypeMove() { - bool isHeaderTxtChange = headerTxtChangeAnm(); - bool isFileRecScale = fileRecScaleAnm2(); - bool isModoruTxtDisp = modoruTxtDispAnm(); - // If we want to start bringing in the name input if (mDusk.mStartNameAnm) { // Only do so when no documents are visible @@ -1383,8 +1422,8 @@ void dFile_select_c::selectDataPlayTypeMove() { } } // If the file select elements have disappeared, setup the play type modal - else if (isHeaderTxtChange == true && isFileRecScale == true && - isModoruTxtDisp == true) + else if (headerTxtChangeAnm() == true && fileRecScaleAnm2() == true && + modoruTxtDispAnm() == true) { // Push our modal for selecting the play type auto& playTypeModal = dusk::ui::push_document(std::make_unique(dusk::ui::Modal::Props{ @@ -1415,7 +1454,7 @@ void dFile_select_c::selectDataPlayTypeMove() { mDusk.mBackToFileSelect = false; mDoAud_seStartMenu(Z2SE_SY_CURSOR_OK); modal.hide(true); - dusk::archi::ArchipelagoContext::GenerateLocalWorldData(); + dusk::ui::BeginArchipelagoConnectionUI(true); }}, }, // If we dismiss this modal, go back to file selection @@ -1442,6 +1481,35 @@ void dFile_select_c::selectDataPlayTypeMove() { playTypeModal.focus(); } } + +void dFile_select_c::menuArchipelagoConnect() { + if (mDusk.mArchipelagoBeginConnect) { + dusk::ui::BeginArchipelagoConnectionUI(); + mDusk.mArchipelagoBeginConnect = false; + mDusk.mPendingRmlCloseFrames = 6; + }else if (mDusk.mArchiStartCloseFile) { + bool isHeaderTxtChange = headerTxtChangeAnm(); + bool isSelDataMove = selectDataMoveAnm(); + bool isKetteiTxtDisp = ketteiTxtDispAnm(); + + if (isHeaderTxtChange && isSelDataMove && isKetteiTxtDisp) { + mDataSelProc = DATASELPROC_SELECT_DATA_OPEN_MOVE; + mDusk.mArchipelagoBeginConnect = false; + mDusk.mArchiStartCloseFile = false; + } + }else if (!dusk::ui::any_document_visible()) { + if (mDusk.mPendingRmlCloseFrames > 0) { + mDusk.mPendingRmlCloseFrames -= 1; + } + if (mDusk.mPendingRmlCloseFrames == 0) { + mDusk.mArchiStartCloseFile = true; + + selectDataMoveAnmInitSet(SelOpenStartFrameTbl[mSelectNum], SelOpenEndFrameTbl[mSelectNum]); + menuMoveAnmInitSet(799, 809); + selectWakuAlpahAnmInit(mSelectNum, 0xff, 0, g_fsHIO.select_box_appear_frames); + } + } +} #endif void dFile_select_c::selectDataOpenEraseMove() { diff --git a/src/dusk/archipelago/archipelago_context.cpp b/src/dusk/archipelago/archipelago_context.cpp index d5d6808a09..c092e5c4ef 100644 --- a/src/dusk/archipelago/archipelago_context.cpp +++ b/src/dusk/archipelago/archipelago_context.cpp @@ -257,13 +257,25 @@ void ArchipelagoContext::GetSeedDirectoryPath(std::filesystem::path& outPath) { } } -void ArchipelagoContext::ConnectToServer() { +bool ArchipelagoContext::IsSeedHashArchipelago(const std::string& seedStr) { + return seedStr.starts_with("AP_"); +} + +bool ArchipelagoContext::IsCurrentSeedHash(const std::string& seedStr) { + return GetArchipelagoSeedName() == seedStr; +} + +bool ArchipelagoContext::ConnectToServer(bool isBlocking) { config::Save(); instance().LoadTempItemInfo(); instance().LoadTempLocationInfo(); + AP_SetLogCallback([](const std::string& msg) { + DuskLog.info("{}", msg); + }); + AP_Init(GetServerIp().c_str(), "Twilight Princess", GetSlotName().c_str(), GetPassword().c_str()); AP_NetworkVersion ver{0, 6,7}; @@ -291,21 +303,29 @@ void ArchipelagoContext::ConnectToServer() { AP_Start(); - if (AP_GetConnectionStatus() == AP_ConnectionStatus::ConnectionRefused) { - DuskLog.warn("Failed to Connect to Archipelago Server."); - return; + // above func spawns a websocket thread, but there isn't really a good way to ensure a connection + // attempt has been made except to wait for that thread to tick once + + if (isBlocking) { + // wait for ws thread to run a frame before checking for status + std::this_thread::sleep_for(std::chrono::seconds(1)); + + while (AP_GetConnectionStatus() == AP_ConnectionStatus::Connecting) + std::this_thread::yield(); + + if (!IsConnected()) { + DuskLog.error("Failed to connect to Archipelago Server!"); + return false; + } } std::thread messageThread = std::thread(MessageThreadFunc); messageThread.detach(); + + return true; } void ArchipelagoContext::DisconnectFromServer() { - if (!IsConnected()) { - DuskLog.warn("Attempted to disconnect from an already disconnected state!"); - return; - } - AP_Shutdown(); } @@ -522,7 +542,7 @@ bool ArchipelagoContext::IsLocationChecked(int locId) { void ArchipelagoContext::SetLocationChecked(int locId, bool collected) { // func was ran before location scouts could be sent out, cache result until scouts return. - if (instance().m_locationItemInfo.empty()) { + if (!IsReceivedLocationScouts()) { instance().m_initLocationCollectState[locId] = collected; return; } @@ -575,6 +595,10 @@ void ArchipelagoContext::UpdateAllLocationState() { } } +bool ArchipelagoContext::IsReceivedLocationScouts() { + return !instance().m_locationItemInfo.empty(); +} + void ArchipelagoContext::RequestAllLocationScout(bool isHint) { std::set locations; // TEMP: apworld has 475 locations with ids in sequential order, so add them all individually to location set diff --git a/src/dusk/archipelago/archipelago_context.hpp b/src/dusk/archipelago/archipelago_context.hpp index 08a5174db3..f0c5daad0b 100644 --- a/src/dusk/archipelago/archipelago_context.hpp +++ b/src/dusk/archipelago/archipelago_context.hpp @@ -74,9 +74,13 @@ namespace dusk::archi static void GetSeedDirectoryPath(std::filesystem::path& outPath); + static bool IsSeedHashArchipelago(const std::string& seedStr); + + static bool IsCurrentSeedHash(const std::string& seedStr); + // Connection Handlers - static void ConnectToServer(); + static bool ConnectToServer(bool isBlocking = false); static void DisconnectFromServer(); @@ -106,6 +110,8 @@ namespace dusk::archi static void UpdateAllLocationState(); + static bool IsReceivedLocationScouts(); + // State Requesters static void RequestAllLocationScout(bool isHint = false); diff --git a/src/dusk/randomizer/randomizer.cmake b/src/dusk/randomizer/randomizer.cmake index 97ca3d1588..1a6107fd93 100644 --- a/src/dusk/randomizer/randomizer.cmake +++ b/src/dusk/randomizer/randomizer.cmake @@ -56,7 +56,7 @@ message(STATUS "randomizer: Fetching APCpp") FetchContent_Declare( APCpp GIT_REPOSITORY https://github.com/CraftyBoss/APCpp.git - GIT_TAG 5091686 + GIT_TAG 3557bbb ) FetchContent_MakeAvailable(yaml-cpp base64pp battery-embed APCpp) diff --git a/src/dusk/ui/archi_connect_modal.cpp b/src/dusk/ui/archi_connect_modal.cpp new file mode 100644 index 0000000000..724781f69a --- /dev/null +++ b/src/dusk/ui/archi_connect_modal.cpp @@ -0,0 +1,166 @@ +#include +#include + +#include + +#include "dusk/logging.h" +#include "dusk/archipelago/archipelago_context.hpp" +#include "m_Do/m_Do_audio.h" + +namespace dusk::ui { + + +static std::atomic connectStatus = ArchiConnectModal::ConnectionStatus::None; + +void CreateSetupConnectionInfoModal(); + +ArchiConnectModal::ArchiConnectModal() : + Modal({ + .title = "Archipelago", + .bodyRml = "Connecting to Server...", + .onDismiss = [this](Modal& modal) { + mDoAud_seStartMenu(kSoundWindowClose); + modal.pop(false); + }, + .icon = "verifying", + }) { + mRoot->SetProperty("white-space", "pre-line"); +} + +void ArchiConnectModal::update() { + Modal::update(); + + auto currentStatus = connectStatus.load(); + + if (currentStatus != mDisplayedStatus) { + mDisplayedStatus = currentStatus; + + if (currentStatus == ConnectionStatus::Success || + currentStatus == ConnectionStatus::Error) + { + if (currentStatus == ConnectionStatus::Success) { + mDoAud_seStartMenu(kSoundSeedGenerateSuccess); + set_icon("celebration"); + set_body("Sucessfully Connected to server!"); + } else { + mDoAud_seStartMenu(kSoundSeedGenerateError); + set_icon("error"); + set_body("Failed to Connect to server."); + } + add_action({ + .label = "OK", + .onPressed = [currentStatus](Modal& modal) { + mDoAud_seStartMenu(kSoundWindowClose); + modal.pop(false); + + // show connection setup modal on failure + if (currentStatus == ConnectionStatus::Error) { + CreateSetupConnectionInfoModal(); + } + } + }); + + // Refocus so that we focus the new button + focus(); + + mDisplayedStatus = ConnectionStatus::Ready; + }else if (currentStatus == ConnectionStatus::Generating) { + set_body("Loading seed data into game..."); + }else if (currentStatus == ConnectionStatus::Disconnecting) { + set_body("Disconnecting from previous server..."); + } + + connectStatus.store(mDisplayedStatus); + } +} + +void HandleArchipelagoConnect() { + // if a connection was already established, disconnect before attempting a new connection. + if (archi::ArchipelagoContext::IsConnected()) { + connectStatus.store(ArchiConnectModal::ConnectionStatus::Disconnecting); + archi::ArchipelagoContext::DisconnectFromServer(); + } + + if (!archi::ArchipelagoContext::ConnectToServer(true)) { + archi::ArchipelagoContext::DisconnectFromServer(); + connectStatus.store(ArchiConnectModal::ConnectionStatus::Error); + return; + } + + connectStatus.store(ArchiConnectModal::ConnectionStatus::Generating); + + while (!archi::ArchipelagoContext::IsReceivedLocationScouts()) { + std::this_thread::yield(); + } + + archi::ArchipelagoContext::GenerateLocalWorldData(); + + connectStatus.store(ArchiConnectModal::ConnectionStatus::Success); +} + +void ConnectAndLoadArchipelago() { + connectStatus.store(ArchiConnectModal::ConnectionStatus::Connecting); + std::thread archiConnectThread(HandleArchipelagoConnect); + archiConnectThread.detach(); + + push_document(std::make_unique()); + + if (auto* doc = top_document()) { + doc->focus(); + } +} + +void CreateSetupConnectionInfoModal() { + auto& doc = push_document(std::make_unique(Modal::Props{ + .title = "Connection Info", + .bodyRml = "", + .actions = { + ModalAction{ + .label = "Connect", + .onPressed = [](Modal& modal) { + auto textModal = dynamic_cast(&modal); + + archi::ArchipelagoContext::SetServerIp(textModal->get_input_text(0)); + archi::ArchipelagoContext::SetPassword(textModal->get_input_text(1)); + archi::ArchipelagoContext::SetSlotName(textModal->get_input_text(2)); + + modal.pop(false); + + ConnectAndLoadArchipelago(); + }, + }, + ModalAction{ + .label = "Cancel", + .onPressed = [](Modal& modal) { + // TODO: cancelling this modal will still progress file select to starting the save, + // which could potentially allow a user to break their save file. + + modal.pop(false); + }, + }, + }, + .icon = "information" + })); + auto inputModal = dynamic_cast(&doc); + + inputModal->add_input_text("Server IP", archi::ArchipelagoContext::GetServerIp()); + inputModal->add_input_text("Password", archi::ArchipelagoContext::GetPassword()); + inputModal->add_input_text("Slot Name", archi::ArchipelagoContext::GetSlotName()); +} + +void BeginArchipelagoConnectionUI(bool forceChangeConnection) { + if (forceChangeConnection) { + CreateSetupConnectionInfoModal(); + return; + } + + // TODO: associate server connection info to save slots + bool hasConnectInfo = (!archi::ArchipelagoContext::GetServerIp().empty() && !archi::ArchipelagoContext::GetSlotName().empty()); + + if (hasConnectInfo) { + ConnectAndLoadArchipelago(); + }else { + CreateSetupConnectionInfoModal(); + } +} +} \ No newline at end of file diff --git a/src/dusk/ui/archi_connect_modal.hpp b/src/dusk/ui/archi_connect_modal.hpp new file mode 100644 index 0000000000..7d36b72a83 --- /dev/null +++ b/src/dusk/ui/archi_connect_modal.hpp @@ -0,0 +1,29 @@ +#pragma once +#include "modal.hpp" + +namespace dusk::ui +{ + + + class ArchiConnectModal : public Modal { + public: + enum class ConnectionStatus { + None, + Ready, + Disconnecting, + Connecting, + Generating, + Success, + Error, + }; + + explicit ArchiConnectModal(); + void update() override; + + private: + ConnectionStatus mDisplayedStatus = ConnectionStatus::None; + }; + + void BeginArchipelagoConnectionUI(bool forceChangeConnection = false); + +} \ No newline at end of file diff --git a/src/dusk/ui/modal.cpp b/src/dusk/ui/modal.cpp index 0a50960a90..71638ff9f0 100644 --- a/src/dusk/ui/modal.cpp +++ b/src/dusk/ui/modal.cpp @@ -133,4 +133,67 @@ void TextInputModal::update() { Document::update(); } +MultiTextInputModal::MultiTextInputModal(Props props) : Modal(props) {} + +void MultiTextInputModal::add_input_text(const Rml::String& label, const Rml::String& startValue) { + auto modalBody = mDialog->QuerySelector(".modal-body"); + + bool isFirst = mInputs.empty(); + + mInputs.push_back(InputEntry {}); + auto& entry = mInputs.back(); + int idx = mInputs.size() - 1; + + entry.text = startValue; + entry.button = std::make_unique(modalBody, StringButton::Props{ + .key = label, + .getValue = [idx, this]{return mInputs[idx].text;}, + .setValue = [idx, this](Rml::String value) { mInputs[idx].text = value; }, + }); + + if (isFirst) + entry.button->start_editing(); +} + +bool MultiTextInputModal::handle_nav_command(Rml::Event& event, NavCommand cmd) { + auto retVal = Modal::handle_nav_command(event, cmd); + if (!retVal) { + if (cmd == NavCommand::Down) { + if (mTextSelIdx < mInputs.size()) { + mTextSelIdx++; + if (mTextSelIdx >= mInputs.size()) { + mButtons[0]->focus(); + }else { + mInputs[mTextSelIdx].button->focus(); + } + }else { + mTextSelIdx = 0; + mInputs[mTextSelIdx].button->focus(); + } + + mDoAud_seStartMenu(kSoundItemFocus); + retVal = true; + }else if (cmd == NavCommand::Up && mTextSelIdx >= 0) { + mTextSelIdx--; + if (mTextSelIdx < 0) { + mTextSelIdx = mInputs.size(); + mButtons[0]->focus(); + }else { + mInputs[mTextSelIdx].button->focus(); + } + + mDoAud_seStartMenu(kSoundItemFocus); + retVal = true; + } + } + + return retVal; +} + +void MultiTextInputModal::update() { + for (const auto& input : mInputs) { + input.button->update(); + } + Modal::update(); +} } // namespace dusk::ui diff --git a/src/dusk/ui/modal.hpp b/src/dusk/ui/modal.hpp index 2a9f31e6ac..600e77bf21 100644 --- a/src/dusk/ui/modal.hpp +++ b/src/dusk/ui/modal.hpp @@ -53,4 +53,27 @@ private: Rml::String mInputText{}; }; +class MultiTextInputModal : public Modal { +public: + explicit MultiTextInputModal(Props props); + + void add_input_text(const Rml::String& label, const Rml::String& startValue = ""); + + Rml::String get_input_text(int idx) {return mInputs[idx].text; } + + void update() override; + +protected: + bool handle_nav_command(Rml::Event& event, NavCommand cmd) override; +private: + struct InputEntry { + std::unique_ptr button; + Rml::String text; + }; + + std::vector mInputs{}; + int mTextSelIdx = 0; +}; + + } // namespace dusk::ui