UiService: Dialog controls (#2332)

This commit is contained in:
Luke Street
2026-08-21 23:31:35 -06:00
committed by GitHub
parent 14f9b6c762
commit 41d5148793
10 changed files with 208 additions and 25 deletions
+26 -4
View File
@@ -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
+77 -3
View File
@@ -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%;
}
}
+13 -7
View File
@@ -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 {
+16 -3
View File
@@ -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<ModDialog>(
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;
}
+49 -2
View File
@@ -1,5 +1,7 @@
#include "modal.hpp"
#include <algorithm>
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<Pane>(mContentRoot, Pane::Type::Uncontrolled);
}
return *mContentPane;
}
void Modal::add_action(ModalAction action) {
auto* actions = mDialog->QuerySelector(".modal-actions");
auto btn = std::make_unique<Button>(actions, action.label);
btn->root()->SetClass("modal-btn", true);
btn->on_pressed([this, callback = std::move(action.onPressed)] {
if (callback) {
if (!callback) {
return;
}
if (mContentPane != nullptr) {
mPendingAction = callback;
} else {
callback(*this);
}
});
@@ -68,6 +97,9 @@ void Modal::set_icon(const Rml::String& icon) {
}
bool Modal::focus() {
if (mContentPane != nullptr && mContentPane->focus()) {
return true;
}
if (!mButtons.empty()) {
return mButtons.front()->focus();
}
@@ -89,6 +121,22 @@ bool Modal::handle_nav_command(Rml::Event& event, NavCommand cmd) {
return true;
}
auto* target = event.GetTargetElement();
if (mContentPane != nullptr && mContentPane->contains(target) && cmd == NavCommand::Down &&
!mButtons.empty() && mButtons.front()->focus())
{
mDoAud_seStartMenu(kSoundItemFocus);
return true;
}
if (mContentPane != nullptr && cmd == NavCommand::Up &&
std::ranges::any_of(
mButtons, [target](const auto& button) { return button->contains(target); }) &&
mContentPane->focus_last())
{
mDoAud_seStartMenu(kSoundItemFocus);
return true;
}
int direction = 0;
NavCommand prevCommand = mProps.isVertical ? NavCommand::Up : NavCommand::Left;
NavCommand nextCommand = mProps.isVertical ? NavCommand::Down : NavCommand::Right;
@@ -100,7 +148,6 @@ bool Modal::handle_nav_command(Rml::Event& event, NavCommand cmd) {
return false;
}
auto* target = event.GetTargetElement();
for (int i = 0; i < static_cast<int>(mButtons.size()); ++i) {
if (mButtons[i]->contains(target)) {
const int next = i + direction;
+8 -2
View File
@@ -1,6 +1,7 @@
#pragma once
#include "button.hpp"
#include "pane.hpp"
#include "window.hpp"
namespace dusk::ui {
@@ -20,13 +21,15 @@ public:
std::function<void(Modal&)> onDismiss;
Rml::String variant;
Rml::String icon = "";
bool isVertical;
bool isVertical = false;
};
explicit Modal(Props props);
void update() override;
bool focus() override;
Pane& content_pane();
void add_action(ModalAction action);
void set_body(const Rml::String& bodyRml);
void set_icon(const Rml::String& icon);
@@ -38,7 +41,10 @@ private:
void dismiss();
Props mProps;
std::vector<std::unique_ptr<Button> > mButtons;
Rml::Element* mContentRoot = nullptr;
std::unique_ptr<Pane> mContentPane;
std::function<void(Modal&)> mPendingAction;
std::vector<std::unique_ptr<Button>> mButtons;
};
} // namespace dusk::ui
+9
View File
@@ -163,6 +163,15 @@ bool Pane::focus() {
return false;
}
bool Pane::focus_last() {
for (auto child = mChildren.rbegin(); child != mChildren.rend(); ++child) {
if ((*child)->focus()) {
return true;
}
}
return false;
}
Rml::Element* Pane::add_section(const Rml::String& text) {
auto* elem = append(mRoot, "div");
elem->SetClass("section-heading", true);
+1
View File
@@ -17,6 +17,7 @@ public:
explicit Pane(Rml::Element* parent, Type type);
bool focus() override;
bool focus_last();
void update() override;
void set_selected_item(int index);
+7 -3
View File
@@ -52,6 +52,10 @@ Popover::Popover(Rml::Element* anchor, Side side, const Rml::String& windowClass
true);
}
Popover::~Popover() {
notify_close(false);
}
void Popover::show() {
Document::show();
reposition();
@@ -60,7 +64,7 @@ void Popover::show() {
}
void Popover::hide(bool close) {
notify_close();
notify_close(true);
mBody->RemoveAttribute("open");
mPendingClose = close;
}
@@ -136,13 +140,13 @@ void Popover::reposition() {
mBody->SetProperty(Rml::PropertyId::Top, Rml::Property{pos.y, Rml::Unit::PX});
}
void Popover::notify_close() {
void Popover::notify_close(bool restoreFocus) {
if (mOnClose) {
auto callback = std::move(mOnClose);
mOnClose = nullptr;
callback();
}
if (mAnchor != nullptr && mAnchor->IsVisible(true)) {
if (restoreFocus && mAnchor != nullptr && mAnchor->IsVisible(true)) {
mAnchor->Focus(true);
}
}
+2 -1
View File
@@ -14,6 +14,7 @@ public:
};
Popover(Rml::Element* anchor, Side side, const Rml::String& windowClass = "");
~Popover() override;
void show() override;
void hide(bool close) override;
@@ -32,7 +33,7 @@ protected:
private:
void reposition();
void notify_close();
void notify_close(bool restoreFocus);
Rml::Element* mAnchor = nullptr;
Side mSide;