From 41d5148793cf67f0e93e79cf3eab8718d6e9849d Mon Sep 17 00:00:00 2001 From: Luke Street Date: Fri, 21 Aug 2026 23:31:35 -0600 Subject: [PATCH] UiService: Dialog controls (#2332) --- docs/modding.md | 30 +++++++++++++-- res/rml/window.rcss | 80 +++++++++++++++++++++++++++++++++++++-- sdk/include/mods/svc/ui.h | 20 ++++++---- src/dusk/mods/svc/ui.cpp | 19 ++++++++-- src/dusk/ui/modal.cpp | 51 ++++++++++++++++++++++++- src/dusk/ui/modal.hpp | 10 ++++- src/dusk/ui/pane.cpp | 9 +++++ src/dusk/ui/pane.hpp | 1 + src/dusk/ui/popover.cpp | 10 +++-- src/dusk/ui/popover.hpp | 3 +- 10 files changed, 208 insertions(+), 25 deletions(-) diff --git a/docs/modding.md b/docs/modding.md index 06fcb1389a..e98abe0026 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -516,10 +516,32 @@ 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. +default icon, and actions become buttons. The optional `build` callback allows you to add controls to a pane between +the body and actions. It uses the same text, progress, and control builders as panels. + +```cpp +ModResult build_dialog(ModContext*, UiElementHandle pane, void*, ModError*) { + UiControlDesc input = UI_CONTROL_DESC_INIT; + input.kind = UI_CONTROL_STRING; + input.label = "Name"; + input.get = get_name; + input.set = set_name; + return svc_ui->pane_add_control(mod_ctx, pane, &input, nullptr); +} + +UiDialogAction action = {"Save", save, nullptr, false}; +UiDialogDesc dialog = UI_DIALOG_DESC_INIT; +dialog.title = "New Preset"; +dialog.body_rml = "Choose a name for the preset."; +dialog.actions = &action; +dialog.action_count = 1; +dialog.build = build_dialog; +svc_ui->dialog_push(mod_ctx, &dialog, nullptr); +``` + +After an action's `on_pressed`, the dialog closes unless the action sets `keep_open`. It can then be closed later +(or immediately) with `dialog_close`. Cancel fires `on_dismiss` and always closes. `dialog_set_body`, `dialog_set_icon`, +and `dialog_add_action` mutate a live dialog. **Toasts:** `push_toast` enqueues a notification. Titles and bodies accept RML. The optional `type` is applied as an RCSS class; `warning` uses the built-in warning appearance, and mods can define their own types. A duration of 0 uses diff --git a/res/rml/window.rcss b/res/rml/window.rcss index a25b0895f5..364d047c1d 100644 --- a/res/rml/window.rcss +++ b/res/rml/window.rcss @@ -48,6 +48,12 @@ window.modal { max-width: 640dp; } +@media (max-height: 896dp) { + window.modal { + max-height: 100%; + } +} + window[open] { filter: opacity(1); transform: scale(1); @@ -62,6 +68,16 @@ window[open] { } } +@media (max-width: 768dp) { + body { + padding: 16dp; + } + window.modal { + width: 100%; + max-width: 100%; + } +} + window tab-bar { flex: 0 0 64dp; height: 64dp; @@ -419,14 +435,17 @@ progress { margin: 6dp 0 2dp 0; } +progress fill { + background-color: rgba(194, 164, 45, 80%); + border-radius: 3dp; +} + progress.progress-done fill { background-color: #44aa22; - border-radius: 3dp; } progress.progress-ongoing fill { background-color: #2255bb; - border-radius: 3dp; } button.achievement-clear { @@ -466,7 +485,9 @@ button.achievement-clear { padding: 24dp; gap: 20dp; flex: 0 1 auto; + min-height: 0; width: 100%; + overflow: hidden; text-align: left; } @@ -507,7 +528,7 @@ window.modal.danger .modal-header icon { .modal-body { display: block; width: 100%; - flex: 0 1 auto; + flex: 0 0 auto; min-width: 0; font-size: 20dp; color: #FFFFFF; @@ -519,6 +540,37 @@ window.modal.danger .modal-header icon { color: #92875B; } +.modal-content { + display: none; + width: 100%; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.modal-content.active { + display: flex; + flex-direction: column; +} + +.modal-content pane { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + width: 100%; + gap: 8dp; + overflow: hidden auto; +} + +.modal-content pane > * { + flex: 0 0 auto; +} + +.modal-content pane > spacer { + display: none; +} + .verification-progress { display: flex; flex-direction: column; @@ -563,3 +615,25 @@ progress.verification-progress-bar { flex: 0 0 auto; width: 100%; } + +@media (max-height: 640dp) { + .modal-dialog { + padding: 16dp; + gap: 12dp; + } + + .modal-body { + font-size: 17dp; + } +} + +@media (max-width: 640dp) { + .modal-actions { + flex-direction: column; + } + + .modal-actions button.modal-btn { + flex: 0 0 auto; + width: 100%; + } +} diff --git a/sdk/include/mods/svc/ui.h b/sdk/include/mods/svc/ui.h index 397231e833..38d67e3192 100644 --- a/sdk/include/mods/svc/ui.h +++ b/sdk/include/mods/svc/ui.h @@ -9,7 +9,7 @@ #define UI_SERVICE_ID "dev.twilitrealm.dusklight.ui" #define UI_SERVICE_MAJOR 1u -#define UI_SERVICE_MINOR 3u +#define UI_SERVICE_MINOR 4u /* * UI primitives: a panel inside the host Mods window, mod-owned windows, dialogs, toasts, @@ -127,11 +127,14 @@ typedef struct UiControlDesc { {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, NULL, 0u, false} +/* Build pane contents. A non-MOD_OK result fails the mod. */ +typedef ModResult (*UiPaneBuildFn)( + ModContext* ctx, UiElementHandle pane, void* user_data, ModError* out_error); + /* 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); +typedef UiPaneBuildFn UiPanelBuildFn; /* Called every frame while the panel is the visible tab. */ typedef ModResult (*UiPanelUpdateFn)(ModContext* ctx, void* user_data, ModError* out_error); @@ -147,8 +150,7 @@ typedef struct UiModsPanelDesc { /* Builds the contents associated with a group button. The target pane is cleared immediately * before this callback and is valid only while its tab remains built. */ -typedef ModResult (*UiGroupBuildFn)( - ModContext* ctx, UiElementHandle target_pane, void* user_data, ModError* out_error); +typedef UiPaneBuildFn UiGroupBuildFn; typedef struct UiGroupDesc { uint32_t struct_size; @@ -216,11 +218,15 @@ typedef struct UiDialogDesc { /* 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 */ + void* user_data; /* passed to build and on_dismiss */ + /* Optional content builder. The pane is rendered below body_rml and above the actions. Its + * handle and all child element handles remain valid until the dialog closes. + * Added in UiService minor version 4. */ + UiPaneBuildFn build; } UiDialogDesc; #define UI_DIALOG_DESC_INIT \ - {sizeof(UiDialogDesc), NULL, NULL, UI_DIALOG_NORMAL, NULL, NULL, 0u, NULL, NULL} + {sizeof(UiDialogDesc), NULL, NULL, UI_DIALOG_NORMAL, NULL, NULL, 0u, NULL, NULL, NULL} /* A tab added to the in-game menu bar. */ typedef struct UiMenuTabDesc { diff --git a/src/dusk/mods/svc/ui.cpp b/src/dusk/mods/svc/ui.cpp index 49b10bd0fc..7296909b67 100644 --- a/src/dusk/mods/svc/ui.cpp +++ b/src/dusk/mods/svc/ui.cpp @@ -808,7 +808,8 @@ ModResult ui_window_close(LoadedMod& mod, uint64_t handle) { return MOD_OK; } -ModResult ui_dialog_push(LoadedMod& mod, const UiDialogDesc& desc, uint64_t& outHandle) { +ModResult ui_dialog_push( + LoadedMod& mod, const UiDialogDesc& desc, UiPaneBuildFn build, uint64_t& outHandle) { outHandle = 0; if (!aurora::rmlui::is_initialized()) { return MOD_UNAVAILABLE; @@ -851,6 +852,16 @@ ModResult ui_dialog_push(LoadedMod& mod, const UiDialogDesc& desc, uint64_t& out auto dialog = std::make_unique( std::move(props), [handle] { on_mod_dialog_destroyed(handle); }); + if (build != nullptr) { + auto& pane = dialog->content_pane(); + const uint64_t paneHandle = wrap_pane(mod, pane, nullptr); + invoke_mod_ui_callback(mod, "mod UI dialog build", [&](ModError* error) { + return build(mod.context.get(), paneHandle, desc.user_data, error); + }); + if (!mod.active) { + return MOD_ERROR; + } + } if (auto* slot = slot_from_handle(handle)) { slot->document = dialog.get(); } @@ -1338,7 +1349,8 @@ ModResult ui_dialog_push(ModContext* context, const UiDialogDesc* desc, UiDialog *outDialog = 0; } auto* mod = mod_from_context(context); - if (mod == nullptr || desc == nullptr || desc->struct_size < sizeof(UiDialogDesc) || + constexpr size_t kLegacyDescSize = offsetof(UiDialogDesc, build); + if (mod == nullptr || desc == nullptr || desc->struct_size < kLegacyDescSize || desc->title == nullptr || desc->body_rml == nullptr || desc->actions == nullptr || desc->action_count == 0 || desc->variant > UI_DIALOG_DANGER) { @@ -1349,8 +1361,9 @@ ModResult ui_dialog_push(ModContext* context, const UiDialogDesc* desc, UiDialog return MOD_INVALID_ARGUMENT; } } + const UiPaneBuildFn build = desc->struct_size >= sizeof(UiDialogDesc) ? desc->build : nullptr; uint64_t handle = 0; - const auto result = ui_impl::ui_dialog_push(*mod, *desc, handle); + const auto result = ui_impl::ui_dialog_push(*mod, *desc, build, handle); if (result == MOD_OK && outDialog != nullptr) { *outDialog = handle; } diff --git a/src/dusk/ui/modal.cpp b/src/dusk/ui/modal.cpp index 8c8dcd5a3b..d4d3e479dc 100644 --- a/src/dusk/ui/modal.cpp +++ b/src/dusk/ui/modal.cpp @@ -1,5 +1,7 @@ #include "modal.hpp" +#include + namespace dusk::ui { Modal::Modal(Props props) : WindowSmall("modal", "modal-dialog"), mProps(std::move(props)) { @@ -23,6 +25,9 @@ Modal::Modal(Props props) : WindowSmall("modal", "modal-dialog"), mProps(std::mo body->SetClass("modal-body", true); body->SetInnerRML(mProps.bodyRml); + mContentRoot = append(mDialog, "div"); + mContentRoot->SetClass("modal-content", true); + auto* actions = append(mDialog, "div"); actions->SetClass("modal-actions", true); if (props.isVertical) { @@ -36,12 +41,36 @@ Modal::Modal(Props props) : WindowSmall("modal", "modal-dialog"), mProps(std::mo mDoAud_seStartMenu(kSoundWindowOpen); } +void Modal::update() { + if (mContentPane != nullptr) { + mContentPane->update(); + } + if (mPendingAction) { + auto action = std::move(mPendingAction); + action(*this); + } + WindowSmall::update(); +} + +Pane& Modal::content_pane() { + if (mContentPane == nullptr) { + mContentRoot->SetClass("active", true); + mContentPane = std::make_unique(mContentRoot, Pane::Type::Uncontrolled); + } + return *mContentPane; +} + void Modal::add_action(ModalAction action) { auto* actions = mDialog->QuerySelector(".modal-actions"); auto btn = std::make_unique