feat(cpp/core): logger + log_window + selectable_text widgets
Logger global thread-safe con ring buffer in-memory de 2000 entradas + escritura opcional a archivo. log_window flotante consume el ring buffer con filtros por nivel, busqueda y autoscroll; se abre desde Settings -> Logs en la menubar. selectable_text cubre el patron drag-to-select + Ctrl+C en cualquier ventana. app_menubar y framework run_app integran log_window_render() en el frame loop. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+150
-2
@@ -316,8 +316,20 @@ int run_app(AppConfig config, std::function<void()> render_fn) {
|
||||
// Menubar canonica (View / Layouts / Settings / About) si la app la
|
||||
// configuro en AppConfig. Se renderiza ANTES del render_fn para que
|
||||
// el render_fn pueda hacer DockSpaceOverViewport debajo.
|
||||
if (config.panels != nullptr || config.layouts_cb != nullptr) {
|
||||
fn_ui::app_menubar(config.panels, config.panel_count, config.layouts_cb);
|
||||
if (config.panels != nullptr || config.layouts_cb != nullptr ||
|
||||
(bool)config.view_extras) {
|
||||
// Adapter: std::function<bool()> -> ViewMenuExtrasFn(void*).
|
||||
fn_ui::ViewMenuExtrasFn extras_fn = nullptr;
|
||||
void* extras_user = nullptr;
|
||||
if ((bool)config.view_extras) {
|
||||
extras_fn = [](void* ud) -> bool {
|
||||
auto* fn_ptr = static_cast<std::function<bool()>*>(ud);
|
||||
return (*fn_ptr) ? (*fn_ptr)() : false;
|
||||
};
|
||||
extras_user = (void*)&config.view_extras;
|
||||
}
|
||||
fn_ui::app_menubar(config.panels, config.panel_count,
|
||||
config.layouts_cb, extras_fn, extras_user);
|
||||
}
|
||||
|
||||
render_fn();
|
||||
@@ -385,3 +397,139 @@ int run_app(std::function<void()> render_fn) {
|
||||
}
|
||||
|
||||
} // namespace fn
|
||||
|
||||
#ifdef IMGUI_ENABLE_TEST_ENGINE
|
||||
#include "imgui_te_engine.h"
|
||||
#include "imgui_te_ui.h"
|
||||
#include "imgui_te_context.h"
|
||||
#include "imgui_te_exporters.h"
|
||||
|
||||
namespace fn {
|
||||
|
||||
int run_app_test(AppConfig config,
|
||||
std::function<void()> render_fn,
|
||||
std::function<void(::ImGuiTestEngine*)> register_tests,
|
||||
const char* filter) {
|
||||
if (!register_tests) {
|
||||
fprintf(stderr, "run_app_test: register_tests callback is null\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
glfwSetErrorCallback(glfw_error_callback);
|
||||
if (!glfwInit()) { fprintf(stderr, "GLFW init failed\n"); return 1; }
|
||||
|
||||
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
|
||||
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
|
||||
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
|
||||
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
|
||||
|
||||
GLFWwindow* window = glfwCreateWindow(
|
||||
config.width, config.height,
|
||||
config.title ? config.title : "fn_test", nullptr, nullptr);
|
||||
if (!window) { glfwTerminate(); fprintf(stderr, "createWindow failed\n"); return 1; }
|
||||
glfwMakeContextCurrent(window);
|
||||
glfwSwapInterval(0); // tests run as fast as possible — no vsync
|
||||
|
||||
if (config.init_gl_loader) {
|
||||
if (!fn::gfx::gl_loader_init()) {
|
||||
glfwDestroyWindow(window); glfwTerminate();
|
||||
fprintf(stderr, "gl_loader_init failed\n"); return 1;
|
||||
}
|
||||
}
|
||||
|
||||
IMGUI_CHECKVERSION();
|
||||
ImGui::CreateContext();
|
||||
ImPlot::CreateContext();
|
||||
ImPlot3D::CreateContext();
|
||||
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
|
||||
io.ConfigFlags |= ImGuiConfigFlags_DockingEnable;
|
||||
// No viewports in tests — the engine drives the main window only.
|
||||
io.IniFilename = nullptr; // tests don't persist .ini
|
||||
|
||||
fn_ui::settings_load();
|
||||
fn_ui::load_fonts_from_settings();
|
||||
|
||||
switch (config.theme) {
|
||||
case ThemeMode::FnDark: fn_tokens::apply_dark_theme(); break;
|
||||
case ThemeMode::ImGuiDark: ImGui::StyleColorsDark(); break;
|
||||
case ThemeMode::ImGuiLight: ImGui::StyleColorsLight(); break;
|
||||
case ThemeMode::None: break;
|
||||
}
|
||||
|
||||
ImGui_ImplGlfw_InitForOpenGL(window, true);
|
||||
ImGui_ImplOpenGL3_Init("#version 330");
|
||||
|
||||
// --- Test engine setup ---
|
||||
ImGuiTestEngine* engine = ImGuiTestEngine_CreateContext();
|
||||
ImGuiTestEngineIO& te_io = ImGuiTestEngine_GetIO(engine);
|
||||
te_io.ConfigVerboseLevel = ImGuiTestVerboseLevel_Info;
|
||||
te_io.ConfigVerboseLevelOnError = ImGuiTestVerboseLevel_Debug;
|
||||
te_io.ConfigRunSpeed = ImGuiTestRunSpeed_Fast;
|
||||
te_io.ConfigStopOnError = false;
|
||||
te_io.ConfigCaptureEnabled = false;
|
||||
te_io.ConfigSavedSettings = false;
|
||||
|
||||
register_tests(engine);
|
||||
|
||||
ImGuiTestEngine_Start(engine, ImGui::GetCurrentContext());
|
||||
ImGuiTestEngine_QueueTests(engine, ImGuiTestGroup_Tests, filter,
|
||||
ImGuiTestRunFlags_RunFromCommandLine);
|
||||
|
||||
// --- Loop until tests finish ---
|
||||
bool tests_queued_done = false;
|
||||
int frames_after_done = 0;
|
||||
while (!glfwWindowShouldClose(window)) {
|
||||
glfwPollEvents();
|
||||
|
||||
ImGui_ImplOpenGL3_NewFrame();
|
||||
ImGui_ImplGlfw_NewFrame();
|
||||
ImGui::NewFrame();
|
||||
|
||||
render_fn();
|
||||
|
||||
ImGui::Render();
|
||||
int display_w, display_h;
|
||||
glfwGetFramebufferSize(window, &display_w, &display_h);
|
||||
glViewport(0, 0, display_w, display_h);
|
||||
glClearColor(config.bg_r, config.bg_g, config.bg_b, 1.0f);
|
||||
glClear(GL_COLOR_BUFFER_BIT);
|
||||
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
|
||||
|
||||
glfwSwapBuffers(window);
|
||||
|
||||
if (!tests_queued_done && ImGuiTestEngine_IsTestQueueEmpty(engine)) {
|
||||
tests_queued_done = true;
|
||||
}
|
||||
if (tests_queued_done) {
|
||||
// Let the engine flush its final state for a few frames before exit.
|
||||
if (++frames_after_done > 2) break;
|
||||
}
|
||||
}
|
||||
|
||||
int count_tested = 0, count_success = 0;
|
||||
ImGuiTestEngine_GetResult(engine, count_tested, count_success);
|
||||
bool all_passed = (count_tested > 0) && (count_tested == count_success);
|
||||
|
||||
ImGuiTestEngine_PrintResultSummary(engine);
|
||||
ImGuiTestEngine_Stop(engine);
|
||||
|
||||
ImGui_ImplOpenGL3_Shutdown();
|
||||
ImGui_ImplGlfw_Shutdown();
|
||||
ImPlot3D::DestroyContext();
|
||||
ImPlot::DestroyContext();
|
||||
ImGui::DestroyContext();
|
||||
|
||||
ImGuiTestEngine_DestroyContext(engine);
|
||||
|
||||
glfwDestroyWindow(window);
|
||||
glfwTerminate();
|
||||
|
||||
fprintf(stdout, "\n[fn::run_app_test] %d/%d tests passed%s\n",
|
||||
count_success, count_tested, all_passed ? "" : " — FAILED");
|
||||
return all_passed ? 0 : 1;
|
||||
}
|
||||
|
||||
} // namespace fn
|
||||
#endif // IMGUI_ENABLE_TEST_ENGINE
|
||||
|
||||
@@ -115,6 +115,13 @@ struct AppConfig {
|
||||
// llama fn_ui::app_menubar(panels, panel_count, layouts_cb) cada frame.
|
||||
fn_ui::LayoutCallbacks* layouts_cb = nullptr;
|
||||
|
||||
// Items extra dentro del menu "View", al final tras los toggles de
|
||||
// paneles. Si view_extras != nullptr, run_app lo pasa a app_menubar.
|
||||
// El callback se invoca dentro de un BeginMenu("View") ya abierto:
|
||||
// la app llama directamente a ImGui::Separator(), MenuItem(), etc.
|
||||
// NO debe abrir/cerrar el menu View.
|
||||
std::function<bool()> view_extras{};
|
||||
|
||||
// Si true, run_app llama fn::gfx::gl_loader_init() tras crear el contexto
|
||||
// GL y antes del primer frame. Necesario para apps que llaman gl* directo
|
||||
// en Windows (en Linux es no-op).
|
||||
@@ -136,3 +143,35 @@ int run_app(AppConfig config, std::function<void()> render_fn);
|
||||
int run_app(std::function<void()> render_fn);
|
||||
|
||||
} // namespace fn
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// E2E testing — Dear ImGui Test Engine integration.
|
||||
// ----------------------------------------------------------------------------
|
||||
//
|
||||
// Only available when the registry is built with -DFN_BUILD_TESTS=ON. The
|
||||
// CMake option defines IMGUI_ENABLE_TEST_ENGINE on imgui+fn_framework and
|
||||
// links the imgui_test_engine static lib. Without the option, run_app_test is
|
||||
// not declared and apps build identically to today.
|
||||
#ifdef IMGUI_ENABLE_TEST_ENGINE
|
||||
struct ImGuiTestEngine;
|
||||
|
||||
namespace fn {
|
||||
|
||||
// Run an app under Dear ImGui Test Engine. Same as run_app, but:
|
||||
// 1. Creates a test engine and binds it to the ImGui context.
|
||||
// 2. Calls register_tests(engine) once before the main loop. Tests are
|
||||
// registered with IM_REGISTER_TEST(engine, "category", "name") and
|
||||
// assigned a TestFunc lambda that drives the UI.
|
||||
// 3. Queues all tests matching `filter` (default "all") and ticks frames
|
||||
// until the queue empties.
|
||||
// 4. Exits with code 0 if all tests pass, 1 if any failed or crashed.
|
||||
//
|
||||
// register_tests must be non-null and must register at least one test or the
|
||||
// function returns 1.
|
||||
int run_app_test(AppConfig config,
|
||||
std::function<void()> render_fn,
|
||||
std::function<void(ImGuiTestEngine*)> register_tests,
|
||||
const char* filter = "all");
|
||||
|
||||
} // namespace fn
|
||||
#endif // IMGUI_ENABLE_TEST_ENGINE
|
||||
|
||||
@@ -1,17 +1,38 @@
|
||||
#include "core/app_menubar.h"
|
||||
#include "core/log_window.h"
|
||||
#include <imgui.h>
|
||||
|
||||
namespace fn_ui {
|
||||
|
||||
bool app_menubar(const PanelToggle* panels, std::size_t count,
|
||||
LayoutCallbacks* layouts_cb) {
|
||||
LayoutCallbacks* layouts_cb,
|
||||
ViewMenuExtrasFn view_extras,
|
||||
void* view_extras_user) {
|
||||
if (!ImGui::BeginMainMenuBar()) return false;
|
||||
|
||||
bool changed = false;
|
||||
|
||||
// Menu "View" — solo si hay panels
|
||||
if (panels && count > 0) {
|
||||
changed |= panel_menu_items("View", panels, count);
|
||||
// Menu "View" — combinamos los toggles de paneles con los extras de
|
||||
// la app (si los hay) bajo el mismo BeginMenu para que el usuario los
|
||||
// vea juntos. Si no hay panels NI extras, omitimos el menu entero.
|
||||
bool has_panels = (panels && count > 0);
|
||||
if (has_panels || view_extras) {
|
||||
if (ImGui::BeginMenu("View")) {
|
||||
if (has_panels) {
|
||||
for (std::size_t i = 0; i < count; ++i) {
|
||||
const PanelToggle& item = panels[i];
|
||||
if (!item.open) continue;
|
||||
if (ImGui::MenuItem(item.label, item.shortcut, item.open)) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (view_extras) {
|
||||
if (has_panels) ImGui::Separator();
|
||||
changed |= view_extras(view_extras_user);
|
||||
}
|
||||
ImGui::EndMenu();
|
||||
}
|
||||
}
|
||||
|
||||
// Menu "Layouts" — solo si hay callbacks
|
||||
@@ -19,10 +40,11 @@ bool app_menubar(const PanelToggle* panels, std::size_t count,
|
||||
changed |= layouts_menu_items("Layouts", *layouts_cb);
|
||||
}
|
||||
|
||||
// Menu "Settings" — siempre. Submenus: Settings... y About...
|
||||
// Menu "Settings" — siempre. Submenus: Settings... / Logs... / About...
|
||||
// Las ventanas se renderizan al final del frame en fn::run_app.
|
||||
if (ImGui::BeginMenu("Settings")) {
|
||||
changed |= settings_window_menu_item("Settings...");
|
||||
changed |= log_window_menu_item("Logs...");
|
||||
ImGui::Separator();
|
||||
changed |= about_window_menu_item("About...");
|
||||
ImGui::EndMenu();
|
||||
|
||||
@@ -7,6 +7,14 @@
|
||||
|
||||
namespace fn_ui {
|
||||
|
||||
// Callback opcional que la app puede inyectar para añadir items propios
|
||||
// al final del menu "View", justo despues de los toggles de paneles.
|
||||
// El callback se invoca dentro de un `BeginMenu("View")` ya abierto: la
|
||||
// app llama directamente a `ImGui::Separator()`, `ImGui::MenuItem(...)`,
|
||||
// `ImGui::BeginDisabled()`, etc. NO debe hacer Begin/EndMenu para "View".
|
||||
// Devuelve true si el usuario activo algun item este frame.
|
||||
using ViewMenuExtrasFn = bool(*)(void* user_data);
|
||||
|
||||
// Renderiza una MainMenuBar completa con:
|
||||
// * Menu "View" (panel_menu_items con los toggles dados) [si panels]
|
||||
// * Menu "Layouts" (layouts_menu_items con las callbacks dadas) [si layouts_cb]
|
||||
@@ -16,13 +24,16 @@ namespace fn_ui {
|
||||
//
|
||||
// Llamar despues de NewFrame() y antes del DockSpaceOverViewport.
|
||||
// Si layouts_cb es nullptr, omite Layouts.
|
||||
// Si panels es nullptr o count == 0, omite View.
|
||||
// Si panels es nullptr o count == 0, omite View (o solo dibuja extras
|
||||
// si view_extras != nullptr).
|
||||
// Las ventanas Settings y About se renderizan al final del frame en
|
||||
// fn::run_app via settings_window_render() y about_window_render().
|
||||
//
|
||||
// Returns: true si el usuario togglo paneles, disparo accion de layouts,
|
||||
// o abrio una ventana este frame.
|
||||
bool app_menubar(const PanelToggle* panels, std::size_t count,
|
||||
LayoutCallbacks* layouts_cb);
|
||||
LayoutCallbacks* layouts_cb,
|
||||
ViewMenuExtrasFn view_extras = nullptr,
|
||||
void* view_extras_user = nullptr);
|
||||
|
||||
} // namespace fn_ui
|
||||
|
||||
@@ -8,7 +8,7 @@ purity: pure
|
||||
signature: "bool fn_ui::app_menubar(const fn_ui::PanelToggle* panels, size_t count, fn_ui::LayoutCallbacks* layouts_cb)"
|
||||
description: "MainMenuBar ImGui completa con menu View (toggles de paneles) y menu Layouts (guardar/aplicar layouts persistentes). Punto de entrada unificado para la menubar de cualquier app fn_ui."
|
||||
tags: [imgui, ui, menu, panels, layouts, dockspace, menubar]
|
||||
uses_functions: ["app_about_cpp_core", "app_settings_cpp_core", "layouts_menu_cpp_core", "panel_menu_cpp_core"]
|
||||
uses_functions: ["app_about_cpp_core", "app_settings_cpp_core", "layouts_menu_cpp_core", "panel_menu_cpp_core", "log_window_cpp_core"]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
@@ -135,3 +135,11 @@ El item plano `Settings...` pasa a ser un `BeginMenu("Settings")` con dos subite
|
||||
`fn::run_app` ahora llama tambien `fn_ui::about_window_render()` al final del frame, justo despues de `settings_window_render()`. Apps que no usan `fn::run_app` deben llamar ambos manualmente.
|
||||
|
||||
Cambio retro-compatible: las apps que solo invocan `fn_ui::app_menubar(nullptr, 0, nullptr)` no necesitan tocar nada — el menu `Settings` aparece con About vacio (defaults `"fn_registry app"` / sin version) hasta que llamen `about_window_set_info`.
|
||||
|
||||
## Notas — Logs item (sesion 2026-05-01)
|
||||
|
||||
El submenu `Settings` añade un tercer item `Logs...` entre `Settings...` y `About...` que abre la ventana de `log_window_cpp_core`. La ventana muestra el ring buffer in-memory de `logger_cpp_core` con filtros por nivel + busqueda + autoscroll.
|
||||
|
||||
Funciona aunque la app no haya activado el logger en disco: la ventana siempre tiene el buffer in-memory disponible. Para activar tambien la persistencia a archivo, la app declara `AppLogConfig` en `AppConfig.log` (ver `logger_cpp_core`).
|
||||
|
||||
`fn::run_app` invoca `fn_ui::log_window_render()` al final del frame, justo despues de `settings_window_render()` y antes de `about_window_render()`.
|
||||
|
||||
@@ -6,45 +6,86 @@ namespace fn_ui {
|
||||
|
||||
bool layouts_menu_items(const char* menu_label, LayoutCallbacks& cb) {
|
||||
bool acted = false;
|
||||
// Flag para abrir el popup al frame siguiente — NO podemos hacer
|
||||
// BeginPopup dentro de BeginMenu porque MenuItem cierra el menu y
|
||||
// BeginPopup queda fuera del flujo. Hay que hacer BeginPopup en el
|
||||
// scope de la menubar (al nivel donde se llama esta funcion).
|
||||
static bool s_open_save_popup = false;
|
||||
static char s_name_buf[64] = "";
|
||||
|
||||
if (!ImGui::BeginMenu(menu_label)) return false;
|
||||
|
||||
// ── Lista de layouts guardados ────────────────────────────────────────
|
||||
if (cb.list) {
|
||||
std::vector<std::string> names = cb.list();
|
||||
for (const std::string& name : names) {
|
||||
// Construir label con marker si es el activo
|
||||
std::string label;
|
||||
if (!cb.active_name.empty() && name == cb.active_name) {
|
||||
label = "* " + name;
|
||||
} else {
|
||||
label = " " + name;
|
||||
}
|
||||
if (ImGui::MenuItem(label.c_str()) && cb.on_apply) {
|
||||
cb.on_apply(name);
|
||||
acted = true;
|
||||
if (ImGui::BeginMenu(menu_label)) {
|
||||
// ── Lista de layouts guardados ───────────────────────────────────
|
||||
if (cb.list) {
|
||||
std::vector<std::string> names = cb.list();
|
||||
for (const std::string& name : names) {
|
||||
std::string label;
|
||||
if (!cb.active_name.empty() && name == cb.active_name) {
|
||||
label = "* " + name;
|
||||
} else {
|
||||
label = " " + name;
|
||||
}
|
||||
if (ImGui::MenuItem(label.c_str()) && cb.on_apply) {
|
||||
cb.on_apply(name);
|
||||
acted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::Separator();
|
||||
|
||||
// ── Save current as... ──── solo levanta la flag, el popup va fuera
|
||||
if (ImGui::MenuItem("Save current as...")) {
|
||||
s_open_save_popup = true;
|
||||
}
|
||||
|
||||
// ── Delete submenu ───────────────────────────────────────────────
|
||||
if (ImGui::BeginMenu("Delete")) {
|
||||
std::vector<std::string> names;
|
||||
if (cb.list) names = cb.list();
|
||||
|
||||
if (names.empty()) {
|
||||
ImGui::MenuItem("(no layouts)", nullptr, false, false);
|
||||
} else {
|
||||
for (const std::string& name : names) {
|
||||
if (ImGui::MenuItem(name.c_str()) && cb.on_delete) {
|
||||
cb.on_delete(name);
|
||||
acted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
ImGui::EndMenu();
|
||||
}
|
||||
|
||||
ImGui::Separator();
|
||||
|
||||
// ── Reset to default ─────────────────────────────────────────────
|
||||
if (ImGui::MenuItem("Reset to default") && cb.on_reset) {
|
||||
cb.on_reset();
|
||||
acted = true;
|
||||
}
|
||||
|
||||
ImGui::EndMenu();
|
||||
}
|
||||
|
||||
ImGui::Separator();
|
||||
|
||||
// ── Save current as... ────────────────────────────────────────────────
|
||||
if (ImGui::MenuItem("Save current as...")) {
|
||||
// ── Popup "Save current as..." (FUERA del BeginMenu) ──────────────────
|
||||
// OpenPopup debe llamarse desde el mismo nivel donde se llama BeginPopup.
|
||||
// Por eso disparamos s_open_save_popup arriba y aqui hacemos el OpenPopup
|
||||
// + BeginPopup en el scope de menubar.
|
||||
if (s_open_save_popup) {
|
||||
ImGui::OpenPopup("##save_layout");
|
||||
s_open_save_popup = false;
|
||||
}
|
||||
|
||||
// Popup "Save as..."
|
||||
// Estado local del input: buffer estatico de 64 char.
|
||||
static char s_name_buf[64] = "";
|
||||
if (ImGui::BeginPopup("##save_layout")) {
|
||||
ImGui::Text("Layout name:");
|
||||
ImGui::SetNextItemWidth(200.0f);
|
||||
ImGui::InputText("##layout_name", s_name_buf, sizeof(s_name_buf));
|
||||
bool enter_pressed = ImGui::InputText(
|
||||
"##layout_name", s_name_buf, sizeof(s_name_buf),
|
||||
ImGuiInputTextFlags_EnterReturnsTrue);
|
||||
|
||||
bool name_valid = s_name_buf[0] != '\0';
|
||||
if (!name_valid) ImGui::BeginDisabled();
|
||||
if (ImGui::Button("Save") && name_valid && cb.on_save) {
|
||||
if ((ImGui::Button("Save") || enter_pressed)
|
||||
&& name_valid && cb.on_save) {
|
||||
cb.on_save(std::string(s_name_buf));
|
||||
s_name_buf[0] = '\0';
|
||||
acted = true;
|
||||
@@ -59,34 +100,6 @@ bool layouts_menu_items(const char* menu_label, LayoutCallbacks& cb) {
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
// ── Delete submenu ────────────────────────────────────────────────────
|
||||
if (ImGui::BeginMenu("Delete")) {
|
||||
std::vector<std::string> names;
|
||||
if (cb.list) names = cb.list();
|
||||
|
||||
if (names.empty()) {
|
||||
ImGui::MenuItem("(no layouts)", nullptr, false, false);
|
||||
} else {
|
||||
for (const std::string& name : names) {
|
||||
if (ImGui::MenuItem(name.c_str()) && cb.on_delete) {
|
||||
cb.on_delete(name);
|
||||
acted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
ImGui::EndMenu();
|
||||
}
|
||||
|
||||
ImGui::Separator();
|
||||
|
||||
// ── Reset to default ──────────────────────────────────────────────────
|
||||
if (ImGui::MenuItem("Reset to default") && cb.on_reset) {
|
||||
cb.on_reset();
|
||||
acted = true;
|
||||
}
|
||||
|
||||
ImGui::EndMenu();
|
||||
return acted;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
#include "core/log_window.h"
|
||||
#include "core/logger.h"
|
||||
|
||||
#include "imgui.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace fn_ui {
|
||||
|
||||
namespace {
|
||||
|
||||
bool g_open = false;
|
||||
bool g_show_debug = true;
|
||||
bool g_show_info = true;
|
||||
bool g_show_warn = true;
|
||||
bool g_show_error = true;
|
||||
bool g_autoscroll = true;
|
||||
char g_filter_buf[128] = "";
|
||||
|
||||
// Tamano previo del buffer — usado para detectar entradas nuevas y forzar
|
||||
// scroll-to-bottom solo cuando llegan logs (no en cada frame).
|
||||
std::size_t g_prev_buffer_size = 0;
|
||||
|
||||
ImVec4 color_for_level(fn_log::Level level) {
|
||||
switch (level) {
|
||||
case fn_log::Level::Debug: return ImVec4(0.55f, 0.60f, 0.66f, 1.0f); // gris azulado
|
||||
case fn_log::Level::Info: return ImVec4(0.78f, 0.82f, 0.86f, 1.0f); // texto
|
||||
case fn_log::Level::Warn: return ImVec4(0.95f, 0.74f, 0.31f, 1.0f); // ambar
|
||||
case fn_log::Level::Error: return ImVec4(0.95f, 0.43f, 0.43f, 1.0f); // rojo
|
||||
}
|
||||
return ImVec4(1, 1, 1, 1);
|
||||
}
|
||||
|
||||
bool level_visible(fn_log::Level level) {
|
||||
switch (level) {
|
||||
case fn_log::Level::Debug: return g_show_debug;
|
||||
case fn_log::Level::Info: return g_show_info;
|
||||
case fn_log::Level::Warn: return g_show_warn;
|
||||
case fn_log::Level::Error: return g_show_error;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool matches_filter(const char* text) {
|
||||
if (g_filter_buf[0] == '\0') return true;
|
||||
return std::strstr(text, g_filter_buf) != nullptr;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool log_window_is_open() { return g_open; }
|
||||
void log_window_set_open(bool v) { g_open = v; }
|
||||
void log_window_toggle() { g_open = !g_open; }
|
||||
|
||||
bool log_window_menu_item(const char* label) {
|
||||
if (ImGui::MenuItem(label)) {
|
||||
g_open = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void log_window_render() {
|
||||
if (!g_open) return;
|
||||
|
||||
ImGui::SetNextWindowSize(ImVec2(720, 420), ImGuiCond_FirstUseEver);
|
||||
if (!ImGui::Begin("Logs", &g_open, ImGuiWindowFlags_NoCollapse)) {
|
||||
ImGui::End();
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Toolbar: filtros por nivel + busqueda + acciones ---
|
||||
ImGui::Checkbox("Debug", &g_show_debug); ImGui::SameLine();
|
||||
ImGui::Checkbox("Info", &g_show_info); ImGui::SameLine();
|
||||
ImGui::Checkbox("Warn", &g_show_warn); ImGui::SameLine();
|
||||
ImGui::Checkbox("Error", &g_show_error);
|
||||
|
||||
ImGui::SameLine();
|
||||
ImGui::Dummy(ImVec2(12, 0));
|
||||
ImGui::SameLine();
|
||||
ImGui::SetNextItemWidth(220.0f);
|
||||
ImGui::InputTextWithHint("##log_filter", "filtro (substring)", g_filter_buf, sizeof(g_filter_buf));
|
||||
|
||||
ImGui::SameLine();
|
||||
ImGui::Checkbox("Auto-scroll", &g_autoscroll);
|
||||
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Clear")) {
|
||||
fn_log::buffer_clear();
|
||||
g_prev_buffer_size = 0;
|
||||
}
|
||||
|
||||
// Path del archivo activo (si hay)
|
||||
const char* path = fn_log::logger_path();
|
||||
if (path && *path) {
|
||||
ImGui::SameLine();
|
||||
ImGui::TextDisabled(" → %s", path);
|
||||
}
|
||||
|
||||
ImGui::Separator();
|
||||
|
||||
// --- Region scroll con todas las entradas filtradas ---
|
||||
const float footer_h = ImGui::GetFrameHeightWithSpacing();
|
||||
if (ImGui::BeginChild("##log_scroll",
|
||||
ImVec2(0, -footer_h),
|
||||
true,
|
||||
ImGuiWindowFlags_HorizontalScrollbar)) {
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4, 1));
|
||||
|
||||
std::size_t total = fn_log::buffer_size();
|
||||
std::size_t shown = 0;
|
||||
for (std::size_t i = 0; i < total; ++i) {
|
||||
const fn_log::Entry* e = fn_log::buffer_at(i);
|
||||
if (!e) continue;
|
||||
if (!level_visible(e->level)) continue;
|
||||
if (!matches_filter(e->text)) continue;
|
||||
|
||||
ImVec4 col = color_for_level(e->level);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, col);
|
||||
ImGui::TextUnformatted(e->text);
|
||||
ImGui::PopStyleColor();
|
||||
++shown;
|
||||
}
|
||||
|
||||
ImGui::PopStyleVar();
|
||||
|
||||
// Auto-scroll: solo cuando llegan entradas nuevas
|
||||
if (g_autoscroll && total != g_prev_buffer_size) {
|
||||
ImGui::SetScrollHereY(1.0f);
|
||||
}
|
||||
g_prev_buffer_size = total;
|
||||
|
||||
// Footer dentro del child para que se vea pegado abajo si esta vacio
|
||||
if (shown == 0) {
|
||||
ImGui::TextDisabled("Sin entradas que coincidan con los filtros.");
|
||||
}
|
||||
}
|
||||
ImGui::EndChild();
|
||||
|
||||
// --- Status footer ---
|
||||
ImGui::TextDisabled("%zu entradas en buffer (cap %zu)",
|
||||
fn_log::buffer_size(), fn_log::kBufferCapacity);
|
||||
|
||||
ImGui::End();
|
||||
}
|
||||
|
||||
} // namespace fn_ui
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
// Ventana flotante "Logs" para apps del registry. Muestra las entradas del
|
||||
// buffer in-memory de fn_log (ver logger.h) con filtros por nivel y un
|
||||
// buscador por substring.
|
||||
//
|
||||
// Lifecycle:
|
||||
// - app_menubar() incluye el MenuItem "Logs..." en el submenu "Settings"
|
||||
// - end of frame: log_window_render() — invocado por fn::run_app
|
||||
//
|
||||
// Apps que NO usan fn::run_app deben llamar log_window_render() manualmente.
|
||||
//
|
||||
// El visualizador no toca el archivo en disco; solo lee el ring buffer. Si
|
||||
// la app nunca llamo logger_init, la ventana sigue funcionando contra el
|
||||
// buffer (los logs simplemente no se persisten).
|
||||
|
||||
namespace fn_ui {
|
||||
|
||||
bool log_window_is_open();
|
||||
void log_window_set_open(bool v);
|
||||
void log_window_toggle();
|
||||
|
||||
// MenuItem componible. Llamar dentro de un BeginMenu/BeginMainMenuBar
|
||||
// exitoso. Click → abre la ventana. Returns true si el usuario clico.
|
||||
bool log_window_menu_item(const char* label = "Logs...");
|
||||
|
||||
// Render de la ventana. No-op si is_open == false. Llamada por fn::run_app
|
||||
// al final del frame, despues del render_fn de la app.
|
||||
void log_window_render();
|
||||
|
||||
} // namespace fn_ui
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: log_window
|
||||
kind: function
|
||||
lang: cpp
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "bool fn_ui::log_window_is_open(); void fn_ui::log_window_set_open(bool); void fn_ui::log_window_toggle(); bool fn_ui::log_window_menu_item(const char* label = \"Logs...\"); void fn_ui::log_window_render()"
|
||||
description: "Ventana flotante 'Logs' para apps C++ del registry. Muestra el ring buffer in-memory de fn_log con filtros por nivel (Debug/Info/Warn/Error), buscador por substring y autoscroll. Se abre desde el submenu Settings -> Logs... de la MainMenuBar."
|
||||
tags: [imgui, logger, viewer, window, ui]
|
||||
uses_functions: [logger_cpp_core]
|
||||
uses_types: [log_level_cpp_core, log_entry_cpp_core]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [imgui, cstring]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "cpp/functions/core/log_window.cpp"
|
||||
framework: imgui
|
||||
params:
|
||||
- name: v
|
||||
desc: "Estado deseado de la ventana (open=true / closed=false) en log_window_set_open"
|
||||
- name: label
|
||||
desc: "Texto del MenuItem en la menubar (default 'Logs...')"
|
||||
output: "log_window_render no-op si is_open == false. log_window_menu_item retorna true si el usuario clico el item. Las demas mutan estado global del modulo"
|
||||
notes: "consumido por cpp/framework/app_base.cpp (render automatico) y cpp/functions/core/app_menubar.cpp (menu item)"
|
||||
---
|
||||
|
||||
# log_window
|
||||
|
||||
Visualizador ImGui del ring buffer de `fn_log`. Render automatico via `fn::run_app`.
|
||||
|
||||
## UI
|
||||
|
||||
- **Toolbar superior:** checkboxes para mostrar/ocultar Debug, Info, Warn, Error; campo de busqueda por substring; checkbox de autoscroll; boton `Clear` para vaciar el buffer in-memory; al lado, la ruta del archivo de log activo (si hay).
|
||||
- **Region central:** lista de lineas formateadas, coloreadas por nivel (gris/blanco/ambar/rojo).
|
||||
- **Footer:** contador de entradas vs capacidad del buffer (2000 por defecto).
|
||||
|
||||
## Apertura
|
||||
|
||||
- Submenu **Settings -> Logs...** del menubar canonico (via `app_menubar`).
|
||||
- Programaticamente: `fn_ui::log_window_set_open(true)`.
|
||||
|
||||
## Reglas
|
||||
|
||||
- No toca el archivo en disco; solo lee el buffer in-memory de `fn_log`.
|
||||
- `Clear` borra el buffer pero NO trunca el archivo.
|
||||
- Funciona aunque la app no haya llamado `logger_init` (el buffer existe siempre).
|
||||
- Single-threaded como cualquier ventana ImGui — no llamar `log_window_render` desde otro hilo.
|
||||
@@ -0,0 +1,156 @@
|
||||
#include "core/logger.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdarg>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
namespace fn_log {
|
||||
|
||||
namespace {
|
||||
|
||||
std::mutex g_mu;
|
||||
FILE* g_file = nullptr;
|
||||
std::string g_path;
|
||||
Level g_min_level = Level::Info;
|
||||
|
||||
// Ring buffer in-memory. g_count es el numero de entradas vivas (clamp a
|
||||
// capacity); g_head es el indice donde se escribira la proxima entrada.
|
||||
Entry g_buf[kBufferCapacity];
|
||||
std::size_t g_count = 0;
|
||||
std::size_t g_head = 0;
|
||||
|
||||
long long now_ms() {
|
||||
using namespace std::chrono;
|
||||
return duration_cast<milliseconds>(system_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
|
||||
// Formato: "YYYY-MM-DD HH:MM:SS.mmm". out debe tener al menos 24 bytes.
|
||||
void format_ts(long long ts_ms, char* out, std::size_t out_size) {
|
||||
std::time_t secs = static_cast<std::time_t>(ts_ms / 1000);
|
||||
int millis = static_cast<int>(ts_ms % 1000);
|
||||
if (millis < 0) millis = 0;
|
||||
if (millis > 999) millis = 999;
|
||||
std::tm tm_buf{};
|
||||
#ifdef _WIN32
|
||||
localtime_s(&tm_buf, &secs);
|
||||
#else
|
||||
localtime_r(&secs, &tm_buf);
|
||||
#endif
|
||||
std::snprintf(out, out_size, "%04d-%02d-%02d %02d:%02d:%02d.%03d",
|
||||
(tm_buf.tm_year + 1900) % 10000,
|
||||
(tm_buf.tm_mon + 1) % 100,
|
||||
tm_buf.tm_mday % 100,
|
||||
tm_buf.tm_hour % 100,
|
||||
tm_buf.tm_min % 100,
|
||||
tm_buf.tm_sec % 100,
|
||||
millis);
|
||||
}
|
||||
|
||||
void push_entry(Level level, long long ts_ms, const char* text) {
|
||||
Entry& e = g_buf[g_head];
|
||||
e.level = level;
|
||||
e.ts_ms = ts_ms;
|
||||
std::snprintf(e.text, kEntryTextMax, "%s", text);
|
||||
g_head = (g_head + 1) % kBufferCapacity;
|
||||
if (g_count < kBufferCapacity) ++g_count;
|
||||
}
|
||||
|
||||
// Convierte el indice "logico" (0 = mas antigua) al indice fisico del array.
|
||||
std::size_t logical_to_physical(std::size_t i) {
|
||||
if (g_count < kBufferCapacity) return i; // buffer aun no lleno
|
||||
return (g_head + i) % kBufferCapacity;
|
||||
}
|
||||
|
||||
void emit(Level level, const char* fmt, std::va_list ap) {
|
||||
if (static_cast<int>(level) < static_cast<int>(g_min_level)) return;
|
||||
|
||||
// msg deja 64 bytes para que el prefijo "[ts] [LEVEL] " quepa siempre en
|
||||
// el buffer destino sin que -Wformat-truncation se queje.
|
||||
char msg[kEntryTextMax - 64];
|
||||
std::vsnprintf(msg, sizeof(msg), fmt, ap);
|
||||
|
||||
long long ts = now_ms();
|
||||
char ts_buf[32];
|
||||
format_ts(ts, ts_buf, sizeof(ts_buf));
|
||||
|
||||
char line[kEntryTextMax];
|
||||
std::snprintf(line, sizeof(line), "[%s] [%s] %s",
|
||||
ts_buf, level_label(level), msg);
|
||||
|
||||
std::lock_guard<std::mutex> lk(g_mu);
|
||||
push_entry(level, ts, line);
|
||||
if (g_file) {
|
||||
std::fputs(line, g_file);
|
||||
std::fputc('\n', g_file);
|
||||
std::fflush(g_file);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool logger_init(const char* file_path, Level min_level) {
|
||||
std::lock_guard<std::mutex> lk(g_mu);
|
||||
if (g_file) {
|
||||
std::fclose(g_file);
|
||||
g_file = nullptr;
|
||||
}
|
||||
g_min_level = min_level;
|
||||
g_path.clear();
|
||||
if (!file_path || !*file_path) return false;
|
||||
|
||||
g_file = std::fopen(file_path, "a");
|
||||
if (!g_file) {
|
||||
std::fprintf(stderr, "[fn_log] no pude abrir %s para append\n", file_path);
|
||||
return false;
|
||||
}
|
||||
g_path = file_path;
|
||||
return true;
|
||||
}
|
||||
|
||||
void logger_close() {
|
||||
std::lock_guard<std::mutex> lk(g_mu);
|
||||
if (g_file) {
|
||||
std::fclose(g_file);
|
||||
g_file = nullptr;
|
||||
}
|
||||
g_path.clear();
|
||||
}
|
||||
|
||||
void logger_set_level(Level level) { std::lock_guard<std::mutex> lk(g_mu); g_min_level = level; }
|
||||
Level logger_level() { std::lock_guard<std::mutex> lk(g_mu); return g_min_level; }
|
||||
const char* logger_path() { return g_path.c_str(); }
|
||||
|
||||
void log_debug(const char* fmt, ...) { std::va_list ap; va_start(ap, fmt); emit(Level::Debug, fmt, ap); va_end(ap); }
|
||||
void log_info (const char* fmt, ...) { std::va_list ap; va_start(ap, fmt); emit(Level::Info, fmt, ap); va_end(ap); }
|
||||
void log_warn (const char* fmt, ...) { std::va_list ap; va_start(ap, fmt); emit(Level::Warn, fmt, ap); va_end(ap); }
|
||||
void log_error(const char* fmt, ...) { std::va_list ap; va_start(ap, fmt); emit(Level::Error, fmt, ap); va_end(ap); }
|
||||
|
||||
std::size_t buffer_size() { std::lock_guard<std::mutex> lk(g_mu); return g_count; }
|
||||
|
||||
const Entry* buffer_at(std::size_t i) {
|
||||
std::lock_guard<std::mutex> lk(g_mu);
|
||||
if (i >= g_count) return nullptr;
|
||||
return &g_buf[logical_to_physical(i)];
|
||||
}
|
||||
|
||||
void buffer_clear() {
|
||||
std::lock_guard<std::mutex> lk(g_mu);
|
||||
g_count = 0;
|
||||
g_head = 0;
|
||||
}
|
||||
|
||||
const char* level_label(Level level) {
|
||||
switch (level) {
|
||||
case Level::Debug: return "DEBUG";
|
||||
case Level::Info: return "INFO";
|
||||
case Level::Warn: return "WARN";
|
||||
case Level::Error: return "ERROR";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
} // namespace fn_log
|
||||
@@ -0,0 +1,81 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
// Logger global thread-safe para apps del registry. Escribe a archivo (cwd
|
||||
// junto al ejecutable, igual que app_settings.ini) y mantiene un ring buffer
|
||||
// in-memory que el visualizador (log_window) consume.
|
||||
//
|
||||
// Lifecycle:
|
||||
// - run_app llama logger_init(cfg.log_file, cfg.log_level) si log_file != nullptr
|
||||
// - app llama log_info / log_warn / ... durante su ciclo de vida
|
||||
// - run_app llama logger_close() al exit
|
||||
//
|
||||
// Apps que NO usan fn::run_app deben llamar logger_init/close manualmente.
|
||||
// Si nunca se llama logger_init, los log_* siguen funcionando contra el ring
|
||||
// buffer in-memory pero no escriben a disco.
|
||||
//
|
||||
// Formato de cada linea:
|
||||
// [YYYY-MM-DD HH:MM:SS.mmm] [LEVEL] mensaje
|
||||
namespace fn_log {
|
||||
|
||||
enum class Level : int {
|
||||
Debug = 0,
|
||||
Info = 1,
|
||||
Warn = 2,
|
||||
Error = 3,
|
||||
};
|
||||
|
||||
// Inicializa el logger global. file_path se interpreta relativo al cwd
|
||||
// (donde la app ya escribe app_settings.ini). Crea/trunca el archivo si no
|
||||
// existe; si existe, abre en modo append.
|
||||
//
|
||||
// Idempotente: si ya hay un archivo abierto, lo cierra y reabre el nuevo.
|
||||
// Returns true si pudo abrir el archivo (false → solo buffer in-memory).
|
||||
bool logger_init(const char* file_path, Level min_level = Level::Info);
|
||||
|
||||
// Cierra el archivo. log_* siguen funcionando contra el buffer in-memory.
|
||||
void logger_close();
|
||||
|
||||
// Nivel minimo. Mensajes por debajo se descartan silenciosamente (no van ni
|
||||
// al archivo ni al buffer).
|
||||
void logger_set_level(Level level);
|
||||
Level logger_level();
|
||||
|
||||
// Path del archivo activo. Vacio si no inicializado o cerrado.
|
||||
const char* logger_path();
|
||||
|
||||
// Emisores. Formato printf-style. Cada llamada escribe una linea completa.
|
||||
// Thread-safe (mutex interno).
|
||||
void log_debug(const char* fmt, ...);
|
||||
void log_info (const char* fmt, ...);
|
||||
void log_warn (const char* fmt, ...);
|
||||
void log_error(const char* fmt, ...);
|
||||
|
||||
// === Ring buffer in-memory (para log_window) ===
|
||||
|
||||
constexpr std::size_t kBufferCapacity = 2000;
|
||||
constexpr std::size_t kEntryTextMax = 480; // deja sitio para timestamp + level
|
||||
|
||||
struct Entry {
|
||||
Level level;
|
||||
long long ts_ms; // unix epoch en milisegundos
|
||||
char text[kEntryTextMax];
|
||||
};
|
||||
|
||||
// Numero de entradas vivas en el buffer (≤ kBufferCapacity).
|
||||
std::size_t buffer_size();
|
||||
|
||||
// Acceso por indice [0, buffer_size()). i==0 es la entrada mas antigua viva.
|
||||
// Nullptr si i fuera de rango. Snapshot — el caller debe asumir que la
|
||||
// entrada puede ser sobrescrita en la siguiente llamada thread-unsafe; para
|
||||
// el viewer esto no es problema porque ImGui es single-threaded.
|
||||
const Entry* buffer_at(std::size_t i);
|
||||
|
||||
// Limpia el ring buffer. No toca el archivo en disco.
|
||||
void buffer_clear();
|
||||
|
||||
// Helper para el viewer: nombre corto del nivel ("DEBUG"/"INFO"/...).
|
||||
const char* level_label(Level level);
|
||||
|
||||
} // namespace fn_log
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
name: logger
|
||||
kind: function
|
||||
lang: cpp
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "bool fn_log::logger_init(const char* file_path, fn_log::Level min_level = Level::Info); void fn_log::logger_close(); void fn_log::logger_set_level(Level); fn_log::Level fn_log::logger_level(); const char* fn_log::logger_path(); void fn_log::log_debug(const char* fmt, ...); void fn_log::log_info(const char* fmt, ...); void fn_log::log_warn(const char* fmt, ...); void fn_log::log_error(const char* fmt, ...); std::size_t fn_log::buffer_size(); const fn_log::Entry* fn_log::buffer_at(std::size_t); void fn_log::buffer_clear(); const char* fn_log::level_label(fn_log::Level)"
|
||||
description: "Logger global thread-safe para apps C++ del registry. Escribe a archivo (cwd, junto al ejecutable) en modo append y mantiene un ring buffer in-memory de 2000 entradas que el visualizador log_window consume. Formato: [YYYY-MM-DD HH:MM:SS.mmm] [LEVEL] mensaje."
|
||||
tags: [logger, logging, file, infra, thread-safe]
|
||||
uses_functions: []
|
||||
uses_types: [log_level_cpp_core, log_entry_cpp_core]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [chrono, cstdarg, cstdio, ctime, mutex, string]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "cpp/functions/core/logger.cpp"
|
||||
framework: ""
|
||||
params:
|
||||
- name: file_path
|
||||
desc: "Ruta del archivo de log relativa al cwd (junto al ejecutable). Modo append. Si vacio o nullptr, no se escribe a disco — solo buffer in-memory"
|
||||
- name: min_level
|
||||
desc: "Nivel minimo a aceptar. Mensajes por debajo se descartan antes de tocar archivo o buffer"
|
||||
- name: fmt
|
||||
desc: "Formato printf-style de log_debug/info/warn/error. Cada llamada produce una linea independiente"
|
||||
- name: i
|
||||
desc: "Indice [0, buffer_size()) en el ring buffer. 0 = entrada mas antigua viva"
|
||||
output: "logger_init retorna true si pudo abrir el archivo (false → solo buffer). logger_close cierra archivo (idempotente). log_* mutan estado global thread-safe. buffer_at retorna puntero valido o nullptr si i fuera de rango"
|
||||
notes: "consumido por cpp/framework/app_base.cpp (init/close automatico via AppConfig.log) y cpp/functions/core/log_window.cpp (lectura del buffer)"
|
||||
---
|
||||
|
||||
# logger
|
||||
|
||||
Logger global, thread-safe, integrado en `fn::run_app`: las apps solo declaran un `AppLogConfig` y emiten con `log_info(...)` etc.
|
||||
|
||||
## Uso desde una app
|
||||
|
||||
```cpp
|
||||
#include "app_base.h"
|
||||
#include "core/logger.h"
|
||||
|
||||
int main() {
|
||||
return fn::run_app({
|
||||
.title = "Mi App",
|
||||
.log = {.file_path = "mi_app.log",
|
||||
.level = static_cast<int>(fn_log::Level::Info)}
|
||||
}, render);
|
||||
}
|
||||
```
|
||||
|
||||
Tras esto, `fn::run_app` llama `logger_init` antes del primer frame y `logger_close` al exit. La app solo necesita usar los emisores:
|
||||
|
||||
```cpp
|
||||
fn_log::log_info ("usuario abrio archivo %s", path);
|
||||
fn_log::log_warn ("retry %d/%d", attempt, max);
|
||||
fn_log::log_error("connection failed: %s", reason);
|
||||
fn_log::log_debug("estado interno: %d items", n);
|
||||
```
|
||||
|
||||
## Uso sin fn::run_app
|
||||
|
||||
Apps que arman su propio main loop deben llamar manualmente:
|
||||
|
||||
```cpp
|
||||
fn_log::logger_init("app.log", fn_log::Level::Info);
|
||||
// ... vida de la app ...
|
||||
fn_log::logger_close();
|
||||
```
|
||||
|
||||
## Reglas
|
||||
|
||||
- Ruta relativa al cwd (igual convencion que `app_settings.ini`).
|
||||
- Modo append: relanzar la app conserva el historico previo en disco.
|
||||
- Thread-safe: un mutex interno protege archivo + buffer + nivel.
|
||||
- Truncacion: cada mensaje cabe en `kEntryTextMax - 64` caracteres formateados; el resto se trunca silenciosamente.
|
||||
- Si `logger_init` no se llama o falla, los `log_*` siguen siendo seguros: solo escriben al ring buffer in-memory (que la ventana `Logs` puede mostrar igualmente).
|
||||
|
||||
## Integracion
|
||||
|
||||
- `fn::AppConfig::log` activa el logger desde el framework.
|
||||
- `fn_ui::log_window` lee el ring buffer y pinta la ventana "Logs..." del menubar.
|
||||
@@ -0,0 +1,224 @@
|
||||
#include "selectable_text.h"
|
||||
|
||||
#include <cstdarg>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
namespace fn_ui {
|
||||
|
||||
namespace {
|
||||
|
||||
// Empuja un estilo "frame transparente" para que InputText quede
|
||||
// visualmente como Text() — sin borde, sin fondo, sin padding interno.
|
||||
struct ScopedTransparentFrame {
|
||||
ScopedTransparentFrame() {
|
||||
ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32(0,0,0,0));
|
||||
ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, IM_COL32(0,0,0,0));
|
||||
ImGui::PushStyleColor(ImGuiCol_FrameBgActive, IM_COL32(0,0,0,0));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0, 0));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ItemInnerSpacing, ImVec2(0, 0));
|
||||
}
|
||||
~ScopedTransparentFrame() {
|
||||
ImGui::PopStyleVar(3);
|
||||
ImGui::PopStyleColor(3);
|
||||
}
|
||||
};
|
||||
|
||||
// Genera un id ImGui estable a partir del puntero del texto. Suficiente
|
||||
// para evitar colisiones en el mismo frame mientras el caller no llame
|
||||
// dos veces con la misma direccion.
|
||||
void make_id(char* out, size_t n, const char* prefix, const char* text) {
|
||||
std::snprintf(out, n, "##%s%p", prefix, (const void*)text);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Versión wrapped (InputTextMultiline + read-only)
|
||||
// ----------------------------------------------------------------------------
|
||||
void selectable_text_wrapped(const char* text) {
|
||||
if (!text) text = "";
|
||||
if (!*text) {
|
||||
ImGui::Dummy(ImVec2(0, ImGui::GetTextLineHeight()));
|
||||
return;
|
||||
}
|
||||
float wrap_w = ImGui::GetContentRegionAvail().x;
|
||||
if (wrap_w < 32.0f) wrap_w = 32.0f;
|
||||
|
||||
// CalcTextSize con wrap_w nos da la altura exacta del bloque renderizado.
|
||||
ImVec2 sz = ImGui::CalcTextSize(text, nullptr, false, wrap_w);
|
||||
if (sz.y < ImGui::GetTextLineHeight()) sz.y = ImGui::GetTextLineHeight();
|
||||
// Pequeño margen para evitar que aparezca el scrollbar vertical.
|
||||
float h = sz.y + 2.0f;
|
||||
|
||||
ScopedTransparentFrame _frame;
|
||||
char id[40];
|
||||
make_id(id, sizeof(id), "selw", text);
|
||||
size_t len = std::strlen(text);
|
||||
// ImGui requiere buffer no-const aunque ReadOnly impide escritura.
|
||||
ImGui::InputTextMultiline(id,
|
||||
const_cast<char*>(text), len + 1,
|
||||
ImVec2(wrap_w, h),
|
||||
ImGuiInputTextFlags_ReadOnly | ImGuiInputTextFlags_NoHorizontalScroll);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Variante con wrap forzado (rompe palabras sin espacios)
|
||||
// ----------------------------------------------------------------------------
|
||||
namespace {
|
||||
|
||||
// Inserta '\n' donde el ancho acumulado de la linea supera wrap_w.
|
||||
// Maneja UTF-8: no rompe en mitad de un codepoint multi-byte. Si la
|
||||
// palabra actual cabe completa, espera al proximo separador; si no,
|
||||
// fuerza un break al limite. Espacios y newlines existentes se
|
||||
// respetan.
|
||||
std::string force_wrap(const char* text, float wrap_w) {
|
||||
std::string out;
|
||||
if (!text || !*text || wrap_w <= 0.0f) {
|
||||
if (text) out = text;
|
||||
return out;
|
||||
}
|
||||
out.reserve(std::strlen(text) + std::strlen(text) / 32);
|
||||
|
||||
auto is_utf8_cont = [](unsigned char b) { return (b & 0xC0) == 0x80; };
|
||||
auto width_of = [](const char* a, const char* b) -> float {
|
||||
return ImGui::CalcTextSize(a, b).x;
|
||||
};
|
||||
|
||||
const char* line_start = text;
|
||||
const char* last_space = nullptr;
|
||||
const char* p = text;
|
||||
|
||||
while (*p) {
|
||||
if (*p == '\n') {
|
||||
out.append(line_start, p + 1);
|
||||
line_start = p + 1;
|
||||
last_space = nullptr;
|
||||
++p;
|
||||
continue;
|
||||
}
|
||||
if (*p == ' ' || *p == '\t') last_space = p;
|
||||
|
||||
// Avanzar al final del codepoint UTF-8 actual.
|
||||
const char* cp_end = p + 1;
|
||||
while (*cp_end && is_utf8_cont((unsigned char)*cp_end)) ++cp_end;
|
||||
|
||||
float w = width_of(line_start, cp_end);
|
||||
if (w > wrap_w && cp_end != line_start) {
|
||||
if (last_space && last_space > line_start) {
|
||||
out.append(line_start, last_space);
|
||||
out.push_back('\n');
|
||||
line_start = last_space + 1;
|
||||
last_space = nullptr;
|
||||
p = line_start;
|
||||
continue;
|
||||
}
|
||||
// Sin espacio en la linea — break forzado al codepoint actual.
|
||||
// Si solo hay un codepoint, evitamos un loop infinito
|
||||
// dejando que se pinte aunque no quepa.
|
||||
if (p == line_start) {
|
||||
p = cp_end;
|
||||
continue;
|
||||
}
|
||||
out.append(line_start, p);
|
||||
out.push_back('\n');
|
||||
line_start = p;
|
||||
last_space = nullptr;
|
||||
continue;
|
||||
}
|
||||
p = cp_end;
|
||||
}
|
||||
if (line_start < p) out.append(line_start, p);
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void selectable_text_wrapped_force(const char* text) {
|
||||
if (!text) text = "";
|
||||
if (!*text) {
|
||||
ImGui::Dummy(ImVec2(0, ImGui::GetTextLineHeight()));
|
||||
return;
|
||||
}
|
||||
float wrap_w = ImGui::GetContentRegionAvail().x;
|
||||
if (wrap_w < 32.0f) wrap_w = 32.0f;
|
||||
// Margen para el padding interno del InputText — sin esto la
|
||||
// ultima letra de una linea larga puede quedar tras el borde.
|
||||
float effective_w = wrap_w - 4.0f;
|
||||
|
||||
std::string wrapped = force_wrap(text, effective_w);
|
||||
|
||||
ImVec2 sz = ImGui::CalcTextSize(wrapped.c_str(), nullptr, false, wrap_w);
|
||||
if (sz.y < ImGui::GetTextLineHeight()) sz.y = ImGui::GetTextLineHeight();
|
||||
float h = sz.y + 2.0f;
|
||||
|
||||
ScopedTransparentFrame _frame;
|
||||
char id[40];
|
||||
make_id(id, sizeof(id), "selwf", text);
|
||||
ImGui::InputTextMultiline(id,
|
||||
const_cast<char*>(wrapped.c_str()), wrapped.size() + 1,
|
||||
ImVec2(wrap_w, h),
|
||||
ImGuiInputTextFlags_ReadOnly | ImGuiInputTextFlags_NoHorizontalScroll);
|
||||
}
|
||||
|
||||
void selectable_text_wrapped_fmt(const char* fmt, ...) {
|
||||
char buf[4096];
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
int n = std::vsnprintf(buf, sizeof(buf), fmt, ap);
|
||||
va_end(ap);
|
||||
if (n < 0) return;
|
||||
selectable_text_wrapped(buf);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Versión single-line (InputText + read-only)
|
||||
// ----------------------------------------------------------------------------
|
||||
void selectable_text(const char* text) {
|
||||
if (!text) text = "";
|
||||
if (!*text) return;
|
||||
ScopedTransparentFrame _frame;
|
||||
char id[40];
|
||||
make_id(id, sizeof(id), "sel", text);
|
||||
ImVec2 sz = ImGui::CalcTextSize(text);
|
||||
ImGui::SetNextItemWidth(sz.x + 4.0f);
|
||||
size_t len = std::strlen(text);
|
||||
ImGui::InputText(id, const_cast<char*>(text), len + 1,
|
||||
ImGuiInputTextFlags_ReadOnly
|
||||
| ImGuiInputTextFlags_AutoSelectAll);
|
||||
}
|
||||
|
||||
void selectable_text_fmt(const char* fmt, ...) {
|
||||
char buf[1024];
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
int n = std::vsnprintf(buf, sizeof(buf), fmt, ap);
|
||||
va_end(ap);
|
||||
if (n < 0) return;
|
||||
selectable_text(buf);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Versión ligera con right-click → Copy
|
||||
// ----------------------------------------------------------------------------
|
||||
void text_with_copy(const char* text) {
|
||||
if (!text) text = "";
|
||||
ImGui::TextUnformatted(text);
|
||||
if (ImGui::BeginPopupContextItem("##copy_one")) {
|
||||
if (ImGui::MenuItem("Copy")) ImGui::SetClipboardText(text);
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
}
|
||||
|
||||
void text_wrapped_with_copy(const char* text) {
|
||||
if (!text) text = "";
|
||||
ImGui::TextWrapped("%s", text);
|
||||
if (ImGui::BeginPopupContextItem("##copy_one_w")) {
|
||||
if (ImGui::MenuItem("Copy")) ImGui::SetClipboardText(text);
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace fn_ui
|
||||
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
//
|
||||
// selectable_text — texto que el usuario puede seleccionar y copiar dentro
|
||||
// de cualquier ventana ImGui. Reemplazo drop-in de ImGui::Text /
|
||||
// ImGui::TextWrapped cuando se quiere permitir copia.
|
||||
//
|
||||
// ImGui::Text/TextWrapped son draw-list puro: no aceptan eventos de mouse.
|
||||
// Para permitir seleccion de caracteres usamos InputTextMultiline con flag
|
||||
// ReadOnly y el frame stylado a transparente — visualmente identico a
|
||||
// TextWrapped pero arrastrable y copiable con Ctrl+C / boton derecho.
|
||||
//
|
||||
// Uso recomendado: en paneles donde el usuario quiere copiar contenido (chat,
|
||||
// inspector, logs, notes view, output de queries, etc.). En toolbars y
|
||||
// labels cortas no merece la pena cambiar — la version "TextCopy" basada en
|
||||
// Text() + popup de boton derecho es mas ligera.
|
||||
|
||||
#include <cstdarg>
|
||||
#include "imgui.h"
|
||||
|
||||
namespace fn_ui {
|
||||
|
||||
// Texto seleccionable con wrap automatico al ancho disponible (drop-in de
|
||||
// ImGui::TextWrapped). Multi-linea. El usuario puede arrastrar para
|
||||
// seleccionar y copiar con Ctrl+C o desde el menu contextual.
|
||||
void selectable_text_wrapped(const char* text);
|
||||
|
||||
// Como selectable_text_wrapped pero forzando el wrap incluso para
|
||||
// "palabras" que no tienen espacios (URLs largas, JSON, hashes). El
|
||||
// InputTextMultiline base solo rompe en separadores; sin esto, el
|
||||
// resto del token desaparece a la derecha. Esta variante pre-rompe el
|
||||
// texto insertando saltos donde la linea supera el ancho disponible.
|
||||
void selectable_text_wrapped_force(const char* text);
|
||||
|
||||
// Variante con printf-format. IM_FMTARGS(1) activa los warnings de
|
||||
// formato del compilador.
|
||||
void selectable_text_wrapped_fmt(const char* fmt, ...) IM_FMTARGS(1);
|
||||
|
||||
// Texto seleccionable de una sola linea (drop-in de ImGui::Text).
|
||||
// Sin wrap. Para textos cortos / labels que el usuario quiere copiar.
|
||||
void selectable_text(const char* text);
|
||||
void selectable_text_fmt(const char* fmt, ...) IM_FMTARGS(1);
|
||||
|
||||
// Variante ligera: dibuja con ImGui::Text/TextWrapped y adjunta un popup
|
||||
// "Copy" al boton derecho. NO permite seleccion de caracter, pero copia
|
||||
// el texto completo de un click. Indicado para labels cortas, stats,
|
||||
// status lines — nada que justifique el coste de InputText.
|
||||
void text_with_copy(const char* text);
|
||||
void text_wrapped_with_copy(const char* text);
|
||||
|
||||
} // namespace fn_ui
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
id: selectable_text_cpp_core
|
||||
name: selectable_text
|
||||
kind: function
|
||||
lang: cpp
|
||||
domain: core
|
||||
version: 1.0.0
|
||||
purity: pure
|
||||
signature: "void fn_ui::selectable_text_wrapped(const char* text)"
|
||||
description: "Texto seleccionable y copiable (drag-to-select + Ctrl+C) para ventanas ImGui. Drop-in de ImGui::Text/TextWrapped cuando se quiere permitir copia. Tambien expone variantes ligeras con right-click → Copy."
|
||||
tags: [imgui, text, selectable, clipboard, copy, accessibility]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports:
|
||||
- "imgui.h"
|
||||
params:
|
||||
- name: text
|
||||
desc: "Texto a renderizar (UTF-8). Caller-owned, no copiado. El cast interno a char* es seguro porque ReadOnly impide escritura."
|
||||
output: "Renderiza el texto en la ventana ImGui actual con drag-to-select y Ctrl+C habilitados. No retorna valor."
|
||||
example: |
|
||||
// En lugar de ImGui::TextWrapped("%s", chat_msg.c_str()):
|
||||
fn_ui::selectable_text_wrapped(chat_msg.c_str());
|
||||
|
||||
// Single line (drop-in de ImGui::Text):
|
||||
fn_ui::selectable_text(entity_id.c_str());
|
||||
|
||||
// Variante ligera (sin char-selection, copia el texto entero con
|
||||
// boton derecho):
|
||||
fn_ui::text_wrapped_with_copy(stats_line.c_str());
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "cpp/functions/core/selectable_text.cpp"
|
||||
framework: "imgui"
|
||||
notes: |
|
||||
ImGui::Text/TextWrapped son draw-list puro: no aceptan eventos de mouse, asi
|
||||
que NO pueden seleccionarse. La unica via reliable hoy en ImGui (1.91.x) para
|
||||
permitir char-level selection es usar InputTextMultiline con flag ReadOnly.
|
||||
|
||||
Esta funcion stylea el frame a transparente (sin borde, sin fondo, sin
|
||||
padding, sin spacing) — visualmente identico a TextWrapped pero arrastrable.
|
||||
Calcula la altura exacta con ImGui::CalcTextSize(text, NULL, false, wrap_w)
|
||||
asi NO aparece scrollbar.
|
||||
|
||||
El cast `const_cast<char*>(text)` es seguro porque ImGui no escribe cuando
|
||||
ReadOnly esta activo. La sobrecarga es minima (un InputText no es tan caro
|
||||
como parece — ImGui ya cachea el layout entre frames).
|
||||
|
||||
Para textos muy largos (>10k chars) considera usar un InputTextMultiline
|
||||
explicito con scrollbar visible — la altura calculada para "todo el bloque"
|
||||
rompe el flujo de la ventana.
|
||||
|
||||
Cuando el usuario solo quiere copiar el texto entero (sin seleccionar parte),
|
||||
text_with_copy() / text_wrapped_with_copy() son mas ligeros: dibujan con
|
||||
Text() normal y abren un popup "Copy" con boton derecho.
|
||||
documentation: |
|
||||
## Diferencia con ImGui::Text
|
||||
|
||||
| Aspecto | ImGui::Text | fn_ui::selectable_text |
|
||||
|---|---|---|
|
||||
| Drag selection | No | Si |
|
||||
| Ctrl+C | No | Si |
|
||||
| Right-click | No | Menu de InputText |
|
||||
| Coste | 1 DrawList ops | 1 InputText (cacheado) |
|
||||
| Apariencia | Identica | Identica si frame transparente |
|
||||
|
||||
## Cuando usar cada variante
|
||||
|
||||
- **selectable_text_wrapped** — chat outputs, logs, descriptions, JSON dumps,
|
||||
cualquier sitio donde el usuario podria querer copiar parte del texto.
|
||||
- **selectable_text** — IDs cortos, paths, nombres tecnicos.
|
||||
- **text_wrapped_with_copy** — stats lines, tooltips persistentes, status
|
||||
messages. El usuario puede copiar todo de una vez sin seleccionar.
|
||||
|
||||
## Compatibilidad
|
||||
|
||||
Tambien funciona dentro de tablas ImGui (TableNextColumn → selectable_text).
|
||||
No se rompe con docking/multi-viewport.
|
||||
---
|
||||
Reference in New Issue
Block a user