Mods manager UI & logs viewer

This commit is contained in:
Luke Street
2026-07-08 17:51:13 -06:00
parent 36635f8c62
commit bc11bf3563
35 changed files with 1709 additions and 176 deletions
+301
View File
@@ -0,0 +1,301 @@
#include "logs_window.hpp"
#include <array>
#include <ctime>
#include <SDL3/SDL_timer.h>
#include <fmt/format.h>
#include "pane.hpp"
namespace dusk::ui {
namespace {
const char* level_name(LogLevel level) {
switch (level) {
case LOG_LEVEL_TRACE:
return "Trace";
case LOG_LEVEL_DEBUG:
return "Debug";
case LOG_LEVEL_INFO:
return "Info";
case LOG_LEVEL_WARN:
return "Warn";
case LOG_LEVEL_ERROR:
return "Error";
}
return "?";
}
const char* level_logger_name(LogLevel level) {
switch (level) {
case LOG_LEVEL_TRACE:
return "TRACE";
case LOG_LEVEL_DEBUG:
return "DEBUG";
case LOG_LEVEL_INFO:
return "INFO";
case LOG_LEVEL_WARN:
return "WARNING";
case LOG_LEVEL_ERROR:
return "ERROR";
}
return "?";
}
const char* level_class(LogLevel level) {
switch (level) {
case LOG_LEVEL_TRACE:
return "lvl-trace";
case LOG_LEVEL_DEBUG:
return "lvl-debug";
case LOG_LEVEL_INFO:
return "lvl-info";
case LOG_LEVEL_WARN:
return "lvl-warn";
case LOG_LEVEL_ERROR:
return "lvl-error";
}
return "lvl-info";
}
std::string format_time(int64_t timeMs) {
const auto seconds = static_cast<std::time_t>(timeMs / 1000);
std::tm localTime{};
#if _WIN32
localtime_s(&localTime, &seconds);
#else
localtime_r(&seconds, &localTime);
#endif
std::array<char, 16> buffer{};
std::strftime(buffer.data(), buffer.size(), "%H:%M:%S", &localTime);
return fmt::format("{}.{:03}", buffer.data(), timeMs % 1000);
}
void append_text(Rml::ElementDocument* doc, Rml::Element* parent, const Rml::String& text) {
parent->AppendChild(doc->CreateTextNode(text));
}
Rml::Element* append_span(Rml::ElementDocument* doc, Rml::Element* parent, const char* className,
const Rml::String& text) {
auto span = doc->CreateElement("span");
span->SetClass(className, true);
append_text(doc, span.get(), text);
return parent->AppendChild(std::move(span));
}
} // namespace
LogsWindow::LogsWindow(std::string modFilter)
: Window{Props{.tabBar = false, .styleSheets = {"res/rml/logs.rcss"}}},
mModFilter{std::move(modFilter)} {
mRoot->SetClass("logs", true);
set_content([this](Rml::Element* content) { build_content(content); });
}
void LogsWindow::build_content(Rml::Element* content) {
auto* toolbar = append(content, "div");
toolbar->SetClass("log-toolbar", true);
auto* title = append(toolbar, "div");
title->SetClass("log-title", true);
title->SetInnerRML("Logs");
auto* modLabel = append(toolbar, "div");
modLabel->SetClass("log-title-mod", true);
modLabel->SetInnerRML(mModFilter.empty() ? "All mods" : fmt::format("{}", escape(mModFilter)));
append(toolbar, "div")->SetClass("log-toolbar-spacer", true);
for (const LogLevel level :
{LOG_LEVEL_TRACE, LOG_LEVEL_DEBUG, LOG_LEVEL_INFO, LOG_LEVEL_WARN, LOG_LEVEL_ERROR})
{
add_child<ControlledButton>(toolbar,
ControlledButton::Props{
.text = level_name(level),
.isSelected = [this, level] { return mMinLevel <= level; },
})
.on_pressed([this, level] {
mMinLevel = level;
rebuild_lines();
});
}
append(toolbar, "div")->SetClass("log-toolbar-spacer", true);
add_child<Button>(toolbar, "Copy").on_pressed([this] { copy_to_clipboard(); });
add_child<Button>(toolbar, "Clear").on_pressed([this] {
mods::log::clear();
rebuild_lines();
});
auto& pane = add_child<Pane>(content, Pane::Type::Uncontrolled);
pane.root()->SetClass("log-view", true);
mScrollElem = pane.root();
mLinesElem = append(pane.root(), "div");
mLinesElem->SetClass("log-lines", true);
pane.finalize();
listen(mScrollElem, Rml::EventId::Scroll, [this](Rml::Event&) {
const float bottom = mScrollElem->GetScrollHeight() - mScrollElem->GetClientHeight();
mStickToBottom = mScrollElem->GetScrollTop() >= bottom - 4.0f;
});
rebuild_lines();
}
void LogsWindow::update() {
Window::update();
if (mLinesElem == nullptr) {
return;
}
const Uint64 perfFreq = SDL_GetPerformanceFrequency();
const Uint64 now = SDL_GetPerformanceCounter();
// Limit refreshes to ~8 per second
const bool refresh =
perfFreq == 0 || mLastRefresh == 0 ||
static_cast<double>(now - mLastRefresh) >= 0.125 * static_cast<double>(perfFreq);
if (refresh) {
mLastRefresh = now;
refresh_lines();
}
// Applied every frame: layout of freshly appended lines is deferred, so a
// single post-append scroll would land short of the real bottom.
if (mStickToBottom && mScrollElem != nullptr) {
mScrollElem->SetScrollTop(mScrollElem->GetScrollHeight() - mScrollElem->GetClientHeight());
}
update_visible_window();
}
// Mark items fully outside the scroll view as `visibility: hidden;`.
// They retain their layout, but stops RmlUi from trying to render them.
void LogsWindow::update_visible_window() {
const float viewTop = mScrollElem->GetAbsoluteOffset(Rml::BoxArea::Border).y;
const float viewHeight = mScrollElem->GetClientHeight();
const int count = mLinesElem->GetNumChildren();
for (int i = 0; i < count && i < static_cast<int>(mLines.size()); ++i) {
auto* elem = mLinesElem->GetChild(i);
const float top = elem->GetAbsoluteOffset(Rml::BoxArea::Border).y - viewTop;
const bool shown = top + elem->GetOffsetHeight() >= -viewHeight && top <= viewHeight * 2.0f;
if (shown != mLines[i].shown) {
mLines[i].shown = shown;
if (shown) {
elem->RemoveProperty("visibility");
} else {
elem->SetProperty("visibility", "hidden");
}
}
}
}
void LogsWindow::refresh_lines() {
mScratch.clear();
const auto [firstSeq, nextSeq] = mods::log::copy_since(mNextSeq, mScratch);
mNextSeq = nextSeq;
// Drop displayed lines that fell out of the buffer (ring wrap or clear)
while (!mLines.empty() && mLines.front().seq < firstSeq) {
if (auto* first = mLinesElem->GetFirstChild()) {
mLinesElem->RemoveChild(first);
}
mLines.pop_front();
}
if (mScratch.empty()) {
return;
}
for (const auto& line : mScratch) {
if (line.modIndex >= mModIds.size()) {
mModIds = mods::log::ids();
break;
}
}
for (const auto& line : mScratch) {
if (!line_visible(line)) {
continue;
}
append_log_line(line);
mLines.push_back({.seq = line.seq});
}
}
void LogsWindow::rebuild_lines() {
if (mLinesElem == nullptr) {
return;
}
mModIds = mods::log::ids();
mScratch.clear();
const auto [_, nextSeq] = mods::log::copy_since(0, mScratch);
mNextSeq = nextSeq;
mLines.clear();
while (auto* child = mLinesElem->GetFirstChild()) {
mLinesElem->RemoveChild(child);
}
for (const auto& line : mScratch) {
if (!line_visible(line)) {
continue;
}
append_log_line(line);
mLines.push_back({.seq = line.seq});
}
mStickToBottom = true;
}
bool LogsWindow::line_visible(const mods::log::Line& line) const {
if (line.level < mMinLevel) {
return false;
}
if (mModFilter.empty()) {
return true;
}
return line.modIndex < mModIds.size() && mModIds[line.modIndex] == mModFilter;
}
Rml::Element* LogsWindow::append_log_line(const mods::log::Line& line) {
std::string_view modId;
if (line.source == mods::log::Source::Loader) {
modId = "loader";
} else if (line.modIndex < mModIds.size()) {
modId = std::string_view{mModIds[line.modIndex]};
} else {
modId = "?";
}
auto elem = mDocument->CreateElement("div");
elem->SetClass("log-line", true);
elem->SetClass(level_class(line.level), true);
constexpr const char* kNbsp = "\xc2\xa0";
append_span(mDocument, elem.get(), "log-time", format_time(line.timeMs));
append_text(mDocument, elem.get(), kNbsp);
append_span(mDocument, elem.get(), "log-mod", fmt::format("[{}]", modId));
append_text(mDocument, elem.get(), kNbsp);
append_span(mDocument, elem.get(), "log-msg", line.message);
return mLinesElem->AppendChild(std::move(elem));
}
void LogsWindow::copy_to_clipboard() {
mModIds = mods::log::ids();
std::vector<mods::log::Line> lines;
mods::log::copy_since(0, lines);
std::string text;
for (const auto& line : lines) {
if (!line_visible(line)) {
continue;
}
const std::string_view modId =
line.modIndex < mModIds.size() ? std::string_view{mModIds[line.modIndex]} : "?";
text += fmt::format("{} [{}] [{}] {}\n", format_time(line.timeMs),
level_logger_name(line.level), modId, line.message);
}
Rml::GetSystemInterface()->SetClipboardText(text);
push_toast({.content = "Copied to clipboard", .duration = std::chrono::seconds(2)});
}
} // namespace dusk::ui
+44
View File
@@ -0,0 +1,44 @@
#pragma once
#include "window.hpp"
#include <deque>
#include <string>
#include <vector>
#include "dusk/mods/log_buffer.hpp"
namespace dusk::ui {
class LogsWindow : public Window {
public:
explicit LogsWindow(std::string modFilter = {});
void update() override;
private:
struct DisplayLine {
uint64_t seq = 0;
bool shown = true;
};
void build_content(Rml::Element* content);
void rebuild_lines();
void refresh_lines();
void update_visible_window();
bool line_visible(const mods::log::Line& line) const;
Rml::Element* append_log_line(const mods::log::Line& line);
void copy_to_clipboard();
std::string mModFilter;
LogLevel mMinLevel = LOG_LEVEL_DEBUG;
std::vector<std::string> mModIds;
uint64_t mNextSeq = 0;
std::vector<mods::log::Line> mScratch;
std::deque<DisplayLine> mLines;
Rml::Element* mLinesElem = nullptr;
Rml::Element* mScrollElem = nullptr;
bool mStickToBottom = true;
Uint64 mLastRefresh = 0;
};
} // namespace dusk::ui
+16 -2
View File
@@ -7,15 +7,16 @@
#include "achievements.hpp"
#include "aurora/rmlui.hpp"
#include "dusk/speedrun.h"
#include "dusk/livesplit.h"
#include "dusk/main.h"
#include "dusk/settings.h"
#include "dusk/speedrun.h"
#include "editor.hpp"
#include "f_pc/f_pc_manager.h"
#include "f_pc/f_pc_name.h"
#include "imgui.h"
#include "modal.hpp"
#include "mods_window.hpp"
#include "settings.hpp"
#include "ui.hpp"
#include "warp.hpp"
@@ -58,7 +59,7 @@ MenuBar::MenuBar() : Document(kDocumentSource), mRoot(mDocument->GetElementById(
}
mTabBar->add_tab("Achievements", [this] { push(std::make_unique<AchievementsWindow>()); });
mTabBar->add_tab("Mods", [this] { push(std::make_unique<ModsWindow>()); });
mTabBar->add_tab("Reset", [this] {
mTabBar->set_active_tab(-1);
@@ -229,4 +230,17 @@ bool MenuBar::focus() {
return mTabBar->focus();
}
void MenuBar::rebuild() {
for (auto& doc : get_document_stack()) {
if (auto* menuBar = dynamic_cast<MenuBar*>(doc.get())) {
const bool wasVisible = menuBar->visible();
doc = std::make_unique<MenuBar>();
if (wasVisible) {
doc->show();
}
break;
}
}
}
} // namespace dusk::ui
+2
View File
@@ -21,6 +21,8 @@ public:
bool focus() override;
bool visible() const override;
static void rebuild();
protected:
bool handle_nav_command(Rml::Event& event, NavCommand cmd) override;
+210
View File
@@ -0,0 +1,210 @@
#include "mod_texture_provider.hpp"
#include "dusk/mod_loader.hpp"
#include <fmt/format.h>
namespace dusk::ui {
std::string mod_image_source(const mods::LoadedMod& mod, std::string_view bundlePath) {
return fmt::format("mod://{}/{}?rev={}", mod.metadata.id, bundlePath, mod.cacheGeneration);
}
} // namespace dusk::ui
#ifdef AURORA_ENABLE_RMLUI
#include <SDL3/SDL_iostream.h>
#include <SDL3/SDL_surface.h>
#include <aurora/lib/logging.hpp>
#include <aurora/rmlui.hpp>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <memory>
#include <optional>
#include <span>
#include <stdexcept>
#include <unordered_map>
#include <vector>
#include "dusk/mods/loader/loader.hpp"
namespace dusk::ui {
namespace {
aurora::Module Log{"dusk::ui::modTexture"};
constexpr std::string_view kScheme = "mod";
constexpr std::string_view kSourcePrefix = "mod://";
constexpr size_t kMaxCachedImages = 64;
constexpr size_t kMaxImageFileSize = 16 * 1024 * 1024;
constexpr uint32_t kMaxImageDimension = 4096;
struct CachedImage {
std::vector<uint8_t> pixels;
uint32_t width = 0;
uint32_t height = 0;
};
std::unordered_map<std::string, CachedImage>& image_cache() {
static auto* cache = new std::unordered_map<std::string, CachedImage>();
return *cache;
}
std::string_view strip_query(std::string_view path) noexcept {
const auto queryPos = path.find_first_of("?#");
if (queryPos != std::string_view::npos) {
path = path.substr(0, queryPos);
}
return path;
}
std::optional<CachedImage> decode_png(std::span<const uint8_t> data, std::string_view source) {
SDL_IOStream* stream = SDL_IOFromConstMem(data.data(), data.size());
if (stream == nullptr) {
Log.warn("Failed to open image stream for '{}': {}", source, SDL_GetError());
return std::nullopt;
}
SDL_Surface* loadedSurface = SDL_LoadPNG_IO(stream, true);
if (loadedSurface == nullptr) {
Log.warn("Failed to decode image '{}': {}", source, SDL_GetError());
return std::nullopt;
}
SDL_Surface* rgbaSurface = SDL_ConvertSurface(loadedSurface, SDL_PIXELFORMAT_RGBA32);
SDL_DestroySurface(loadedSurface);
if (rgbaSurface == nullptr) {
Log.warn("Failed to convert image '{}': {}", source, SDL_GetError());
return std::nullopt;
}
const auto width = static_cast<uint32_t>(rgbaSurface->w);
const auto height = static_cast<uint32_t>(rgbaSurface->h);
if (width == 0 || height == 0 || width > kMaxImageDimension || height > kMaxImageDimension) {
Log.warn("Image '{}' has unsupported dimensions {}x{}", source, width, height);
SDL_DestroySurface(rgbaSurface);
return std::nullopt;
}
const size_t rowSize = static_cast<size_t>(width) * 4;
CachedImage image{
.pixels = std::vector<uint8_t>(rowSize * height),
.width = width,
.height = height,
};
for (uint32_t row = 0; row < height; ++row) {
const auto* src = static_cast<const uint8_t*>(rgbaSurface->pixels) +
static_cast<size_t>(row) * static_cast<size_t>(rgbaSurface->pitch);
auto* dst = image.pixels.data() + static_cast<size_t>(row) * rowSize;
std::memcpy(dst, src, rowSize);
// Convert to premultiplied alpha for correct compositing.
for (size_t col = 0; col < rowSize; col += 4) {
const uint8_t alpha = dst[col + 3];
for (size_t channel = 0; channel < 3; ++channel) {
dst[col + channel] = static_cast<uint8_t>(
(static_cast<uint32_t>(dst[col + channel]) * static_cast<uint32_t>(alpha)) /
255);
}
}
}
SDL_DestroySurface(rgbaSurface);
return image;
}
std::optional<CachedImage> load_mod_image(std::string_view idAndPath, std::string_view source) {
const auto slash = idAndPath.find('/');
if (slash == std::string_view::npos || slash == 0 || slash + 1 >= idAndPath.size()) {
Log.warn("Malformed mod image source '{}'", source);
return std::nullopt;
}
const std::string modId{idAndPath.substr(0, slash)};
const std::string path{idAndPath.substr(slash + 1)};
if (!mods::is_safe_resource_path(path)) {
Log.warn("Unsafe path in mod image source '{}'", source);
return std::nullopt;
}
std::shared_ptr<mods::ModBundle> bundle;
for (const auto& mod : mods::ModLoader::instance().mods()) {
if (mod.metadata.id == modId) {
bundle = mod.bundle;
break;
}
}
if (bundle == nullptr) {
Log.warn("Unknown mod in image source '{}'", source);
return std::nullopt;
}
std::vector<u8> data;
try {
if (bundle->getFileSize(path) > kMaxImageFileSize) {
Log.warn("Image '{}' exceeds the {} MiB limit", source, kMaxImageFileSize >> 20);
return std::nullopt;
}
data = bundle->readFile(path);
} catch (const std::runtime_error& e) {
Log.warn("Failed to read image '{}': {}", source, e.what());
return std::nullopt;
}
return decode_png(std::span{data.data(), data.size()}, source);
}
std::optional<aurora::rmlui::RuntimeTexture> mod_texture_provider(std::string_view source) {
if (!source.starts_with(kSourcePrefix)) {
return std::nullopt;
}
auto& cache = image_cache();
const std::string key{source};
auto it = cache.find(key);
if (it == cache.end()) {
auto image = load_mod_image(strip_query(source.substr(kSourcePrefix.size())), source);
if (!image) {
return std::nullopt;
}
if (cache.size() >= kMaxCachedImages) {
cache.erase(cache.begin());
}
it = cache.emplace(key, std::move(*image)).first;
}
const auto& image = it->second;
return aurora::rmlui::RuntimeTexture{
.width = image.width,
.height = image.height,
.rgba8 =
std::span{reinterpret_cast<const std::byte*>(image.pixels.data()), image.pixels.size()},
.premultipliedAlpha = true,
};
}
} // namespace
void register_mod_texture_provider() noexcept {
aurora::rmlui::register_texture_provider(std::string{kScheme}, mod_texture_provider);
}
void unregister_mod_texture_provider() noexcept {
aurora::rmlui::unregister_texture_provider(kScheme);
image_cache().clear();
}
} // namespace dusk::ui
#else
namespace dusk::ui {
void register_mod_texture_provider() noexcept {}
void unregister_mod_texture_provider() noexcept {}
} // namespace dusk::ui
#endif
+19
View File
@@ -0,0 +1,19 @@
#pragma once
#include <string>
#include <string_view>
namespace dusk::mods {
struct LoadedMod;
} // namespace dusk::mods
namespace dusk::ui {
// Serves PNG images out of mod bundles to RmlUi via the mod:// texture provider scheme.
// Sources embed the mod's cacheGeneration so reloads bust RmlUi's texture cache.
std::string mod_image_source(const mods::LoadedMod& mod, std::string_view bundlePath);
void register_mod_texture_provider() noexcept;
void unregister_mod_texture_provider() noexcept;
} // namespace dusk::ui
+325
View File
@@ -0,0 +1,325 @@
#include "mods_window.hpp"
#include "dusk/mod_loader.hpp"
#include "fmt/format.h"
#include "logs_window.hpp"
#include "mod_texture_provider.hpp"
#include "pane.hpp"
#include "Z2AudioLib/Z2SeMgr.h"
#include "m_Do/m_Do_audio.h"
#include <memory>
#include <string>
#include <string_view>
namespace dusk::ui {
namespace {
struct ModStatus {
const char* badgeClass = "";
const char* text = "";
};
bool mod_enabled(const mods::LoadedMod& mod) {
return mod.cvarIsEnabled != nullptr && mod.cvarIsEnabled->getValue();
}
ModStatus mod_status(const mods::LoadedMod& mod) {
if (mod.loadFailed) {
return {"failed", "Failed"};
}
if (mod.active) {
return {"active", "Active"};
}
if (mod.suspendedByProvider) {
return {"suspended", "Suspended"};
}
return {"", "Disabled"};
}
// Truncates to at most maxBytes without splitting a UTF-8 sequence.
std::string snippet(std::string_view text, size_t maxBytes) {
if (text.size() <= maxBytes) {
return std::string{text};
}
size_t end = maxBytes;
while (end > 0 && (static_cast<unsigned char>(text[end]) & 0xC0) == 0x80) {
--end;
}
return std::string{text.substr(0, end)} + "...";
}
class ModListEntry : public FluentComponent<ModListEntry> {
public:
ModListEntry(Rml::Element* parent, const mods::LoadedMod& mod)
: FluentComponent{append(parent, "mod-entry")} {
Rml::String iconRml;
if (!mod.metadata.iconPath.empty()) {
iconRml = fmt::format(R"(<img class="mod-icon" src="{}"/>)",
mod_image_source(mod, mod.metadata.iconPath));
} else {
iconRml = R"(<icon class="mod-icon placeholder"/>)";
}
const auto status = mod_status(mod);
mRoot->SetInnerRML(fmt::format(
R"({})"
R"(<div class="mod-entry-info">)"
R"(<div class="mod-entry-name"><span class="mod-entry-name-text">{}</span>)"
R"(<span class="mod-entry-version">v{}</span></div>)"
R"(<div class="mod-entry-sub">{} - <span class="mod-entry-status {}">{}</span></div>)"
R"(<div class="mod-entry-desc">{}</div>)"
R"(</div>)",
iconRml, escape(mod.metadata.name), escape(mod.metadata.version),
escape(mod.metadata.author), status.badgeClass, status.text,
escape(snippet(mod.metadata.description, 90))));
mRoot->SetClass("inactive", !mod.active);
mRoot->SetClass("failed", mod.loadFailed);
on_nav_command([this](Rml::Event&, NavCommand cmd) {
if (cmd == NavCommand::Confirm) {
mRoot->DispatchEvent(Rml::EventId::Submit, {});
return true;
}
return false;
});
}
};
class ModDetailHeader : public FluentComponent<ModDetailHeader> {
public:
ModDetailHeader(
Rml::Element* parent, const mods::LoadedMod& mod, std::function<void()> onShowLogs)
: FluentComponent{append(parent, "mod-header")} {
const bool hasBanner = !mod.metadata.bannerPath.empty();
mRoot->SetClass(hasBanner ? "has-banner" : "no-banner", true);
if (hasBanner) {
mRoot->SetProperty("decorator", fmt::format(R"(image("{}" cover center top))",
mod_image_source(mod, mod.metadata.bannerPath)));
}
auto* actions = append(mRoot, "div");
actions->SetClass("mod-actions", true);
const std::string modId = mod.metadata.id;
if (mod_enabled(mod)) {
if (!mod.inPlace) {
make_button(actions, "Reload").on_pressed([modId] {
mods::ModLoader::instance().request_reload(modId);
});
}
make_button(actions, "Disable").on_pressed([modId] {
mods::ModLoader::instance().request_disable(modId);
});
} else {
make_button(actions, "Enable").on_pressed([modId] {
mods::ModLoader::instance().request_enable(modId);
});
}
make_button(actions, "Logs").on_pressed(std::move(onShowLogs));
listen(Rml::EventId::Keydown, [this](Rml::Event& event) {
const auto cmd = map_nav_event(event);
if (cmd != NavCommand::Left && cmd != NavCommand::Right) {
return;
}
int index = -1;
for (int i = 0; i < static_cast<int>(mButtons.size()); ++i) {
if (mButtons[i]->contains(event.GetTargetElement())) {
index = i;
break;
}
}
if (index == -1) {
return;
}
const int next = index + (cmd == NavCommand::Right ? 1 : -1);
if (next >= 0 && next < static_cast<int>(mButtons.size()) && mButtons[next]->focus()) {
mDoAud_seStartMenu(kSoundItemFocus);
event.StopPropagation();
}
});
}
bool focus() override {
for (auto* button : mButtons) {
if (button->focus()) {
return true;
}
}
return false;
}
private:
Button& make_button(Rml::Element* parent, Rml::String text) {
auto button = std::make_unique<Button>(parent, std::move(text));
Button& ref = *button;
mChildren.emplace_back(std::move(button));
mButtons.push_back(&ref);
return ref;
}
std::vector<Button*> mButtons;
};
} // namespace
ModsWindow::ModsWindow() : Window{Props{.tabBar = false, .styleSheets = {"res/rml/mods.rcss"}}} {
mRoot->SetClass("mods", true);
for (auto& trackedMod : mods::ModLoader::instance().mods()) {
mSnapshot.push_back({
.mod = &trackedMod,
.active = trackedMod.active,
.loadFailed = trackedMod.loadFailed,
.enabled = mod_enabled(trackedMod),
.suspended = trackedMod.suspendedByProvider,
.cacheGeneration = trackedMod.cacheGeneration,
});
}
set_content([this](Rml::Element* content) { build_content(content); });
}
void ModsWindow::build_content(Rml::Element* content) {
mEntries.clear();
mEntryMods.clear();
auto& listPane = add_child<Pane>(content, Pane::Type::Controlled);
listPane.root()->SetClass("mod-list", true);
auto& detailPane = add_child<Pane>(content, Pane::Type::Uncontrolled);
detailPane.root()->SetClass("mod-detail", true);
if (mods::ModLoader::instance().mods().empty()) {
listPane.add_text("No mods installed.");
listPane.finalize();
detailPane.finalize();
return;
}
for (auto& trackedMod : mods::ModLoader::instance().mods()) {
auto& entry = listPane.add_child<ModListEntry>(trackedMod);
mEntries.push_back(&entry);
mEntryMods.push_back(&trackedMod);
listPane.register_control(entry, detailPane, [this, tracked = &trackedMod](Pane& pane) {
mSelectedMod = tracked;
pane.clear();
build_detail(pane, *tracked);
mark_current_entry();
});
}
if (mSelectedMod == nullptr) {
mSelectedMod = mEntryMods.front();
}
build_detail(detailPane, *mSelectedMod);
mark_current_entry();
listPane.finalize();
}
void ModsWindow::build_detail(Pane& pane, mods::LoadedMod& mod) {
pane.add_child<ModDetailHeader>(
mod, [this, id = mod.metadata.id] { push(std::make_unique<LogsWindow>(id)); });
Rml::String statusBadge;
if (mod.loadFailed || mod.suspendedByProvider) {
const auto status = mod_status(mod);
statusBadge = fmt::format(
R"(&nbsp;<span class="status-badge {}">{}</span>)", status.badgeClass, status.text);
}
pane.add_rml(fmt::format(R"(<div class="mod-title">{} )"
R"(<span class="mod-title-version">v{}</span>{}</div>)"
R"(<div class="mod-author">by {}</div>)",
escape(mod.metadata.name), escape(mod.metadata.version), statusBadge,
escape(mod.metadata.author)));
if (mod.loadFailed && !mod.failureReason.empty()) {
pane.add_rml(fmt::format(R"(<div class="mod-info-row">)"
R"(<span class="mod-info-label failed">Reason</span>)"
R"(<span class="mod-info-value">{}</span>)"
R"(</div>)",
escape(mod.failureReason)));
} else if (mod.suspendedByProvider) {
std::string providers;
for (const auto& edge : mod.dependencies) {
if (edge.required && edge.mod != nullptr && !edge.mod->active) {
if (!providers.empty()) {
providers += ", ";
}
providers += edge.mod->metadata.name;
}
}
pane.add_rml(fmt::format(R"(<div class="mod-info-row">)"
R"(<span class="mod-info-label">Waiting on</span>)"
R"(<span class="mod-info-value">{}</span>)"
R"(</div>)",
escape(providers)));
}
std::string activeDependents;
for (const auto& edge : mod.dependents) {
if (edge.mod != nullptr && edge.mod->active) {
if (!activeDependents.empty()) {
activeDependents += ", ";
}
activeDependents += edge.mod->metadata.name;
}
}
if (mod.active && !activeDependents.empty()) {
pane.add_rml(fmt::format(R"(<div class="mod-restart-note">{}</div>)",
escape(fmt::format("Disabling or reloading also restarts: {}", activeDependents))));
}
if (!mod.metadata.description.empty()) {
pane.add_text(mod.metadata.description)->SetClass("mod-description", true);
}
pane.finalize();
}
void ModsWindow::mark_current_entry() {
for (size_t i = 0; i < mEntries.size(); ++i) {
mEntries[i]->root()->SetClass("current", mEntryMods[i] == mSelectedMod);
}
}
void ModsWindow::update() {
bool dirty = false;
for (auto& snapshot : mSnapshot) {
const auto& mod = *snapshot.mod;
if (mod.active != snapshot.active || mod.loadFailed != snapshot.loadFailed ||
mod_enabled(mod) != snapshot.enabled || mod.suspendedByProvider != snapshot.suspended ||
mod.cacheGeneration != snapshot.cacheGeneration)
{
snapshot.active = mod.active;
snapshot.loadFailed = mod.loadFailed;
snapshot.enabled = mod_enabled(mod);
snapshot.suspended = mod.suspendedByProvider;
snapshot.cacheGeneration = mod.cacheGeneration;
dirty = true;
}
}
if (dirty) {
auto* focused = mDocument != nullptr ? mDocument->GetFocusLeafNode() : nullptr;
bool hadContentFocus = false;
for (auto* node = focused; node != nullptr; node = node->GetParentNode()) {
if (node == mContentRoot) {
hadContentFocus = true;
break;
}
}
rebuild_content();
if (hadContentFocus) {
for (size_t i = 0; i < mEntryMods.size(); ++i) {
if (mEntryMods[i] == mSelectedMod) {
mEntries[i]->focus();
break;
}
}
}
}
Window::update();
}
} // namespace dusk::ui
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include "window.hpp"
#include <vector>
#include "dusk/mod_loader.hpp"
namespace dusk::ui {
class Pane;
class ModsWindow : public Window {
public:
ModsWindow();
void update() override;
private:
struct ModSnapshot {
mods::LoadedMod* mod = nullptr;
bool active = false;
bool loadFailed = false;
bool enabled = false;
bool suspended = false;
u32 cacheGeneration = 0;
};
void build_content(Rml::Element* content);
void build_detail(Pane& pane, mods::LoadedMod& mod);
void mark_current_entry();
std::vector<ModSnapshot> mSnapshot;
std::vector<Component*> mEntries;
std::vector<mods::LoadedMod*> mEntryMods;
mods::LoadedMod* mSelectedMod = nullptr;
};
} // namespace dusk::ui
+3
View File
@@ -89,6 +89,9 @@ Rml::Element* create_toast(Rml::Element* parent, const Toast& toast) {
} else if (toast.type == "controller") {
auto* icon = append(heading, "icon");
icon->SetClass("controller", true);
} else if (toast.type == "warning") {
auto* icon = append(heading, "icon");
icon->SetClass("warning", true);
}
}
{
+11 -3
View File
@@ -8,6 +8,7 @@
#include "dusk/settings.h"
#include "dusk/update_check.hpp"
#include "modal.hpp"
#include "mods_window.hpp"
#include "preset.hpp"
#include "settings.hpp"
#include "version.h"
@@ -49,14 +50,14 @@ const Rml::String kDocumentSource = R"RML(
</hero>
<div id="menu-list" />
</menu>
<disc-info class="intro-item delay-4">
<disc-info class="intro-item delay-5">
<div id="disc-status">
<icon />
<span id="disc-status-label" />
</div>
<span id="disc-version" class="detail" />
</disc-info>
<version-info class="intro-item delay-5">
<version-info class="intro-item delay-6">
<div class="version">Version <span id="version-text"></span></div>
<div id="update-status" class="update">
<span id="update-message"></span>
@@ -726,9 +727,16 @@ Prelaunch::Prelaunch() : Document(kDocumentSource), mRoot(mDocument->GetElementB
});
apply_intro_animation(mMenuButtons.back()->root(), "delay-2");
mMenuButtons.push_back(std::make_unique<Button>(menuList, "Mods"));
mMenuButtons.back()->on_pressed([this] {
mRestartSuppressed = false;
push(std::make_unique<ModsWindow>());
});
apply_intro_animation(mMenuButtons.back()->root(), "delay-3");
mMenuButtons.push_back(std::make_unique<Button>(menuList, "Quit"));
mMenuButtons.back()->on_pressed([] { IsRunning = false; });
apply_intro_animation(mMenuButtons.back()->root(), "delay-3");
apply_intro_animation(mMenuButtons.back()->root(), "delay-4");
}
mDiscStatus = mDocument->GetElementById("disc-status");
+2 -15
View File
@@ -1329,12 +1329,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) {
speedrun::disconnectLiveSplit();
}
}
for (auto& doc : get_document_stack()) {
if (dynamic_cast<MenuBar*>(doc.get())) {
doc = std::make_unique<MenuBar>();
break;
}
}
MenuBar::rebuild();
},
});
config_bool_select(leftPane, rightPane, getSettings().game.liveSplitEnabled,
@@ -1568,15 +1563,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) {
.helpText = "Show advanced settings and debugging tools with "
"Shift+F1.<br/><br/><icon class=\"warning\"/> WARNING: Debugging tools "
"can easily break your game. Do not use on a regular save!",
.onChange =
[](bool) {
for (auto& doc : get_document_stack()) {
if (dynamic_cast<MenuBar*>(doc.get())) {
doc = std::make_unique<MenuBar>();
break;
}
}
},
.onChange = [](bool) { MenuBar::rebuild(); },
.isDisabled = [] { return getSettings().game.speedrunMode.getValue(); },
});
config_bool_select(leftPane, rightPane, getSettings().game.showInputViewer,
+4 -1
View File
@@ -17,8 +17,9 @@
#include "aurora/lib/window.hpp"
#include "dusk/config.hpp"
#include "dusk/io.hpp"
#include "input.hpp"
#include "icon_provider.hpp"
#include "input.hpp"
#include "mod_texture_provider.hpp"
#include "prelaunch.hpp"
#include "window.hpp"
@@ -62,11 +63,13 @@ bool initialize() noexcept {
load_font("NotoMono-Regular.ttf");
register_icon_texture_provider();
register_mod_texture_provider();
sInitialized = true;
return true;
}
void shutdown() noexcept {
unregister_mod_texture_provider();
unregister_icon_texture_provider();
sDocumentStack.clear();
sPassiveDocuments.clear();
+68 -14
View File
@@ -2,6 +2,7 @@
#include "aurora/lib/window.hpp"
#include "aurora/rmlui.hpp"
#include "fmt/format.h"
#include "magic_enum.hpp"
#include "pane.hpp"
#include "ui.hpp"
@@ -24,17 +25,24 @@ float base_body_padding(Rml::Context* context) noexcept {
return 64.0f * dpRatio;
}
const Rml::String kDocumentSource = R"RML(
Rml::String window_document_source(const std::vector<Rml::String>& styleSheets) {
Rml::String links;
for (const auto& sheet : styleSheets) {
links += fmt::format(" <link type=\"text/rcss\" href=\"{}\" />\n", sheet);
}
return fmt::format(R"RML(
<rml>
<head>
<link type="text/rcss" href="res/rml/tabbing.rcss" />
<link type="text/rcss" href="res/rml/window.rcss" />
</head>
{}</head>
<body>
<window id="window"></window>
</body>
</rml>
)RML";
)RML",
links);
}
const Rml::String kDocumentSourceSmall = R"RML(
<rml>
@@ -51,12 +59,25 @@ const Rml::String kDocumentSourceSmall = R"RML(
} // namespace
Window::Window() : Document(kDocumentSource), mRoot(mDocument->GetElementById("window")) {
mTabBar = std::make_unique<TabBar>(mRoot, TabBar::Props{
.onClose = [this] { request_close(); },
.selectedTabIndex = 0,
.autoSelect = true,
});
Window::Window(Props props)
: Document(window_document_source(props.styleSheets)),
mRoot(mDocument->GetElementById("window")) {
if (props.tabBar) {
mTabBar = std::make_unique<TabBar>(mRoot, TabBar::Props{
.onClose = [this] { request_close(); },
.selectedTabIndex = 0,
.autoSelect = true,
});
} else {
mCloseButton = std::make_unique<Button>(mRoot, Button::Props{}, "close");
mCloseButton->on_nav_command([this](Rml::Event&, NavCommand cmd) {
if (cmd == NavCommand::Confirm) {
request_close();
return true;
}
return false;
});
}
auto elem = mDocument->CreateElement("content");
elem->SetAttribute("id", "content");
@@ -152,7 +173,7 @@ void Window::update_safe_area() noexcept {
}
bool Window::set_active_tab(int index) {
return mTabBar->set_active_tab(index);
return mTabBar && mTabBar->set_active_tab(index);
}
void Window::request_close() {
@@ -167,10 +188,17 @@ bool Window::consume_close_request() {
}
void Window::refresh_active_tab() {
mTabBar->refresh_active_tab();
if (mTabBar) {
mTabBar->refresh_active_tab();
} else {
rebuild_content();
}
}
void Window::add_tab(const Rml::String& title, TabBuilder builder) {
if (!mTabBar) {
return;
}
mTabBar->add_tab(title, [this, builder = std::move(builder)] {
clear_content();
if (builder) {
@@ -179,6 +207,18 @@ void Window::add_tab(const Rml::String& title, TabBuilder builder) {
});
}
void Window::set_content(TabBuilder builder) {
mContentBuilder = std::move(builder);
rebuild_content();
}
void Window::rebuild_content() {
clear_content();
if (mContentBuilder) {
mContentBuilder(mContentRoot);
}
}
void Window::clear_content() noexcept {
mContentComponents.clear();
while (mContentRoot->GetNumChildren() != 0) {
@@ -187,7 +227,15 @@ void Window::clear_content() noexcept {
}
bool Window::focus() {
return mTabBar->focus();
if (mTabBar) {
return mTabBar->focus();
}
for (const auto& component : mContentComponents) {
if (component->focus()) {
return true;
}
}
return mCloseButton && mCloseButton->focus();
}
bool Window::visible() const {
@@ -211,7 +259,7 @@ bool Window::handle_nav_command(Rml::Event& event, NavCommand cmd) {
request_close();
return true;
}
if (mTabBar->handle_nav_command(event, cmd)) {
if (mTabBar && mTabBar->handle_nav_command(event, cmd)) {
return true;
}
return mSuppressNavFallback ? false : Document::handle_nav_command(event, cmd);
@@ -219,6 +267,9 @@ bool Window::handle_nav_command(Rml::Event& event, NavCommand cmd) {
bool Window::handle_content_nav(Rml::Event& event, NavCommand cmd) noexcept {
if (cmd == NavCommand::Up) {
if (!mTabBar) {
return true;
}
if (focus()) {
mDoAud_seStartMenu(kSoundItemFocus);
return true;
@@ -246,6 +297,9 @@ bool Window::handle_content_nav(Rml::Event& event, NavCommand cmd) noexcept {
return true;
}
}
if (!mTabBar) {
return false;
}
return focus();
} else if (cmd == NavCommand::Left || cmd == NavCommand::Right) {
int currentComponent = -1;
@@ -305,4 +359,4 @@ bool WindowSmall::visible() const {
return mRoot->HasAttribute("open");
}
} // namespace dusk::ui
} // namespace dusk::ui
+12 -1
View File
@@ -17,8 +17,13 @@ public:
std::unique_ptr<Button> button;
TabBuilder builder;
};
struct Props {
bool tabBar = true;
std::vector<Rml::String> styleSheets;
};
Window();
Window() : Window(Props{}) {}
explicit Window(Props props);
Window(const Window&) = delete;
Window& operator=(const Window&) = delete;
@@ -34,6 +39,9 @@ protected:
void request_close();
virtual bool consume_close_request();
void add_tab(const Rml::String& title, TabBuilder builder);
// Tab-bar-less counterpart of add_tab: stores the builder and runs it immediately.
void set_content(TabBuilder builder);
void rebuild_content();
void refresh_active_tab();
void update_safe_area() noexcept;
void clear_content() noexcept;
@@ -52,6 +60,9 @@ protected:
Rml::Element* mRoot;
Rml::Element* mContentRoot;
std::unique_ptr<TabBar> mTabBar;
// Only set for tab-bar-less windows.
std::unique_ptr<Button> mCloseButton;
TabBuilder mContentBuilder;
std::vector<std::unique_ptr<Component> > mContentComponents;
Insets mBodyPadding;
bool mInitialOpen = true;