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.
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
#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
|
||||
Reference in New Issue
Block a user