Refactor command console UI lifecycle

This commit is contained in:
Luke Street
2026-08-20 17:33:13 -06:00
parent d3f10221b8
commit fbe7b57132
10 changed files with 264 additions and 117 deletions
+26 -11
View File
@@ -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%;
+1 -1
View File
@@ -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;
}
+167 -72
View File
@@ -2,9 +2,7 @@
#include <RmlUi/Core.h>
#include <RmlUi/Core/Elements/ElementFormControlInput.h>
#include <SDL3/SDL_keyboard.h>
#include <aurora/rmlui.hpp>
#include <imgui.h>
#include "dusk/settings.h"
@@ -12,13 +10,12 @@
#include <string>
#include <string_view>
#include "fmt/format.h"
#include "ui.hpp"
namespace dusk::ui {
namespace {
static const Rml::String kDocumentSource = R"RML(
const Rml::String kDocumentSource = R"RML(
<rml>
<head>
<link type="text/rcss" href="res/rml/command_console.rcss" />
@@ -32,13 +29,13 @@ static const Rml::String kDocumentSource = R"RML(
</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<Rml::Input::KeyIdentifier>(event.GetParameter<int>("key_identifier", Rml::Input::KI_UNKNOWN));
const auto key = static_cast<Rml::Input::KeyIdentifier>(
event.GetParameter<int>("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) ? "<line class=\"cmd\">" + escape(msg) + "</line>" : "<line>" + escape(msg) + "</line>";
}
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("<line{} style=\"opacity: {:.3f}\">{}</line>"), 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<float>(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
+27 -14
View File
@@ -3,8 +3,9 @@
#include "document.hpp"
#include "dusk/commands.hpp"
#include <chrono>
#include <deque>
#include <string>
#include <vector>
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<OutputLine> mOutputLines;
std::vector<std::string> mMsgHistory;
std::deque<MessageLine> 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
+1
View File
@@ -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();
+1
View File
@@ -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();
+1 -4
View File
@@ -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<Prelaunch>(), true);
}
+36 -14
View File
@@ -69,6 +69,7 @@ void restyle_scope(DocumentScope scope) {
std::deque<Toast> 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<CommandConsole*>(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<CommandConsole*>(find_document(DocumentScope::CommandConsole));
if (sConsoleShortcutHeld) {
return;
}
if (!found) {
push_document(std::make_unique<CommandConsole>(), 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<Document> 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();
+3 -1
View File
@@ -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<Document> 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;
+1
View File
@@ -786,6 +786,7 @@ int game_main(int argc, char* argv[]) {
dusk::ui::push_document(std::make_unique<dusk::ui::Overlay>(), true, true);
dusk::ui::push_document(std::make_unique<dusk::ui::TouchControls>(), false, true);
dusk::ui::push_document(std::make_unique<dusk::ui::MenuBar>(), false);
dusk::ui::push_document(std::make_unique<dusk::ui::CommandConsole>(), 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.