diff --git a/app.md b/app.md index 2d466c2..5d09f18 100644 --- a/app.md +++ b/app.md @@ -2,31 +2,39 @@ name: altsnap_jitter_test lang: cpp domain: tools -description: "Regression test for multi-viewport window jitter triggered by external window movers (AltSnap on Windows, tiling WMs). Drives glfwSetWindowPos every frame and asserts ImGui viewport tracks the OS pos within 1px." +description: "Regression test for multi-viewport window jitter + iconified survival + Alt+RMB resize + Alt+LMB move. Six phases: p1 main-window sync, p2 AltSnap on main HWND, p3 AltSnap on secondary viewport HWND, p4 iconify+restore preserves floating panels, p5 Alt+RMB consumed by WndProc, p6 Alt+LMB consumed by WndProc." tags: [imgui, test, regression, headless] uses_functions: [] uses_types: [] framework: "imgui" entry_point: "main.cpp" -dir_path: "cpp/apps/altsnap_jitter_test" +dir_path: "apps/altsnap_jitter_test" repo_url: "" --- # altsnap_jitter_test -Headless C++ harness para validar el fix de jitter de multi-viewport (AltSnap, tiling WMs). +Headless C++ harness para validar el subclass anti-jitter del framework +(`cpp/framework/app_base.cpp`): movimiento/redimensionado externos sin +temblor en la ventana principal y en viewports secundarios, iconified main +no pierde paneles flotantes, Alt+RMB resize anywhere, Alt+LMB move anywhere. ## Que hace -1. Arranca `fn::run_app` con viewports ON (config exacta que reproducia el bug). -2. Tras 8 frames de warmup, mueve la ventana via `glfwSetWindowPos` un step por frame durante 60 frames. -3. Cada frame muestrea pos OS (`glfwGetWindowPos`) y pos ImGui (`GetMainViewport()->Pos`). -4. Cuenta divergencias > 1px entre ambos. Cero divergencias = PASS, exit 0. -5. Reporta tambien clamp del WM (cuando el compositor rechaza la pos pedida). +Seis fases en una sola sesion de `fn::run_app`: + +1. **p1.sync** (cross-platform). Warmup 8 frames, mueve la ventana principal via `glfwSetWindowPos` un step por frame durante 60 frames. Muestrea pos OS (`glfwGetWindowPos`) vs pos ImGui (`GetMainViewport()->Pos`). Tolerancia 1px. Cuenta divergencias = `bad_sync`. +2. **p2.altsnap** (Windows). Worker thread fakea `WM_ENTERSIZEMOVE` + burst de 30 `SetWindowPos(SWP_ASYNCWINDOWPOS)` + `WM_EXITSIZEMOVE` sobre el HWND principal. Aserta `renders_during==0` (subclass del WndProc principal y gate del main loop activos). +3. **p3.secondary** (Windows). Fuerza creacion de un viewport secundario (panel flotante, `ConfigViewportsNoAutoMerge=true`), localiza su HWND en `pio.Viewports` via `find_secondary_viewport_hwnd`, y repite el bracket sobre el. Valida que el subclass per-frame cubre tambien el HWND secundario. +4. **p4.minimize** (Windows). State machine 4 steps con `kP4SettleFrames=20` entre cada uno. Captura `IsWindow(secondary_hwnd)` antes/durante/despues de `glfwIconifyWindow` + `glfwRestoreWindow`. Asserta los 3 estados vivos AND `renders_iconified > 0` (frame loop sigue activo durante iconify para que los flotantes no se pierdan). +5. **p5.alt_rmb** (Windows). `fn::internal::set_force_alt_for_test(true)` + `SendMessageW(hwnd, WM_RBUTTONDOWN, MK_RBUTTON, MAKELPARAM(40,40))` sincrono mismo-hilo. Asserta `fn::internal::alt_rmb_resize_count()` incrementa en 1. En test mode el handler salta el `PostMessage SC_SIZE` para no atrapar al harness en modal. +6. **p6.alt_lmb** (Windows). Mismo patron para `WM_LBUTTONDOWN`. Asserta `fn::internal::alt_lmb_move_count()` incrementa en 1. + +PASS = todas las fases con sus deltas positivos. SKIP en Linux para p2-p6. ## Run -WSL/Linux: +WSL/Linux (sanity build; p2-p6 skipped): ```bash cd cpp && cmake -B build/linux -DFN_BUILD_TESTS=OFF cmake --build build/linux --target altsnap_jitter_test -j4 @@ -36,22 +44,52 @@ xvfb-run -a -s "-screen 0 1280x800x24" \ echo "EXIT: $?" ``` -Windows (cross-compile + Desktop deploy): +Windows (cross-compile + Desktop deploy + run): ```bash source bash/functions/infra/e2e_run_cpp_windows.sh e2e_run_cpp_windows altsnap_jitter_test ``` -## Output esperado (PASS) +## Output esperado (PASS, Windows) ``` -[altsnap_jitter] f=0 target=(200,200) actual=(200,200) vp=(200.0,200.0) sync_d=0 clamp_d=0 -[altsnap_jitter] f=10 target=(240,200) actual=(240,200) vp=(240.0,200.0) sync_d=0 clamp_d=0 -... -[altsnap_jitter] DONE frames=60 max_sync_divergence=0px max_clamp_divergence=0px bad_sync=0 bad_clamp=0 -[altsnap_jitter] PASS +[p1.sync] DONE frames=60 max_sync=0px max_clamp=0px bad_sync=0 bad_clamp=0 +[p2.altsnap] DONE renders before=N during=0 after=M +[p3.secondary] DONE renders before=N during=0 after=M +[p4.minimize] DONE alive(before=1 during=1 after=1) renders_iconified=20 +[p5.alt_rmb] DONE alt_resize delta=1 (after=1) sizemove_enter delta=0 (after=2) +[p6.alt_lmb] DONE alt_move delta=1 (after=1) +[altsnap_jitter] p1=PASS p2=PASS p3=PASS p4=PASS p5=PASS p6=PASS overall=PASS ``` +En Linux/xvfb p2-p6 reportan SKIPPED. P1 puede mostrar lag pre-existente bajo xvfb por como X procesa `SetWindowPos`; no es un fallo del fix. + ## Criterio de fallo -`bad_sync > 0` significa que ImGui viewport->Pos quedo fuera de sincronia con la pos real OS, exactamente la condicion que produce el visible "temblor" cuando AltSnap arrastra la ventana. Ese feedback loop es lo que arregla la patch en `cpp/framework/app_base.cpp` (callback GLFW + per-frame sync de viewports). +- `p1_bad_sync > 0`: ImGui viewport->Pos quedo fuera de sincronia con la pos OS. Roto el callback GLFW + per-frame sync. +- `p2_renders_during > 0`: la app sigue dibujando durante un bracket AltSnap en el HWND principal. Roto el subclass del WndProc principal o la gate del main loop. +- `p3_renders_during > 0`: la app sigue dibujando durante un bracket AltSnap en un HWND **secundario** (panel flotante). Roto el escaneo per-frame de `pio.Viewports` que instala el subclass en cada platform window. +- `p4 alive(during)=0`: floating panel se cierra/desaparece al minimizar el main. Regresion del fix iconified+secondary. +- `p4 renders_iconified == 0`: el iconified-gate volvio a `glfwWaitEvents+continue` global sin chequear secondaries. Floating panels se congelarian. +- `p5 alt_resize delta == 0`: Alt+RMB no se consume. Subclass no esta en el chain (`ImGui_ImplGlfw_WndProc` capturo nuestra WndProc como prev y no chainea bien) o flag `force_alt_for_test` no se aplica. +- `p6 alt_move delta == 0`: misma raiz que p5 pero para LMB. + +## Donde vive el fix + +`cpp/framework/app_base.cpp`: +- GLFW pos/size callbacks (anti-jitter capa 1). +- Per-frame viewport sync (capa 2). +- `unordered_map g_subclassed` + per-frame `install_sizemove_subclass_hwnd` sobre `pio.Viewports` (capa 3, multi-HWND). +- Iconified gate detecta secondary viewports y fall-through si existen. +- `WM_LBUTTONDOWN`/`WM_RBUTTONDOWN` Alt-held → `WM_SYSCOMMAND, SC_MOVE|HTCAPTION` / `SC_SIZE|dir`. +- `io.ConfigWindowsMoveFromTitleBarOnly = true` (floating panels respetan header-only). +- `fn::internal::*` counters expuestos para tests headless. + +`cpp/framework/app_base.h`: +- `namespace fn::internal { sizemove_enter_count(); alt_rmb_resize_count(); alt_lmb_move_count(); rbuttondown_seen_count(); set_force_alt_for_test(bool); }`. + +## Notas + +- `keybd_event(VK_MENU)` NO es fiable para drivear `GetAsyncKeyState` desde tests headless cross-compilados — la sesion de input del proceso no esta foreground. Usa `set_force_alt_for_test(true)` + `SendMessageW` sincrono mismo-hilo. Bypassa kernel-input filter (que dropea silenciosamente `PostMessage(WM_RBUTTONDOWN)` sintetizado). +- ImGui_ImplGlfw subclassea el HWND despues que nosotros (vendor `cpp/vendor/imgui/backends/imgui_impl_glfw.cpp` linea ~820, captura `bd->PrevWndProc = our_subclass`). Por eso ImGui llama a nuestro WndProc via `CallWindowProc(prev_wndproc, ...)` y todos los mensajes nos llegan en orden correcto. No re-subclassear (provoca recursion infinita via cycle). +- Test mode (`set_force_alt_for_test(true)`) hace que el WndProc cuente pero NO postee `SC_SIZE`/`SC_MOVE` — evita quedarse atrapado en modal sizemove. La parte "entrar al modal" se valida por p2/p3 fakeando `WM_ENTERSIZEMOVE` directamente. diff --git a/appicon.ico b/appicon.ico new file mode 100644 index 0000000..d883afe Binary files /dev/null and b/appicon.ico differ diff --git a/main.cpp b/main.cpp index b289fbe..98ae855 100644 --- a/main.cpp +++ b/main.cpp @@ -9,8 +9,9 @@ // reapplies stale value" bug fixed by the GLFW pos callback in // cpp/framework/app_base.cpp. // -// Phase 2 — AltSnap simulation (Windows-only, no-op on Linux). -// A worker thread reproduces AltSnap's exact wire format: +// Phase 2 — AltSnap simulation on the MAIN window (Windows-only). +// A worker thread reproduces AltSnap's exact wire format on the main +// HWND: // 1. PostMessage(hwnd, WM_ENTERSIZEMOVE) — fake bracket open // 2. Burst of SetWindowPos(SWP_ASYNCWINDOWPOS) — async drag steps // 3. PostMessage(hwnd, WM_EXITSIZEMOVE) — fake bracket close @@ -21,8 +22,19 @@ // bracket: zero = pass, anything >0 = the fix didn't engage and the // visible "grab and release" jitter would still appear. // +// Phase 3 — AltSnap simulation on a SECONDARY viewport (Windows-only). +// With ConfigViewportsNoAutoMerge ON we force ImGui to allocate a +// dedicated platform window (HWND) for a panel positioned outside the +// main viewport, exactly like the user dragging a tab out of the +// dockspace. The same WM_ENTERSIZEMOVE / SetWindowPos burst / +// WM_EXITSIZEMOVE sequence is then sent to that secondary HWND. The +// framework must subclass every viewport HWND (not just main) so the +// bracket is observed and rendering pauses globally — otherwise the +// "grab and release" jitter the user reported when AltSnap moves a +// floating panel would still appear. Pass criteria mirror Phase 2. +// // Linux/WSL: only Phase 1 runs. With xvfb the WM honors glfwSetWindowPos, -// so the sync test is meaningful. Phase 2 returns immediately. +// so the sync test is meaningful. Phases 2 and 3 return immediately. // // Windows (target): cross-compiled via build_cpp_windows, deployed to the // Desktop apps dir, launched via cmd.exe; this is where AltSnap lives, so @@ -85,6 +97,57 @@ int g_p2_renders_during = 0; int g_p2_renders_after = 0; bool g_p2_skipped = false; +// ----- Phase 3 (secondary viewport AltSnap sim) tunables ----- +constexpr int kP3InitFrames = 30; // frames waiting for the platform window to materialize +constexpr int kP3SecondaryX = 1400; // outside the 800x600 main viewport +constexpr int kP3SecondaryY = 200; +constexpr int kP3SecondaryW = 320; +constexpr int kP3SecondaryH = 220; + +// Phase 3 state +std::atomic g_p3_started{false}; +std::atomic g_p3_done{false}; +int g_p3_init_frames = 0; +int g_p3_renders_before = 0; +int g_p3_renders_during = 0; +int g_p3_renders_after = 0; +bool g_p3_skipped = false; +bool g_p3_show_window = true; // when true, render() draws the floating panel +#ifdef _WIN32 +HWND g_p3_secondary_hwnd = nullptr; +#endif + +// ----- Phase 4 (iconified main, floating panels must survive) ----- +constexpr int kP4SettleFrames = 20; // frames between each step of the dance +std::atomic g_p4_started{false}; +std::atomic g_p4_done{false}; +int g_p4_step = 0; +int g_p4_renders_iconified = 0; +bool g_p4_secondary_alive_before = false; +bool g_p4_secondary_alive_during = false; +bool g_p4_secondary_alive_after = false; +bool g_p4_skipped = false; + +// ----- Phase 5 (Alt + RMB triggers native resize modal) ----- +constexpr int kP5SettleFrames = 20; +std::atomic g_p5_started{false}; +std::atomic g_p5_done{false}; +int g_p5_step = 0; +int g_p5_alt_resize_count_before = 0; +int g_p5_alt_resize_count_after = 0; +int g_p5_sizemove_enter_before = 0; +int g_p5_sizemove_enter_after = 0; +bool g_p5_skipped = false; + +// ----- Phase 6 (Alt + LMB triggers native move modal) ----- +constexpr int kP6SettleFrames = 20; +std::atomic g_p6_started{false}; +std::atomic g_p6_done{false}; +int g_p6_step = 0; +int g_p6_alt_move_before = 0; +int g_p6_alt_move_after = 0; +bool g_p6_skipped = false; + #ifdef _WIN32 void altsnap_worker(HWND hwnd) { // Wait briefly so the render loop is firmly running and the test app's @@ -129,6 +192,49 @@ void altsnap_worker(HWND hwnd) { g_p2_done.store(true, std::memory_order_release); } + +// Worker that targets a secondary viewport HWND (Phase 3). Identical wire +// shape to altsnap_worker — only the HWND differs. +void altsnap_worker_secondary(HWND hwnd) { + Sleep(50); + g_p3_renders_before = g_render_counter.load(std::memory_order_acquire); + + PostMessageW(hwnd, WM_ENTERSIZEMOVE, 0, 0); + Sleep(kAltSnapStepMs); + int during_baseline = g_render_counter.load(std::memory_order_acquire); + + UINT flags = SWP_NOSIZE | SWP_NOZORDER | SWP_NOOWNERZORDER + | SWP_NOACTIVATE | SWP_ASYNCWINDOWPOS; + for (int i = 0; i < kAltSnapSteps; ++i) { + int x = kP3SecondaryX + i * kAltSnapStepPx; + int y = kP3SecondaryY; + SetWindowPos(hwnd, nullptr, x, y, 0, 0, flags); + Sleep(kAltSnapStepMs); + } + int during_after = g_render_counter.load(std::memory_order_acquire); + g_p3_renders_during = during_after - during_baseline; + + PostMessageW(hwnd, WM_EXITSIZEMOVE, 0, 0); + Sleep(200); + g_p3_renders_after = g_render_counter.load(std::memory_order_acquire) - during_after; + + g_p3_done.store(true, std::memory_order_release); +} + +// Walk ImGui's PlatformIO viewports and return the first HWND that is NOT +// the main window's HWND. Returns nullptr if no secondary platform window +// has been materialized yet. +HWND find_secondary_viewport_hwnd(HWND main_hwnd) { + ImGuiPlatformIO& pio = ImGui::GetPlatformIO(); + for (int i = 0; i < pio.Viewports.Size; ++i) { + ImGuiViewport* vp = pio.Viewports[i]; + if (!vp || !vp->PlatformHandle) continue; + GLFWwindow* gw = (GLFWwindow*)vp->PlatformHandle; + HWND h = glfwGetWin32Window(gw); + if (h && h != main_hwnd) return h; + } + return nullptr; +} #endif // _WIN32 void render() { @@ -215,15 +321,320 @@ void render() { return; } - // Phase 2 finished — log and exit. - if (g_p2_skipped) { - std::fprintf(stdout, "[p2.altsnap] SKIPPED (non-Windows)\n"); - } else { - std::fprintf(stdout, - "[p2.altsnap] DONE renders before=%d during=%d after=%d (during should be 0)\n", - g_p2_renders_before, g_p2_renders_during, g_p2_renders_after); + // Phase 2 finished — log once. + static bool s_p2_logged = false; + if (!s_p2_logged) { + if (g_p2_skipped) { + std::fprintf(stdout, "[p2.altsnap] SKIPPED (non-Windows)\n"); + } else { + std::fprintf(stdout, + "[p2.altsnap] DONE renders before=%d during=%d after=%d (during should be 0)\n", + g_p2_renders_before, g_p2_renders_during, g_p2_renders_after); + } + std::fflush(stdout); + s_p2_logged = true; } - std::fflush(stdout); + + // ----------------------------------------------------------------- + // Phase 3 — AltSnap simulation on a SECONDARY viewport (Windows only) + // ----------------------------------------------------------------- +#ifdef _WIN32 + // Always render the floating panel while p3 is active so the platform + // window keeps existing and the worker can target it. + if (g_p3_show_window && !g_p3_done.load(std::memory_order_acquire)) { + ImGui::SetNextWindowPos(ImVec2((float)kP3SecondaryX, (float)kP3SecondaryY), + ImGuiCond_Once); + ImGui::SetNextWindowSize(ImVec2((float)kP3SecondaryW, (float)kP3SecondaryH), + ImGuiCond_Once); + if (ImGui::Begin("p3_secondary", + nullptr, + ImGuiWindowFlags_NoDocking | ImGuiWindowFlags_NoCollapse)) { + ImGui::TextUnformatted("floating panel under AltSnap test"); + } + ImGui::End(); + } + + if (!g_p3_started.load(std::memory_order_acquire)) { + // Give the backend kP3InitFrames to materialize the secondary + // platform window (UpdatePlatformWindows runs once per frame, and + // the GLFW backend needs a couple of cycles). + if (g_p3_init_frames < kP3InitFrames) { + ++g_p3_init_frames; + ++g_frame; + return; + } + HWND main_hwnd = glfwGetWin32Window(g_window); + HWND sec_hwnd = find_secondary_viewport_hwnd(main_hwnd); + if (!sec_hwnd) { + std::fprintf(stdout, + "[p3.secondary] SKIPPED — backend never created a secondary platform window " + "(ConfigViewportsNoAutoMerge maybe disabled?)\n"); + std::fflush(stdout); + g_p3_skipped = true; + g_p3_done.store(true, std::memory_order_release); + g_p3_started.store(true, std::memory_order_release); + } else { + g_p3_secondary_hwnd = sec_hwnd; + std::fprintf(stdout, + "[p3.secondary] starting worker — bracket=ENTERSIZEMOVE -> %d SetWindowPos -> EXITSIZEMOVE " + "on secondary HWND=%p\n", + kAltSnapSteps, (void*)sec_hwnd); + std::fflush(stdout); + g_p3_started.store(true, std::memory_order_release); + std::thread(altsnap_worker_secondary, sec_hwnd).detach(); + } + } + + if (!g_p3_done.load(std::memory_order_acquire)) { + ++g_frame; + return; + } +#else + if (!g_p3_started.exchange(true, std::memory_order_acq_rel)) { + g_p3_skipped = true; + g_p3_done.store(true, std::memory_order_release); + } +#endif + + // Phase 3 finished — log once. + static bool s_p3_logged = false; + if (!s_p3_logged) { + if (g_p3_skipped) { + std::fprintf(stdout, "[p3.secondary] SKIPPED\n"); + } else { + std::fprintf(stdout, + "[p3.secondary] DONE renders before=%d during=%d after=%d (during should be 0)\n", + g_p3_renders_before, g_p3_renders_during, g_p3_renders_after); + } + std::fflush(stdout); + s_p3_logged = true; + } + + // ----------------------------------------------------------------- + // Phase 4 — minimize main, floating panel must survive + // ----------------------------------------------------------------- + // Keep drawing the secondary panel from p3 so the floating viewport + // keeps existing across the dance. We never set g_p3_show_window=false. +#ifdef _WIN32 + if (g_p3_show_window) { + ImGui::SetNextWindowPos(ImVec2((float)kP3SecondaryX, (float)kP3SecondaryY), + ImGuiCond_Once); + ImGui::SetNextWindowSize(ImVec2((float)kP3SecondaryW, (float)kP3SecondaryH), + ImGuiCond_Once); + if (ImGui::Begin("p3_secondary", + nullptr, + ImGuiWindowFlags_NoDocking | ImGuiWindowFlags_NoCollapse)) { + ImGui::TextUnformatted("floating panel under AltSnap + minimize test"); + } + ImGui::End(); + } + + if (!g_p4_done.load(std::memory_order_acquire)) { + // Multi-step state machine. Each step waits kP4SettleFrames before + // advancing so the OS / WM have time to react. + // step 0: snapshot "alive before" + // step 1..N: iconify main + // step 2: snapshot "alive while iconified" + count renders during + // step 3: restore main + // step 4: snapshot "alive after restore" -> done + static int s_frame_at_step = 0; + static int s_renders_at_step = 0; + if (!g_p4_started.exchange(true, std::memory_order_acq_rel)) { + s_frame_at_step = g_frame; + s_renders_at_step = g_render_counter.load(std::memory_order_acquire); + g_p4_step = 0; + std::fprintf(stdout, "[p4.minimize] start — iconify dance\n"); + std::fflush(stdout); + } + if (g_frame - s_frame_at_step >= kP4SettleFrames) { + HWND main_hwnd = glfwGetWin32Window(g_window); + HWND sec_hwnd = find_secondary_viewport_hwnd(main_hwnd); + switch (g_p4_step) { + case 0: + g_p4_secondary_alive_before = (sec_hwnd != nullptr) && IsWindow(sec_hwnd); + glfwIconifyWindow(g_window); + break; + case 1: + // Mid-iconified probe: still alive? + g_p4_secondary_alive_during = (sec_hwnd != nullptr) && IsWindow(sec_hwnd); + g_p4_renders_iconified = + g_render_counter.load(std::memory_order_acquire) - s_renders_at_step; + break; + case 2: + glfwRestoreWindow(g_window); + break; + case 3: + g_p4_secondary_alive_after = (sec_hwnd != nullptr) && IsWindow(sec_hwnd); + g_p4_done.store(true, std::memory_order_release); + break; + } + s_frame_at_step = g_frame; + s_renders_at_step = g_render_counter.load(std::memory_order_acquire); + ++g_p4_step; + } + ++g_frame; + return; + } +#else + if (!g_p4_started.exchange(true, std::memory_order_acq_rel)) { + g_p4_skipped = true; + g_p4_done.store(true, std::memory_order_release); + } +#endif + + static bool s_p4_logged = false; + if (!s_p4_logged) { + if (g_p4_skipped) { + std::fprintf(stdout, "[p4.minimize] SKIPPED (non-Windows)\n"); + } else { + std::fprintf(stdout, + "[p4.minimize] DONE alive(before=%d during=%d after=%d) renders_iconified=%d\n", + g_p4_secondary_alive_before ? 1 : 0, + g_p4_secondary_alive_during ? 1 : 0, + g_p4_secondary_alive_after ? 1 : 0, + g_p4_renders_iconified); + } + std::fflush(stdout); + s_p4_logged = true; + } + + // ----------------------------------------------------------------- + // Phase 5 — Alt + RMB triggers native resize modal (Windows only) + // ----------------------------------------------------------------- +#ifdef _WIN32 + if (!g_p5_done.load(std::memory_order_acquire)) { + static int s_frame_at_step5 = 0; + if (!g_p5_started.exchange(true, std::memory_order_acq_rel)) { + s_frame_at_step5 = g_frame; + g_p5_alt_resize_count_before = fn::internal::alt_rmb_resize_count(); + g_p5_sizemove_enter_before = fn::internal::sizemove_enter_count(); + std::fprintf(stdout, + "[p5.alt_rmb] start — counters before alt=%d sizemove_enter=%d\n", + g_p5_alt_resize_count_before, g_p5_sizemove_enter_before); + std::fflush(stdout); + } + if (g_frame - s_frame_at_step5 >= kP5SettleFrames) { + switch (g_p5_step) { + case 0: { + // Synchronous same-thread dispatch through the full + // WndProc chain. No marshalling, no input-injection + // filter to worry about. The framework's WndProc + // observes force_alt_for_test, increments the counter, + // and (in test mode) skips emitting SC_SIZE so the + // headless harness doesn't get stuck in Windows' modal + // resize loop. The modal entry itself is covered by p2 + // (which fakes WM_ENTERSIZEMOVE directly). + HWND hwnd = glfwGetWin32Window(g_window); + fn::internal::set_force_alt_for_test(true); + SendMessageW(hwnd, WM_RBUTTONDOWN, + MK_RBUTTON, + MAKELPARAM(40, 40)); + fn::internal::set_force_alt_for_test(false); + break; + } + case 1: { + g_p5_alt_resize_count_after = fn::internal::alt_rmb_resize_count(); + g_p5_sizemove_enter_after = fn::internal::sizemove_enter_count(); + std::fprintf(stdout, + "[p5.alt_rmb] diag rbuttondown_seen=%d\n", + fn::internal::rbuttondown_seen_count()); + std::fflush(stdout); + g_p5_done.store(true, std::memory_order_release); + break; + } + } + s_frame_at_step5 = g_frame; + ++g_p5_step; + } + ++g_frame; + return; + } +#else + if (!g_p5_started.exchange(true, std::memory_order_acq_rel)) { + g_p5_skipped = true; + g_p5_done.store(true, std::memory_order_release); + } +#endif + + static bool s_p5_logged = false; + if (!s_p5_logged) { + if (g_p5_skipped) { + std::fprintf(stdout, "[p5.alt_rmb] SKIPPED (non-Windows)\n"); + } else { + std::fprintf(stdout, + "[p5.alt_rmb] DONE alt_resize delta=%d (after=%d) sizemove_enter delta=%d (after=%d)\n", + g_p5_alt_resize_count_after - g_p5_alt_resize_count_before, + g_p5_alt_resize_count_after, + g_p5_sizemove_enter_after - g_p5_sizemove_enter_before, + g_p5_sizemove_enter_after); + } + std::fflush(stdout); + s_p5_logged = true; + } + + // ----------------------------------------------------------------- + // Phase 6 — Alt + LMB triggers native MOVE modal (Windows only) + // ----------------------------------------------------------------- +#ifdef _WIN32 + if (!g_p6_done.load(std::memory_order_acquire)) { + static int s_frame_at_step6 = 0; + if (!g_p6_started.exchange(true, std::memory_order_acq_rel)) { + s_frame_at_step6 = g_frame; + g_p6_alt_move_before = fn::internal::alt_lmb_move_count(); + std::fprintf(stdout, + "[p6.alt_lmb] start — counter before alt_lmb_move=%d\n", + g_p6_alt_move_before); + std::fflush(stdout); + } + if (g_frame - s_frame_at_step6 >= kP6SettleFrames) { + switch (g_p6_step) { + case 0: { + // Same harness shape as p5: synchronous same-thread + // SendMessage with force_alt enabled. Framework consumes + // the click, increments g_alt_lmb_move_count, and (in + // test mode) skips emitting SC_MOVE so the harness + // doesn't enter Windows' move modal. + HWND hwnd = glfwGetWin32Window(g_window); + fn::internal::set_force_alt_for_test(true); + SendMessageW(hwnd, WM_LBUTTONDOWN, + MK_LBUTTON, + MAKELPARAM(60, 60)); + fn::internal::set_force_alt_for_test(false); + break; + } + case 1: { + g_p6_alt_move_after = fn::internal::alt_lmb_move_count(); + g_p6_done.store(true, std::memory_order_release); + break; + } + } + s_frame_at_step6 = g_frame; + ++g_p6_step; + } + ++g_frame; + return; + } +#else + if (!g_p6_started.exchange(true, std::memory_order_acq_rel)) { + g_p6_skipped = true; + g_p6_done.store(true, std::memory_order_release); + } +#endif + + static bool s_p6_logged = false; + if (!s_p6_logged) { + if (g_p6_skipped) { + std::fprintf(stdout, "[p6.alt_lmb] SKIPPED (non-Windows)\n"); + } else { + std::fprintf(stdout, + "[p6.alt_lmb] DONE alt_move delta=%d (after=%d)\n", + g_p6_alt_move_after - g_p6_alt_move_before, + g_p6_alt_move_after); + } + std::fflush(stdout); + s_p6_logged = true; + } + glfwSetWindowShouldClose(g_window, GLFW_TRUE); } @@ -244,6 +655,19 @@ int main(int argc, char** argv) { cfg.log = {"altsnap_jitter_test.log", 1}; cfg.auto_dockspace = false; // test no necesita dockspace + // pre_frame runs after ImGui::NewFrame each tick. We flip + // ConfigViewportsNoAutoMerge ON exactly once so Phase 3's floating panel + // is guaranteed to land on its own platform window (HWND) instead of + // auto-merging into the main viewport. Done in pre_frame (not in main()) + // because ImGui::CreateContext / GetIO are not valid yet when AppConfig + // is built. + cfg.pre_frame = []() { + static bool s_done = false; + if (s_done) return; + ImGui::GetIO().ConfigViewportsNoAutoMerge = true; + s_done = true; + }; + int rc = fn::run_app(cfg, render); if (rc != 0) { std::fprintf(stderr, "[altsnap_jitter] run_app returned %d\n", rc); @@ -252,17 +676,45 @@ int main(int argc, char** argv) { // Pass criteria: // p1_bad_sync == 0 (existing GLFW callback fix still works) - // p2_renders_during == 0 (subclass + main-loop gate kicked in) + // p2_renders_during == 0 (subclass + main-loop gate kicked in on main HWND) // p2_renders_after > 0 (rendering resumed after EXITSIZEMOVE) + // p3_renders_during == 0 (subclass installed on secondary viewport HWND too) + // p3_renders_after > 0 (rendering resumed after EXITSIZEMOVE on secondary) + // p4: secondary alive before AND during iconify AND after restore. + // Renders during iconified > 0 (frame loop kept going for floaters). + // p5: alt_rmb_resize_count incremented (Alt+RMB consumed by WndProc) AND + // sizemove_enter_count incremented (Windows entered SC_SIZE modal). bool p1_pass = (g_p1_bad_sync == 0); bool p2_pass = g_p2_skipped || (g_p2_renders_during == 0 && g_p2_renders_after > 0); - bool pass = p1_pass && p2_pass; + bool p3_pass = g_p3_skipped + || (g_p3_renders_during == 0 && g_p3_renders_after > 0); + bool p4_pass = g_p4_skipped + || (g_p4_secondary_alive_before && + g_p4_secondary_alive_during && + g_p4_secondary_alive_after && + g_p4_renders_iconified > 0); + // p5: in headless test mode the framework intentionally skips posting + // SC_SIZE (to avoid the modal trapping the test thread), so we only + // assert the Alt+RMB counter incremented. The real SC_SIZE -> modal + // sizemove path is exercised by p2/p3 which fake WM_ENTERSIZEMOVE + // directly. + int p5_alt_delta = g_p5_alt_resize_count_after - g_p5_alt_resize_count_before; + int p5_sizemove_delta = g_p5_sizemove_enter_after - g_p5_sizemove_enter_before; + (void)p5_sizemove_delta; + bool p5_pass = g_p5_skipped || (p5_alt_delta >= 1); + int p6_alt_delta = g_p6_alt_move_after - g_p6_alt_move_before; + bool p6_pass = g_p6_skipped || (p6_alt_delta >= 1); + bool pass = p1_pass && p2_pass && p3_pass && p4_pass && p5_pass && p6_pass; std::fprintf(stdout, - "[altsnap_jitter] p1=%s p2=%s overall=%s\n", + "[altsnap_jitter] p1=%s p2=%s p3=%s p4=%s p5=%s p6=%s overall=%s\n", p1_pass ? "PASS" : "FAIL", g_p2_skipped ? "SKIP" : (p2_pass ? "PASS" : "FAIL"), + g_p3_skipped ? "SKIP" : (p3_pass ? "PASS" : "FAIL"), + g_p4_skipped ? "SKIP" : (p4_pass ? "PASS" : "FAIL"), + g_p5_skipped ? "SKIP" : (p5_pass ? "PASS" : "FAIL"), + g_p6_skipped ? "SKIP" : (p6_pass ? "PASS" : "FAIL"), pass ? "PASS" : "FAIL"); return pass ? 0 : 1; }