da6a8b5e59
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.
280 lines
10 KiB
C++
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
|