- Chat: a dismissible first-run intro card (how chat works — encrypted, sent as shielded txs with a small fee, tied to the seed) with a "Copy my chat address" button, shown in the empty thread pane; dismissal persisted. The copy logic is shared with the existing sidebar button (moved to a helper, unchanged). - Toasts: clicking a toast now opens the existing notification-history panel, and a "+N more" badge appears when several live toasts are queued (render only ever showed the newest). Badge opacity is a ui.toml token. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
218 lines
10 KiB
C++
218 lines
10 KiB
C++
// 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<unsigned char>(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
|