chore: snapshot WIP previo + flow 0008 + 7 sub-issues (0112-0119)

Snapshot de WIP acumulado de sesiones previas antes de merge wave 1
del flow 0008 (kanban_cpp + agent_runner_api + DoD schema).

Incluye:
- dev/flows/0008-kanban-cpp-and-agent-workflows.md
- dev/issues/0112-0119*.md (7 sub-issues)
- WIP previo en cmd/fn/doctor.go, registry/*, modules/, cpp/, etc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 18:17:08 +02:00
parent ddb5366884
commit b9716a7cd6
119 changed files with 14929 additions and 3084 deletions
+293
View File
@@ -2,6 +2,7 @@
#include "version_generated.h"
#include "imgui.h"
#include "imgui_internal.h"
#include "imgui_impl_glfw.h"
#include "imgui_impl_opengl3.h"
#include "implot.h"
@@ -16,9 +17,11 @@
#include "core/log_window.h"
#include "core/layout_storage.h"
#include "gfx/gl_loader.h"
#include "app_modules.h"
#include <GLFW/glfw3.h>
#include <atomic>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <filesystem>
@@ -26,6 +29,7 @@
#include <sys/stat.h>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#ifdef _WIN32
#ifndef WIN32_LEAN_AND_MEAN
@@ -224,6 +228,43 @@ static void prune_dead_icon_attached() {
}
}
// Pinta la title bar (caption + bordes) en oscuro via DWM, para que el
// header del SO no se quede blanco mientras el cliente es dark. DWM la pinta
// el OS, no GLFW/ImGui — sin esta llamada queda en blanco aunque el resto
// este oscuro.
//
// Carga dwmapi.dll dinamicamente: evita anadir la dep al toolchain. Si la
// DLL/atributo no existe (Win10 < 1809), no hace nada — silencioso.
// Attr 20 = DWMWA_USE_IMMERSIVE_DARK_MODE (Win11 / Win10 >= build 18985).
// Attr 19 = nombre antiguo (Win10 1809..18984). Probar 20 primero, fallback 19.
// Force repaint con SWP_FRAMECHANGED — la ventana ya fue mostrada por GLFW
// antes de que lleguemos aqui.
typedef HRESULT (WINAPI *PFN_DwmSetWindowAttribute)(HWND, DWORD, LPCVOID, DWORD);
static std::unordered_set<HWND> g_dark_titlebar_applied;
static void attach_dark_titlebar_to_hwnd(HWND hwnd, bool dark) {
if (!hwnd) return;
if (g_dark_titlebar_applied.count(hwnd)) return; // idempotent
static HMODULE h_dwmapi = LoadLibraryW(L"dwmapi.dll");
if (!h_dwmapi) return;
static auto p_set = (PFN_DwmSetWindowAttribute)GetProcAddress(h_dwmapi, "DwmSetWindowAttribute");
if (!p_set) return;
BOOL value = dark ? TRUE : FALSE;
HRESULT hr = p_set(hwnd, 20 /* DWMWA_USE_IMMERSIVE_DARK_MODE */, &value, sizeof(value));
if (FAILED(hr)) {
p_set(hwnd, 19 /* legacy name on Win10 1809..18984 */, &value, sizeof(value));
}
SetWindowPos(hwnd, nullptr, 0, 0, 0, 0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED);
g_dark_titlebar_applied.insert(hwnd);
}
static void prune_dead_dark_titlebar() {
for (auto it = g_dark_titlebar_applied.begin(); it != g_dark_titlebar_applied.end();) {
if (!IsWindow(*it)) it = g_dark_titlebar_applied.erase(it);
else ++it;
}
}
static void install_sizemove_subclass(GLFWwindow* w) {
if (!w) return;
install_sizemove_subclass_hwnd(glfwGetWin32Window(w));
@@ -391,6 +432,221 @@ const char* framework_description() {
return FN_MODULE_FRAMEWORK_DESCRIPTION;
}
// ----------------------------------------------------------------------------
// Header badge overlay — identidad por app en viewports secundarios.
// ----------------------------------------------------------------------------
// Cuando una app C++ tiene N panels y el usuario arrastra varios fuera del
// main window, cada panel se convierte en su propio OS viewport. Sin marcas
// visuales adicionales, si tienes 3 apps abiertas a la vez no sabes de cual
// viene cada panel flotante. Este overlay dibuja un cuadrado redondeado de
// ~18px con la inicial de la app en la esquina top-left de la title bar de
// cada viewport secundario. Solo en secundarios — el main ya tiene icono
// del SO en titlebar/taskbar (attach_app_icon_to_hwnd).
//
// Filosofia:
// - Defaults producen identidad util sin tocar la app (color hash-derivado
// desde about.name, glyph = primera letra).
// - Apps con icon.accent en su app.md pueden pasar el mismo hex para
// coherencia con App Hub.
// - ForegroundDrawList: dibuja por encima del titlebar de ImGui sin
// necesidad de envolver Begin() ni hookear el render del titulo.
// ----------------------------------------------------------------------------
// Parsea "#RRGGBB" o "RRGGBB" a ImU32 ABGR. Devuelve 0 si invalido.
static ImU32 parse_hex_color_abgr(const char* s) {
if (!s || !*s) return 0;
if (*s == '#') ++s;
auto h = [](char c) -> int {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
};
int v[6];
for (int i = 0; i < 6; ++i) {
v[i] = h(s[i]);
if (v[i] < 0) return 0;
}
int r = (v[0] << 4) | v[1];
int g = (v[2] << 4) | v[3];
int b = (v[4] << 4) | v[5];
return IM_COL32(r, g, b, 255);
}
// Hash-derivado: FNV-1a 32-bit -> H en [0,360), S=0.58, V=0.78 -> ImU32 ABGR.
// Estable por nombre, distribuye razonablemente entre N apps.
static ImU32 derive_color_from_name(const char* name) {
if (!name || !*name) name = "fn_registry";
unsigned h = 2166136261u;
for (const char* p = name; *p; ++p) {
h ^= (unsigned char)*p;
h *= 16777619u;
}
float hue = (float)(h % 360u);
float s = 0.58f, v = 0.78f;
float c = v * s;
float hp = hue / 60.0f;
float x = c * (1.0f - std::fabs(std::fmod(hp, 2.0f) - 1.0f));
float r=0, g=0, b=0;
if (hp < 1) { r=c; g=x; }
else if (hp < 2) { r=x; g=c; }
else if (hp < 3) { g=c; b=x; }
else if (hp < 4) { g=x; b=c; }
else if (hp < 5) { r=x; b=c; }
else { r=c; b=x; }
float m = v - c;
int R = (int)((r + m) * 255.0f + 0.5f);
int G = (int)((g + m) * 255.0f + 0.5f);
int B = (int)((b + m) * 255.0f + 0.5f);
return IM_COL32(R, G, B, 255);
}
// Decide string a renderizar como glyph. Devuelve puntero a buffer estatico
// thread-local cuando hace falta normalizar la primera letra del nombre.
static const char* resolve_badge_glyph(const AppConfig& cfg) {
const char* g = cfg.header_badge.glyph;
if (g && *g) return g;
static thread_local char letter[8] = {0};
const char* nm = (cfg.about.name && *cfg.about.name) ? cfg.about.name : cfg.title;
char first = (nm && *nm) ? nm[0] : '?';
if (first >= 'a' && first <= 'z') first = (char)(first - 'a' + 'A');
letter[0] = first;
letter[1] = '\0';
return letter;
}
// Color final con precedencia:
// 1) Override explicito en cfg.header_badge.accent_hex (main.cpp)
// 2) Codegen extern fn::app_header_accent_hex (icon.accent del app.md)
// 3) Hash-derived desde about.name (siempre estable, da identidad gratis)
static ImU32 resolve_badge_color(const AppConfig& cfg) {
ImU32 c = parse_hex_color_abgr(cfg.header_badge.accent_hex);
if (c != 0) return c;
c = parse_hex_color_abgr(app_header_accent_hex);
if (c != 0) return c;
const char* nm = (cfg.about.name && *cfg.about.name) ? cfg.about.name : cfg.title;
return derive_color_from_name(nm);
}
// Textura GL del icono de la app — extraida del HICON embebido en el .exe
// (resource ID 101 generado por add_imgui_app desde appicon.ico). Cargada
// perezosamente al primer frame y reutilizada en cada draw. 0 = no disponible.
static GLuint g_app_icon_texture = 0;
static int g_app_icon_size = 0;
#ifdef _WIN32
// Carga el icono embebido a 32x32 y sube como textura GL RGBA8. Linear
// filtering para que escalar 32->18 sea suave. Devuelve 0 si falla.
static GLuint upload_hicon_to_gl_texture() {
const int sz = 32;
HICON hicon = (HICON)LoadImageW(GetModuleHandleW(nullptr),
MAKEINTRESOURCEW(FN_APP_ICON_RES_ID),
IMAGE_ICON, sz, sz,
LR_DEFAULTCOLOR);
if (!hicon) return 0;
ICONINFO ii{};
if (!GetIconInfo(hicon, &ii)) { DestroyIcon(hicon); return 0; }
HDC hdc = CreateCompatibleDC(nullptr);
BITMAPINFO bmi{};
bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
bmi.bmiHeader.biWidth = sz;
bmi.bmiHeader.biHeight = -sz; // top-down
bmi.bmiHeader.biPlanes = 1;
bmi.bmiHeader.biBitCount = 32;
bmi.bmiHeader.biCompression = BI_RGB;
std::vector<unsigned char> pixels(sz * sz * 4, 0);
int ok = GetDIBits(hdc, ii.hbmColor, 0, sz, pixels.data(), &bmi, DIB_RGB_COLORS);
DeleteDC(hdc);
if (ii.hbmColor) DeleteObject(ii.hbmColor);
if (ii.hbmMask) DeleteObject(ii.hbmMask);
DestroyIcon(hicon);
if (ok == 0) return 0;
// BGRA -> RGBA (Windows DIB es BGRA).
for (int i = 0; i < sz * sz; ++i) {
unsigned char b = pixels[i*4 + 0];
unsigned char r = pixels[i*4 + 2];
pixels[i*4 + 0] = r;
pixels[i*4 + 2] = b;
}
GLuint tex = 0;
glGenTextures(1, &tex);
glBindTexture(GL_TEXTURE_2D, tex);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, sz, sz, 0, GL_RGBA, GL_UNSIGNED_BYTE,
pixels.data());
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glBindTexture(GL_TEXTURE_2D, 0);
return tex;
}
#endif
// Itera todas las ventanas ImGui y pinta el badge en la title bar de las que
// viven en un viewport secundario (panel arrastrado fuera del main window).
// Usa imgui_internal.h para acceder a g.Windows y a TitleBarRect(), y dibuja
// directamente en la DrawList propia de cada ventana — asi el badge va en
// el mismo paso de render que el resto del panel, sin depender de
// ForegroundDrawList del viewport (que no se renderiza en algunas combos de
// backend + multi-viewport).
//
// Si tenemos icono GL cargado (Windows con appicon.ico embebido), se dibuja
// el icono real (mismo bitmap que el taskbar). Sin icono, fallback a
// cuadrado redondeado del color accent con la inicial blanca del app name.
//
// Filtro de ventanas:
// - Activa, no Hidden, no Collapsed.
// - No es child window.
// - No es popup/tooltip/menu (NoTitleBar => skip).
// - No esta dockeada en un nodo (DockIsActive => su titlebar es tabbar del
// host; el host window aparece en g.Windows por separado y SI recibe badge).
// - Su viewport != main viewport.
static void draw_header_badge_on_floating_panels(const AppConfig& cfg) {
if (!cfg.header_badge.enabled) return;
ImGuiContext& g = *ImGui::GetCurrentContext();
ImGuiViewport* main_vp = ImGui::GetMainViewport();
const ImU32 bg = resolve_badge_color(cfg);
const char* glyph = resolve_badge_glyph(cfg);
const float sz = cfg.header_badge.size_px > 4.0f ? cfg.header_badge.size_px : 18.0f;
const float mg = cfg.header_badge.margin_px >= 0.0f ? cfg.header_badge.margin_px : 6.0f;
const float round = sz * 0.22f;
const bool has_texture = (g_app_icon_texture != 0);
for (int i = 0; i < g.Windows.Size; ++i) {
ImGuiWindow* w = g.Windows[i];
if (!w || !w->WasActive || w->Hidden) continue;
if (w->Flags & ImGuiWindowFlags_ChildWindow) continue;
if (w->Flags & ImGuiWindowFlags_NoTitleBar) continue;
if (w->DockIsActive) continue; // titlebar reemplazado por tab bar del host
if (w->Viewport == nullptr || w->Viewport == main_vp) continue;
if (w->Collapsed) continue;
ImRect tb = w->TitleBarRect();
ImVec2 p0(tb.Min.x + mg, tb.Min.y + (tb.GetHeight() - sz) * 0.5f);
ImVec2 p1(p0.x + sz, p0.y + sz);
ImDrawList* dl = w->DrawList;
if (has_texture) {
dl->AddImageRounded((ImTextureID)(intptr_t)g_app_icon_texture,
p0, p1, ImVec2(0,0), ImVec2(1,1),
IM_COL32_WHITE, round);
} else {
dl->AddRectFilled(p0, p1, bg, round);
ImVec2 ts = ImGui::CalcTextSize(glyph);
ImVec2 tp(p0.x + (sz - ts.x) * 0.5f,
p0.y + (sz - ts.y) * 0.5f);
dl->AddText(tp, IM_COL32(255, 255, 255, 255), glyph);
}
}
}
int run_app(AppConfig config, std::function<void()> render_fn) {
// Logger primero para capturar fallos del propio init (GLFW, ventana, GL).
if (config.log.file_path != nullptr) {
@@ -460,6 +716,14 @@ int run_app(AppConfig config, std::function<void()> render_fn) {
// barra de tareas, Alt+Tab y title bar (GLFW no propaga el icono de
// recursos del .exe a su WNDCLASS por defecto).
attach_app_icon_to_hwnd(glfwGetWin32Window(window));
// Title bar oscuro (DWM) si el tema lo es. Sin esto el header del SO
// queda blanco aunque el cliente sea dark.
{
const bool dark = (config.theme == ThemeMode::FnDark ||
config.theme == ThemeMode::ImGuiDark);
attach_dark_titlebar_to_hwnd(glfwGetWin32Window(window), dark);
}
#endif
// Carga punteros a funciones GL >= 2.0 si la app lo pide. En Linux es
@@ -484,6 +748,14 @@ int run_app(AppConfig config, std::function<void()> render_fn) {
ImGuiIO& io = ImGui::GetIO();
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
io.ConfigFlags |= ImGuiConfigFlags_DockingEnable;
// Multi-viewport docking: payload viewport y target viewport swappean
// buffers independientes, asi que los dock preview overlays (los rects
// azul/gris que indican zonas droppeables) parecen vibrar 1px contra el
// payload arrastrado. Con TransparentPayload el payload se vuelve
// invisible al arrastrar y los rects solo se pintan en el target ->
// ningun desync visible. Recomendado por upstream cuando "rendering of
// multiple viewport cannot be synced".
io.ConfigDockingTransparentPayload = true;
// Title-bar-only move for ImGui windows. Critical for secondary viewports
// (floating panels) whose entire OS window is a single borderless ImGui
// window: without this flag, ImGui moves the window when the user drags
@@ -625,6 +897,9 @@ int run_app(AppConfig config, std::function<void()> render_fn) {
if (io.ConfigFlags & ImGuiConfigFlags_ViewportsEnable) {
prune_dead_subclassed();
prune_dead_icon_attached();
prune_dead_dark_titlebar();
const bool dark_tb = (config.theme == ThemeMode::FnDark ||
config.theme == ThemeMode::ImGuiDark);
ImGuiPlatformIO& pio_sub = ImGui::GetPlatformIO();
for (int i = 0; i < pio_sub.Viewports.Size; ++i) {
ImGuiViewport* vp = pio_sub.Viewports[i];
@@ -636,6 +911,9 @@ int run_app(AppConfig config, std::function<void()> render_fn) {
// SetClassLongPtrW. WM_SETICON per-HWND es la unica forma de
// que el taskbar/titlebar muestren el icono.
attach_app_icon_to_hwnd(glfwGetWin32Window(gw));
// Misma logica para el title bar oscuro — cada viewport
// secundario tiene su propio HWND con caption pintado por DWM.
attach_dark_titlebar_to_hwnd(glfwGetWin32Window(gw), dark_tb);
}
}
#endif
@@ -750,6 +1028,17 @@ int run_app(AppConfig config, std::function<void()> render_fn) {
fps_overlay();
}
// Identidad por app en viewports secundarios — badge en el title bar
// de cada panel arrastrado fuera del main window. Si Windows + tiene
// appicon.ico embebido, dibuja el mismo icono que el taskbar (PNG
// RGBA escalado). Si no, fallback a cuadrado accent + inicial.
#ifdef _WIN32
if (g_app_icon_texture == 0) {
g_app_icon_texture = upload_hicon_to_gl_texture();
}
#endif
draw_header_badge_on_floating_panels(config);
ImGui::Render();
int display_w, display_h;
glfwGetFramebufferSize(window, &display_w, &display_h);
@@ -800,6 +1089,10 @@ int run_app(AppConfig config, std::function<void()> render_fn) {
}
// Cleanup
if (g_app_icon_texture != 0) {
glDeleteTextures(1, &g_app_icon_texture);
g_app_icon_texture = 0;
}
ImGui_ImplOpenGL3_Shutdown();
ImGui_ImplGlfw_Shutdown();
ImPlot3D::DestroyContext();