mirror of
https://github.com/TwilitRealm/dusklight
synced 2026-08-18 20:57:46 -04:00
Mods manager UI & logs viewer
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"( <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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user