diff --git a/docs/modding.md b/docs/modding.md
index 80e1998ea5..86af7ce34c 100644
--- a/docs/modding.md
+++ b/docs/modding.md
@@ -422,6 +422,22 @@ sets `keep_open`. A `keep_open` action can close it later (or immediately) with
`on_dismiss` if present 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
+the default of 5 seconds.
+
+Toasts have a `mod-id` attribute, so `UI_SCOPE_OVERLAY` styles can use selectors such as
+`toast[mod-id="com.example.randomizer"].success`.
+
+```cpp
+UiToastDesc toast = UI_TOAST_DESC_INIT;
+toast.type = "success";
+toast.title_rml = "Randomizer";
+toast.body_rml = "Seed loaded successfully.";
+toast.duration_ms = 3000;
+svc_ui->push_toast(mod_ctx, &toast);
+```
+
**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.
diff --git a/sdk/include/mods/svc/ui.h b/sdk/include/mods/svc/ui.h
index 1635a96c5f..5dd9066421 100644
--- a/sdk/include/mods/svc/ui.h
+++ b/sdk/include/mods/svc/ui.h
@@ -9,11 +9,11 @@
#define UI_SERVICE_ID "dev.twilitrealm.dusklight.ui"
#define UI_SERVICE_MAJOR 1u
-#define UI_SERVICE_MINOR 0u
+#define UI_SERVICE_MINOR 1u
/*
- * UI primitives: a panel inside the host Mods window, mod-owned windows, dialogs, scoped
- * RCSS stylesheets and menu bar tabs.
+ * UI primitives: a panel inside the host Mods window, mod-owned windows, dialogs, toasts,
+ * 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
@@ -212,6 +212,19 @@ typedef struct UiMenuTabDesc {
#define UI_MENU_TAB_DESC_INIT {sizeof(UiMenuTabDesc), NULL, NULL, NULL}
+typedef struct UiToastDesc {
+ uint32_t struct_size;
+ /* Optional RCSS class, such as "warning" or a custom mod-defined type. */
+ const char* type;
+ /* Optional RML. At least one of title_rml or body_rml must be non-empty. */
+ const char* title_rml;
+ const char* body_rml;
+ /* How long the toast remains open; 0 uses the default of 5000 ms. */
+ uint32_t duration_ms;
+} UiToastDesc;
+
+#define UI_TOAST_DESC_INIT {sizeof(UiToastDesc), NULL, NULL, NULL, 0u}
+
typedef struct UiService {
ServiceHeader header;
@@ -275,6 +288,9 @@ typedef struct UiService {
ModResult (*register_menu_tab)(
ModContext* ctx, const UiMenuTabDesc* desc, UiMenuTabHandle* out_tab);
ModResult (*unregister_menu_tab)(ModContext* ctx, UiMenuTabHandle tab);
+
+ /* Enqueue a toast notification. */
+ ModResult (*push_toast)(ModContext* ctx, const UiToastDesc* desc);
} UiService;
MOD_DECLARE_SERVICE(UiService, svc_ui, UI_SERVICE_ID, UI_SERVICE_MAJOR, UI_SERVICE_MINOR);
diff --git a/src/dusk/mods/svc/ui.cpp b/src/dusk/mods/svc/ui.cpp
index cf4ea86808..6f0ff297fc 100644
--- a/src/dusk/mods/svc/ui.cpp
+++ b/src/dusk/mods/svc/ui.cpp
@@ -18,6 +18,7 @@
#include
#include
+#include
#include
#include
#include
@@ -1321,6 +1322,27 @@ ModResult ui_unregister_menu_tab(ModContext* context, UiMenuTabHandle tab) {
return ui_impl::ui_unregister_menu_tab(*mod, tab);
}
+ModResult ui_push_toast(ModContext* context, const UiToastDesc* desc) {
+ auto* mod = mod_from_context(context);
+ if (mod == nullptr || desc == nullptr || desc->struct_size < sizeof(UiToastDesc) ||
+ ((desc->title_rml == nullptr || desc->title_rml[0] == '\0') &&
+ (desc->body_rml == nullptr || desc->body_rml[0] == '\0')))
+ {
+ return MOD_INVALID_ARGUMENT;
+ }
+
+ constexpr uint32_t kDefaultDurationMs = 5000;
+ const uint32_t durationMs = desc->duration_ms == 0 ? kDefaultDurationMs : desc->duration_ms;
+ ui::push_toast({
+ .type = desc->type != nullptr ? desc->type : "",
+ .title = desc->title_rml != nullptr ? desc->title_rml : "",
+ .content = desc->body_rml != nullptr ? desc->body_rml : "",
+ .duration = std::chrono::milliseconds{durationMs},
+ .modId = mod->metadata.id,
+ });
+ return MOD_OK;
+}
+
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) {
@@ -1371,6 +1393,7 @@ constexpr UiService s_uiService{
.unregister_styles = ui_unregister_styles,
.register_menu_tab = ui_register_menu_tab,
.unregister_menu_tab = ui_unregister_menu_tab,
+ .push_toast = ui_push_toast,
};
} // namespace
diff --git a/src/dusk/ui/overlay.cpp b/src/dusk/ui/overlay.cpp
index d2bd3fe6c1..d0ee19cc6a 100644
--- a/src/dusk/ui/overlay.cpp
+++ b/src/dusk/ui/overlay.cpp
@@ -71,6 +71,9 @@ Rml::Element* create_toast(Rml::Element* parent, const Toast& toast) {
}
auto* elem = append(parent, "toast");
+ if (!toast.modId.empty()) {
+ elem->SetAttribute("mod-id", toast.modId);
+ }
if (!toast.type.empty()) {
elem->SetClass(toast.type, true);
}
diff --git a/src/dusk/ui/ui.hpp b/src/dusk/ui/ui.hpp
index bd993ea34d..d5db990570 100644
--- a/src/dusk/ui/ui.hpp
+++ b/src/dusk/ui/ui.hpp
@@ -30,6 +30,7 @@ struct Toast {
Rml::String title;
Rml::String content;
clock::duration duration;
+ Rml::String modId;
};
// Button clicked/pressed