fix(notifications): prune expired toasts and skip invisible draws
The status-bar notifications only expired the front of the queue, so an expired newer toast could sit in front of a still-live older one and hide it from view. The liveness check that drives continuous repaints also disagreed with what render() actually showed. - Expired toasts are now pruned wherever they sit in the queue, not just at the front, so the newest remaining toast is always a live one. - The visible toast is the newest live entry, skipping an expired newer one. The liveness check reports active while a toast is visible plus one trailing frame, so the final faded-out state is painted once and then repaints stop; it self-expires if render() is skipped. Tests: pruning removes expired entries from anywhere in the queue, the visible toast skips an expired newest entry, and liveness tracks it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -14,15 +14,15 @@ namespace ui {
|
|||||||
|
|
||||||
void Notifications::render()
|
void Notifications::render()
|
||||||
{
|
{
|
||||||
// Remove expired notifications
|
// Remove every expired notification; the newest remaining one is then both the last and a live one.
|
||||||
while (!notifications_.empty() && notifications_.front().isExpired()) {
|
pruneExpired(notifications_, std::chrono::steady_clock::now());
|
||||||
notifications_.pop_front();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (notifications_.empty()) {
|
if (notifications_.empty()) {
|
||||||
|
last_drawn_ = {}; // nothing painted: the trailing "clean frame" is this one
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
last_drawn_ = std::chrono::steady_clock::now();
|
||||||
|
|
||||||
// Only show the most recent (last) notification as a compact status-bar pill
|
// Only show the most recent (last) notification as a compact status-bar pill
|
||||||
const auto& notif = notifications_.back();
|
const auto& notif = notifications_.back();
|
||||||
|
|
||||||
|
|||||||
+25
-9
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <deque>
|
#include <deque>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
@@ -48,11 +49,10 @@ struct Notification {
|
|||||||
, duration_seconds(duration)
|
, duration_seconds(duration)
|
||||||
{}
|
{}
|
||||||
|
|
||||||
bool isExpired() const {
|
bool isExpiredAt(std::chrono::steady_clock::time_point now) const {
|
||||||
auto now = std::chrono::steady_clock::now();
|
return std::chrono::duration<float>(now - created_at).count() >= duration_seconds;
|
||||||
auto elapsed = std::chrono::duration<float>(now - created_at).count();
|
|
||||||
return elapsed >= duration_seconds;
|
|
||||||
}
|
}
|
||||||
|
bool isExpired() const { return isExpiredAt(std::chrono::steady_clock::now()); }
|
||||||
|
|
||||||
float getProgress() const {
|
float getProgress() const {
|
||||||
auto now = std::chrono::steady_clock::now();
|
auto now = std::chrono::steady_clock::now();
|
||||||
@@ -144,12 +144,27 @@ public:
|
|||||||
|
|
||||||
void render();
|
void render();
|
||||||
|
|
||||||
/// @brief Check if there are non-expired notifications (needs continuous frames for animation)
|
/// Drops every expired entry (not just the front: an expired back entry hid a still-live older toast).
|
||||||
|
static void pruneExpired(std::deque<Notification>& q, std::chrono::steady_clock::time_point now) {
|
||||||
|
q.erase(std::remove_if(q.begin(), q.end(),
|
||||||
|
[&](const Notification& n) { return n.isExpiredAt(now); }), q.end());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The toast render() shows: the newest live one (nullptr when none).
|
||||||
|
const Notification* visibleToast() const {
|
||||||
|
const auto now = std::chrono::steady_clock::now();
|
||||||
|
for (auto it = notifications_.rbegin(); it != notifications_.rend(); ++it)
|
||||||
|
if (!it->isExpiredAt(now)) return &*it;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @brief True while a toast is visible, plus one trailing frame after it disappears (so the
|
||||||
|
/// final, fully-faded state is actually painted). hasActive() now implies a visible pill.
|
||||||
bool hasActive() const {
|
bool hasActive() const {
|
||||||
for (const auto& n : notifications_) {
|
if (visibleToast()) return true;
|
||||||
if (!n.isExpired()) return true;
|
using namespace std::chrono;
|
||||||
}
|
return last_drawn_ != steady_clock::time_point{} &&
|
||||||
return false;
|
steady_clock::now() - last_drawn_ < milliseconds(250); // self-expires if render() is skipped
|
||||||
}
|
}
|
||||||
|
|
||||||
void clear() {
|
void clear() {
|
||||||
@@ -177,6 +192,7 @@ private:
|
|||||||
|
|
||||||
std::deque<Notification> notifications_;
|
std::deque<Notification> notifications_;
|
||||||
size_t max_notifications_ = 5;
|
size_t max_notifications_ = 5;
|
||||||
|
std::chrono::steady_clock::time_point last_drawn_{};
|
||||||
std::function<void(const std::string&, bool)> console_callback_;
|
std::function<void(const std::string&, bool)> console_callback_;
|
||||||
|
|
||||||
std::deque<AlertRecord> history_;
|
std::deque<AlertRecord> history_;
|
||||||
|
|||||||
@@ -3515,6 +3515,30 @@ void testStalenessBadge()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void testNotificationLiveness() {
|
||||||
|
using namespace dragonx::ui;
|
||||||
|
using clk = std::chrono::steady_clock;
|
||||||
|
// prune removes expired entries anywhere in the queue, not just the front
|
||||||
|
std::deque<Notification> q;
|
||||||
|
q.emplace_back("long", NotificationType::Error, 30.0f);
|
||||||
|
q.emplace_back("short", NotificationType::Info, 1.0f);
|
||||||
|
q.back().created_at = clk::now() - std::chrono::seconds(2); // expired
|
||||||
|
q.emplace_back("mid", NotificationType::Info, 5.0f);
|
||||||
|
q[2].created_at = clk::now() - std::chrono::seconds(10); // expired
|
||||||
|
Notifications::pruneExpired(q, clk::now());
|
||||||
|
EXPECT_EQ((int)q.size(), 1);
|
||||||
|
EXPECT_EQ(q.back().message, std::string("long"));
|
||||||
|
// visibleToast skips an expired newest entry and hasActive tracks visibility
|
||||||
|
auto& n = Notifications::instance(); n.clear();
|
||||||
|
n.push("a", NotificationType::Warning, 30.0f);
|
||||||
|
n.push("b", NotificationType::Info, 0.001f);
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||||
|
EXPECT_TRUE(n.visibleToast() != nullptr && n.visibleToast()->message == "a");
|
||||||
|
EXPECT_TRUE(n.hasActive());
|
||||||
|
n.clear();
|
||||||
|
EXPECT_TRUE(!n.hasActive());
|
||||||
|
}
|
||||||
|
|
||||||
void testNotificationHistory()
|
void testNotificationHistory()
|
||||||
{
|
{
|
||||||
using dragonx::ui::Notifications;
|
using dragonx::ui::Notifications;
|
||||||
@@ -18185,6 +18209,7 @@ int main()
|
|||||||
testNodeStatusBanner();
|
testNodeStatusBanner();
|
||||||
testStalenessBadge();
|
testStalenessBadge();
|
||||||
testNotificationHistory();
|
testNotificationHistory();
|
||||||
|
testNotificationLiveness();
|
||||||
testSeedMigrationResume();
|
testSeedMigrationResume();
|
||||||
testLoggerFileSink();
|
testLoggerFileSink();
|
||||||
testDaemonLifecycleExecution();
|
testDaemonLifecycleExecution();
|
||||||
|
|||||||
Reference in New Issue
Block a user