mirror of
https://github.com/spice2x/spice2x.github.io.git
synced 2026-08-01 22:30:42 -07:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user