// DragonX Wallet - ImGui Edition // Copyright 2024-2026 The Hush Developers // Released under the GPLv3 #include "notifications.h" #include "schema/ui_schema.h" #include "material/type.h" #include "material/draw_helpers.h" #include "../embedded/IconsMaterialDesign.h" #include "imgui.h" namespace dragonx { namespace ui { void Notifications::render() { // Pause-on-hover: while the pointer rests on the toast, keep it alive so a long error message can // actually be read (errors/warnings already last longer, but hovering holds any of them open). if (toast_hovered_ && !notifications_.empty()) notifications_.back().created_at = std::chrono::steady_clock::now(); // 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(); const auto& S = schema::UI(); auto nde = [&](const char* key, float fb) { float v = S.drawElement("components.notifications", key).size; return v >= 0 ? v : fb; }; // Status bar geometry. These are logical-px schema values; the icon/text drawn into the pill // are DPI-baked, so scale the box by dpiScale to match the (also DPI-scaled) rendered status bar // and keep the icon/text inside the pill at HiDPI. const float dp = Layout::dpiScale(); float sbHeight = S.window("components.status-bar").height * dp; if (sbHeight <= 0.0f) sbHeight = 30.0f * dp; ImGuiViewport* viewport = ImGui::GetMainViewport(); float viewBottom = viewport->WorkPos.y + viewport->WorkSize.y; float viewCenterX = viewport->WorkPos.x + viewport->WorkSize.x * 0.5f; // Toast pill sizing — fit inside status bar with margin float pillMarginY = nde("pill-margin-y", 3.0f) * dp; float pillHeight = sbHeight - pillMarginY * 2.0f; float pillPadX = nde("padding-x", 12.0f) * dp; float pillRounding = nde("pill-rounding", 12.0f) * dp; // Get accent color based on type — resolved from theme palette ImVec4 accent_color, text_color; const char* icon = ""; switch (notif.type) { case NotificationType::Success: accent_color = ImGui::ColorConvertU32ToFloat4(S.resolveColor("var(--toast-success-accent)", IM_COL32(50, 180, 80, 255))); text_color = ImGui::ColorConvertU32ToFloat4(S.resolveColor("var(--toast-success-text)", IM_COL32(180, 255, 180, 255))); icon = ICON_MD_CHECK_CIRCLE; break; case NotificationType::Warning: accent_color = ImGui::ColorConvertU32ToFloat4(S.resolveColor("var(--toast-warning-accent)", IM_COL32(204, 166, 50, 255))); text_color = ImGui::ColorConvertU32ToFloat4(S.resolveColor("var(--toast-warning-text)", IM_COL32(255, 230, 130, 255))); icon = ICON_MD_WARNING; break; case NotificationType::Error: accent_color = ImGui::ColorConvertU32ToFloat4(S.resolveColor("var(--toast-error-accent)", IM_COL32(204, 64, 64, 255))); text_color = ImGui::ColorConvertU32ToFloat4(S.resolveColor("var(--toast-error-text)", IM_COL32(255, 153, 153, 255))); icon = ICON_MD_ERROR; break; case NotificationType::Info: default: accent_color = ImGui::ColorConvertU32ToFloat4(S.resolveColor("var(--toast-info-accent)", IM_COL32(100, 160, 220, 255))); text_color = ImGui::ColorConvertU32ToFloat4(S.resolveColor("var(--toast-info-text)", IM_COL32(215, 235, 255, 255))); icon = ICON_MD_INFO; break; } // Calculate fade based on progress float progress = notif.getProgress(); float alpha = 1.0f; if (progress > 0.7f) { // Fade out in last 30% alpha = (1.0f - progress) / 0.3f; } accent_color.w *= alpha; text_color.w *= alpha; // Measure text width to auto-size the pill ImFont* textFont = material::Type().caption(); ImFont* iconFont = material::Type().iconSmall(); float iconW = iconFont ? iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0.0f, icon).x : 0.0f; float iconGap = 4.0f * dp; float msgW = textFont ? textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, notif.message.c_str()).x : 100.0f; float pillWidth = pillPadX + iconW + iconGap + msgW + pillPadX; // "+N more" badge when several live toasts are queued (render shows only the newest). const size_t moreCount = hiddenLiveCount(); std::string badge = moreCount ? ("+" + std::to_string(moreCount)) : std::string(); float badgeW = 0.0f, badgeGap = 0.0f; if (!badge.empty() && textFont) { badgeW = textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, badge.c_str()).x + 10.0f * dp; badgeGap = 6.0f * dp; pillWidth += badgeW + badgeGap; } // Clamp to reasonable bounds float maxPillW = viewport->WorkSize.x * 0.5f; if (pillWidth > maxPillW) pillWidth = maxPillW; // Position: centered horizontally, inside status bar vertically float pillX = viewCenterX - pillWidth * 0.5f; float pillY = viewBottom - sbHeight + pillMarginY; // Draw directly on foreground draw list (no ImGui window overhead) ImDrawList* dl = ImGui::GetForegroundDrawList(); ImVec2 pMin(pillX, pillY); ImVec2 pMax(pillX + pillWidth, pillY + pillHeight); // Glass card background — translucent white fill + noise grain + light border int glassAlpha = (int)(nde("glass-fill-alpha", 18.0f) * alpha); int glassBorderAlpha = (int)(nde("glass-border-alpha", 35.0f) * alpha); material::GlassPanelSpec glassSpec; glassSpec.rounding = pillRounding; glassSpec.fillAlpha = glassAlpha; glassSpec.borderAlpha = glassBorderAlpha; glassSpec.borderWidth = 1.0f; material::DrawGlassPanel(dl, pMin, pMax, glassSpec); // Severity-tinted fill so the toast differs by more than hue (helps when color alone is hard to read). { ImVec4 tint = accent_color; // already premultiplied by fade `alpha` tint.w *= nde("accent-fill-alpha", 0.22f); dl->AddRectFilled(pMin, pMax, ImGui::ColorConvertFloat4ToU32(tint), pillRounding); } // Colored accent border on top of the glass border for type indication ImU32 accentCol = ImGui::ColorConvertFloat4ToU32(accent_color); dl->AddRect(pMin, pMax, accentCol, pillRounding, 0, 1.5f * dp); // Progress bar at bottom of pill (accent-colored), clipped to pill rounded // corners. Draw a full-pill-size rounded rect and clip it to just the // bottom-left progress strip so both bottom corners are respected. float progH = nde("progress-bar-height", 2.0f) * dp; float progW = pillWidth * (1.0f - progress); if (progW > 0.0f) { ImVec2 clipMin(pillX, pMax.y - progH); ImVec2 clipMax(pillX + progW, pMax.y); dl->PushClipRect(clipMin, clipMax, true); dl->AddRectFilled(pMin, pMax, accentCol, pillRounding); dl->PopClipRect(); } // Icon + text vertically centered float contentY = pillY + (pillHeight - (textFont ? textFont->LegacySize : 14.0f)) * 0.5f; float cursorX = pillX + pillPadX; // Icon (accent colored) if (iconFont) { float iconY = pillY + (pillHeight - iconFont->LegacySize) * 0.5f; dl->AddText(iconFont, iconFont->LegacySize, ImVec2(cursorX, iconY), accentCol, icon); cursorX += iconW + iconGap; } // Message text — ellipsized to fit the (viewport-clamped) pill rather than hard-clipped mid-word, // with the full text shown in a tooltip on hover. bool clipped = false; if (textFont) { ImU32 textCol = ImGui::ColorConvertFloat4ToU32(text_color); const float availTextW = (pMax.x - pillPadX) - cursorX - (badge.empty() ? 0.0f : (badgeW + badgeGap)); std::string shown = notif.message; if (availTextW > 0.0f && textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, shown.c_str()).x > availTextW) { clipped = true; const std::string ell = "\xE2\x80\xA6"; // "…" while (!shown.empty() && textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, (shown + ell).c_str()).x > availTextW) { shown.pop_back(); while (!shown.empty() && (static_cast(shown.back()) & 0xC0) == 0x80) shown.pop_back(); // drop any trailing UTF-8 continuation bytes } shown += ell; } dl->PushClipRect(ImVec2(cursorX, pillY), ImVec2(pMax.x - pillPadX, pMax.y), true); dl->AddText(textFont, textFont->LegacySize, ImVec2(cursorX, contentY), textCol, shown.c_str()); dl->PopClipRect(); } // "+N more" badge, right-aligned inside the pill. if (!badge.empty() && textFont) { float badgeX = pMax.x - pillPadX - badgeW; ImVec4 bf = accent_color; bf.w *= nde("badge-fill-alpha", 0.35f); dl->AddRectFilled(ImVec2(badgeX, pillY + 3.0f * dp), ImVec2(badgeX + badgeW, pMax.y - 3.0f * dp), ImGui::ColorConvertFloat4ToU32(bf), (pillHeight - 6.0f * dp) * 0.5f); dl->AddText(textFont, textFont->LegacySize, ImVec2(badgeX + 5.0f * dp, contentY), ImGui::ColorConvertFloat4ToU32(text_color), badge.c_str()); } // Hover: pause the auto-dismiss (next frame), reveal the full message if clipped, and allow a click to // open the notification history (the status bar consumes the request). toast_hovered_ = ImGui::IsMouseHoveringRect(pMin, pMax); if (toast_hovered_) { ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) requestHistory(); } if (toast_hovered_ && clipped) ImGui::SetTooltip("%s", notif.message.c_str()); } } // namespace ui } // namespace dragonx