#include "core/toast.h" #include "core/tokens.h" #include #include #include #include #include #include namespace fn_ui { namespace { struct Toast { ToastKind kind; std::string text; std::chrono::steady_clock::time_point created; }; constexpr float kToastDurationSec = 3.5f; constexpr float kFadeOutSec = 0.6f; constexpr float kToastWidth = 320.0f; constexpr float kToastVerticalGap = 8.0f; constexpr float kToastMarginX = 16.0f; constexpr float kToastMarginY = 16.0f; constexpr int kHistoryMax = 50; // Cola de toasts activos + historial completo (hasta kHistoryMax). std::deque g_queue; std::deque g_history; // mas reciente al final int g_unread = 0; // no-leidos desde la ultima vez que se abrio el inbox std::mutex g_mutex; ImVec4 color_for(ToastKind k) { using namespace fn_tokens::colors; switch (k) { case ToastKind::Info: return info; case ToastKind::Success: return success; case ToastKind::Warning: return warning; case ToastKind::Error: return error; } return info; } const char* glyph_for(ToastKind k) { switch (k) { case ToastKind::Info: return "\xe2\x84\xb9"; // i case ToastKind::Success: return "\xe2\x9c\x93"; // check case ToastKind::Warning: return "\xe2\x9a\xa0"; // warning case ToastKind::Error: return "\xe2\x9c\x97"; // x } return ""; } std::string format_age(const std::chrono::steady_clock::time_point& t, const std::chrono::steady_clock::time_point& now) { auto s = std::chrono::duration_cast(now - t).count(); char buf[32]; if (s < 60) std::snprintf(buf, sizeof(buf), "%llds", (long long)s); else if (s < 3600) std::snprintf(buf, sizeof(buf), "%lldm", (long long)(s / 60)); else std::snprintf(buf, sizeof(buf), "%lldh", (long long)(s / 3600)); return buf; } } // namespace void toast_push(ToastKind kind, const char* text) { const char* safe = (text && *text) ? text : "(no message)"; std::lock_guard lk(g_mutex); auto now = std::chrono::steady_clock::now(); Toast t{kind, std::string(safe), now}; g_queue.push_back(t); g_history.push_back(t); while ((int)g_history.size() > kHistoryMax) g_history.pop_front(); g_unread++; } void toast_render() { using namespace fn_tokens; std::lock_guard lk(g_mutex); if (g_queue.empty()) return; const auto now = std::chrono::steady_clock::now(); while (!g_queue.empty()) { float age = std::chrono::duration(now - g_queue.front().created).count(); if (age > kToastDurationSec) g_queue.pop_front(); else break; } if (g_queue.empty()) return; const ImGuiViewport* vp = ImGui::GetMainViewport(); float cur_y = vp->WorkPos.y + vp->WorkSize.y - kToastMarginY; const float x = vp->WorkPos.x + vp->WorkSize.x - kToastMarginX - kToastWidth; int i = 0; for (const auto& t : g_queue) { float age = std::chrono::duration(now - t.created).count(); float alpha = 1.0f; if (age > kToastDurationSec - kFadeOutSec) { alpha = (kToastDurationSec - age) / kFadeOutSec; if (alpha < 0.0f) alpha = 0.0f; } ImVec4 accent = color_for(t.kind); ImVec4 surf = colors::surface; surf.w *= alpha; accent.w *= alpha; ImGui::SetNextWindowPos(ImVec2(x, cur_y), ImGuiCond_Always, ImVec2(0, 1)); ImGui::SetNextWindowSize(ImVec2(kToastWidth, 0)); ImGui::PushStyleColor(ImGuiCol_WindowBg, surf); ImGui::PushStyleColor(ImGuiCol_Border, accent); ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, radius::md); ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 1.0f); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(spacing::md, spacing::sm)); ImGui::PushStyleVar(ImGuiStyleVar_Alpha, alpha); char id[32]; std::snprintf(id, sizeof(id), "##toast_%d", i); ImGui::Begin(id, nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_AlwaysAutoResize); ImGui::PushStyleColor(ImGuiCol_Text, accent); ImGui::TextUnformatted(glyph_for(t.kind)); ImGui::PopStyleColor(); ImGui::SameLine(); ImGui::TextWrapped("%s", t.text.c_str()); ImVec2 sz = ImGui::GetWindowSize(); ImGui::End(); ImGui::PopStyleVar(4); ImGui::PopStyleColor(2); cur_y -= (sz.y + kToastVerticalGap); i++; } } int toast_unread_count() { std::lock_guard lk(g_mutex); return g_unread; } void toast_history_clear() { std::lock_guard lk(g_mutex); g_history.clear(); g_unread = 0; } void toast_inbox_button(const char* id) { using namespace fn_tokens; // Snapshot tread-safe del estado int unread = 0; std::vector snapshot; { std::lock_guard lk(g_mutex); unread = g_unread; snapshot.assign(g_history.begin(), g_history.end()); } // Estilo del boton: subtle como icon_button ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0)); ImGui::PushStyleColor(ImGuiCol_ButtonHovered, colors::surface_hover); ImGui::PushStyleColor(ImGuiCol_ButtonActive, colors::surface); ImGui::PushStyleColor(ImGuiCol_Text, colors::text); ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, radius::sm); ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(spacing::xs, spacing::xs)); // Label: campana UTF-8 (U+1F514 = bell). Si la fuente no lo tiene, // fallback a "!". char label[64]; std::snprintf(label, sizeof(label), "\xf0\x9f\x94\x94%s", id); ImVec2 btn_pos = ImGui::GetCursorScreenPos(); const bool clicked = ImGui::Button(label, ImVec2(28.0f, 28.0f)); ImVec2 btn_max = ImGui::GetItemRectMax(); ImGui::PopStyleVar(2); ImGui::PopStyleColor(4); // Badge: circulo rojo con numero en la esquina superior derecha if (unread > 0) { ImDrawList* dl = ImGui::GetWindowDrawList(); const float r = 7.0f; ImVec2 c(btn_max.x - r + 2.0f, btn_pos.y + r - 2.0f); dl->AddCircleFilled(c, r, ImGui::ColorConvertFloat4ToU32(colors::error)); char txt[8]; if (unread > 9) std::snprintf(txt, sizeof(txt), "9+"); else std::snprintf(txt, sizeof(txt), "%d", unread); ImVec2 ts = ImGui::CalcTextSize(txt); dl->AddText(ImVec2(c.x - ts.x * 0.5f, c.y - ts.y * 0.5f), IM_COL32_WHITE, txt); } char popup_id[64]; std::snprintf(popup_id, sizeof(popup_id), "##inbox_popup%s", id); // Posicion fija del popup = anclada al boton en el frame del click. // Acotada al WorkRect del viewport PRIMARIO (IMPORTANTE: con viewports // activados, una posicion fuera del WorkRect se renderiza en otra // ventana del OS / otro monitor). static ImVec2 s_popup_anchor{0, 0}; if (clicked) { const ImGuiViewport* vp = ImGui::GetMainViewport(); constexpr float popup_w = 360.0f; constexpr float popup_h = 360.0f; float x = btn_max.x - popup_w; // alinear lado derecho con el boton float y = btn_max.y + 4.0f; // Clamp dentro del WorkRect del viewport principal if (x < vp->WorkPos.x) x = vp->WorkPos.x; if (y < vp->WorkPos.y) y = vp->WorkPos.y; if (x + popup_w > vp->WorkPos.x + vp->WorkSize.x) x = vp->WorkPos.x + vp->WorkSize.x - popup_w; if (y + popup_h > vp->WorkPos.y + vp->WorkSize.y) y = vp->WorkPos.y + vp->WorkSize.y - popup_h; s_popup_anchor = ImVec2(x, y); ImGui::SetNextWindowViewport(vp->ID); // pinear al viewport principal ImGui::OpenPopup(popup_id); std::lock_guard lk(g_mutex); g_unread = 0; } ImGui::SetNextWindowPos(s_popup_anchor, ImGuiCond_Appearing); // Popover del inbox ImGui::PushStyleColor(ImGuiCol_PopupBg, colors::surface); ImGui::PushStyleColor(ImGuiCol_Border, colors::border); ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, radius::md); ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 1.0f); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(spacing::sm, spacing::sm)); ImGui::SetNextWindowSize(ImVec2(360.0f, 0.0f), ImGuiCond_Appearing); if (ImGui::BeginPopup(popup_id)) { // Header ImGui::PushStyleColor(ImGuiCol_Text, colors::text_muted); ImGui::TextUnformatted("Notifications"); ImGui::PopStyleColor(); ImGui::SameLine(ImGui::GetWindowContentRegionMax().x - 50.0f); if (ImGui::SmallButton("Clear")) { toast_history_clear(); snapshot.clear(); } ImGui::Separator(); if (snapshot.empty()) { ImGui::PushStyleColor(ImGuiCol_Text, colors::text_dim); ImGui::TextUnformatted("(no notifications yet)"); ImGui::PopStyleColor(); } else { const auto now = std::chrono::steady_clock::now(); ImGui::BeginChild("##inbox_list", ImVec2(0, 320.0f)); // Mostrar mas reciente arriba for (auto it = snapshot.rbegin(); it != snapshot.rend(); ++it) { const auto& t = *it; ImGui::PushStyleColor(ImGuiCol_Text, color_for(t.kind)); ImGui::TextUnformatted(glyph_for(t.kind)); ImGui::PopStyleColor(); ImGui::SameLine(); ImGui::TextWrapped("%s", t.text.c_str()); ImGui::PushStyleColor(ImGuiCol_Text, colors::text_dim); ImGui::SameLine(ImGui::GetWindowContentRegionMax().x - 30.0f); ImGui::TextUnformatted(format_age(t.created, now).c_str()); ImGui::PopStyleColor(); ImGui::Separator(); } ImGui::EndChild(); } ImGui::EndPopup(); } ImGui::PopStyleVar(3); ImGui::PopStyleColor(2); } } // namespace fn_ui