diff --git a/src/ui/notifications.cpp b/src/ui/notifications.cpp index 7d6d1d5..04cebfb 100644 --- a/src/ui/notifications.cpp +++ b/src/ui/notifications.cpp @@ -14,15 +14,15 @@ namespace ui { void Notifications::render() { - // Remove expired notifications - while (!notifications_.empty() && notifications_.front().isExpired()) { - notifications_.pop_front(); - } - + // Remove every expired notification; the newest remaining one is then both the last and a live one. + pruneExpired(notifications_, std::chrono::steady_clock::now()); + if (notifications_.empty()) { + last_drawn_ = {}; // nothing painted: the trailing "clean frame" is this one return; } - + last_drawn_ = std::chrono::steady_clock::now(); + // Only show the most recent (last) notification as a compact status-bar pill const auto& notif = notifications_.back(); diff --git a/src/ui/notifications.h b/src/ui/notifications.h index 25e764e..1527b72 100644 --- a/src/ui/notifications.h +++ b/src/ui/notifications.h @@ -4,6 +4,7 @@ #pragma once +#include #include #include #include @@ -48,11 +49,10 @@ struct Notification { , duration_seconds(duration) {} - bool isExpired() const { - auto now = std::chrono::steady_clock::now(); - auto elapsed = std::chrono::duration(now - created_at).count(); - return elapsed >= duration_seconds; + bool isExpiredAt(std::chrono::steady_clock::time_point now) const { + return std::chrono::duration(now - created_at).count() >= duration_seconds; } + bool isExpired() const { return isExpiredAt(std::chrono::steady_clock::now()); } float getProgress() const { auto now = std::chrono::steady_clock::now(); @@ -144,12 +144,27 @@ public: 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& 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 { - for (const auto& n : notifications_) { - if (!n.isExpired()) return true; - } - return false; + if (visibleToast()) return true; + using namespace std::chrono; + return last_drawn_ != steady_clock::time_point{} && + steady_clock::now() - last_drawn_ < milliseconds(250); // self-expires if render() is skipped } void clear() { @@ -177,6 +192,7 @@ private: std::deque notifications_; size_t max_notifications_ = 5; + std::chrono::steady_clock::time_point last_drawn_{}; std::function console_callback_; std::deque history_; diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index a5435e4..a1ccd28 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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 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() { using dragonx::ui::Notifications; @@ -18185,6 +18209,7 @@ int main() testNodeStatusBanner(); testStalenessBadge(); testNotificationHistory(); + testNotificationLiveness(); testSeedMigrationResume(); testLoggerFileSink(); testDaemonLifecycleExecution();