Files
fn_registry/cpp/functions/core/toast.cpp
T
egutierrez dff0d735c1 feat(cpp/core): primitivas UI estilo Mantine
Anade 9 primitivas reutilizables al registry C++ que replican el comportamiento
de los componentes correspondientes de @fn_library / Mantine v9, todas
estilizadas con tokens_cpp_core (colores Mantine dark + indigo):

- button_cpp_core         (component, pure)  variantes primary/secondary/subtle/danger + sm/md/lg
- icon_button_cpp_core    (component, pure)  cuadrado 28x28 con glyph centrado + tooltip
- toolbar_cpp_core        (component, pure)  grupo horizontal de acciones con separadores
- modal_dialog_cpp_core   (component, pure)  popup modal centrada + close con Escape
- text_input_cpp_core     (component, impure) InputText con label muted + placeholder
- select_cpp_core         (component, impure) dropdown con label + opcion '(none)' opcional
- toast_cpp_core          (component, impure) notificaciones efimeras + inbox con badge
- tree_view_cpp_core      (component, impure) jerarquia low-level con tree_node_clicked helper
- process_runner_cpp_core (component, impure) tarea en std::thread + spinner inline

Cada primitiva tiene su .md con frontmatter completo (params/output) y se
indexa via fn index. Son la base del primitives_gallery y de cualquier
app fn_ui futura.
2026-04-25 21:25:39 +02:00

280 lines
10 KiB
C++

#include "core/toast.h"
#include "core/tokens.h"
#include <imgui.h>
#include <chrono>
#include <deque>
#include <mutex>
#include <string>
#include <vector>
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<Toast> g_queue;
std::deque<Toast> 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<std::chrono::seconds>(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<std::mutex> 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<std::mutex> 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<float>(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<float>(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<std::mutex> lk(g_mutex);
return g_unread;
}
void toast_history_clear() {
std::lock_guard<std::mutex> 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<Toast> snapshot;
{
std::lock_guard<std::mutex> 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<std::mutex> 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