diff --git a/docs/modding.md b/docs/modding.md index 9208f550e6..7b616989e8 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -316,6 +316,99 @@ Change callbacks fire on the game thread whenever the value changes at runtime ( Writes that store the same value are silent. Values applied from `config.json` or `--cvar` at registration do **not** fire callbacks; read the value after `register_var` for the starting state. +### UiService (`mods/svc/ui.h`) + +Integrate seamlessly with Dusklight's UI system: add controls and buttons to your mod's detail pane in the Mods window, +create custom windows and modal dialogs, apply custom RCSS stylesheets (anywhere!), and add menu bar tabs. + +**Mod panel:** Registers or replaces the panel rendered in your mod's detail pane; `build` runs every time the detail +content is rebuilt, and `update` runs every frame while that mod is selected. While your mod is selected, the detail +pane carries your mod's id as a `mod-id` attribute (like custom window roots), so scoped RCSS can target it (e.g. +`[mod-id="com.example.mod"]`). + +```cpp +IMPORT_SERVICE(UiService, svc_ui); + +UiElementHandle statusText = 0; + +ModResult build(ModContext*, UiElementHandle panel, void*, ModError*) { + svc_ui->pane_add_section(mod_ctx, panel, "Status"); + svc_ui->pane_add_text(mod_ctx, panel, "starting...", &statusText); + svc_ui->pane_add_progress(mod_ctx, panel, 0.5f, nullptr); + return MOD_OK; +} + +ModResult update(ModContext*, void*, ModError*) { + svc_ui->elem_set_text(mod_ctx, statusText, "running"); + return MOD_OK; +} + +UiModsPanelDesc panel = UI_MODS_PANEL_DESC_INIT; +panel.build = build; +panel.update = update; +svc_ui->register_mods_panel(mod_ctx, &panel); +``` + +Element setters must match the element kind: `elem_set_text`/`elem_set_rml` on text rows, and `elem_set_progress` on +progress bars. `elem_set_class` sets or clears an RCSS class on any element handle, for styling via scoped or +per-window RCSS. A non-`MOD_OK` result from `build`/`update` fails your mod, as do exceptions thrown from any UI +callback. + +**Controls:** `pane_add_control` adds an input row described by a `UiControlDesc`: `UI_CONTROL_BUTTON`, +`UI_CONTROL_TOGGLE`, `UI_CONTROL_NUMBER`, `UI_CONTROL_STRING`, or `UI_CONTROL_SELECT`. Values bind with callbacks or +directly to a config var. + +```cpp +UiControlDesc control = UI_CONTROL_DESC_INIT; +control.kind = UI_CONTROL_TOGGLE; +control.label = "Enable rainbows"; +control.help_rml = "Shown in the help pane while focused."; +control.binding = UI_BINDING_CONFIG_VAR; +control.config_var = myBoolVar; // from svc_config->register_var +svc_ui->pane_add_control(mod_ctx, leftPane, &control, nullptr); +``` + +`UI_BINDING_CONFIG_VAR` wires persistence, change notifications, and the modified indicator automatically. The var +type must match the control: `TOGGLE` = bool, `NUMBER` and `SELECT` = int, `STRING` = string. Float vars are not +bindable; use callbacks and convert. `help_rml` and `SELECT` option lists render in a help pane, so `SELECT` controls +are only available inside window tabs. + +**Windows:** `window_push` pushes a tabbed two-pane window onto the document stack and shows it. Each tab's `build` +receives the window handle plus fresh left and right pane handles on every activation. The optional per-tab `update` +runs each frame while that tab is active. `on_closed` fires when the window is destroyed. `desc.rcss` optionally styles +that window's document only; custom windows carry the owning mod's id as a `mod-id` attribute on the window root, so +scoped RCSS can target your specific mod's windows (e.g. `window[mod-id="com.example.mod"]`). + +```cpp +UiTabDesc tabs[1] = {UI_TAB_DESC_INIT}; +tabs[0].title = "Options"; +tabs[0].build = build_options_tab; + +UiWindowDesc desc = UI_WINDOW_DESC_INIT; +desc.tabs = tabs; +desc.tab_count = 1; +desc.on_closed = options_window_closed; +UiWindowHandle window = 0; +svc_ui->window_push(mod_ctx, &desc, &window); +``` + +**Dialogs:** `dialog_push` shows a modal dialog. `variant` picks the style, `icon` optionally overrides the variant's +default icon, and actions become buttons. After an action's `on_pressed` returns, the dialog closes unless the action +sets `keep_open`. A `keep_open` action can close it later (or immediately) with `dialog_close`. Cancel fires +`on_dismiss` if present and always closes. `dialog_set_body`, `dialog_set_icon`, and `dialog_add_action` mutate a live +dialog. + +**Menu bar tabs:** `register_menu_tab` adds a tab to the in-game menu bar. `on_selected` fires when the user activates +the tab: typically you'd push a window from it. The tab is removed by `unregister_menu_tab`, or automatically when the +mod is disabled. + +**Custom styles:** `register_styles(scope, rcss, &handle)` applies an RCSS stylesheet to every document of a scope: +existing documents restyle immediately, and future ones pick it up when created. `register_styles_file(scope, path, +&handle)` reads the sheet from your bundle's `res/` directory. Scopes are `UI_SCOPE_PRELAUNCH`, `UI_SCOPE_WINDOW`, +`UI_SCOPE_MENU_BAR`, `UI_SCOPE_OVERLAY`, `UI_SCOPE_TOUCH_CONTROLS`, and `UI_SCOPE_GRAPHICS_TUNER`. Sheets apply after +host styles and may override them. Scope selectors tightly (use `[mod-id="..."]`!), especially for `UI_SCOPE_WINDOW`, +unless changing host UI is intentional. + --- ## Hooking Game Functions diff --git a/files.cmake b/files.cmake index 1cbef1694b..15b64c3a3f 100644 --- a/files.cmake +++ b/files.cmake @@ -1500,6 +1500,8 @@ set(DUSK_FILES src/dusk/ui/logs_window.hpp src/dusk/ui/mod_texture_provider.cpp src/dusk/ui/mod_texture_provider.hpp + src/dusk/ui/mod_window.cpp + src/dusk/ui/mod_window.hpp src/dusk/ui/modal.cpp src/dusk/ui/modal.hpp src/dusk/ui/nav_types.hpp @@ -1569,6 +1571,8 @@ set(DUSK_FILES src/dusk/mods/svc/overlay.cpp src/dusk/mods/svc/resource.cpp src/dusk/mods/svc/texture.cpp + src/dusk/mods/svc/ui.cpp + src/dusk/mods/svc/ui.hpp src/dusk/mods/svc/registry.cpp src/dusk/mods/svc/registry.hpp src/dusk/discord.cpp diff --git a/include/mods/svc/ui.h b/include/mods/svc/ui.h new file mode 100644 index 0000000000..d9f2ac669e --- /dev/null +++ b/include/mods/svc/ui.h @@ -0,0 +1,284 @@ +#pragma once + +#include "mods/api.h" +#include "mods/svc/config.h" + +#define UI_SERVICE_ID "dev.twilitrealm.dusklight.ui" +#define UI_SERVICE_MAJOR 1u +#define UI_SERVICE_MINOR 0u + +/* + * UI primitives: a panel inside the host Mods window, mod-owned windows, dialogs, scoped + * RCSS stylesheets and menu bar tabs. + * + * All calls must be made on the game thread from mod callbacks (initialize, update, hooks, or UI + * callbacks). Handles are opaque, generation-checked ids; a stale or unknown handle fails with + * MOD_INVALID_ARGUMENT. Element handles die with the content that owns them: a panel or tab rebuild + * destroys the previous build's elements, so re-acquire handles inside the build callback rather + * than caching them. Strings are UTF-8 and, in both directions, only valid for the duration of the + * call. + */ + +/* 0 is never a valid handle. */ +typedef uint64_t UiWindowHandle; +typedef uint64_t UiDialogHandle; +typedef uint64_t UiElementHandle; +typedef uint64_t UiStyleHandle; +typedef uint64_t UiMenuTabHandle; + +typedef enum UiStyleScope { + UI_SCOPE_PRELAUNCH = 0, /* the pre-launch menu */ + UI_SCOPE_WINDOW = 1, /* every tabbed/small window, host and mod alike */ + UI_SCOPE_MENU_BAR = 2, /* the in-game menu bar */ + UI_SCOPE_OVERLAY = 3, /* the passive overlay (toasts, FPS counter, timers) */ + UI_SCOPE_TOUCH_CONTROLS = 4, /* touch controls and their editor */ + UI_SCOPE_GRAPHICS_TUNER = 5, /* the graphics tuner overlay window */ +} UiStyleScope; + +typedef enum UiDialogVariant { + UI_DIALOG_NORMAL = 0, + UI_DIALOG_WARNING = 1, /* warning icon by default */ + UI_DIALOG_DANGER = 2, /* red styling, error icon by default */ +} UiDialogVariant; + +typedef enum UiControlKind { + UI_CONTROL_BUTTON = 0, /* action button (on_pressed) */ + UI_CONTROL_TOGGLE = 1, /* boolean on/off */ + UI_CONTROL_NUMBER = 2, /* integer stepper with min/max/step */ + UI_CONTROL_STRING = 3, /* text input */ + UI_CONTROL_SELECT = 4, /* one of `options`; the value is the option index */ +} UiControlKind; + +typedef enum UiControlBinding { + /* Values flow through the `get`/`set` callbacks. Getters are polled every frame while the + control is visible and must be cheap. */ + UI_BINDING_CALLBACKS = 0, + /* The control reads and writes `config_var` (a ConfigService handle owned by the calling mod) + * directly: persistence, change notifications and the modified indicator (value != default) are + * wired automatically. The var type must match the control kind: TOGGLE = bool, NUMBER and + * SELECT = int, STRING = string. Float vars are not bindable; use callbacks. */ + UI_BINDING_CONFIG_VAR = 1, +} UiControlBinding; + +/* Tagged by the control's kind: TOGGLE reads bool_value, NUMBER and SELECT read int_value, STRING + * reads string_value. string_value passed to a setter is only valid during the call; a getter + * should point it at storage owned by the mod (e.g. a static buffer) that stays valid until the + * next call into the mod — the host copies it right after the getter returns. */ +typedef struct UiControlValue { + uint32_t struct_size; + bool bool_value; + int64_t int_value; + const char* string_value; +} UiControlValue; + +#define UI_CONTROL_VALUE_INIT {sizeof(UiControlValue), false, 0, NULL} + +typedef void (*UiControlGetFn)(ModContext* ctx, void* user_data, UiControlValue* out_value); +typedef void (*UiControlSetFn)(ModContext* ctx, void* user_data, const UiControlValue* value); +/* Polled every frame while the control is visible. */ +typedef bool (*UiPredicateFn)(ModContext* ctx, void* user_data); +typedef void (*UiPressedFn)(ModContext* ctx, void* user_data); + +typedef struct UiControlDesc { + uint32_t struct_size; + UiControlKind kind; + /* Row label (plain text). Required. */ + const char* label; + /* Optional RML shown as contextual help when the control is focused or hovered. Only rendered + * where a help pane exists (mod window tabs). */ + const char* help_rml; + UiControlBinding binding; /* ignored for BUTTON */ + ConfigVarHandle config_var; /* UI_BINDING_CONFIG_VAR */ + UiControlGetFn get; /* UI_BINDING_CALLBACKS (all kinds but BUTTON) */ + UiControlSetFn set; /* UI_BINDING_CALLBACKS (all kinds but BUTTON) */ + UiPressedFn on_pressed; /* BUTTON only. Required for BUTTON. */ + UiPredicateFn is_disabled; /* optional */ + /* Optional override for the modified indicator. CONFIG_VAR controls derive it from value != + * default when this is NULL. */ + UiPredicateFn is_modified; + /* Passed to every callback above. */ + void* user_data; + /* NUMBER: inclusive clamp range and step. min == max means the defaults (0 .. INT32_MAX); step + * < 1 means 1. */ + int64_t min; + int64_t max; + int64_t step; + const char* prefix; /* NUMBER: optional text before the value */ + const char* suffix; /* NUMBER: optional text after the value */ + /* SELECT: option labels (plain text). Required for SELECT. SELECT controls + * present their options in the help pane, so they are only available where + * one exists (mod window tabs); MOD_UNSUPPORTED elsewhere. */ + const char* const* options; + size_t option_count; + int32_t max_length; /* STRING: maximum input length; < 1 means unlimited */ +} UiControlDesc; + +#define UI_CONTROL_DESC_INIT \ + {sizeof(UiControlDesc), UI_CONTROL_BUTTON, NULL, NULL, UI_BINDING_CALLBACKS, 0u, NULL, NULL, \ + NULL, NULL, NULL, NULL, 0, 0, 1, NULL, NULL, NULL, 0u, 0} + +/* Build the panel contents. `panel` accepts the pane_add_* functions; it and + * every element created in it are destroyed (handles invalidated) whenever the + * panel is rebuilt, e.g. on tab switches. A non-MOD_OK result fails the mod. */ +typedef ModResult (*UiPanelBuildFn)( + ModContext* ctx, UiElementHandle panel, void* user_data, ModError* out_error); +/* Called every frame while the panel is the visible tab. */ +typedef ModResult (*UiPanelUpdateFn)(ModContext* ctx, void* user_data, ModError* out_error); + +/* A panel rendered inside the calling mod's tab of the host Mods window. */ +typedef struct UiModsPanelDesc { + uint32_t struct_size; + UiPanelBuildFn build; /* required */ + UiPanelUpdateFn update; + void* user_data; +} UiModsPanelDesc; + +#define UI_MODS_PANEL_DESC_INIT {sizeof(UiModsPanelDesc), NULL, NULL, NULL} + +/* Build one tab of a mod window. `left_pane` is the interactive column, + * `right_pane` shows contextual help (controls' help_rml and SELECT options + * render there). Rebuilt on every tab activation. */ +typedef ModResult (*UiTabBuildFn)(ModContext* ctx, UiWindowHandle window, UiElementHandle left_pane, + UiElementHandle right_pane, void* user_data, ModError* out_error); + +typedef struct UiTabDesc { + uint32_t struct_size; + const char* title; /* required */ + UiTabBuildFn build; /* required */ + UiPanelUpdateFn update; + void* user_data; +} UiTabDesc; + +#define UI_TAB_DESC_INIT {sizeof(UiTabDesc), NULL, NULL, NULL, NULL} + +typedef void (*UiWindowClosedFn)(ModContext* ctx, UiWindowHandle window, void* user_data); + +typedef struct UiWindowDesc { + uint32_t struct_size; + const UiTabDesc* tabs; /* at least one */ + size_t tab_count; + /* Optional RCSS applied to this window's document only (on top of any + * UI_SCOPE_WINDOW sheets, which apply automatically). */ + const char* rcss; + /* Fired when the window is destroyed by user close or window_close. Not + * fired during owning-mod teardown/shutdown. The handle is already invalid. */ + UiWindowClosedFn on_closed; + void* user_data; +} UiWindowDesc; + +#define UI_WINDOW_DESC_INIT {sizeof(UiWindowDesc), NULL, 0u, NULL, NULL, NULL} + +typedef void (*UiDialogActionFn)(ModContext* ctx, UiDialogHandle dialog, void* user_data); + +/* Note: array element without struct_size; a future change requires appending + * a v2 desc struct rather than growing this one. */ +typedef struct UiDialogAction { + const char* label; /* required */ + UiDialogActionFn on_pressed; + void* user_data; + bool keep_open; /* false = the dialog closes after on_pressed returns */ +} UiDialogAction; + +typedef struct UiDialogDesc { + uint32_t struct_size; + const char* title; /* plain text; required */ + const char* body_rml; /* RML body; required */ + UiDialogVariant variant; + /* Optional icon name overriding the variant default: "warning", "error", + * "question-mark", "verifying", "celebration". */ + const char* icon; + const UiDialogAction* actions; /* at least one */ + size_t action_count; + /* Fired on cancel (B/Escape) before the dialog closes; the dialog always + * closes on dismiss. */ + UiDialogActionFn on_dismiss; + void* user_data; /* passed to on_dismiss */ +} UiDialogDesc; + +#define UI_DIALOG_DESC_INIT \ + {sizeof(UiDialogDesc), NULL, NULL, UI_DIALOG_NORMAL, NULL, NULL, 0u, NULL, NULL} + +/* A tab added to the in-game menu bar. */ +typedef struct UiMenuTabDesc { + uint32_t struct_size; + const char* label; /* plain text; required */ + UiPressedFn on_selected; /* fired when the user activates the tab; required */ + void* user_data; +} UiMenuTabDesc; + +#define UI_MENU_TAB_DESC_INIT {sizeof(UiMenuTabDesc), NULL, NULL, NULL} + +typedef struct UiService { + ServiceHeader header; + + /* Register or replace the panel shown in the calling mod's Mods-window tab. */ + ModResult (*register_mods_panel)(ModContext* ctx, const UiModsPanelDesc* desc); + + /* Content builders. `pane` is a panel or tab pane handle; out_elem (where + * present, optional) receives a handle for later elem_set_* updates. */ + ModResult (*pane_add_section)(ModContext* ctx, UiElementHandle pane, const char* title); + ModResult (*pane_add_text)( + ModContext* ctx, UiElementHandle pane, const char* text, UiElementHandle* out_elem); + ModResult (*pane_add_rml)( + ModContext* ctx, UiElementHandle pane, const char* rml, UiElementHandle* out_elem); + ModResult (*pane_add_progress)( + ModContext* ctx, UiElementHandle pane, float value, UiElementHandle* out_elem); + ModResult (*pane_add_control)(ModContext* ctx, UiElementHandle pane, const UiControlDesc* desc, + UiElementHandle* out_elem); + + /* Element updates. The handle kind must match the setter (text/rml on text + * rows, progress on progress bars). */ + ModResult (*elem_set_text)(ModContext* ctx, UiElementHandle elem, const char* text); + ModResult (*elem_set_rml)(ModContext* ctx, UiElementHandle elem, const char* rml); + ModResult (*elem_set_progress)(ModContext* ctx, UiElementHandle elem, float value); + /* Set or clear an RCSS class on any element handle (rows, progress bars, + * controls, panes), for styling via scoped or window RCSS. */ + ModResult (*elem_set_class)( + ModContext* ctx, UiElementHandle elem, const char* name, bool active); + + /* Push a tabbed two-pane window onto the document stack and show it. */ + ModResult (*window_push)(ModContext* ctx, const UiWindowDesc* desc, UiWindowHandle* out_window); + ModResult (*window_close)(ModContext* ctx, UiWindowHandle window); + + /* Push a modal dialog onto the document stack and show it. */ + ModResult (*dialog_push)(ModContext* ctx, const UiDialogDesc* desc, UiDialogHandle* out_dialog); + ModResult (*dialog_close)(ModContext* ctx, UiDialogHandle dialog); + ModResult (*dialog_set_body)(ModContext* ctx, UiDialogHandle dialog, const char* body_rml); + /* Replace the dialog icon ("" removes it; names as in UiDialogDesc.icon). */ + ModResult (*dialog_set_icon)(ModContext* ctx, UiDialogHandle dialog, const char* icon); + /* Append one action button (same callback rules as at push). */ + ModResult (*dialog_add_action)( + ModContext* ctx, UiDialogHandle dialog, const UiDialogAction* action); + + /* Whether any focus-stack document is currently visible (visible documents block gamepad + * input). */ + ModResult (*is_any_document_visible)(ModContext* ctx, bool* out_visible); + + /* Register an RCSS sheet applied to every document of `scope`, now and in the future, until + * unregistered or the calling mod is torn down. Sheets apply in registration order after the + * host styles. Fails with MOD_INVALID_ARGUMENT if the RCSS does not parse. */ + ModResult (*register_styles)( + ModContext* ctx, UiStyleScope scope, const char* rcss, UiStyleHandle* out_style); + /* Like register_styles, but reads the RCSS from the mod bundle's res/ directory (same path + * rules as ResourceService::load). MOD_UNAVAILABLE if the file does not exist. */ + ModResult (*register_styles_file)( + ModContext* ctx, UiStyleScope scope, const char* path, UiStyleHandle* out_style); + ModResult (*unregister_styles)(ModContext* ctx, UiStyleHandle style); + + /* Add a tab to the in-game menu bar, on_selected runs with the menu open; pushing a window from + * it stacks the window over the menu like the host tabs do. The tab is removed when + * unregistered or the mod is torn down. */ + ModResult (*register_menu_tab)( + ModContext* ctx, const UiMenuTabDesc* desc, UiMenuTabHandle* out_tab); + ModResult (*unregister_menu_tab)(ModContext* ctx, UiMenuTabHandle tab); +} UiService; + +#ifdef __cplusplus +#include "mods/service.hpp" + +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = UI_SERVICE_ID; + static constexpr uint16_t major_version = UI_SERVICE_MAJOR; +}; +#endif diff --git a/src/dusk/mods/svc/config.cpp b/src/dusk/mods/svc/config.cpp index cae6bf840e..d919c0ab1f 100644 --- a/src/dusk/mods/svc/config.cpp +++ b/src/dusk/mods/svc/config.cpp @@ -438,6 +438,11 @@ void config_mark_dirty() { s_dirty = true; } +config::ConfigVarBase* config_find_var( + LoadedMod& mod, const ConfigVarHandle handle, const uint32_t expectedType) { + return find_var(mod, handle, expectedType); +} + constinit const ServiceModule g_configModule{ .id = CONFIG_SERVICE_ID, .majorVersion = CONFIG_SERVICE_MAJOR, diff --git a/src/dusk/mods/svc/config.hpp b/src/dusk/mods/svc/config.hpp index 657c1cbf14..b095c05ec6 100644 --- a/src/dusk/mods/svc/config.hpp +++ b/src/dusk/mods/svc/config.hpp @@ -1,7 +1,18 @@ #pragma once +#include "dusk/config.hpp" +#include "mods/svc/config.h" + +namespace dusk::mods { +struct LoadedMod; +} // namespace dusk::mods + namespace dusk::mods::svc { +// Returns a config var owned by `mod` when the handle exists and its type matches. +config::ConfigVarBase* config_find_var( + LoadedMod& mod, ConfigVarHandle handle, uint32_t expectedType); + // Marks the mods' config keys dirty for the config service's debounced save. The loader // calls this for the enabled cvars it owns. void config_mark_dirty(); diff --git a/src/dusk/mods/svc/registry.cpp b/src/dusk/mods/svc/registry.cpp index 0c7c61276a..206acc8994 100644 --- a/src/dusk/mods/svc/registry.cpp +++ b/src/dusk/mods/svc/registry.cpp @@ -206,6 +206,7 @@ void ModLoader::init_services() { &svc::g_overlayModule, &svc::g_textureModule, &svc::g_configModule, + &svc::g_uiModule, &svc::g_gameModule, }) { diff --git a/src/dusk/mods/svc/registry.hpp b/src/dusk/mods/svc/registry.hpp index e8d613057e..71fda98abd 100644 --- a/src/dusk/mods/svc/registry.hpp +++ b/src/dusk/mods/svc/registry.hpp @@ -68,6 +68,7 @@ extern const ServiceModule g_hookModule; extern const ServiceModule g_overlayModule; extern const ServiceModule g_textureModule; extern const ServiceModule g_configModule; +extern const ServiceModule g_uiModule; extern const ServiceModule g_gameModule; } // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/ui.cpp b/src/dusk/mods/svc/ui.cpp new file mode 100644 index 0000000000..72366aaab7 --- /dev/null +++ b/src/dusk/mods/svc/ui.cpp @@ -0,0 +1,1461 @@ +#include "ui.hpp" + +#include "config.hpp" +#include "registry.hpp" + +#include "aurora/lib/logging.hpp" +#include "dusk/mod_loader.hpp" +#include "dusk/mods/loader/loader.hpp" +#include "dusk/ui/menu_bar.hpp" +#include "dusk/ui/mod_window.hpp" +#include "dusk/ui/modal.hpp" +#include "dusk/ui/ui.hpp" +#include "mods/svc/ui.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dusk::mods::svc::ui_impl { +namespace { + +aurora::Module Log("dusk::mods::ui"); + +enum class UiSlotKind : u8 { + Free, + Window, + Dialog, + Pane, + Text, + Progress, + Control, + Style, + MenuTab, +}; + +const char* slot_kind_name(UiSlotKind kind) { + switch (kind) { + case UiSlotKind::Window: + return "window"; + case UiSlotKind::Dialog: + return "dialog"; + case UiSlotKind::Pane: + return "pane"; + case UiSlotKind::Text: + return "text"; + case UiSlotKind::Progress: + return "progress"; + case UiSlotKind::Control: + return "control"; + case UiSlotKind::Style: + return "style"; + case UiSlotKind::MenuTab: + return "menu tab"; + default: + return "free"; + } +} + +// Generation-checked handle slots. Game thread only: all mutations happen in service calls made +// from mod code, in UI callbacks (ui::update), or in the loader's deactivate paths. +struct UiSlot { + // Bumped on free, which invalidates every outstanding handle to this slot. + uint32_t generation = 1; + UiSlotKind kind = UiSlotKind::Free; + LoadedMod* owner = nullptr; + // Pane/Text/Progress/Control: freed automatically when the element is destroyed + Rml::Element* element = nullptr; + // Pane payload + ui::Pane* pane = nullptr; + ui::Pane* helpPane = nullptr; + // Window/Dialog payload (non-owning; the document stack owns the document) + ui::Document* document = nullptr; + UiWindowClosedFn onClosed = nullptr; + void* onClosedUserData = nullptr; + // Style payload + ui::DocumentScope styleScope = ui::DocumentScope::None; + std::string styleId; + // Cached rendered values for element setters. These make the natural "set every update" + // style cheap when the displayed value has not changed. + std::string elementRml; + float elementFloat = 0.0f; + bool hasElementValue = false; +}; + +std::vector s_slots; +std::vector s_freeSlots; + +struct ModUiPanel { + UiPanelBuildFn build = nullptr; + UiPanelUpdateFn update = nullptr; + void* userData = nullptr; +}; +std::unordered_map s_modPanels; + +struct ModMenuTab { + uint64_t handle = 0; + std::string label; + UiPressedFn onSelected = nullptr; + void* userData = nullptr; +}; +std::unordered_map> s_modMenuTabs; +bool s_menuTabsDirty = false; + +uint64_t handle_for(uint32_t index) { + return (uint64_t{s_slots[index].generation} << 32) | index; +} + +uint32_t slot_index(const UiSlot& slot) { + return static_cast(&slot - s_slots.data()); +} + +UiSlot* slot_from_handle(uint64_t handle) { + const auto index = static_cast(handle & 0xFFFFFFFFu); + const auto generation = static_cast(handle >> 32); + if (index >= s_slots.size()) { + return nullptr; + } + auto& slot = s_slots[index]; + if (slot.kind == UiSlotKind::Free || slot.generation != generation) { + return nullptr; + } + return &slot; +} + +// Note: s_slots may reallocate on any later allocation, so callers must not hold the returned +// slot reference across calls that can allocate (e.g. mod build callbacks); re-resolve instead. +UiSlot& alloc_slot(LoadedMod& mod, UiSlotKind kind, uint64_t& outHandle) { + uint32_t index; + if (!s_freeSlots.empty()) { + index = s_freeSlots.back(); + s_freeSlots.pop_back(); + } else { + index = static_cast(s_slots.size()); + s_slots.emplace_back(); + } + auto& slot = s_slots[index]; + slot.kind = kind; + slot.owner = &mod; + outHandle = handle_for(index); + return slot; +} + +void free_slot(UiSlot& slot) { + slot.generation++; + slot.kind = UiSlotKind::Free; + slot.owner = nullptr; + slot.element = nullptr; + slot.pane = nullptr; + slot.helpPane = nullptr; + slot.document = nullptr; + slot.onClosed = nullptr; + slot.onClosedUserData = nullptr; + slot.styleScope = ui::DocumentScope::None; + slot.styleId.clear(); + slot.elementRml.clear(); + slot.elementFloat = 0.0f; + slot.hasElementValue = false; + s_freeSlots.push_back(slot_index(slot)); +} + +UiSlot* resolve(LoadedMod& mod, uint64_t handle, UiSlotKind kind, const char* what) { + auto* slot = slot_from_handle(handle); + if (slot == nullptr || slot->owner != &mod || slot->kind != kind) { + Log.error("[{}] {}: stale or invalid {} handle {:#x}", mod.metadata.id, what, + slot_kind_name(kind), handle); + return nullptr; + } + return slot; +} + +// Whether the registration a callback was created under is still live. Callbacks captured by +// host-owned UI must check this before calling into the mod: `mod->active` alone is true again +// once a reload completes, but captured fn pointers still target the unloaded image. Teardown +// frees the slots (ui_remove_mod), which invalidates every callback built under them. +bool slot_live(uint64_t handle) { + return slot_from_handle(handle) != nullptr; +} + +bool dialog_open(uint64_t handle) { + auto* slot = slot_from_handle(handle); + return slot != nullptr && slot->kind == UiSlotKind::Dialog && slot->document != nullptr && + slot->document->active(); +} + +// Frees the slot when the tracked element is destroyed (tab rebuilds, window teardown, ...). +// The generation check makes a late detach of an already-recycled slot a no-op. +class SlotDetachListener final : public Rml::EventListener { +public: + SlotDetachListener(uint32_t index, uint32_t generation) + : m_index{index}, m_generation{generation} {} + + void ProcessEvent(Rml::Event&) override {} + + void OnDetach(Rml::Element*) override { + if (m_index < s_slots.size()) { + auto& slot = s_slots[m_index]; + if (slot.kind != UiSlotKind::Free && slot.generation == m_generation) { + free_slot(slot); + } + } + delete this; + } + +private: + uint32_t m_index; + uint32_t m_generation; +}; + +void track_element(UiSlot& slot, Rml::Element& element) { + slot.element = &element; + element.AddEventListener( + Rml::EventId::Click, new SlotDetachListener(slot_index(slot), slot.generation)); +} + +template +T guarded_call(LoadedMod& mod, const char* what, T fallback, Fn&& fn) { + if (!mod.active) { + return fallback; + } + try { + return fn(); + } catch (const std::exception& e) { + fail_mod(mod, MOD_ERROR, fmt::format("exception in {}: {}", what, e.what())); + } catch (...) { + fail_mod(mod, MOD_ERROR, fmt::format("unknown exception in {}", what)); + } + return fallback; +} + +template +void guarded_call(LoadedMod& mod, const char* what, Fn&& fn) { + if (!mod.active) { + return; + } + try { + fn(); + } catch (const std::exception& e) { + fail_mod(mod, MOD_ERROR, fmt::format("exception in {}: {}", what, e.what())); + } catch (...) { + fail_mod(mod, MOD_ERROR, fmt::format("unknown exception in {}", what)); + } +} + +// Shared by panel/tab build and update callbacks: translates a non-OK result or an escaped +// exception into fail_mod, mirroring mod_update handling. +template +void invoke_mod_ui_callback(LoadedMod& mod, const char* what, Fn&& fn) { + ModError error = MOD_ERROR_INIT; + const ModResult result = guarded_call(mod, what, MOD_OK, [&] { return fn(&error); }); + if (result != MOD_OK && mod.active) { + fail_mod( + mod, result, error.message[0] != '\0' ? error.message : fmt::format("{} failed", what)); + } +} + +uint64_t wrap_pane(LoadedMod& mod, ui::Pane& pane, ui::Pane* helpPane) { + uint64_t handle = 0; + auto& slot = alloc_slot(mod, UiSlotKind::Pane, handle); + slot.pane = &pane; + slot.helpPane = helpPane; + track_element(slot, *pane.root()); + return handle; +} + +int clamp_to_int(int64_t value) { + return static_cast(std::clamp(value, INT_MIN, INT_MAX)); +} + +std::function wrap_predicate( + LoadedMod& mod, UiPredicateFn fn, void* userData, uint64_t guardHandle) { + if (fn == nullptr) { + return {}; + } + return [modPtr = &mod, fn, userData, guardHandle] { + if (!slot_live(guardHandle)) { + return false; + } + return guarded_call(*modPtr, "control predicate", false, + [&] { return fn(modPtr->context.get(), userData); }); + }; +} + +void wire_callback_binding( + LoadedMod& mod, const UiControlDesc& desc, ui::ModControlSpec& spec, uint64_t guardHandle) { + auto* modPtr = &mod; + const auto get = desc.get; + const auto set = desc.set; + auto* userData = desc.user_data; + const auto getValue = [modPtr, get, userData, guardHandle] { + UiControlValue value = UI_CONTROL_VALUE_INIT; + if (!slot_live(guardHandle)) { + return value; + } + guarded_call(*modPtr, "control getter", [&] { + get(modPtr->context.get(), userData, &value); + }); + return value; + }; + const auto setValue = [modPtr, set, userData, guardHandle](const UiControlValue& value) { + if (!slot_live(guardHandle)) { + return; + } + guarded_call(*modPtr, "control setter", [&] { + set(modPtr->context.get(), userData, &value); + }); + }; + switch (desc.kind) { + case UI_CONTROL_TOGGLE: + spec.getBool = [getValue] { return getValue().bool_value; }; + spec.setBool = [setValue](bool value) { + UiControlValue raw = UI_CONTROL_VALUE_INIT; + raw.bool_value = value; + setValue(raw); + }; + break; + case UI_CONTROL_NUMBER: + case UI_CONTROL_SELECT: + spec.getInt = [getValue] { return clamp_to_int(getValue().int_value); }; + spec.setInt = [setValue](int value) { + UiControlValue raw = UI_CONTROL_VALUE_INIT; + raw.int_value = value; + setValue(raw); + }; + break; + case UI_CONTROL_STRING: + spec.getString = [getValue]() -> Rml::String { + const UiControlValue value = getValue(); + return value.string_value != nullptr ? value.string_value : ""; + }; + spec.setString = [setValue](Rml::String value) { + UiControlValue raw = UI_CONTROL_VALUE_INIT; + raw.string_value = value.c_str(); + setValue(raw); + }; + break; + default: + break; + } +} + +// The lambdas re-resolve the var on every call, so a control whose var was unregistered +// mid-flight degrades to a no-op instead of a dangling read. +bool wire_config_var_binding(LoadedMod& mod, const UiControlDesc& desc, ui::ModControlSpec& spec) { + auto* modPtr = &mod; + const uint64_t varHandle = desc.config_var; + switch (desc.kind) { + case UI_CONTROL_TOGGLE: { + const auto find = [modPtr, varHandle] { + return static_cast*>( + config_find_var(*modPtr, varHandle, CONFIG_VAR_BOOL)); + }; + if (find() == nullptr) { + return false; + } + spec.getBool = [find] { + const auto* var = find(); + return var != nullptr && var->getValue(); + }; + spec.setBool = [find](bool value) { + auto* var = find(); + if (var == nullptr || var->getValue() == value) { + return; + } + var->setValue(value); + config_mark_dirty(); + }; + if (!spec.isModified) { + spec.isModified = [find] { + const auto* var = find(); + return var != nullptr && var->getValue() != var->getDefaultValue(); + }; + } + return true; + } + case UI_CONTROL_NUMBER: + case UI_CONTROL_SELECT: { + const auto find = [modPtr, varHandle] { + return static_cast*>( + config_find_var(*modPtr, varHandle, CONFIG_VAR_INT)); + }; + if (find() == nullptr) { + return false; + } + spec.getInt = [find] { + const auto* var = find(); + return var != nullptr ? clamp_to_int(var->getValue()) : 0; + }; + spec.setInt = [find](int value) { + auto* var = find(); + if (var == nullptr || var->getValue() == value) { + return; + } + var->setValue(value); + config_mark_dirty(); + }; + if (!spec.isModified) { + spec.isModified = [find] { + const auto* var = find(); + return var != nullptr && var->getValue() != var->getDefaultValue(); + }; + } + return true; + } + case UI_CONTROL_STRING: { + const auto find = [modPtr, varHandle] { + return static_cast*>( + config_find_var(*modPtr, varHandle, CONFIG_VAR_STRING)); + }; + if (find() == nullptr) { + return false; + } + spec.getString = [find]() -> Rml::String { + const auto* var = find(); + return var != nullptr ? var->getValue() : ""; + }; + spec.setString = [find](Rml::String value) { + auto* var = find(); + if (var == nullptr || var->getValue() == value) { + return; + } + var->setValue(std::move(value)); + config_mark_dirty(); + }; + if (!spec.isModified) { + spec.isModified = [find] { + const auto* var = find(); + return var != nullptr && var->getValue() != var->getDefaultValue(); + }; + } + return true; + } + default: + return false; + } +} + +void on_mod_window_destroyed(uint64_t handle) { + auto* slot = slot_from_handle(handle); + if (slot == nullptr || slot->kind != UiSlotKind::Window) { + return; + } + LoadedMod* mod = slot->owner; + const UiWindowClosedFn onClosed = slot->onClosed; + void* userData = slot->onClosedUserData; + free_slot(*slot); + if (mod != nullptr && onClosed != nullptr) { + guarded_call(*mod, "window on_closed callback", [&] { + onClosed(mod->context.get(), handle, userData); + }); + } +} + +void on_mod_dialog_destroyed(uint64_t handle) { + auto* slot = slot_from_handle(handle); + if (slot != nullptr && slot->kind == UiSlotKind::Dialog) { + free_slot(*slot); + } +} + +class ModDialog final : public ui::Modal { +public: + ModDialog(Props props, std::function onDestroyed) + : Modal{std::move(props)}, m_onDestroyed{std::move(onDestroyed)} {} + + ~ModDialog() override { + if (m_onDestroyed) { + m_onDestroyed(); + } + } + + void close() { pop(); } + void force_close() { Document::hide(true); } + +private: + std::function m_onDestroyed; +}; + +void push_stacked_document(std::unique_ptr document) { + if (auto* previousTop = ui::top_document()) { + previousTop->push(std::move(document)); + } else { + ui::push_document(std::move(document)); + } +} + +// Shared by dialog_push and dialog_add_action; the guard handle keeps a +// pressed callback from calling into a torn-down mod. +ui::ModalAction make_dialog_action(LoadedMod& mod, uint64_t handle, const UiDialogAction& action) { + return { + .label = action.label, + .onPressed = + [modPtr = &mod, handle, fn = action.on_pressed, userData = action.user_data, + keepOpen = action.keep_open != 0](ui::Modal& modal) { + if (!dialog_open(handle)) { + return; // already being torn down + } + if (fn != nullptr) { + guarded_call(*modPtr, "dialog action callback", [&] { + fn(modPtr->context.get(), handle, userData); + }); + } + // The callback may have closed the dialog already + if (!keepOpen && dialog_open(handle)) { + static_cast(modal).close(); + } + }, + }; +} + +} // namespace + +ModResult ui_register_mods_panel(LoadedMod& mod, const UiModsPanelDesc& desc) { + s_modPanels[&mod] = {desc.build, desc.update, desc.user_data}; + return MOD_OK; +} + +void ui_build_mods_panels(LoadedMod& mod, ui::Pane& pane) { + const auto it = s_modPanels.find(&mod); + if (it == s_modPanels.end()) { + return; + } + const uint64_t paneHandle = wrap_pane(mod, pane, nullptr); + const auto& panel = it->second; + if (!mod.active || panel.build == nullptr) { + return; + } + invoke_mod_ui_callback(mod, "mod UI panel build", [&](ModError* error) { + return panel.build(mod.context.get(), paneHandle, panel.userData, error); + }); +} + +void ui_update_mods_panels(LoadedMod& mod) { + const auto it = s_modPanels.find(&mod); + if (it == s_modPanels.end()) { + return; + } + const auto& panel = it->second; + if (!mod.active || panel.update == nullptr) { + return; + } + invoke_mod_ui_callback(mod, "mod UI panel update", [&](ModError* error) { + return panel.update(mod.context.get(), panel.userData, error); + }); +} + +ModResult ui_pane_add_section(LoadedMod& mod, uint64_t pane, const char* title) { + auto* slot = resolve(mod, pane, UiSlotKind::Pane, "pane_add_section"); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + slot->pane->add_section(title); + return MOD_OK; +} + +ModResult ui_pane_add_text(LoadedMod& mod, uint64_t pane, const char* text, uint64_t* outElem) { + auto* slot = resolve(mod, pane, UiSlotKind::Pane, "pane_add_text"); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + auto* elem = slot->pane->add_text(text); + if (outElem != nullptr) { + auto& elemSlot = alloc_slot(mod, UiSlotKind::Text, *outElem); + elemSlot.elementRml = ui::escape(text); + elemSlot.hasElementValue = true; + track_element(elemSlot, *elem); + } + return MOD_OK; +} + +ModResult ui_pane_add_rml(LoadedMod& mod, uint64_t pane, const char* rml, uint64_t* outElem) { + auto* slot = resolve(mod, pane, UiSlotKind::Pane, "pane_add_rml"); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + auto* elem = slot->pane->add_rml(rml); + if (outElem != nullptr) { + auto& elemSlot = alloc_slot(mod, UiSlotKind::Text, *outElem); + elemSlot.elementRml = rml; + elemSlot.hasElementValue = true; + track_element(elemSlot, *elem); + } + return MOD_OK; +} + +ModResult ui_pane_add_progress(LoadedMod& mod, uint64_t pane, float value, uint64_t* outElem) { + auto* slot = resolve(mod, pane, UiSlotKind::Pane, "pane_add_progress"); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + auto* elem = ui::append(slot->element, "progress"); + elem->SetAttribute("value", value); + if (outElem != nullptr) { + auto& elemSlot = alloc_slot(mod, UiSlotKind::Progress, *outElem); + elemSlot.elementFloat = value; + elemSlot.hasElementValue = true; + track_element(elemSlot, *elem); + } + return MOD_OK; +} + +ModResult ui_pane_add_control( + LoadedMod& mod, uint64_t pane, const UiControlDesc& desc, uint64_t* outElem) { + auto* slot = resolve(mod, pane, UiSlotKind::Pane, "pane_add_control"); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + + ui::ModControlSpec spec; + spec.label = desc.label; + spec.helpRml = desc.help_rml != nullptr ? desc.help_rml : ""; + spec.isDisabled = wrap_predicate(mod, desc.is_disabled, desc.user_data, pane); + spec.isModified = wrap_predicate(mod, desc.is_modified, desc.user_data, pane); + switch (desc.kind) { + case UI_CONTROL_BUTTON: + spec.kind = ui::ModControlSpec::Kind::Button; + spec.onPressed = [modPtr = &mod, fn = desc.on_pressed, userData = desc.user_data, + guardHandle = pane] { + if (!slot_live(guardHandle)) { + return; + } + guarded_call(*modPtr, "control on_pressed callback", [&] { + fn(modPtr->context.get(), userData); + }); + }; + break; + case UI_CONTROL_TOGGLE: + spec.kind = ui::ModControlSpec::Kind::Toggle; + break; + case UI_CONTROL_NUMBER: + spec.kind = ui::ModControlSpec::Kind::Number; + if (desc.min != desc.max) { + spec.min = clamp_to_int(desc.min); + spec.max = clamp_to_int(desc.max); + if (spec.max < spec.min) { + std::swap(spec.min, spec.max); + } + } + spec.step = desc.step < 1 ? 1 : clamp_to_int(desc.step); + spec.prefix = desc.prefix != nullptr ? desc.prefix : ""; + spec.suffix = desc.suffix != nullptr ? desc.suffix : ""; + break; + case UI_CONTROL_STRING: + spec.kind = ui::ModControlSpec::Kind::String; + spec.maxLength = desc.max_length < 1 ? -1 : desc.max_length; + break; + case UI_CONTROL_SELECT: + spec.kind = ui::ModControlSpec::Kind::Select; + if (slot->helpPane == nullptr) { + Log.error("[{}] pane_add_control: SELECT controls need a help pane (mod window tabs)", + mod.metadata.id); + return MOD_UNSUPPORTED; + } + for (size_t i = 0; i < desc.option_count; ++i) { + spec.options.emplace_back(desc.options[i]); + } + break; + default: + return MOD_INVALID_ARGUMENT; + } + + if (desc.kind != UI_CONTROL_BUTTON) { + if (desc.binding == UI_BINDING_CONFIG_VAR) { + if (!wire_config_var_binding(mod, desc, spec)) { + Log.error("[{}] pane_add_control: config var handle {:#x} is unknown or its type " + "does not match the control kind", + mod.metadata.id, desc.config_var); + return MOD_INVALID_ARGUMENT; + } + } else { + wire_callback_binding(mod, desc, spec, pane); + } + } + + // Copy the pane pointers out: allocating the control's slot below may reallocate s_slots + auto* paneComponent = slot->pane; + auto* helpPane = slot->helpPane; + auto* control = ui::build_mod_control(*paneComponent, helpPane, std::move(spec)); + if (control == nullptr) { + return MOD_UNSUPPORTED; + } + if (outElem != nullptr) { + auto& elemSlot = alloc_slot(mod, UiSlotKind::Control, *outElem); + track_element(elemSlot, *control->root()); + } + return MOD_OK; +} + +ModResult ui_elem_set_text(LoadedMod& mod, uint64_t elem, const char* text) { + auto* slot = resolve(mod, elem, UiSlotKind::Text, "elem_set_text"); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + const std::string rml = ui::escape(text); + if (slot->hasElementValue && slot->elementRml == rml) { + return MOD_OK; + } + slot->elementRml = rml; + slot->hasElementValue = true; + slot->element->SetInnerRML(slot->elementRml); + return MOD_OK; +} + +ModResult ui_elem_set_rml(LoadedMod& mod, uint64_t elem, const char* rml) { + auto* slot = resolve(mod, elem, UiSlotKind::Text, "elem_set_rml"); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + if (slot->hasElementValue && slot->elementRml == rml) { + return MOD_OK; + } + slot->elementRml = rml; + slot->hasElementValue = true; + slot->element->SetInnerRML(rml); + return MOD_OK; +} + +ModResult ui_elem_set_progress(LoadedMod& mod, uint64_t elem, float value) { + auto* slot = resolve(mod, elem, UiSlotKind::Progress, "elem_set_progress"); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + if (slot->hasElementValue && slot->elementFloat == value) { + return MOD_OK; + } + slot->elementFloat = value; + slot->hasElementValue = true; + slot->element->SetAttribute("value", value); + return MOD_OK; +} + +ModResult ui_elem_set_class(LoadedMod& mod, uint64_t elem, const char* name, bool active) { + auto* slot = slot_from_handle(elem); + if (slot == nullptr || slot->owner != &mod || slot->element == nullptr) { + Log.error( + "[{}] elem_set_class: stale or invalid element handle {:#x}", mod.metadata.id, elem); + return MOD_INVALID_ARGUMENT; + } + slot->element->SetClass(name, active); + return MOD_OK; +} + +ModResult ui_window_push(LoadedMod& mod, const UiWindowDesc& desc, uint64_t& outHandle) { + outHandle = 0; + if (!aurora::rmlui::is_initialized()) { + return MOD_UNAVAILABLE; + } + if (desc.rcss != nullptr && desc.rcss[0] != '\0' && + Rml::Factory::InstanceStyleSheetString(desc.rcss) == nullptr) + { + Log.error("[{}] window_push: failed to parse window RCSS", mod.metadata.id); + return MOD_INVALID_ARGUMENT; + } + + uint64_t handle = 0; + { + auto& slot = alloc_slot(mod, UiSlotKind::Window, handle); + slot.onClosed = desc.on_closed; + slot.onClosedUserData = desc.user_data; + } + + ui::ModWindow::Desc windowDesc; + windowDesc.modId = mod.metadata.id; + windowDesc.rcss = desc.rcss != nullptr ? desc.rcss : ""; + windowDesc.onDestroyed = [handle] { on_mod_window_destroyed(handle); }; + for (size_t i = 0; i < desc.tab_count; ++i) { + const UiTabDesc& tab = desc.tabs[i]; + ui::ModWindow::Tab hostTab; + hostTab.title = tab.title; + hostTab.build = [modPtr = &mod, handle, build = tab.build, userData = tab.user_data]( + ui::ModWindow&, ui::Pane& left, ui::Pane& right) { + if (build == nullptr || !slot_live(handle) || !modPtr->active) { + return; + } + const uint64_t leftHandle = wrap_pane(*modPtr, left, &right); + const uint64_t rightHandle = wrap_pane(*modPtr, right, nullptr); + invoke_mod_ui_callback(*modPtr, "mod UI tab build", [&](ModError* error) { + return build( + modPtr->context.get(), handle, leftHandle, rightHandle, userData, error); + }); + }; + if (tab.update != nullptr) { + hostTab.update = [modPtr = &mod, handle, update = tab.update, + userData = tab.user_data] { + if (!slot_live(handle) || !modPtr->active) { + return; + } + invoke_mod_ui_callback(*modPtr, "mod UI tab update", [&](ModError* error) { + return update(modPtr->context.get(), userData, error); + }); + }; + } + windowDesc.tabs.push_back(std::move(hostTab)); + } + + // The first tab builds during construction, which can allocate slots; only + // re-resolve the window slot afterwards. + auto window = std::make_unique(std::move(windowDesc)); + if (auto* slot = slot_from_handle(handle)) { + slot->document = window.get(); + } + push_stacked_document(std::move(window)); + outHandle = handle; + return MOD_OK; +} + +ModResult ui_window_close(LoadedMod& mod, uint64_t handle) { + auto* slot = resolve(mod, handle, UiSlotKind::Window, "window_close"); + if (slot == nullptr || slot->document == nullptr) { + return MOD_INVALID_ARGUMENT; + } + slot->document->hide(true); + return MOD_OK; +} + +ModResult ui_dialog_push(LoadedMod& mod, const UiDialogDesc& desc, uint64_t& outHandle) { + outHandle = 0; + if (!aurora::rmlui::is_initialized()) { + return MOD_UNAVAILABLE; + } + uint64_t handle = 0; + alloc_slot(mod, UiSlotKind::Dialog, handle); + + const char* defaultIcon = ""; + ui::Modal::Props props; + switch (desc.variant) { + case UI_DIALOG_WARNING: + defaultIcon = "warning"; + break; + case UI_DIALOG_DANGER: + props.variant = "danger"; + defaultIcon = "error"; + break; + default: + break; + } + props.title = ui::escape(desc.title); + props.bodyRml = desc.body_rml; + props.icon = desc.icon != nullptr ? desc.icon : defaultIcon; + props.onDismiss = [modPtr = &mod, handle, fn = desc.on_dismiss, userData = desc.user_data]( + ui::Modal& modal) { + if (!dialog_open(handle)) { + return; // already being torn down + } + if (fn != nullptr) { + guarded_call(*modPtr, "dialog on_dismiss callback", [&] { + fn(modPtr->context.get(), handle, userData); + }); + } + if (dialog_open(handle)) { + static_cast(modal).close(); + } + }; + for (size_t i = 0; i < desc.action_count; ++i) { + props.actions.push_back(make_dialog_action(mod, handle, desc.actions[i])); + } + + auto dialog = + std::make_unique(std::move(props), [handle] { on_mod_dialog_destroyed(handle); }); + if (auto* slot = slot_from_handle(handle)) { + slot->document = dialog.get(); + } + push_stacked_document(std::move(dialog)); + outHandle = handle; + return MOD_OK; +} + +ModResult ui_dialog_close(LoadedMod& mod, uint64_t handle) { + auto* slot = resolve(mod, handle, UiSlotKind::Dialog, "dialog_close"); + if (slot == nullptr || slot->document == nullptr) { + return MOD_INVALID_ARGUMENT; + } + // Programmatic close: no dismiss notification, no sound + static_cast(slot->document)->close(); + return MOD_OK; +} + +ModResult ui_dialog_set_body(LoadedMod& mod, uint64_t handle, const char* rml) { + auto* slot = resolve(mod, handle, UiSlotKind::Dialog, "dialog_set_body"); + if (slot == nullptr || slot->document == nullptr) { + return MOD_INVALID_ARGUMENT; + } + static_cast(slot->document)->set_body(rml); + return MOD_OK; +} + +ModResult ui_dialog_set_icon(LoadedMod& mod, uint64_t handle, const char* icon) { + auto* slot = resolve(mod, handle, UiSlotKind::Dialog, "dialog_set_icon"); + if (slot == nullptr || slot->document == nullptr) { + return MOD_INVALID_ARGUMENT; + } + static_cast(slot->document)->set_icon(icon); + return MOD_OK; +} + +ModResult ui_dialog_add_action(LoadedMod& mod, uint64_t handle, const UiDialogAction& action) { + auto* slot = resolve(mod, handle, UiSlotKind::Dialog, "dialog_add_action"); + if (slot == nullptr || slot->document == nullptr) { + return MOD_INVALID_ARGUMENT; + } + static_cast(slot->document)->add_action(make_dialog_action(mod, handle, action)); + return MOD_OK; +} + +ModResult ui_register_menu_tab(LoadedMod& mod, const UiMenuTabDesc& desc, uint64_t& outHandle) { + outHandle = 0; + for (const auto& [owner, tabs] : s_modMenuTabs) { + for (const auto& tab : tabs) { + if (owner != &mod && tab.label == desc.label) { + Log.warn("[{}] register_menu_tab: label '{}' is already used by [{}]", + mod.metadata.id, desc.label, owner->metadata.id); + } + } + } + uint64_t handle = 0; + alloc_slot(mod, UiSlotKind::MenuTab, handle); + s_modMenuTabs[&mod].push_back({.handle = handle, + .label = desc.label, + .onSelected = desc.on_selected, + .userData = desc.user_data}); + s_menuTabsDirty = true; + outHandle = handle; + return MOD_OK; +} + +ModResult ui_unregister_menu_tab(LoadedMod& mod, uint64_t handle) { + auto* slot = resolve(mod, handle, UiSlotKind::MenuTab, "unregister_menu_tab"); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + const auto it = s_modMenuTabs.find(&mod); + if (it != s_modMenuTabs.end()) { + std::erase_if(it->second, [&](const auto& tab) { return tab.handle == handle; }); + if (it->second.empty()) { + s_modMenuTabs.erase(it); + } + } + free_slot(*slot); + s_menuTabsDirty = true; + return MOD_OK; +} + +std::vector ui_mod_menu_tabs() { + // The consumer (a MenuBar being constructed) now reflects the current tab + // set, so a pending rebuild for earlier mutations is moot. + s_menuTabsDirty = false; + std::vector entries; + for (auto& mod : ModLoader::instance().mods()) { + if (!mod.active) { + continue; + } + const auto it = s_modMenuTabs.find(&mod); + if (it == s_modMenuTabs.end()) { + continue; + } + for (const auto& tab : it->second) { + entries.push_back({.label = tab.label, + .onSelected = [modPtr = &mod, handle = tab.handle, fn = tab.onSelected, + userData = tab.userData] { + if (!slot_live(handle) || !modPtr->active) { + return; // registered by a since-unloaded mod image + } + guarded_call(*modPtr, "menu tab on_selected callback", [&] { + fn(modPtr->context.get(), userData); + }); + }}); + } + } + return entries; +} + +void ui_sync_menu_tabs() { + if (!s_menuTabsDirty) { + return; + } + s_menuTabsDirty = false; + if (aurora::rmlui::is_initialized()) { + ui::MenuBar::rebuild(); + } +} + +bool ui_any_document_visible() { + return ui::any_document_visible(); +} + +ModResult ui_register_styles( + LoadedMod& mod, uint32_t scope, const char* rcss, uint64_t& outHandle) { + outHandle = 0; + ui::DocumentScope docScope; + switch (scope) { + case UI_SCOPE_PRELAUNCH: + docScope = ui::DocumentScope::Prelaunch; + break; + case UI_SCOPE_WINDOW: + docScope = ui::DocumentScope::Window; + break; + case UI_SCOPE_MENU_BAR: + docScope = ui::DocumentScope::MenuBar; + break; + case UI_SCOPE_OVERLAY: + docScope = ui::DocumentScope::Overlay; + break; + case UI_SCOPE_TOUCH_CONTROLS: + docScope = ui::DocumentScope::TouchControls; + break; + case UI_SCOPE_GRAPHICS_TUNER: + docScope = ui::DocumentScope::GraphicsTuner; + break; + default: + return MOD_INVALID_ARGUMENT; + } + + uint64_t handle = 0; + auto& slot = alloc_slot(mod, UiSlotKind::Style, handle); + slot.styleScope = docScope; + slot.styleId = fmt::format("{}:{:x}", mod.metadata.id, handle); + if (!ui::register_scoped_styles(docScope, slot.styleId, rcss)) { + Log.error("[{}] register_styles: failed to parse RCSS", mod.metadata.id); + free_slot(slot); + return MOD_INVALID_ARGUMENT; + } + outHandle = handle; + return MOD_OK; +} + +ModResult ui_register_styles_file( + LoadedMod& mod, uint32_t scope, const char* path, uint64_t& outHandle) { + outHandle = 0; + if (mod.bundle == nullptr) { + return MOD_UNAVAILABLE; + } + std::vector data; + const std::string entry = std::string{"res/"} + path; + try { + data = mod.bundle->readFile(entry); + } catch (const std::runtime_error& e) { + Log.error("[{}] register_styles_file '{}' failed: {}", mod.metadata.id, entry, e.what()); + return MOD_UNAVAILABLE; + } + const std::string rcss{data.begin(), data.end()}; + return ui_register_styles(mod, scope, rcss.c_str(), outHandle); +} + +ModResult ui_unregister_styles(LoadedMod& mod, uint64_t handle) { + auto* slot = resolve(mod, handle, UiSlotKind::Style, "unregister_styles"); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + ui::unregister_scoped_styles(slot->styleScope, slot->styleId); + free_slot(*slot); + return MOD_OK; +} + +void ui_remove_mod(LoadedMod& mod) { + s_modPanels.erase(&mod); + if (s_modMenuTabs.erase(&mod) != 0) { + s_menuTabsDirty = true; + } + for (auto& slot : s_slots) { + if (slot.kind == UiSlotKind::Free || slot.owner != &mod) { + continue; + } + switch (slot.kind) { + case UiSlotKind::Window: { + auto* window = static_cast(slot.document); + free_slot(slot); + if (window != nullptr) { + window->force_close(); + } + break; + } + case UiSlotKind::Dialog: { + auto* dialog = static_cast(slot.document); + free_slot(slot); + if (dialog != nullptr) { + dialog->force_close(); + } + break; + } + case UiSlotKind::Style: + ui::unregister_scoped_styles(slot.styleScope, slot.styleId); + free_slot(slot); + break; + default: + free_slot(slot); + break; + } + } +} + +} // namespace dusk::mods::svc::ui_impl + +namespace dusk::mods::svc { +namespace { + +// Validation of the tagged control descriptor: required fields per kind/binding. Value +// translation and cvar wiring live in loader/ui.cpp. +bool valid_control_desc(const UiControlDesc& desc) { + if (desc.struct_size < sizeof(UiControlDesc) || desc.label == nullptr) { + return false; + } + switch (desc.kind) { + case UI_CONTROL_BUTTON: + return desc.on_pressed != nullptr; + case UI_CONTROL_TOGGLE: + case UI_CONTROL_NUMBER: + case UI_CONTROL_STRING: + case UI_CONTROL_SELECT: + break; + default: + return false; + } + if (desc.kind == UI_CONTROL_SELECT) { + if (desc.options == nullptr || desc.option_count == 0) { + return false; + } + for (size_t i = 0; i < desc.option_count; ++i) { + if (desc.options[i] == nullptr) { + return false; + } + } + } + switch (desc.binding) { + case UI_BINDING_CALLBACKS: + return desc.get != nullptr && desc.set != nullptr; + case UI_BINDING_CONFIG_VAR: + return desc.config_var != 0; + default: + return false; + } +} + +ModResult ui_register_mods_panel(ModContext* context, const UiModsPanelDesc* desc) { + auto* mod = mod_from_context(context); + if (mod == nullptr || desc == nullptr || desc->struct_size < sizeof(UiModsPanelDesc) || + desc->build == nullptr) + { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_register_mods_panel(*mod, *desc); +} + +ModResult ui_pane_add_section(ModContext* context, UiElementHandle pane, const char* title) { + auto* mod = mod_from_context(context); + if (mod == nullptr || pane == 0 || title == nullptr) { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_pane_add_section(*mod, pane, title); +} + +ModResult ui_pane_add_text( + ModContext* context, UiElementHandle pane, const char* text, UiElementHandle* outElem) { + if (outElem != nullptr) { + *outElem = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || pane == 0 || text == nullptr) { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_pane_add_text(*mod, pane, text, outElem); +} + +ModResult ui_pane_add_rml( + ModContext* context, UiElementHandle pane, const char* rml, UiElementHandle* outElem) { + if (outElem != nullptr) { + *outElem = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || pane == 0 || rml == nullptr) { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_pane_add_rml(*mod, pane, rml, outElem); +} + +ModResult ui_pane_add_progress( + ModContext* context, UiElementHandle pane, float value, UiElementHandle* outElem) { + if (outElem != nullptr) { + *outElem = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || pane == 0) { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_pane_add_progress(*mod, pane, value, outElem); +} + +ModResult ui_pane_add_control(ModContext* context, UiElementHandle pane, const UiControlDesc* desc, + UiElementHandle* outElem) { + if (outElem != nullptr) { + *outElem = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || pane == 0 || desc == nullptr || !valid_control_desc(*desc)) { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_pane_add_control(*mod, pane, *desc, outElem); +} + +ModResult ui_elem_set_text(ModContext* context, UiElementHandle elem, const char* text) { + auto* mod = mod_from_context(context); + if (mod == nullptr || elem == 0 || text == nullptr) { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_elem_set_text(*mod, elem, text); +} + +ModResult ui_elem_set_rml(ModContext* context, UiElementHandle elem, const char* rml) { + auto* mod = mod_from_context(context); + if (mod == nullptr || elem == 0 || rml == nullptr) { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_elem_set_rml(*mod, elem, rml); +} + +ModResult ui_elem_set_progress(ModContext* context, UiElementHandle elem, float value) { + auto* mod = mod_from_context(context); + if (mod == nullptr || elem == 0) { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_elem_set_progress(*mod, elem, value); +} + +ModResult ui_elem_set_class( + ModContext* context, UiElementHandle elem, const char* name, bool active) { + auto* mod = mod_from_context(context); + if (mod == nullptr || elem == 0 || name == nullptr || name[0] == '\0') { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_elem_set_class(*mod, elem, name, active); +} + +ModResult ui_window_push(ModContext* context, const UiWindowDesc* desc, UiWindowHandle* outWindow) { + if (outWindow != nullptr) { + *outWindow = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || desc == nullptr || desc->struct_size < sizeof(UiWindowDesc) || + desc->tabs == nullptr || desc->tab_count == 0) + { + return MOD_INVALID_ARGUMENT; + } + for (size_t i = 0; i < desc->tab_count; ++i) { + const UiTabDesc& tab = desc->tabs[i]; + if (tab.struct_size < sizeof(UiTabDesc) || tab.title == nullptr || tab.build == nullptr) { + return MOD_INVALID_ARGUMENT; + } + } + uint64_t handle = 0; + const auto result = ui_impl::ui_window_push(*mod, *desc, handle); + if (result == MOD_OK && outWindow != nullptr) { + *outWindow = handle; + } + return result; +} + +ModResult ui_window_close(ModContext* context, UiWindowHandle window) { + auto* mod = mod_from_context(context); + if (mod == nullptr || window == 0) { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_window_close(*mod, window); +} + +ModResult ui_dialog_push(ModContext* context, const UiDialogDesc* desc, UiDialogHandle* outDialog) { + if (outDialog != nullptr) { + *outDialog = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || desc == nullptr || desc->struct_size < sizeof(UiDialogDesc) || + desc->title == nullptr || desc->body_rml == nullptr || desc->actions == nullptr || + desc->action_count == 0 || desc->variant > UI_DIALOG_DANGER) + { + return MOD_INVALID_ARGUMENT; + } + for (size_t i = 0; i < desc->action_count; ++i) { + if (desc->actions[i].label == nullptr) { + return MOD_INVALID_ARGUMENT; + } + } + uint64_t handle = 0; + const auto result = ui_impl::ui_dialog_push(*mod, *desc, handle); + if (result == MOD_OK && outDialog != nullptr) { + *outDialog = handle; + } + return result; +} + +ModResult ui_dialog_close(ModContext* context, UiDialogHandle dialog) { + auto* mod = mod_from_context(context); + if (mod == nullptr || dialog == 0) { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_dialog_close(*mod, dialog); +} + +ModResult ui_is_any_document_visible(ModContext* context, bool* outVisible) { + auto* mod = mod_from_context(context); + if (mod == nullptr || outVisible == nullptr) { + return MOD_INVALID_ARGUMENT; + } + *outVisible = ui_impl::ui_any_document_visible(); + return MOD_OK; +} + +ModResult ui_register_styles( + ModContext* context, UiStyleScope scope, const char* rcss, UiStyleHandle* outStyle) { + if (outStyle != nullptr) { + *outStyle = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || rcss == nullptr || scope > UI_SCOPE_GRAPHICS_TUNER) { + return MOD_INVALID_ARGUMENT; + } + uint64_t handle = 0; + const auto result = ui_impl::ui_register_styles(*mod, scope, rcss, handle); + if (result == MOD_OK && outStyle != nullptr) { + *outStyle = handle; + } + return result; +} + +ModResult ui_register_styles_file( + ModContext* context, UiStyleScope scope, const char* path, UiStyleHandle* outStyle) { + if (outStyle != nullptr) { + *outStyle = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || path == nullptr || !is_safe_resource_path(path) || + scope > UI_SCOPE_GRAPHICS_TUNER) + { + return MOD_INVALID_ARGUMENT; + } + uint64_t handle = 0; + const auto result = ui_impl::ui_register_styles_file(*mod, scope, path, handle); + if (result == MOD_OK && outStyle != nullptr) { + *outStyle = handle; + } + return result; +} + +ModResult ui_unregister_styles(ModContext* context, UiStyleHandle style) { + auto* mod = mod_from_context(context); + if (mod == nullptr || style == 0) { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_unregister_styles(*mod, style); +} + +ModResult ui_register_menu_tab( + ModContext* context, const UiMenuTabDesc* desc, UiMenuTabHandle* outTab) { + if (outTab != nullptr) { + *outTab = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || desc == nullptr || desc->struct_size < sizeof(UiMenuTabDesc) || + desc->label == nullptr || desc->label[0] == '\0' || desc->on_selected == nullptr) + { + return MOD_INVALID_ARGUMENT; + } + uint64_t handle = 0; + const auto result = ui_impl::ui_register_menu_tab(*mod, *desc, handle); + if (result == MOD_OK && outTab != nullptr) { + *outTab = handle; + } + return result; +} + +ModResult ui_unregister_menu_tab(ModContext* context, UiMenuTabHandle tab) { + auto* mod = mod_from_context(context); + if (mod == nullptr || tab == 0) { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_unregister_menu_tab(*mod, tab); +} + +ModResult ui_dialog_set_body(ModContext* context, UiDialogHandle dialog, const char* bodyRml) { + auto* mod = mod_from_context(context); + if (mod == nullptr || dialog == 0 || bodyRml == nullptr) { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_dialog_set_body(*mod, dialog, bodyRml); +} + +ModResult ui_dialog_set_icon(ModContext* context, UiDialogHandle dialog, const char* icon) { + auto* mod = mod_from_context(context); + if (mod == nullptr || dialog == 0 || icon == nullptr) { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_dialog_set_icon(*mod, dialog, icon); +} + +ModResult ui_dialog_add_action( + ModContext* context, UiDialogHandle dialog, const UiDialogAction* action) { + auto* mod = mod_from_context(context); + if (mod == nullptr || dialog == 0 || action == nullptr || action->label == nullptr) { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_dialog_add_action(*mod, dialog, *action); +} + +constexpr UiService s_uiService{ + .header = SERVICE_HEADER(UiService, UI_SERVICE_MAJOR, UI_SERVICE_MINOR), + .register_mods_panel = ui_register_mods_panel, + .pane_add_section = ui_pane_add_section, + .pane_add_text = ui_pane_add_text, + .pane_add_rml = ui_pane_add_rml, + .pane_add_progress = ui_pane_add_progress, + .pane_add_control = ui_pane_add_control, + .elem_set_text = ui_elem_set_text, + .elem_set_rml = ui_elem_set_rml, + .elem_set_progress = ui_elem_set_progress, + .elem_set_class = ui_elem_set_class, + .window_push = ui_window_push, + .window_close = ui_window_close, + .dialog_push = ui_dialog_push, + .dialog_close = ui_dialog_close, + .dialog_set_body = ui_dialog_set_body, + .dialog_set_icon = ui_dialog_set_icon, + .dialog_add_action = ui_dialog_add_action, + .is_any_document_visible = ui_is_any_document_visible, + .register_styles = ui_register_styles, + .register_styles_file = ui_register_styles_file, + .unregister_styles = ui_unregister_styles, + .register_menu_tab = ui_register_menu_tab, + .unregister_menu_tab = ui_unregister_menu_tab, +}; + +} // namespace + +void ui_build_mods_panels(LoadedMod& mod, ui::Pane& pane) { + ui_impl::ui_build_mods_panels(mod, pane); +} + +void ui_update_mods_panels(LoadedMod& mod) { + ui_impl::ui_update_mods_panels(mod); +} + +std::vector ui_mod_menu_tabs() { + return ui_impl::ui_mod_menu_tabs(); +} + +constinit const ServiceModule g_uiModule{ + .id = UI_SERVICE_ID, + .majorVersion = UI_SERVICE_MAJOR, + .minorVersion = UI_SERVICE_MINOR, + .service = &s_uiService, + .modDetached = ui_impl::ui_remove_mod, + .frameEnd = ui_impl::ui_sync_menu_tabs, +}; + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/ui.hpp b/src/dusk/mods/svc/ui.hpp new file mode 100644 index 0000000000..0f0efc7c9d --- /dev/null +++ b/src/dusk/mods/svc/ui.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include "dusk/mod_loader.hpp" + +#include +#include +#include + +namespace dusk::ui { +class Pane; +} // namespace dusk::ui + +namespace dusk::mods::svc { + +void ui_build_mods_panels(LoadedMod& mod, ui::Pane& pane); +void ui_update_mods_panels(LoadedMod& mod); + +struct ModMenuTabEntry { + std::string label; + std::function onSelected; +}; + +std::vector ui_mod_menu_tabs(); + +} // namespace dusk::mods::svc diff --git a/src/dusk/ui/controller_config.cpp b/src/dusk/ui/controller_config.cpp index 7c8e720ecd..b57b16eba5 100644 --- a/src/dusk/ui/controller_config.cpp +++ b/src/dusk/ui/controller_config.cpp @@ -243,11 +243,7 @@ int rumble_raw_to_percent(u16 raw) { } // namespace -ControllerConfigWindow::ControllerConfigWindow(bool prelaunch) { - if (prelaunch) { - mSuppressNavFallback = true; - } - +ControllerConfigWindow::ControllerConfigWindow() { listen( Rml::EventId::Keydown, [this](Rml::Event& event) { diff --git a/src/dusk/ui/controller_config.hpp b/src/dusk/ui/controller_config.hpp index b5a50ad8b1..e5e5f20cfc 100644 --- a/src/dusk/ui/controller_config.hpp +++ b/src/dusk/ui/controller_config.hpp @@ -9,7 +9,7 @@ namespace dusk::ui { class ControllerConfigWindow : public Window { public: - ControllerConfigWindow(bool prelaunch); + ControllerConfigWindow(); void update() override; void hide(bool close) override; diff --git a/src/dusk/ui/document.cpp b/src/dusk/ui/document.cpp index 1ad03bc1ec..2b387f3fe9 100644 --- a/src/dusk/ui/document.cpp +++ b/src/dusk/ui/document.cpp @@ -5,6 +5,8 @@ #include "m_Do/m_Do_audio.h" +#include + namespace dusk::ui { namespace { @@ -18,8 +20,16 @@ Rml::ElementDocument* load_document(const Rml::String& source) { } // namespace -Document::Document(const Rml::String& source, bool passive) - : mDocument(load_document(source)), mPassive(passive) { +Document::Document(const Rml::String& source, bool passive, DocumentScope scope) + : mDocument(load_document(source)), mScope(scope), mPassive(passive) { + if (mDocument != nullptr) { + if (const auto* base = mDocument->GetStyleSheetContainer()) { + // Clone a pristine snapshot to rebuild from on every restyle + mBaseStyleSheets = base->CombineStyleSheetContainer(Rml::StyleSheetContainer{}); + } + apply_scoped_styles(*this); + } + // Block events while hidden (except for Menu command); play nav sounds when visible listen( Rml::EventId::Keydown, @@ -91,6 +101,51 @@ bool Document::focus() { return false; } +bool Document::set_document_styles(const Rml::String& rcss) { + if (rcss.empty()) { + mDocumentStyleSheets = nullptr; + } else { + auto sheet = Rml::Factory::InstanceStyleSheetString(rcss); + if (sheet == nullptr) { + return false; + } + mDocumentStyleSheets = std::move(sheet); + } + apply_scoped_styles(*this); + return true; +} + +void Document::restyle(std::span sheets) { + if (mDocument == nullptr) { + return; + } + const bool wantsExtra = + mDocumentStyleSheets != nullptr || + std::ranges::any_of(sheets, [](const auto* sheet) { return sheet != nullptr; }); + // Nothing to add + if (!wantsExtra && !mRestyled) { + return; + } + auto combined = mBaseStyleSheets; + const auto combine = [&combined](const Rml::StyleSheetContainer& sheet) { + if (combined != nullptr) { + combined = combined->CombineStyleSheetContainer(sheet); + } else { + combined = sheet.CombineStyleSheetContainer(Rml::StyleSheetContainer{}); + } + }; + for (const auto* sheet : sheets) { + if (sheet != nullptr) { + combine(*sheet); + } + } + if (mDocumentStyleSheets != nullptr) { + combine(*mDocumentStyleSheets); + } + mDocument->SetStyleSheetContainer(std::move(combined)); + mRestyled = wantsExtra; +} + void Document::listen(Rml::Element* element, Rml::EventId event, ScopedEventListener::Callback callback, bool capture) { if (element == nullptr) { @@ -139,6 +194,9 @@ bool Document::handle_nav_event(Rml::Event& event) { bool Document::handle_nav_command(Rml::Event& event, NavCommand cmd) { if (cmd == NavCommand::Menu) { + if (game_obscured_below(*this)) { + return true; + } mDoAud_seStartMenu(visible() ? kSoundMenuClose : kSoundMenuOpen); toggle(); return true; diff --git a/src/dusk/ui/document.hpp b/src/dusk/ui/document.hpp index c3428d9fea..e7bd35947d 100644 --- a/src/dusk/ui/document.hpp +++ b/src/dusk/ui/document.hpp @@ -3,11 +3,14 @@ #include "component.hpp" #include "ui.hpp" +#include + namespace dusk::ui { class Document { public: - explicit Document(const Rml::String& source, bool passive = false); + explicit Document( + const Rml::String& source, bool passive = false, DocumentScope scope = DocumentScope::None); virtual ~Document(); Document(const Document&) = delete; @@ -19,7 +22,22 @@ public: virtual bool focus(); virtual bool visible() const; virtual bool active() const; + virtual bool obscures_game() const { return false; } + virtual void cover() { + mWasVisible = visible(); + hide(false); + } + virtual void uncover() { + if (mWasVisible) { + show(); + } else { + focus(); + } + } + DocumentScope scope() const { return mScope; } + bool set_document_styles(const Rml::String& rcss); + void restyle(std::span sheets); void listen(Rml::Element* element, Rml::EventId event, ScopedEventListener::Callback callback, bool capture = false); void listen(Rml::Element* element, const Rml::String& event, @@ -40,11 +58,11 @@ public: } void push(std::unique_ptr document) { push_document(std::move(document)); - hide(false); + cover(); } - void pop(bool show = true) { + void pop() { hide(true); - focus_top_document(show); + uncover_top_document(); } bool closed() const { return mClosed; } @@ -55,10 +73,15 @@ protected: virtual bool handle_nav_command(Rml::Event& event, NavCommand cmd); Rml::ElementDocument* mDocument; - std::vector > mListeners; + std::vector> mListeners; + Rml::SharedPtr mBaseStyleSheets; + Rml::SharedPtr mDocumentStyleSheets; + DocumentScope mScope = DocumentScope::None; bool mPendingClose = false; bool mClosed = false; bool mPassive = false; + bool mRestyled = false; + bool mWasVisible = false; }; } // namespace dusk::ui diff --git a/src/dusk/ui/graphics_tuner.cpp b/src/dusk/ui/graphics_tuner.cpp index 054c0b302c..cddcf06330 100644 --- a/src/dusk/ui/graphics_tuner.cpp +++ b/src/dusk/ui/graphics_tuner.cpp @@ -244,9 +244,9 @@ Rml::String format_graphics_setting_value(GraphicsOption option, int value) { return ""; } -GraphicsTuner::GraphicsTuner(GraphicsTunerProps props, bool prelaunch) - : Document(kDocumentSource), mOption(props.option), mValueMin(props.valueMin), - mValueMax(props.valueMax), mDefaultValue(props.defaultValue), mPrelaunch(prelaunch) { +GraphicsTuner::GraphicsTuner(GraphicsTunerProps props) + : Document(kDocumentSource, false, DocumentScope::GraphicsTuner), mOption(props.option), + mValueMin(props.valueMin), mValueMax(props.valueMax), mDefaultValue(props.defaultValue) { if (mDocument == nullptr) { return; } @@ -338,7 +338,7 @@ bool GraphicsTuner::handle_nav_command(Rml::Event& event, NavCommand cmd) { return true; } - return mPrelaunch ? false : Document::handle_nav_command(event, cmd); + return Document::handle_nav_command(event, cmd); } void GraphicsTuner::reset_default() { diff --git a/src/dusk/ui/graphics_tuner.hpp b/src/dusk/ui/graphics_tuner.hpp index 03b3d2f1c0..d778583b29 100644 --- a/src/dusk/ui/graphics_tuner.hpp +++ b/src/dusk/ui/graphics_tuner.hpp @@ -63,7 +63,7 @@ struct GraphicsTunerProps { class GraphicsTuner : public Document { public: - explicit GraphicsTuner(GraphicsTunerProps props, bool prelaunch); + explicit GraphicsTuner(GraphicsTunerProps props); void show() override; void hide(bool close) override; @@ -92,7 +92,6 @@ private: std::vector > mComponents; SteppedCarousel* mCarousel; Rml::Element* mRoot; - bool mPrelaunch; }; } // namespace dusk::ui diff --git a/src/dusk/ui/menu_bar.cpp b/src/dusk/ui/menu_bar.cpp index 5c16878a2a..0c6528e424 100644 --- a/src/dusk/ui/menu_bar.cpp +++ b/src/dusk/ui/menu_bar.cpp @@ -9,6 +9,7 @@ #include "aurora/rmlui.hpp" #include "dusk/livesplit.h" #include "dusk/main.h" +#include "dusk/mods/svc/ui.hpp" #include "dusk/settings.h" #include "dusk/speedrun.h" #include "editor.hpp" @@ -42,7 +43,9 @@ const Rml::String kDocumentSource = R"RML( } -MenuBar::MenuBar() : Document(kDocumentSource), mRoot(mDocument->GetElementById("popup")) { +MenuBar::MenuBar() + : Document(kDocumentSource, false, DocumentScope::MenuBar), + mRoot(mDocument->GetElementById("popup")) { mTabBar = std::make_unique(mRoot, TabBar::Props{ .onClose = [this] { @@ -60,6 +63,9 @@ MenuBar::MenuBar() : Document(kDocumentSource), mRoot(mDocument->GetElementById( mTabBar->add_tab("Achievements", [this] { push(std::make_unique()); }); mTabBar->add_tab("Mods", [this] { push(std::make_unique()); }); + for (auto& tab : mods::svc::ui_mod_menu_tabs()) { + mTabBar->add_tab(tab.label, std::move(tab.onSelected)); + } mTabBar->add_tab("Reset", [this] { mTabBar->set_active_tab(-1); @@ -234,7 +240,10 @@ void MenuBar::rebuild() { for (auto& doc : get_document_stack()) { if (auto* menuBar = dynamic_cast(doc.get())) { const bool wasVisible = menuBar->visible(); - doc = std::make_unique(); + auto next = std::make_unique(); + next->mFocusedTabIndex = menuBar->mFocusedTabIndex; + next->mWasVisible = menuBar->mWasVisible; + doc = std::move(next); if (wasVisible) { doc->show(); } diff --git a/src/dusk/ui/mod_window.cpp b/src/dusk/ui/mod_window.cpp new file mode 100644 index 0000000000..3547f92bc8 --- /dev/null +++ b/src/dusk/ui/mod_window.cpp @@ -0,0 +1,141 @@ +#include "mod_window.hpp" + +#include "bool_button.hpp" +#include "number_button.hpp" +#include "string_button.hpp" + +#include "m_Do/m_Do_audio.h" + +namespace dusk::ui { + +Component* build_mod_control(Pane& pane, Pane* helpPane, ModControlSpec spec) { + const auto shared = std::make_shared(std::move(spec)); + auto& s = *shared; + Component* control = nullptr; + switch (s.kind) { + case ModControlSpec::Kind::Button: + control = &pane.add_button(ControlledButton::Props{ + .text = s.label, + .isDisabled = s.isDisabled, + }) + .on_pressed([shared] { + if (shared->onPressed) { + shared->onPressed(); + } + }); + break; + case ModControlSpec::Kind::Toggle: + control = &pane.add_child(BoolButton::Props{ + .key = s.label, + .getValue = s.getBool, + .setValue = s.setBool, + .isDisabled = s.isDisabled, + .isModified = s.isModified, + }); + break; + case ModControlSpec::Kind::Number: + control = &pane.add_child(NumberButton::Props{ + .key = s.label, + .getValue = s.getInt, + .setValue = s.setInt, + .isDisabled = s.isDisabled, + .isModified = s.isModified, + .min = s.min, + .max = s.max, + .step = s.step, + .prefix = s.prefix, + .suffix = s.suffix, + }); + break; + case ModControlSpec::Kind::String: + control = &pane.add_child(StringButton::Props{ + .key = s.label, + .getValue = s.getString, + .setValue = s.setString, + .isDisabled = s.isDisabled, + .isModified = s.isModified, + .maxLength = s.maxLength, + }); + break; + case ModControlSpec::Kind::Select: + if (helpPane == nullptr || s.options.empty()) { + return nullptr; + } + control = &pane.add_child(ControlledSelectButton::Props{ + .key = s.label, + .getValue = [shared]() -> Rml::String { + const int index = shared->getInt ? shared->getInt() : -1; + if (index < 0 || index >= static_cast(shared->options.size())) { + return "?"; + } + return shared->options[index]; + }, + .isDisabled = s.isDisabled, + .isModified = s.isModified, + }); + break; + } + if (control == nullptr) { + return nullptr; + } + + if (helpPane != nullptr && (s.kind == ModControlSpec::Kind::Select || !s.helpRml.empty())) { + pane.register_control(*control, *helpPane, [shared](Pane& help) { + help.clear(); + if (shared->kind == ModControlSpec::Kind::Select) { + for (int i = 0; i < static_cast(shared->options.size()); ++i) { + help.add_button( + ControlledButton::Props{ + .text = shared->options[i], + .isSelected = + [shared, i] { return shared->getInt && shared->getInt() == i; }, + }) + .on_pressed([shared, i] { + mDoAud_seStartMenu(kSoundItemChange); + if (shared->setInt) { + shared->setInt(i); + } + }); + } + } + if (!shared->helpRml.empty()) { + help.add_rml(shared->helpRml); + } + }); + } + return control; +} + +ModWindow::ModWindow(Desc desc) : mDesc(std::move(desc)) { + mRoot->SetAttribute("mod-id", mDesc.modId); + for (int i = 0; i < static_cast(mDesc.tabs.size()); ++i) { + add_tab(mDesc.tabs[i].title, [this, i](Rml::Element* content) { + mActiveTab = i; + auto& left = add_child(content, Pane::Type::Controlled); + auto& right = add_child(content, Pane::Type::Uncontrolled); + if (mDesc.tabs[i].build) { + mDesc.tabs[i].build(*this, left, right); + } + }); + } + if (!mDesc.rcss.empty()) { + set_document_styles(mDesc.rcss); + } +} + +ModWindow::~ModWindow() { + if (mDesc.onDestroyed) { + mDesc.onDestroyed(); + } +} + +void ModWindow::update() { + if (mActiveTab >= 0 && mActiveTab < static_cast(mDesc.tabs.size()) && + mDesc.tabs[mActiveTab].update) + { + mDesc.tabs[mActiveTab].update(); + } + Window::update(); +} + +} // namespace dusk::ui diff --git a/src/dusk/ui/mod_window.hpp b/src/dusk/ui/mod_window.hpp new file mode 100644 index 0000000000..92a5fa81ac --- /dev/null +++ b/src/dusk/ui/mod_window.hpp @@ -0,0 +1,69 @@ +#pragma once + +#include "pane.hpp" +#include "window.hpp" + +#include + +namespace dusk::ui { + +struct ModControlSpec { + enum class Kind : u8 { + Button, + Toggle, + Number, + String, + Select, + }; + + Kind kind = Kind::Button; + Rml::String label; + Rml::String helpRml; + std::function onPressed; + std::function getBool; + std::function setBool; + // Number value, or the selected option index for Select + std::function getInt; + std::function setInt; + std::function getString; + std::function setString; + std::function isDisabled; + std::function isModified; + int min = 0; + int max = INT_MAX; + int step = 1; + Rml::String prefix; + Rml::String suffix; + std::vector options; + int maxLength = -1; +}; + +Component* build_mod_control(Pane& pane, Pane* helpPane, ModControlSpec spec); + +// A mod-owned tabbed two-pane window. +class ModWindow : public Window { +public: + struct Tab { + Rml::String title; + std::function build; + std::function update; + }; + struct Desc { + Rml::String modId; + std::vector tabs; + Rml::String rcss; + std::function onDestroyed; + }; + + explicit ModWindow(Desc desc); + ~ModWindow() override; + + void update() override; + void force_close() { Document::hide(true); } + +private: + Desc mDesc; + int mActiveTab = -1; +}; + +} // namespace dusk::ui diff --git a/src/dusk/ui/modal.cpp b/src/dusk/ui/modal.cpp index b08a333163..8d9956ca13 100644 --- a/src/dusk/ui/modal.cpp +++ b/src/dusk/ui/modal.cpp @@ -27,19 +27,43 @@ Modal::Modal(Props props) : WindowSmall("modal", "modal-dialog"), mProps(std::mo actions->SetClass("modal-actions", true); for (auto& action : mProps.actions) { - auto btn = std::make_unique