diff --git a/cpp/framework/app_base.cpp b/cpp/framework/app_base.cpp index 77c09e22..0fb3a6da 100644 --- a/cpp/framework/app_base.cpp +++ b/cpp/framework/app_base.cpp @@ -316,8 +316,20 @@ int run_app(AppConfig config, std::function 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 -> 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*>(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 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 render_fn, + std::function 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 diff --git a/cpp/framework/app_base.h b/cpp/framework/app_base.h index c6a1d802..6c923fec 100644 --- a/cpp/framework/app_base.h +++ b/cpp/framework/app_base.h @@ -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 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 render_fn); int run_app(std::function 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 render_fn, + std::function register_tests, + const char* filter = "all"); + +} // namespace fn +#endif // IMGUI_ENABLE_TEST_ENGINE diff --git a/cpp/functions/core/app_menubar.cpp b/cpp/functions/core/app_menubar.cpp index 9f324be6..6ae9da35 100644 --- a/cpp/functions/core/app_menubar.cpp +++ b/cpp/functions/core/app_menubar.cpp @@ -1,17 +1,38 @@ #include "core/app_menubar.h" +#include "core/log_window.h" #include 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(); diff --git a/cpp/functions/core/app_menubar.h b/cpp/functions/core/app_menubar.h index f7d72b35..f8694d01 100644 --- a/cpp/functions/core/app_menubar.h +++ b/cpp/functions/core/app_menubar.h @@ -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 diff --git a/cpp/functions/core/app_menubar.md b/cpp/functions/core/app_menubar.md index c746c035..90f12310 100644 --- a/cpp/functions/core/app_menubar.md +++ b/cpp/functions/core/app_menubar.md @@ -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()`. diff --git a/cpp/functions/core/layouts_menu.cpp b/cpp/functions/core/layouts_menu.cpp index 304973f7..895707a1 100644 --- a/cpp/functions/core/layouts_menu.cpp +++ b/cpp/functions/core/layouts_menu.cpp @@ -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 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 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 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 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; } diff --git a/cpp/functions/core/log_window.cpp b/cpp/functions/core/log_window.cpp new file mode 100644 index 00000000..ab4ef92c --- /dev/null +++ b/cpp/functions/core/log_window.cpp @@ -0,0 +1,147 @@ +#include "core/log_window.h" +#include "core/logger.h" + +#include "imgui.h" + +#include + +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 diff --git a/cpp/functions/core/log_window.h b/cpp/functions/core/log_window.h new file mode 100644 index 00000000..f4612ed8 --- /dev/null +++ b/cpp/functions/core/log_window.h @@ -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 diff --git a/cpp/functions/core/log_window.md b/cpp/functions/core/log_window.md new file mode 100644 index 00000000..719375a7 --- /dev/null +++ b/cpp/functions/core/log_window.md @@ -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. diff --git a/cpp/functions/core/logger.cpp b/cpp/functions/core/logger.cpp new file mode 100644 index 00000000..cf483d13 --- /dev/null +++ b/cpp/functions/core/logger.cpp @@ -0,0 +1,156 @@ +#include "core/logger.h" + +#include +#include +#include +#include +#include +#include +#include + +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(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(ts_ms / 1000); + int millis = static_cast(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(level) < static_cast(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 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 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 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 lk(g_mu); g_min_level = level; } +Level logger_level() { std::lock_guard 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 lk(g_mu); return g_count; } + +const Entry* buffer_at(std::size_t i) { + std::lock_guard lk(g_mu); + if (i >= g_count) return nullptr; + return &g_buf[logical_to_physical(i)]; +} + +void buffer_clear() { + std::lock_guard 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 diff --git a/cpp/functions/core/logger.h b/cpp/functions/core/logger.h new file mode 100644 index 00000000..bbb06704 --- /dev/null +++ b/cpp/functions/core/logger.h @@ -0,0 +1,81 @@ +#pragma once + +#include + +// 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 diff --git a/cpp/functions/core/logger.md b/cpp/functions/core/logger.md new file mode 100644 index 00000000..1fa51579 --- /dev/null +++ b/cpp/functions/core/logger.md @@ -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(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. diff --git a/cpp/functions/core/selectable_text.cpp b/cpp/functions/core/selectable_text.cpp new file mode 100644 index 00000000..387503bc --- /dev/null +++ b/cpp/functions/core/selectable_text.cpp @@ -0,0 +1,224 @@ +#include "selectable_text.h" + +#include +#include +#include +#include + +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(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(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(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 diff --git a/cpp/functions/core/selectable_text.h b/cpp/functions/core/selectable_text.h new file mode 100644 index 00000000..60224a35 --- /dev/null +++ b/cpp/functions/core/selectable_text.h @@ -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 +#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 diff --git a/cpp/functions/core/selectable_text.md b/cpp/functions/core/selectable_text.md new file mode 100644 index 00000000..6e2b9c29 --- /dev/null +++ b/cpp/functions/core/selectable_text.md @@ -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(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. +--- diff --git a/types/core/log_entry.md b/types/core/log_entry.md new file mode 100644 index 00000000..c2150090 --- /dev/null +++ b/types/core/log_entry.md @@ -0,0 +1,32 @@ +--- +name: log_entry +lang: cpp +domain: core +version: "1.0.0" +algebraic: product +definition: | + namespace fn_log { + constexpr std::size_t kEntryTextMax = 480; + + struct Entry { + Level level; + long long ts_ms; // unix epoch en milisegundos + char text[kEntryTextMax]; + }; + } +description: "Entrada del ring buffer in-memory del logger. Contiene nivel, timestamp en milisegundos y la linea ya formateada [ts] [LEVEL] mensaje." +tags: [logger, buffer, entry, ringbuffer] +uses_types: [log_level_cpp_core] +file_path: "cpp/functions/core/logger.h" +examples: + - "const fn_log::Entry* e = fn_log::buffer_at(0);" +notes: "POD trivialmente copiable. La ventana Logs (log_window) itera el buffer via buffer_at(i)." +--- + +# log_entry + +Entrada individual del ring buffer in-memory del logger global. La capacidad del buffer es `fn_log::kBufferCapacity` (2000 entradas por defecto) — al llenarse, sobrescribe las mas antiguas. + +`text` contiene la linea ya formateada (con prefijo `[YYYY-MM-DD HH:MM:SS.mmm] [LEVEL]`), lista para ser pintada por el visualizador. `level` y `ts_ms` se mantienen aparte para filtrado y coloreado por nivel. + +Acceso de solo lectura via `fn_log::buffer_at(i)` con `i ∈ [0, fn_log::buffer_size())`. El indice 0 corresponde a la entrada mas antigua viva. diff --git a/types/core/log_level.md b/types/core/log_level.md new file mode 100644 index 00000000..be49bc69 --- /dev/null +++ b/types/core/log_level.md @@ -0,0 +1,37 @@ +--- +name: log_level +lang: cpp +domain: core +version: "1.0.0" +algebraic: sum +definition: | + namespace fn_log { + enum class Level : int { + Debug = 0, + Info = 1, + Warn = 2, + Error = 3, + }; + } +description: "Nivel de severidad de un mensaje de log. Ordenado por severidad ascendente: Debug < Info < Warn < Error." +tags: [logger, level, severity, enum] +uses_types: [] +file_path: "cpp/functions/core/logger.h" +examples: + - "fn_log::Level::Info" + - "fn_log::Level::Error" +notes: "Usado por logger_init y logger_set_level. Mensajes con nivel < min_level se descartan." +--- + +# log_level + +Nivel de severidad de los mensajes emitidos por el logger global de C++ del registry. Se mapea 1:1 con los niveles tipicos de cualquier sistema de logging. + +| Valor | Cuando usar | +|---|---| +| `Debug` | Trazas detalladas de desarrollo. Suelen filtrarse en produccion. | +| `Info` | Eventos normales del flujo (start, exit, milestones). | +| `Warn` | Situaciones anomalas que no impiden el funcionamiento. | +| `Error` | Fallos que afectan a la operacion. | + +El parametro `min_level` de `logger_init` filtra: cualquier mensaje con nivel inferior se descarta antes de tocar el archivo o el ring buffer. La ventana `Logs` permite ademas ocultar niveles ya capturados.