overlay: toast notifications (#704)

## Link to GitHub Issue or related Pull Request, if one exists
n/a

## Description of change
Adds toast notifications to the overlay.

Toast notifications are transient windows (shown for about 4 seconds).
These are drawn on top of the game even when the overlay is inactive
(i.e., when no window is open), and does not cause any input sink
behavior.

Add toasts for some common user-driven actions that could use a
notification, such as:

* screenshot
* card insert
* PIN macro
* API client connect/disconnect
* virtual printer
* screen resize toggle / scene switch

Add an option to change where the toasts are displayed, or to turn it
off entirely.

Add SDK function for it, and add it to the samples.

## Testing
Tested DDR (shown at bottom right, as is for most games) and RB (shown
top right by default).
This commit is contained in:
bicarus
2026-05-27 00:45:27 -07:00
committed by GitHub
parent d929cdf7c8
commit 311404f56b
20 changed files with 574 additions and 43 deletions
+254
View File
@@ -0,0 +1,254 @@
#include "notifications.h"
#include <atomic>
#include <deque>
#include <mutex>
#include <unordered_map>
#include "external/imgui/imgui.h"
#include "external/imgui/imgui_internal.h"
#include "external/fmt/include/fmt/format.h"
#include "overlay/overlay.h"
#include "util/time.h"
namespace overlay::notifications {
bool ENABLED = true;
Position POSITION = Position::BottomRight;
struct Notification {
uint64_t id;
std::string text;
Severity severity;
double created_ms;
float duration_s;
};
static std::mutex g_mutex;
static std::deque<Notification> g_items;
static std::atomic<uint64_t> g_next_id { 1 };
static std::atomic<size_t> g_count { 0 };
// duration in seconds each notification stays visible
static constexpr float DURATION_S = 3.0f;
// maximum number of notifications kept in the queue (oldest dropped beyond this)
static constexpr size_t MAX_NOTIFICATIONS = 6;
// time (ms) over which a toast fades out at the end of its lifetime
static constexpr float FADE_OUT_MS = 400.0f;
// fixed width of each toast window, in unscaled pixels
static constexpr float TOAST_WIDTH = 320.0f;
// gap between the toast stack and the screen edges (right + bottom)
static constexpr float TOAST_MARGIN = 20.0f;
// vertical gap between stacked toasts
static constexpr float TOAST_SPACING = 8.0f;
// inner padding inside a toast window (horizontal / vertical)
static constexpr float TOAST_PAD_X = 10.0f;
static constexpr float TOAST_PAD_Y = 8.0f;
// width of the colored severity accent bar drawn on the left edge
static constexpr float TOAST_ACCENT_W = 6.0f;
// base opacity of the toast background (0..1), multiplied by the fade alpha
static constexpr float TOAST_BG_ALPHA = 0.85f;
static constexpr ImGuiWindowFlags TOAST_FLAGS =
ImGuiWindowFlags_NoDecoration
| ImGuiWindowFlags_NoInputs
| ImGuiWindowFlags_NoNav
| ImGuiWindowFlags_NoMove
| ImGuiWindowFlags_NoSavedSettings
| ImGuiWindowFlags_NoFocusOnAppearing
| ImGuiWindowFlags_NoBringToFrontOnFocus
| ImGuiWindowFlags_AlwaysAutoResize;
static ImU32 severity_accent(Severity sev) {
switch (sev) {
case Severity::Success: return IM_COL32(80, 200, 120, 255);
case Severity::Warning: return IM_COL32(230, 180, 60, 255);
case Severity::Error: return IM_COL32(220, 60, 60, 255);
case Severity::Info:
default: return IM_COL32(90, 160, 230, 255);
}
}
static bool is_expired(const Notification &n, double now_ms) {
return (now_ms - n.created_ms) >= (n.duration_s * 1000.0);
}
// returns 0.0 .. 1.0 fade alpha based on time remaining
static float compute_alpha(const Notification &n, double now_ms) {
const double remaining_ms = (n.duration_s * 1000.0) - (now_ms - n.created_ms);
if (remaining_ms >= FADE_OUT_MS) {
return 1.0f;
}
if (remaining_ms <= 0.0) {
return 0.0f;
}
return static_cast<float>(remaining_ms / FADE_OUT_MS);
}
// drop expired items and copy the rest under a single lock acquisition
static std::vector<Notification> snapshot_and_prune(double now_ms) {
std::vector<Notification> snapshot;
std::lock_guard<std::mutex> lock(g_mutex);
for (auto it = g_items.begin(); it != g_items.end();) {
if (is_expired(*it, now_ms)) {
it = g_items.erase(it);
} else {
++it;
}
}
g_count.store(g_items.size(), std::memory_order_release);
snapshot.assign(g_items.begin(), g_items.end());
return snapshot;
}
// is the configured anchor on the right edge of the screen?
static bool position_is_right(Position p) {
return p == Position::BottomRight || p == Position::TopRight;
}
// is the configured anchor on the bottom edge of the screen?
static bool position_is_bottom(Position p) {
return p == Position::BottomRight || p == Position::BottomLeft;
}
// draw a single toast anchored to the configured corner; `cursor_y` is the
// y-coordinate of the toast edge nearest the anchor (top edge for Top* anchors,
// bottom edge for Bottom* anchors). returns its height in pixels.
static float draw_toast(const Notification &n, float cursor_y, float alpha) {
const float toast_width = apply_scaling(TOAST_WIDTH);
const float margin = apply_scaling(TOAST_MARGIN);
const ImVec2 &display = ImGui::GetIO().DisplaySize;
const Position pos = POSITION;
const auto window_id = fmt::format("##spice_notif_{}", n.id);
// anchor x/pivot.x select the screen edge; pivot.y matches cursor_y semantics
const float anchor_x = position_is_right(pos) ? (display.x - margin) : margin;
const float pivot_x = position_is_right(pos) ? 1.0f : 0.0f;
const float pivot_y = position_is_bottom(pos) ? 1.0f : 0.0f;
ImGui::SetNextWindowPos(ImVec2(anchor_x, cursor_y),
ImGuiCond_Always, ImVec2(pivot_x, pivot_y));
ImGui::SetNextWindowSize(ImVec2(toast_width, 0.f), ImGuiCond_Always);
ImGui::SetNextWindowBgAlpha(TOAST_BG_ALPHA * alpha);
ImGui::PushStyleVar(ImGuiStyleVar_Alpha, alpha);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding,
ImVec2(apply_scaling(TOAST_PAD_X), apply_scaling(TOAST_PAD_Y)));
float height = 0.f;
if (ImGui::Begin(window_id.c_str(), nullptr, TOAST_FLAGS)) {
const ImVec2 win_pos = ImGui::GetWindowPos();
const ImVec2 win_size = ImGui::GetWindowSize();
// accent bar on the left edge of the window
const ImU32 accent = severity_accent(n.severity);
const ImU32 accent_faded =
(accent & 0x00FFFFFFu) | (static_cast<ImU32>(alpha * 255.0f) << 24);
ImGui::GetWindowDrawList()->AddRectFilled(
win_pos,
ImVec2(win_pos.x + apply_scaling(TOAST_ACCENT_W), win_pos.y + win_size.y),
accent_faded);
// small gutter past the accent bar, then wrapped text
ImGui::Dummy(ImVec2(apply_scaling(2.0f), 0.f));
ImGui::SameLine();
ImGui::PushTextWrapPos(win_pos.x + win_size.x - apply_scaling(TOAST_PAD_X));
ImGui::TextUnformatted(n.text.c_str());
ImGui::PopTextWrapPos();
height = ImGui::GetWindowSize().y;
}
ImGui::End();
ImGui::PopStyleVar(2);
return height;
}
uint64_t add(Severity severity, std::string text) {
if (!ENABLED || !overlay::ENABLED || overlay::OVERLAY == nullptr) {
return 0;
}
Notification n {
.id = g_next_id.fetch_add(1, std::memory_order_relaxed),
.text = std::move(text),
.severity = severity,
.created_ms = get_performance_milliseconds(),
.duration_s = DURATION_S,
};
{
std::lock_guard<std::mutex> lock(g_mutex);
g_items.push_back(std::move(n));
while (g_items.size() > MAX_NOTIFICATIONS) {
g_items.pop_front();
}
g_count.store(g_items.size(), std::memory_order_release);
}
return n.id;
}
uint64_t add_throttled(Severity severity, const std::string &key,
double cooldown_seconds, std::string text) {
if (!ENABLED || !overlay::ENABLED || overlay::OVERLAY == nullptr) {
return 0;
}
// per-key last-emit timestamps live behind their own lock so we don't
// hold g_mutex across the map lookup.
static std::mutex throttle_mutex;
static std::unordered_map<std::string, double> last_emit_ms;
const double now_ms = get_performance_milliseconds();
{
std::lock_guard<std::mutex> lock(throttle_mutex);
auto it = last_emit_ms.find(key);
if (it != last_emit_ms.end()
&& (now_ms - it->second) < (cooldown_seconds * 1000.0)) {
return 0;
}
last_emit_ms[key] = now_ms;
}
return add(severity, std::move(text));
}
bool has_pending() {
return g_count.load(std::memory_order_acquire) > 0;
}
void draw() {
const double now_ms = get_performance_milliseconds();
const auto snapshot = snapshot_and_prune(now_ms);
if (snapshot.empty()) {
return;
}
// stack from the anchored edge with newest toast at the anchor.
// Bottom* anchors stack upward; Top* anchors stack downward.
const float spacing = apply_scaling(TOAST_SPACING);
const float margin = apply_scaling(TOAST_MARGIN);
const bool bottom = position_is_bottom(POSITION);
float cursor_y = bottom
? (ImGui::GetIO().DisplaySize.y - margin)
: margin;
for (auto it = snapshot.rbegin(); it != snapshot.rend(); ++it) {
const float alpha = compute_alpha(*it, now_ms);
const float height = draw_toast(*it, cursor_y, alpha);
cursor_y += bottom ? -(height + spacing) : (height + spacing);
}
}
void apply_game_default_position(const std::string &game_name) {
if (game_name == "Reflec Beat") {
POSITION = Position::TopRight;
}
// others keep the default (BottomRight)
}
}
+55
View File
@@ -0,0 +1,55 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
namespace overlay::notifications {
// master switch for the notification system; when false, add() is a no-op.
// controlled by selecting "none" for the -notifypos launcher option.
extern bool ENABLED;
enum class Severity {
Info,
Success,
Warning,
Error,
};
// screen anchor for the toast stack. toasts stack away from the anchored edge.
enum class Position {
BottomRight,
BottomLeft,
TopRight,
TopLeft,
};
// current toast anchor. defaults to BottomRight; may be reassigned by
// apply_game_default_position() or by the user via -notifypos.
extern Position POSITION;
// apply the default toast position appropriate for a game (by display name,
// as returned by eamuse_get_game()). called once after game autodetect, before
// any user -notifypos override is applied.
void apply_game_default_position(const std::string &game_name);
// add a notification (thread-safe). returns the assigned id, or 0 if the
// notification was dropped (overlay disabled or notifications disabled).
uint64_t add(Severity severity, std::string text);
// rate-limited variant of add(). suppresses the toast if another call with
// the same `key` succeeded within the last `cooldown_seconds`. useful for
// events that can fire every frame (e.g. a button held down). returns the
// assigned id, or 0 if the toast was suppressed or dropped. thread-safe.
uint64_t add_throttled(Severity severity, const std::string &key,
double cooldown_seconds, std::string text);
// true if there is at least one notification that still needs to be drawn.
// safe to call from the render thread without locking the underlying store.
bool has_pending();
// draw all active notifications and prune expired ones.
// must be called from the ImGui render thread inside a NewFrame/EndFrame pair.
void draw();
}
+29 -10
View File
@@ -17,6 +17,7 @@
#include "external/imgui/backends/imgui_impl_dx9.h"
#include "overlay/imgui/impl_spice.h"
#include "overlay/imgui/impl_sw.h"
#include "overlay/notifications.h"
#include "window.h"
#ifdef SPICE64
@@ -465,8 +466,16 @@ void overlay::SpiceOverlay::new_frame() {
ImGui_ImplSpice_NewFrame();
this->total_elapsed += ImGui::GetIO().DeltaTime;
// check if inactive
if (!this->active) {
// notifications draw on top of the game without flipping `active`, so the input gates
// (see touch/touch.cpp WndProc, touch_get_points/events, graphics.cpp WM_CHAR) stay
// disabled and input continues to flow to the game.
// SOFTWARE renderer (spicecfg / configurator) never gets notifications - no room.
const bool draw_notifications = this->renderer != OverlayRenderer::SOFTWARE
&& overlay::notifications::has_pending();
// check if there is nothing to draw
this->has_pending_frame = false;
if (!this->active && !draw_notifications) {
return;
}
@@ -480,14 +489,22 @@ void overlay::SpiceOverlay::new_frame() {
break;
}
ImGui::NewFrame();
this->has_pending_frame = true;
// build windows
for (auto &window : this->windows) {
window->build();
// build windows only when the overlay itself is active
if (this->active) {
for (auto &window : this->windows) {
window->build();
}
if (SHOW_DEBUG_LOG_WINDOW) {
ImGui::ShowDebugLogWindow(&SHOW_DEBUG_LOG_WINDOW);
}
}
if (SHOW_DEBUG_LOG_WINDOW) {
ImGui::ShowDebugLogWindow(&SHOW_DEBUG_LOG_WINDOW);
// draw notifications last so they paint on top of any overlay windows
if (draw_notifications) {
overlay::notifications::draw();
}
// end frame
@@ -496,8 +513,8 @@ void overlay::SpiceOverlay::new_frame() {
void overlay::SpiceOverlay::render() {
// check if inactive
if (!this->active) {
// skip if new_frame() didn't begin a frame this tick
if (!this->has_pending_frame) {
return;
}
@@ -541,6 +558,8 @@ void overlay::SpiceOverlay::render() {
for (auto &window : this->windows) {
window->after_render();
}
this->has_pending_frame = false;
}
void overlay::SpiceOverlay::update() {
@@ -700,7 +719,7 @@ void overlay::SpiceOverlay::input_char(unsigned int c) {
uint32_t *overlay::SpiceOverlay::sw_get_pixel_data(int *width, int *height) {
// check if active
// check if active (notifications never draw in software renderer, so no extra gate here)
if (!this->active) {
*width = 0;
*height = 0;
+4
View File
@@ -141,6 +141,10 @@ namespace overlay {
bool hotkey_toggle = false;
bool hotkey_toggle_last = false;
// true between new_frame()'s ImGui::NewFrame() and render()'s ImGui::Render(),
// so render() never runs without a matching NewFrame.
bool has_pending_frame = false;
void init();
void add_font(const char* font, ImFontConfig* config, const ImWchar* glyphs);
};
@@ -5,6 +5,7 @@
#include "cfg/screen_resize.h"
#include "hooks/graphics/graphics.h"
#include "overlay/imgui/extensions.h"
#include "overlay/notifications.h"
#include "misc/eamuse.h"
#include "util/logging.h"
#include "util/utils.h"
@@ -323,6 +324,11 @@ namespace overlay::windows {
if (toggle_screen_resize_new && !this->toggle_screen_resize_state) {
cfg::SCREENRESIZE->enable_screen_resize = !cfg::SCREENRESIZE->enable_screen_resize;
overlay::notifications::add(
overlay::notifications::Severity::Info,
cfg::SCREENRESIZE->enable_screen_resize
? "Screen resize enabled"
: "Screen resize disabled");
}
this->toggle_screen_resize_state = toggle_screen_resize_new;
}
@@ -347,10 +353,16 @@ namespace overlay::windows {
cfg::SCREENRESIZE->enable_screen_resize) {
// this scene is already active, turn scaling off
cfg::SCREENRESIZE->enable_screen_resize = false;
overlay::notifications::add(
overlay::notifications::Severity::Info,
"Screen resize disabled");
} else {
// switch to scene
cfg::SCREENRESIZE->enable_screen_resize = true;
cfg::SCREENRESIZE->screen_resize_current_scene = i;
overlay::notifications::add(
overlay::notifications::Severity::Info,
fmt::format("Screen resize: scene {}", i + 1));
}
break;
}