From fbe7b571323db6810e445006567c50081353e550 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Thu, 20 Aug 2026 17:33:13 -0600 Subject: [PATCH] Refactor command console UI lifecycle --- res/rml/command_console.rcss | 37 +++-- src/dusk/commands.cpp | 2 +- src/dusk/ui/command_console.cpp | 239 ++++++++++++++++++++++---------- src/dusk/ui/command_console.hpp | 41 ++++-- src/dusk/ui/document.hpp | 1 + src/dusk/ui/menu_bar.hpp | 1 + src/dusk/ui/prelaunch.cpp | 5 +- src/dusk/ui/ui.cpp | 50 +++++-- src/dusk/ui/ui.hpp | 4 +- src/m_Do/m_Do_main.cpp | 1 + 10 files changed, 264 insertions(+), 117 deletions(-) diff --git a/res/rml/command_console.rcss b/res/rml/command_console.rcss index 909d5148c6..b5378b5d7a 100644 --- a/res/rml/command_console.rcss +++ b/res/rml/command_console.rcss @@ -24,6 +24,7 @@ console { font-family: "Noto Mono"; font-size: 14dp; color: #FFFFFF; + transition: background-color 0.8s linear-in-out; } output { @@ -37,32 +38,46 @@ output { output[open] { height: 480dp; max-height: 480dp; - overflow-y: scroll; + overflow-y: auto; } console:not([open]) { pointer-events: none; } +console:not([open])[fading] { + background-color: rgba(0, 0, 0, 0%); +} + +console[open] { + background-color: rgba(0, 0, 0, 60%); + transition: none; +} + line { display: block; white-space: nowrap; + opacity: 1; + transition: opacity 0.8s linear-in-out; +} + +output:not([open]) line[fading] { + opacity: 0; +} + +output:not([open]) line[expired] { + display: none; +} + +output[open] line { + opacity: 1; + transition: none; } line.cmd { color: #FFD966; } -line.copyable { - color: #80C8FF; - cursor: pointer; -} - -line.copyable:hover { - color: #FFFFFF; - background-color: rgba(255, 255, 255, 12%); -} - console input { display: none; width: 100%; diff --git a/src/dusk/commands.cpp b/src/dusk/commands.cpp index 3077e0bdbd..b953095936 100644 --- a/src/dusk/commands.cpp +++ b/src/dusk/commands.cpp @@ -558,7 +558,7 @@ void runCommand(std::string_view cmdLine, CommandState& state, const CommandOutp } dusk::game_clock::set_sim_rate((float)*hz); } - output(fmt::format(FMT_STRING("Sim rate: {} hz"), (int)dusk::game_clock::get_sim_rate())); + output(fmt::format(FMT_STRING("Sim rate: {:.0f} hz"), dusk::game_clock::get_sim_rate())); return; } diff --git a/src/dusk/ui/command_console.cpp b/src/dusk/ui/command_console.cpp index 3f964b1f0c..0b8d70983f 100644 --- a/src/dusk/ui/command_console.cpp +++ b/src/dusk/ui/command_console.cpp @@ -2,9 +2,7 @@ #include #include -#include #include -#include #include "dusk/settings.h" @@ -12,13 +10,12 @@ #include #include -#include "fmt/format.h" #include "ui.hpp" namespace dusk::ui { namespace { -static const Rml::String kDocumentSource = R"RML( +const Rml::String kDocumentSource = R"RML( @@ -32,13 +29,13 @@ static const Rml::String kDocumentSource = R"RML( )RML"; -static bool isCommand(std::string_view text) { +bool is_command(std::string_view text) { return text.size() >= 2 && text[0] == '>' && text[1] == ' '; } } // namespace -CommandConsole::CommandConsole() : Document(kDocumentSource) { +CommandConsole::CommandConsole() : Document(kDocumentSource, false, DocumentScope::CommandConsole) { mConsole = mDocument ? mDocument->GetElementById("console") : nullptr; mOutput = mDocument ? mDocument->GetElementById("console-output") : nullptr; auto* rawInput = mDocument ? mDocument->GetElementById("console-input") : nullptr; @@ -50,22 +47,35 @@ CommandConsole::CommandConsole() : Document(kDocumentSource) { if (!mInputActive) { return; } - const auto key = static_cast(event.GetParameter("key_identifier", Rml::Input::KI_UNKNOWN)); + const auto key = static_cast( + event.GetParameter("key_identifier", Rml::Input::KI_UNKNOWN)); if (key == Rml::Input::KI_RETURN) { - executeFromInput(); + execute_from_input(); event.StopImmediatePropagation(); } else if (key == Rml::Input::KI_ESCAPE) { - hide(true); + hide(false); event.StopImmediatePropagation(); } else if (key == Rml::Input::KI_UP) { - navigateHistory(-1); + navigate_history(-1); event.StopImmediatePropagation(); } else if (key == Rml::Input::KI_DOWN) { - navigateHistory(+1); + navigate_history(1); + event.StopImmediatePropagation(); + } else if (key == Rml::Input::KI_PRIOR) { + scroll_messages(-1); + event.StopImmediatePropagation(); + } else if (key == Rml::Input::KI_NEXT) { + scroll_messages(1); event.StopImmediatePropagation(); } }, true); + + listen(mOutput, Rml::EventId::Scroll, [this](Rml::Event&) { + const float bottom = + std::max(0.0f, mOutput->GetScrollHeight() - mOutput->GetClientHeight()); + mStickToBottom = mOutput->GetScrollTop() >= bottom - 4.0f; + }); } bool CommandConsole::handle_nav_command(Rml::Event&, NavCommand) { @@ -73,69 +83,48 @@ bool CommandConsole::handle_nav_command(Rml::Event&, NavCommand) { } void CommandConsole::update() { - if (!getSettings().backend.enableAdvancedSettings) { + if (!getSettings().backend.enableAdvancedSettings || is_prelaunch_open()) { + close_input(); + Document::hide(false); return; } - const float dt = std::max(ImGui::GetIO().DeltaTime, 0.0f); - for (auto& line : mOutputLines) { - line.remain -= dt; - } - const auto [first, last] = - std::ranges::remove_if(mOutputLines, [](const OutputLine& l) { return l.remain <= 0.0f; }); - mOutputLines.erase(first, last); - - if (mOutputLines.empty() && !mInputActive) { - Document::hide(mPendingClose); + update_message_lifetimes(); + if (!mInputActive && !has_onscreen_messages()) { + Document::hide(false); return; } - if (mOutput == nullptr) { - return; + if (!Document::visible()) { + Document::show(); } - - Rml::String html; - if (mInputActive) { - for (const auto& msg : mMsgHistory) { - html += isCommand(msg) ? "" + escape(msg) + "" : "" + escape(msg) + ""; - } - mOutput->SetAttribute("open", ""); - } else { - const int total = (int)mOutputLines.size(); - const int startIdx = std::max(0, total - kMaxVisibleLines); - for (int i = startIdx; i < total; ++i) { - const auto& line = mOutputLines[i]; - const float alpha = line.remain < kFadeSeconds ? line.remain / kFadeSeconds : 1.0f; - const Rml::String cls = isCommand(line.text) ? " class=\"cmd\"" : ""; - html += fmt::format(FMT_STRING("{}"), cls, - alpha, escape(line.text)); - } - mOutput->RemoveAttribute("open"); - } - - mOutput->SetInnerRML(html); - - if (mScrollToBottom && mInputActive) { - mOutput->SetScrollTop(1e9f); - mScrollToBottom = false; + if (mInputActive && mStickToBottom && mOutput != nullptr) { + mOutput->SetScrollTop(mOutput->GetScrollHeight() - mOutput->GetClientHeight()); } } void CommandConsole::show() { - if (mDocument != nullptr) { - mDocument->Show(Rml::ModalFlag::None, Rml::FocusFlag::None, Rml::ScrollFlag::None); + if (mInputActive || !getSettings().backend.enableAdvancedSettings || is_prelaunch_open()) { + return; } mInputActive = true; - mScrollToBottom = true; + mHistoryPos = -1; + mStickToBottom = true; if (mConsole != nullptr) { mConsole->SetAttribute("open", ""); } + if (mOutput != nullptr) { + mOutput->SetAttribute("open", ""); + } + if (mInput != nullptr) { + mInput->SetValue(""); + } + Document::show(); focus(); } bool CommandConsole::focus() { - if (mInput != nullptr) { - mInput->SetValue(""); + if (mInputActive && mInput != nullptr) { aurora::rmlui::set_input_type(aurora::rmlui::InputType::Text); return mInput->Focus(true); } @@ -143,6 +132,30 @@ bool CommandConsole::focus() { } void CommandConsole::hide(bool close) { + close_input(); + if (close) { + Document::hide(true); + } else if (!has_onscreen_messages()) { + Document::hide(false); + } + + if (auto* doc = top_document()) { + doc->focus(); + } +} + +bool CommandConsole::visible() const { + return mInputActive && Document::visible(); +} + +bool CommandConsole::active() const { + return mInputActive && Document::active(); +} + +void CommandConsole::close_input() { + if (!mInputActive) { + return; + } mInputActive = false; mHistoryPos = -1; if (mConsole != nullptr) { @@ -150,32 +163,30 @@ void CommandConsole::hide(bool close) { } if (mInput != nullptr) { mInput->SetValue(""); + mInput->Blur(); } - mPendingClose = close; - // Immediately refocus - if (auto* doc = top_document()) { - doc->focus(); + if (mOutput != nullptr) { + mOutput->RemoveAttribute("open"); } } -void CommandConsole::executeFromInput() { +void CommandConsole::execute_from_input() { if (mInput == nullptr) { return; } const Rml::String value = mInput->GetValue(); - hide(true); + hide(false); if (!value.empty()) { - runCommand(value, mState, [this](std::string text) { ConsolePrint(std::move(text)); }); + runCommand(value, mState, [this](std::string text) { append_message(std::move(text)); }); } - mScrollToBottom = true; } -void CommandConsole::navigateHistory(int dir) { +void CommandConsole::navigate_history(int direction) { if (mState.history.empty() || mInput == nullptr) { return; } const int prev = mHistoryPos; - if (dir < 0) { + if (direction < 0) { if (mHistoryPos == -1) { mHistoryPos = (int)mState.history.size() - 1; } else if (mHistoryPos > 0) { @@ -194,16 +205,100 @@ void CommandConsole::navigateHistory(int dir) { } } -void CommandConsole::ConsolePrint(std::string text) { - mMsgHistory.push_back(text); - if ((int)mMsgHistory.size() > kMaxMsgHistory) { - mMsgHistory.erase(mMsgHistory.begin()); +void CommandConsole::scroll_messages(int pages) { + if (mOutput == nullptr) { + return; } - mOutputLines.push_back({std::move(text), kDurationNormal}); - mScrollToBottom = true; - if ((int)mOutputLines.size() > kMaxStoredLines) { - mOutputLines.erase(mOutputLines.begin()); + const float bottom = std::max(0.0f, mOutput->GetScrollHeight() - mOutput->GetClientHeight()); + const float pageHeight = std::max(1.0f, mOutput->GetClientHeight() * 0.9f); + const float scrollTop = + std::clamp(mOutput->GetScrollTop() + static_cast(pages) * pageHeight, 0.0f, bottom); + mOutput->SetScrollTop(scrollTop); + mStickToBottom = scrollTop >= bottom - 4.0f; +} + +void CommandConsole::append_message(std::string text) { + auto* element = append(mOutput, "line"); + if (element != nullptr) { + element->SetClass("cmd", is_command(text)); + append_text(element, text); + } + + const auto now = clock::now(); + mMessages.push_back({ + .text = std::move(text), + .element = element, + .fadeAt = now + kMessageDuration - kFadeDuration, + .hideAt = now + kMessageDuration, + }); + + while (mMessages.size() > kMaxMessageHistory) { + if (auto* oldElement = mMessages.front().element; oldElement != nullptr) { + if (auto* parent = oldElement->GetParentNode()) { + parent->RemoveChild(oldElement); + } + } + mMessages.pop_front(); + } + limit_visible_messages(); + if (mConsole != nullptr) { + mConsole->RemoveAttribute("fading"); + } + + if (getSettings().backend.enableAdvancedSettings && !is_prelaunch_open() && + !Document::visible()) + { + Document::show(); } } +void CommandConsole::limit_visible_messages() { + std::size_t visibleCount = 0; + for (auto it = mMessages.rbegin(); it != mMessages.rend(); ++it) { + if (it->expired) { + continue; + } + if (++visibleCount <= kMaxVisibleLines) { + continue; + } + it->expired = true; + if (it->element != nullptr) { + it->element->SetAttribute("expired", ""); + } + } +} + +void CommandConsole::update_message_lifetimes() { + const auto now = clock::now(); + for (auto& message : mMessages) { + if (!message.fading && now >= message.fadeAt) { + message.fading = true; + if (message.element != nullptr) { + message.element->SetAttribute("fading", ""); + } + } + if (!message.expired && now >= message.hideAt) { + message.expired = true; + if (message.element != nullptr) { + message.element->SetAttribute("expired", ""); + } + } + } + + const bool hasVisibleMessages = has_onscreen_messages(); + const bool hasOpaqueMessages = std::ranges::any_of( + mMessages, [](const auto& message) { return !message.fading && !message.expired; }); + if (mConsole != nullptr) { + if (hasVisibleMessages && !hasOpaqueMessages) { + mConsole->SetAttribute("fading", ""); + } else { + mConsole->RemoveAttribute("fading"); + } + } +} + +bool CommandConsole::has_onscreen_messages() const { + return std::ranges::any_of(mMessages, [](const auto& message) { return !message.expired; }); +} + } // namespace dusk::ui diff --git a/src/dusk/ui/command_console.hpp b/src/dusk/ui/command_console.hpp index b228158f6a..f40b592369 100644 --- a/src/dusk/ui/command_console.hpp +++ b/src/dusk/ui/command_console.hpp @@ -3,8 +3,9 @@ #include "document.hpp" #include "dusk/commands.hpp" +#include +#include #include -#include namespace Rml { class ElementFormControlInput; @@ -20,36 +21,48 @@ public: void show() override; bool focus() override; void hide(bool close) override; + bool visible() const override; + bool active() const override; + bool permanent() const override { return true; } + + bool input_active() const { return mInputActive; } private: - struct OutputLine { + struct MessageLine { std::string text; - float remain; + Rml::Element* element = nullptr; + clock::time_point fadeAt; + clock::time_point hideAt; + bool fading = false; + bool expired = false; }; - static constexpr float kDurationNormal = 6.0f; - static constexpr float kFadeSeconds = 0.8f; - static constexpr int kMaxStoredLines = 64; - static constexpr int kMaxVisibleLines = 24; - static constexpr int kMaxMsgHistory = 500; + static constexpr auto kMessageDuration = std::chrono::seconds{6}; + static constexpr auto kFadeDuration = std::chrono::milliseconds{800}; + static constexpr std::size_t kMaxVisibleLines = 24; + static constexpr std::size_t kMaxMessageHistory = 500; Rml::Element* mConsole = nullptr; Rml::Element* mOutput = nullptr; Rml::ElementFormControlInput* mInput = nullptr; - std::vector mOutputLines; - std::vector mMsgHistory; + std::deque mMessages; int mHistoryPos = -1; bool mInputActive = false; - bool mScrollToBottom = false; + bool mStickToBottom = true; CommandState mState; bool handle_nav_command(Rml::Event& event, NavCommand cmd) override; - void ConsolePrint(std::string text); - void executeFromInput(); - void navigateHistory(int dir); + void append_message(std::string text); + void close_input(); + void execute_from_input(); + bool has_onscreen_messages() const; + void limit_visible_messages(); + void navigate_history(int direction); + void scroll_messages(int pages); + void update_message_lifetimes(); }; } // namespace dusk::ui diff --git a/src/dusk/ui/document.hpp b/src/dusk/ui/document.hpp index 652b88006e..99bde20c18 100644 --- a/src/dusk/ui/document.hpp +++ b/src/dusk/ui/document.hpp @@ -23,6 +23,7 @@ public: bool has_focus() const; virtual bool visible() const; virtual bool active() const; + virtual bool permanent() const { return false; } virtual bool obscures_game() const { return false; } virtual void cover() { mWasVisible = visible(); diff --git a/src/dusk/ui/menu_bar.hpp b/src/dusk/ui/menu_bar.hpp index 44b4ee77cd..0d33a3f1f7 100644 --- a/src/dusk/ui/menu_bar.hpp +++ b/src/dusk/ui/menu_bar.hpp @@ -20,6 +20,7 @@ public: void update() override; bool focus() override; bool visible() const override; + bool permanent() const override { return true; } static void refresh_tabs(); diff --git a/src/dusk/ui/prelaunch.cpp b/src/dusk/ui/prelaunch.cpp index c1973587b1..193eaacf0d 100644 --- a/src/dusk/ui/prelaunch.cpp +++ b/src/dusk/ui/prelaunch.cpp @@ -1192,10 +1192,7 @@ void Prelaunch::update() { } void return_to_prelaunch() noexcept { - close_documents_except(DocumentScope::MenuBar); - if (auto* menuBar = find_document(DocumentScope::MenuBar)) { - menuBar->force_hide(false); - } + close_all_documents(); push_document(std::make_unique(), true); } diff --git a/src/dusk/ui/ui.cpp b/src/dusk/ui/ui.cpp index e4dd0ef6a2..35fa6833dd 100644 --- a/src/dusk/ui/ui.cpp +++ b/src/dusk/ui/ui.cpp @@ -69,6 +69,7 @@ void restyle_scope(DocumentScope scope) { std::deque sToasts; bool sMenuNotificationRequested = false; +bool sConsoleShortcutHeld = false; // Sometimes gamepads can connect and disconnect quickly, especially during // connection negotiation. In this case, we'll receive an _ADDED event for a @@ -107,6 +108,7 @@ void shutdown() noexcept { sDocumentStack.clear(); sPassiveDocuments.clear(); sConnectedGamepads.clear(); + sConsoleShortcutHeld = false; input::reset_input_state(); input::release_input_block(); sInitialized = false; @@ -216,20 +218,29 @@ void handle_event(const SDL_Event& event) noexcept { } sConnectedGamepads.erase(event.gdevice.which); } - input::handle_event(event); - // TODO: don't overlap with PAD bindings? - if (event.type == SDL_EVENT_KEY_DOWN && event.key.key == SDLK_SLASH) { - bool found = false; - for (auto& doc : sDocumentStack) { - if (auto* console = dynamic_cast(doc.get())) { - console->show(); - found = true; - } + if (event.type == SDL_EVENT_WINDOW_FOCUS_LOST) { + sConsoleShortcutHeld = false; + } + if (event.type == SDL_EVENT_KEY_UP && event.key.key == SDLK_SLASH && sConsoleShortcutHeld) { + sConsoleShortcutHeld = false; + return; + } + if (event.type == SDL_EVENT_KEY_DOWN && event.key.key == SDLK_SLASH && + getSettings().backend.enableAdvancedSettings) + { + auto* console = static_cast(find_document(DocumentScope::CommandConsole)); + if (sConsoleShortcutHeld) { + return; } - if (!found) { - push_document(std::make_unique(), true, false); + if (console != nullptr && !console->input_active() && !event.key.repeat) { + sConsoleShortcutHeld = true; + bring_document_to_front(*console); + console->show(); + input::sync_input_block(); + return; } } + input::handle_event(event); } bool register_scoped_styles(DocumentScope scope, std::string id, const std::string& rcss) noexcept { @@ -274,6 +285,17 @@ Document& push_document(std::unique_ptr doc, bool show, bool passive) return ret; } +void bring_document_to_front(Document& doc) noexcept { + const auto it = std::ranges::find_if( + sDocumentStack, [&doc](const auto& entry) { return entry.get() == &doc; }); + if (it == sDocumentStack.end() || std::next(it) == sDocumentStack.end()) { + return; + } + auto entry = std::move(*it); + sDocumentStack.erase(it); + sDocumentStack.push_back(std::move(entry)); +} + void uncover_top_document() noexcept { if (auto* doc = top_document()) { doc->uncover(); @@ -290,10 +312,10 @@ Document* find_document(DocumentScope scope) noexcept { return nullptr; } -void close_documents_except(DocumentScope scope) noexcept { +void close_all_documents() noexcept { for (auto& doc : sDocumentStack) { - if (!doc->closed() && doc->scope() != scope) { - doc->force_hide(true); + if (!doc->closed()) { + doc->force_hide(!doc->permanent()); } } input::sync_input_block(); diff --git a/src/dusk/ui/ui.hpp b/src/dusk/ui/ui.hpp index b0af9ca5a3..a1b4b191ab 100644 --- a/src/dusk/ui/ui.hpp +++ b/src/dusk/ui/ui.hpp @@ -19,6 +19,7 @@ using clock = std::chrono::steady_clock; enum class DocumentScope : u8 { None, + CommandConsole, Prelaunch, Window, MenuBar, @@ -87,12 +88,13 @@ void update() noexcept; Document& push_document( std::unique_ptr doc, bool show = true, bool passive = false) noexcept; +void bring_document_to_front(Document& doc) noexcept; bool register_scoped_styles(DocumentScope scope, std::string id, const std::string& rcss) noexcept; void unregister_scoped_styles(DocumentScope scope, std::string_view id) noexcept; void apply_scoped_styles(Document& doc) noexcept; void uncover_top_document() noexcept; Document* find_document(DocumentScope scope) noexcept; -void close_documents_except(DocumentScope scope) noexcept; +void close_all_documents() noexcept; bool any_document_visible() noexcept; bool is_prelaunch_open() noexcept; bool game_obscured_below(const Document& doc) noexcept; diff --git a/src/m_Do/m_Do_main.cpp b/src/m_Do/m_Do_main.cpp index 2453a260d8..2813e6a572 100644 --- a/src/m_Do/m_Do_main.cpp +++ b/src/m_Do/m_Do_main.cpp @@ -786,6 +786,7 @@ int game_main(int argc, char* argv[]) { dusk::ui::push_document(std::make_unique(), true, true); dusk::ui::push_document(std::make_unique(), false, true); dusk::ui::push_document(std::make_unique(), false); + dusk::ui::push_document(std::make_unique(), false); // Invalidate a bad saved isoPath so that Dusklight can't get blocked from starting up. // This is only a metadata check; full hash verification is handled by the prelaunch UI.