Hello Omar,
On the frame where the lock starts, nobody owns the wheel yet. Your previous commit 02ccd9f34 does lock the mouse, but might miss the first frame (when lock starts).
See related issues: #2604, #3795 See related commit: 02ccd9f34
As a consequence, an item the scroll brings under the mouse can steal the wheel on the first frame, and read the same wheel event that scrolled the window.
This PR sets the owner on the very first frame, right after LockWheelingWindow().
Repro: keep the mouse still a few lines above the button, then use the wheel slowly. The window scrolls, and zoom changes on the same notch. With a trackpad or slow wheel steps, it happens too: the lock lapses between small events.
https://github.com/user-attachments/assets/aae20f47-7bdf-4dbd-b80d-391efcd73d4e
Repro code extract (see full repro below)
ImGui::Begin("Scroll me");
for (int i = 0; i < 20; i++)
ImGui::Text("Line %d", i);
static float zoom = 1.0f;
ImGui::Button("Zoomable widget", ImVec2(-FLT_MIN, 300));
if (ImGui::SetItemKeyOwner(ImGuiKey_MouseWheelY) && ImGui::GetIO().MouseWheel != 0.0f)
zoom *= powf(1.1f, ImGui::GetIO().MouseWheel);
ImGui::Text("zoom = %.2f", zoom);
for (int i = 20; i < 60; i++)
ImGui::Text("Line %d", i);
ImGui::End();The full test program (click to expand)
// A/B repro of the wheel's first notch: a widget that the scroll brings under the still mouse reads the notch that
// scrolled the window. Built twice: on upstream master (A), and with the lock owning the wheel at once (B).
// Run: ./wheel_ab_A (by hand: put the mouse just above the zoomable widget, give one notch down)
// WHEEL_AB_AUTO=1 ./wheel_ab_A (plays two cases, prints the results, saves a picture, exits)
#define GL_SILENCE_DEPRECATION
#include "imgui.h"
#include "imgui_impl_glfw.h"
#include "imgui_impl_opengl3.h"
#include "implot.h"
#include "imgui_internal.h" // the wheel lock's state, shown to make the repro by hand reliable
#include <GLFW/glfw3.h>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <string>
#include <vector>
#ifndef WHEEL_AB_LABEL
#define WHEEL_AB_LABEL "?"
#endif
struct ReproState
{
float zoom = 1.f;
int notchesTaken = 0; // the notches the widget read
int notchesSent = 0;
float scrollY = 0.f;
ImVec2 widgetMin, widgetMax;
bool resetScroll = false;
bool injectNotch = false; // the N key: one notch, as a wheel mouse gives (a trackpad's first event is tiny)
float lastWheel = 0.f;
double plotXMin = 0., plotXMax = 0.;
};
// The bug zone above an item: one scroll step (five lines), where a still mouse sees the first notch bring the item
// under it
void DrawBugZone(ImVec2 itemMin, ImVec2 itemMax)
{
ImDrawList* dl = ImGui::GetWindowDrawList();
float step = 5.f * ImGui::GetFontSize();
dl->AddRectFilled(ImVec2(itemMin.x, itemMin.y - step), ImVec2(itemMax.x, itemMin.y), IM_COL32(230, 140, 40, 60));
dl->AddText(ImVec2(itemMax.x - 230.f, itemMin.y - ImGui::GetFontSize() - 4.f), IM_COL32(240, 170, 80, 255),
"the mouse here, still, then N");
}
// The window to scroll, and in it a widget that zooms with the wheel, with the docs' idiom (SetItemKeyOwner)
void ShowRepro(ReproState& s)
{
ImGuiIO& io = ImGui::GetIO();
if (io.MouseWheel != 0.f) // a notch received, by hand or by the auto mode
s.notchesSent++, s.lastWheel = io.MouseWheel;
if (ImGui::IsKeyPressed(ImGuiKey_N, false))
s.injectNotch = true;
ImGui::SetNextWindowPos(ImVec2(20, 20), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(420, 380), ImGuiCond_Always);
ImGui::Begin("Scroll me");
if (s.resetScroll)
{
ImGui::SetScrollY(0.f);
s.resetScroll = false;
}
for (int i = 0; i < 18; i++)
ImGui::Text("Line %d", i);
ImVec2 p = ImGui::GetCursorScreenPos();
ImGui::InvisibleButton("zoomable", ImVec2(-FLT_MIN, 350));
bool taken = false;
if (ImGui::SetItemKeyOwner(ImGuiKey_MouseWheelY) && io.MouseWheel != 0.f) // hovered: the wheel is this widget's
{
s.zoom *= powf(1.1f, io.MouseWheel);
s.notchesTaken++;
taken = true;
}
s.widgetMin = ImGui::GetItemRectMin();
s.widgetMax = ImGui::GetItemRectMax();
ImDrawList* dl = ImGui::GetWindowDrawList();
dl->AddRectFilled(s.widgetMin, s.widgetMax, taken || s.notchesTaken > 0 ? IM_COL32(170, 60, 60, 255) : IM_COL32(60, 90, 150, 255));
char label[96];
snprintf(label, sizeof(label), "Zoomable widget (the wheel zooms it)\nzoom: %.2f", s.zoom);
dl->PushClipRect(s.widgetMin, s.widgetMax, true);
dl->AddText(ImGui::GetFont(), ImGui::GetFontSize() * s.zoom, ImVec2(p.x + 10, p.y + 10), IM_COL32_WHITE, label);
dl->PopClipRect();
DrawBugZone(s.widgetMin, s.widgetMax);
for (int i = 18; i < 30; i++)
ImGui::Text("Line %d", i);
// An ImPlot plot: the wheel zooms it (the bundle's fork, which reads the wheel only when no one else owns it)
if (ImPlot::BeginPlot("Plot (the wheel zooms it)", ImVec2(-FLT_MIN, 350)))
{
static float xs[100], ys[100];
for (int i = 0; i < 100; i++)
xs[i] = i * 0.1f, ys[i] = sinf(xs[i]);
ImPlot::PlotLine("sin", xs, ys, 100);
ImPlotRect limits = ImPlot::GetPlotLimits();
s.plotXMin = limits.X.Min, s.plotXMax = limits.X.Max;
ImPlot::EndPlot();
}
DrawBugZone(ImGui::GetItemRectMin(), ImGui::GetItemRectMax());
for (int i = 30; i < 50; i++)
ImGui::Text("Line %d", i);
s.scrollY = ImGui::GetScrollY();
ImGui::End();
ImGui::SetNextWindowPos(ImVec2(460, 20), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(320, 250), ImGuiCond_Always);
ImGui::Begin("Result");
ImGui::Text("Build: %s", WHEEL_AB_LABEL);
ImGui::Text("Wheel notches received: %d", s.notchesSent);
ImGui::Text("Page scroll: %.0f", s.scrollY);
ImGui::Text("Notches read by the widget: %d", s.notchesTaken);
ImGui::Text("Plot's x range: %.2f .. %.2f", s.plotXMin, s.plotXMax);
ImGui::Text("Last wheel amount: %.2f", s.lastWheel);
// The conditions of the bug, for a try by hand: no lock yet (the notch starts one), the mouse in the zone
ImGuiContext& g = *GImGui;
bool locked = g.WheelingWindow != nullptr;
ImVec2 m = io.MousePos;
float step = 5.f * ImGui::GetFontSize();
bool inZone = m.x >= s.widgetMin.x && m.x <= s.widgetMax.x && m.y >= s.widgetMin.y - step && m.y < s.widgetMin.y;
ImVec4 green(0.4f, 0.9f, 0.4f, 1.f), red(1.f, 0.45f, 0.4f, 1.f);
if (locked)
ImGui::TextColored(red, "Wheel lock: on \"%s\" (%.1f s left)", g.WheelingWindow->Name, g.WheelingWindowReleaseTimer);
else
ImGui::TextColored(green, "Wheel lock: none");
ImGui::TextColored(inZone ? green : red, "Mouse in the widget's zone: %s", inZone ? "yes" : "no");
ImGui::TextWrapped("By hand: when both lines above are green, press N (one notch, as a wheel mouse), or turn the "
"wheel slowly: small spaced steps let the lock lapse, and each new lock starts with the gap.");
if (ImGui::Button("Reset")) // the counts and the zoom, and the page back to the top: to redo the test
s.zoom = 1.f, s.notchesTaken = 0, s.notchesSent = 0, s.resetScroll = true;
ImGui::End();
}
// The framebuffer as a PPM file (converted to PNG afterwards with sips)
void SaveFramebuffer(const char* path, int w, int h)
{
std::vector<unsigned char> pixels((size_t)w * h * 3);
glPixelStorei(GL_PACK_ALIGNMENT, 1);
glReadPixels(0, 0, w, h, GL_RGB, GL_UNSIGNED_BYTE, pixels.data());
FILE* f = fopen(path, "wb");
if (!f)
return;
fprintf(f, "P6\n%d %d\n255\n", w, h);
for (int y = h - 1; y >= 0; y--)
fwrite(&pixels[(size_t)y * w * 3], 1, (size_t)w * 3, f);
fclose(f);
}
int main()
{
if (!glfwInit())
return 1;
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 2);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
std::string title = std::string("Wheel A/B: ") + WHEEL_AB_LABEL;
GLFWwindow* window = glfwCreateWindow(800, 400, title.c_str(), nullptr, nullptr);
if (!window)
return 1;
glfwMakeContextCurrent(window);
glfwSwapInterval(1);
IMGUI_CHECKVERSION();
ImGui::CreateContext();
ImPlot::CreateContext();
ImGuiIO& io = ImGui::GetIO();
io.IniFilename = nullptr;
ImGui::StyleColorsDark();
ImGui_ImplGlfw_InitForOpenGL(window, true);
ImGui_ImplOpenGL3_Init("#version 150");
const bool autoMode = getenv("WHEEL_AB_AUTO") != nullptr;
// Record mode: case 1, frame by frame, each frame piped to ffmpeg (an mp4 for the pull request)
const char* recordPath = getenv("WHEEL_AB_RECORD");
FILE* recordPipe = nullptr;
int notchFrame = -1;
if (recordPath)
io.MouseDrawCursor = true; // the capture does not show the system's cursor
ReproState s;
int frame = 0;
double phaseStart = 0.;
int phase = 0;
ImVec2 forcedMouse(-FLT_MAX, -FLT_MAX); // auto mode: the mouse, held still where the case puts it
while (!glfwWindowShouldClose(window))
{
glfwPollEvents();
ImGui_ImplOpenGL3_NewFrame();
ImGui_ImplGlfw_NewFrame();
if (recordPath)
{
// Frame by frame, at 60 fps: the page at rest; the mouse slides into the zone (1.5 s to 2.5 s), stays still;
// one notch (3 s); the result, until 10 s
ImVec2 above((s.widgetMin.x + s.widgetMax.x) * 0.5f, s.widgetMin.y - 30.f);
if (frame >= 90 && frame <= 150)
{
float k = (frame - 90) / 60.f;
k = k * k * (3.f - 2.f * k); // smoothstep
ImVec2 start(620.f, 300.f); // over the Result window
forcedMouse = ImVec2(start.x + (above.x - start.x) * k, start.y + (above.y - start.y) * k);
}
// The position before the notch: after the backend's real position, so that the notch, deferred by the
// input queue after a move, is processed with the mouse in the zone
if (forcedMouse.x != -FLT_MAX)
io.AddMousePosEvent(forcedMouse.x, forcedMouse.y);
if (frame == 180)
{
io.AddMouseWheelEvent(0.f, -1.f);
notchFrame = frame;
}
if (frame == 600)
glfwSetWindowShouldClose(window, 1);
}
if (s.injectNotch)
{
io.AddMouseWheelEvent(0.f, -1.f);
s.injectNotch = false;
}
if (autoMode)
{
// After the backend's update, which sends the real mouse position: ours comes last, at every frame
double t = glfwGetTime();
ImVec2 above((s.widgetMin.x + s.widgetMax.x) * 0.5f, s.widgetMin.y - 30.f);
ImVec2 on((s.widgetMin.x + s.widgetMax.x) * 0.5f, s.widgetMin.y + 30.f); // its top: in the window's view
bool notch = false;
if (phase == 0 && frame == 30) // case 1: the mouse still, 30 px above the widget
forcedMouse = above, phase = 1, phaseStart = t;
else if (phase == 1 && t - phaseStart > 0.3) // one notch down
notch = true, phase = 2, phaseStart = t;
else if (phase == 2 && t - phaseStart > 0.3)
{
printf("[%s] case 1, the mouse still above the widget, one notch: page scroll %.0f, notches read by the widget %d\n",
WHEEL_AB_LABEL, s.scrollY, s.notchesTaken);
phase = 3, phaseStart = t;
}
else if (phase == 3 && t - phaseStart > 0.5) // the picture of case 1, taken after this frame's render
phase = 4;
else if (phase == 5) // reset the counts (the page stays scrolled, the widget in view), wait for the lock to expire (0.7 s)
{
s.zoom = 1.f, s.notchesTaken = 0, s.notchesSent = 0;
phase = 6, phaseStart = t;
}
else if (phase == 6 && t - phaseStart > 1.2) // case 2: the mouse on the widget before the notch
forcedMouse = on, phase = 7, phaseStart = t;
else if (phase == 7 && t - phaseStart > 0.3)
notch = true, phase = 8, phaseStart = t;
else if (phase == 8 && t - phaseStart > 0.3)
{
printf("[%s] case 2, the mouse on the widget, one notch: page scroll %.0f, notches read by the widget %d\n",
WHEEL_AB_LABEL, s.scrollY, s.notchesTaken);
glfwSetWindowShouldClose(window, 1);
}
if (forcedMouse.x != -FLT_MAX)
io.AddMousePosEvent(forcedMouse.x, forcedMouse.y);
if (notch)
io.AddMouseWheelEvent(0.f, -1.f);
}
ImGui::NewFrame();
ShowRepro(s);
if (recordPath && notchFrame >= 0 && frame - notchFrame < 90) // the moment of the notch
ImGui::GetForegroundDrawList()->AddText(ImGui::GetFont(), ImGui::GetFontSize() * 1.4f,
ImVec2(io.MousePos.x + 22, io.MousePos.y - 8), IM_COL32(255, 220, 90, 255),
"one wheel notch");
ImGui::Render();
int w, h;
glfwGetFramebufferSize(window, &w, &h);
glViewport(0, 0, w, h);
glClearColor(0.1f, 0.1f, 0.12f, 1.f);
glClear(GL_COLOR_BUFFER_BIT);
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
if (recordPath)
{
if (!recordPipe)
{
char cmd[512];
snprintf(cmd, sizeof(cmd), "ffmpeg -v error -y -f rawvideo -pix_fmt rgb24 -s %dx%d -r 60 -i - -vf vflip "
"-c:v libx264 -pix_fmt yuv420p -crf 20 '%s'", w, h, recordPath);
recordPipe = popen(cmd, "w");
}
std::vector<unsigned char> pixels((size_t)w * h * 3);
glPixelStorei(GL_PACK_ALIGNMENT, 1);
glReadPixels(0, 0, w, h, GL_RGB, GL_UNSIGNED_BYTE, pixels.data());
fwrite(pixels.data(), 1, pixels.size(), recordPipe);
}
if (phase == 4)
{
std::string path = std::string("wheel_ab_") + WHEEL_AB_LABEL + ".ppm";
SaveFramebuffer(path.c_str(), w, h);
phase = 5;
}
glfwSwapBuffers(window);
frame++;
}
ImGui_ImplOpenGL3_Shutdown();
ImGui_ImplGlfw_Shutdown();
if (recordPipe)
pclose(recordPipe);
ImPlot::DestroyContext();
ImGui::DestroyContext();
glfwDestroyWindow(window);
glfwTerminate();
return 0;
}