#pragma once #include "imgui.h" #include "AnimationUtils.h" #include #include #include enum class NotificationLevel { Info, Success, Warning, Error }; struct NotificationTarget { std::string path; int line = -1; // zero-based int col = -1; // zero-based }; struct Notification { int id = 0; NotificationLevel level = NotificationLevel::Info; std::string message; double timestamp = 0.0; float durationSeconds = 4.0f; bool dismissed = false; bool read = false; bool hasTarget = false; NotificationTarget target; }; class NotificationSystem { public: float toastDurationSeconds = 4.0f; bool showHistory = false; void notify(NotificationLevel level, const std::string& message) { notifyAt(level, message, ImGui::GetTime(), nullptr); } void notify(NotificationLevel level, const std::string& message, const NotificationTarget& target) { notifyAt(level, message, ImGui::GetTime(), &target); } void notifyAt(NotificationLevel level, const std::string& message, double nowSeconds) { notifyAt(level, message, nowSeconds, nullptr); } void notifyAt(NotificationLevel level, const std::string& message, double nowSeconds, const NotificationTarget* target) { Notification n; n.id = nextId++; n.level = level; n.message = message; n.timestamp = nowSeconds; n.durationSeconds = toastDurationSeconds; if (target) { n.hasTarget = true; n.target = *target; } history.push_back(std::move(n)); } void dismiss(int id) { for (auto& n : history) { if (n.id == id) { n.dismissed = true; n.read = true; return; } } } void markAllRead() { for (auto& n : history) { n.read = true; } } int unreadCount() const { int count = 0; for (const auto& n : history) { if (!n.read) ++count; } return count; } const std::vector& getHistory() const { return history; } const Notification* latest() const { if (history.empty()) return nullptr; return &history.back(); } void renderToasts(const std::function& onNavigate = {}, bool reduceMotion = false) { const double now = ImGui::GetTime(); const ImGuiViewport* viewport = ImGui::GetMainViewport(); const ImVec2 base(viewport->WorkPos.x + viewport->WorkSize.x, viewport->WorkPos.y + viewport->WorkSize.y); const float padding = 12.0f; const float toastWidth = 360.0f; const float fadeIn = 0.18f; const float fadeOut = 0.25f; const float slidePixels = 28.0f; float y = base.y - padding; for (int i = (int)history.size() - 1; i >= 0; --i) { auto& n = history[i]; if (n.dismissed) continue; if ((now - n.timestamp) > n.durationSeconds) continue; ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoSavedSettings; float age = (float)(now - n.timestamp); float alpha = 1.0f; float slide = 0.0f; if (!reduceMotion) { float inT = AnimationUtils::clamp01(age / fadeIn); float outT = AnimationUtils::clamp01((age - (n.durationSeconds - fadeOut)) / fadeOut); alpha = AnimationUtils::easeOutCubic(inT); if (age > n.durationSeconds - fadeOut) { alpha *= (1.0f - AnimationUtils::easeOutCubic(outT)); } slide = (1.0f - AnimationUtils::easeOutCubic(inT)) * slidePixels; } ImGui::SetNextWindowPos(ImVec2(base.x - padding + slide, y), ImGuiCond_Always, ImVec2(1.0f, 1.0f)); ImGui::SetNextWindowSize(ImVec2(toastWidth, 0.0f), ImGuiCond_Always); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(12, 10)); ImGui::PushStyleVar(ImGuiStyleVar_Alpha, ImGui::GetStyle().Alpha * alpha); ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.08f, 0.92f)); ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.22f, 0.22f, 0.22f, 0.9f)); ImGui::Begin(("##toast_" + std::to_string(n.id)).c_str(), nullptr, flags); ImVec4 color = levelColor(n.level); ImGui::TextColored(color, "%s", levelLabel(n.level)); ImGui::SameLine(0.0f, 8.0f); ImGui::TextWrapped("%s", n.message.c_str()); if (n.hasTarget) { std::string loc = n.target.path; if (n.target.line >= 0) { loc += ":" + std::to_string(n.target.line + 1); if (n.target.col >= 0) loc += ":" + std::to_string(n.target.col + 1); } ImGui::TextDisabled("%s", loc.c_str()); } bool clicked = ImGui::IsWindowHovered(ImGuiHoveredFlags_AllowWhenBlockedByPopup) && ImGui::IsMouseClicked(0); ImVec2 size = ImGui::GetWindowSize(); ImGui::End(); ImGui::PopStyleColor(2); ImGui::PopStyleVar(2); if (clicked) { n.read = true; n.dismissed = true; if (n.hasTarget && onNavigate) { onNavigate(n); } } y -= size.y + 8.0f; } } void renderHistoryWindow(const std::function& onNavigate = {}) { if (!showHistory) { historyWasOpen = false; return; } if (!historyWasOpen) { markAllRead(); historyWasOpen = true; } ImGui::SetNextWindowSize(ImVec2(520, 360), ImGuiCond_FirstUseEver); if (!ImGui::Begin("Notifications", &showHistory)) { ImGui::End(); return; } if (history.empty()) { ImGui::TextDisabled("(no notifications yet)"); ImGui::End(); return; } ImGui::BeginChild("##notificationHistory", ImVec2(0, 0), false); for (const auto& n : history) { ImGui::PushID(n.id); ImVec4 color = levelColor(n.level); ImGui::TextColored(color, "%s", levelLabel(n.level)); ImGui::SameLine(); if (ImGui::Selectable(n.message.c_str(), false)) { if (n.hasTarget && onNavigate) { onNavigate(n); } } if (n.hasTarget) { std::string loc = n.target.path; if (n.target.line >= 0) { loc += ":" + std::to_string(n.target.line + 1); if (n.target.col >= 0) loc += ":" + std::to_string(n.target.col + 1); } ImGui::TextDisabled("%s", loc.c_str()); } ImGui::Separator(); ImGui::PopID(); } ImGui::EndChild(); ImGui::End(); } private: int nextId = 1; bool historyWasOpen = false; std::vector history; static const char* levelLabel(NotificationLevel level) { switch (level) { case NotificationLevel::Success: return "Success"; case NotificationLevel::Warning: return "Warning"; case NotificationLevel::Error: return "Error"; case NotificationLevel::Info: default: return "Info"; } } static ImVec4 levelColor(NotificationLevel level) { switch (level) { case NotificationLevel::Success: return ImVec4(0.25f, 0.78f, 0.35f, 1.0f); case NotificationLevel::Warning: return ImVec4(0.95f, 0.72f, 0.28f, 1.0f); case NotificationLevel::Error: return ImVec4(0.90f, 0.35f, 0.35f, 1.0f); case NotificationLevel::Info: default: return ImVec4(0.40f, 0.70f, 0.95f, 1.0f); } } };