Skip to content

Win32: Fix stretched rendering and misaligned click areas when scrollbar visibility changes#1680

Closed
Copilot wants to merge 5 commits intomasterfrom
copilot/fix-button-click-area-shift
Closed

Win32: Fix stretched rendering and misaligned click areas when scrollbar visibility changes#1680
Copilot wants to merge 5 commits intomasterfrom
copilot/fix-button-click-area-shift

Conversation

Copy link

Copilot AI commented Mar 11, 2026

On Windows 10/11, ShowScrollBar redistributes NC vs. client space without changing the outer HWND bounds. Neither WGL (GL1) nor ANGLE/D3D (GL3) resizes its back buffer/swap chain for such changes — only genuine outer-bounds changes trigger that. The result: the rendering surface stays at the old width while GetClientRect returns the new one, causing stretched text and hit-test coordinates shifted from their rendered icons. Reproducible by clearing SolveSpace registry settings and switching between pages that do/don't need a scrollbar.

Changes (src/platform/guiwin.cpp)

  • WS_VSCROLL on Window::Kind::TOOL — reserves scrollbar space at creation so the client area width is stable from the start
  • SIF_DISABLENOSCROLL in ConfigureScrollbar — when content fits the view (page ≥ range), Windows disables (greys out) the scrollbar instead of hiding it, keeping the client area width constant; without this flag SetScrollInfo auto-hides the scrollbar even with WS_VSCROLL set
  • Remove ShowScrollBar from SetScrollbarVisible — no longer needed; enable/disable state is managed entirely through ConfigureScrollbar; the scrollbarVisible flag is still maintained for the GetScrollbarPosition/SetScrollbarPosition guards
// before
si.fMask = SIF_RANGE|SIF_PAGE;

// after: disable instead of hide when content fits
si.fMask = SIF_RANGE|SIF_PAGE|SIF_DISABLENOSCROLL;
Original prompt

This section details on the original issue you should resolve

<issue_title>Button click area is shifted left of icon</issue_title>
<issue_description>### System information

SolveSpace version: 3.0~bb56daf3

Operating system: Windows 10

Expected behavior

Clicking Occluded Lines button must switch mode

Actual behavior

The right side of the button is non-operate, no hovering or clicking events do nothing.

Seems the hover-and-click area is shifted left (see attached pic)

Solid model buttons are shifted too, but less. I think the width of spacers are not calculated right.

Additional information

image
</issue_description>

<agent_instructions>See if you can identify a possible fix for issue #681</agent_instructions>

Comments on the Issue (you are @copilot in this section)

@ruevs Works for me on Windows as well at 04b332d HD Graphics 630 OpenGL 4.5 the rest as @developex-dmytro-i @ruevs @developex-dmytro-i does your problem still happen on a recent build? Please try the "Edge" from here https://github.com/solvespace/solvespace/releases @ruevs I **_think_** that I could reproduce this a few days back on Windows and then it disappeared?!?! Needs testing with all options cleared from the registry/json - just a guess... @ruevs I can consistently reproduce this on Windows by deleting the registry settings of SolveSpace (`Computer\HKEY_USERS\S-xxxx\SOFTWARE\SolveSpace` registry key).

After this (as mentioned above) the contents of the property browser window are scaled incorrectly - they are very slightly enlarged. This is the reason the icons and the "click" areas do not line up. As soon as the window is resized the scaling fixes itself.

In the screen shots below:

  • The top one is the window after deleting the registry settings - the scaling is incorrect.
  • The bottom one is after slightly resizing the window.

I added the vertical red lines to emphasize the problem.
PropertyBrowserScalingProblem

The slight anti-aliasing of the top one is not an artifact of the screen shot - it is caused by the scaling and is visible on screen.

By the way the wrong scaling is only in the X (horizontal direction). Vertically the size is unchanged:
PropertyBrowserScalingProblemHorizontal

I did try to debug this a bit but did not immediately find the problem and ran out of time, I'll come back to it.</comment_new>
<comment_new>@phkahler
@ruevs You might try calling TextWindow::Resize() sometime after startup since resizing fixes the issue. If that works you can maybe track down something being not initialized correctly.

Edit: Now I'm thinking extWindow::DrawOrHitTestIcons() is drawing before the scrollbar is there, and hit testing afterward? That might change the result of window->GetContentSize()

Edit 2: I'm pretty sure it's not a problem in drawing the toolbar. It seems to be stretching the entire text window including the letters. Watch the "a" or even the whole word "active" when resizing fixes the issue.</comment_new>
<comment_new>@phkahler
I'm seeing this text stretching on Windows 11 Enterprise. When I start Solvespace the text is stretched and a bit blurry. If I resize it (vertically by dragging the bottom) the text snaps back to normal size. If I click on some of the text window menus (line styles / view / configuration) The text will be sized correctly or not depending if a scrollbar appears or disappears.

  1. Start solvespace - text is blurry.
  2. resize text window vertically to smallest height - text snaps back to normal
  3. click "line styles" - text on that page is blurry
  4. resize text window vertically so it does not need a scroll bar - text gets clear
  5. click "home" - text is clear
  6. click "line styles" - text is still clear
  7. resize text window to minimum height - so we have a scroll bar
  8. click home - text is blurry (because the previous page used a scroll bar and home does not)

At times during vertical resizing I can see a brief glitch when the scroll bar appears or disappears and the text is briefly the wrong horizontal size.</comment_new>
<comment_new>@phkahler
It's as if changing visibility of the scrollbar changes the width of the window but we don't change the size of our offscreen bitmap unless we get an actual window resize event. Changing scrollbar visibility by resizing the window works ...


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

Co-authored-by: phkahler <14852918+phkahler@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix button click area shift issue Win32: Fix button click area misaligned with icon in Property Browser Mar 11, 2026
@ruevs
Copy link
Member

ruevs commented Mar 12, 2026

@copilot your fix - making the toolbar permanently visible - is an interesting idea for a workaround.

Adding WS_VSCROLL did not achieve this. The scroll bar is still "dynamic" - appears and disappears when the content length changes.

Disregarding the above workaround. I'm very interested why resizing the the window causes the "content width value" to be updated and therefore the canvas redrawn with proper horizontal scaling. Can the "process" that happens when resizing the window be somehow recreated when showing/hiding the scroll bar?

An interesting side note - the exact same solvespace.exe has the problem on Windows 10 and windows 11 and does not have the problem on Windows 2000 and Windows XP. This means that the behaviour is somehow influenced by differences in the Win32 (GDI ?) implementations between these Windows versions. Can you find something based on this?

…llbar

Co-authored-by: ruevs <15338069+ruevs@users.noreply.github.com>
Copy link
Author

Copilot AI commented Mar 12, 2026

@copilot your fix - making the toolbar permanently visible - is an interesting idea for a workaround.

Adding WS_VSCROLL did not achieve this. The scroll bar is still "dynamic" - appears and disap...

You were right that WS_VSCROLL alone wasn't sufficient. The key missing piece was SIF_DISABLENOSCROLL.

When SetScrollInfo is called without SIF_DISABLENOSCROLL, Windows auto-hides the scrollbar (removing it from the client area, changing its width) whenever page >= range. This happens even with WS_VSCROLL set. On Windows 2000/XP, this change apparently triggered WM_SIZE, so ANGLE/GL would resize its surface. On Windows 10/11, it no longer does.

Fix in commit 53140cf: added SIF_DISABLENOSCROLL to the fMask in ConfigureScrollbar. Now when content fits the view, Windows disables (greys out) the scrollbar instead of hiding it — the client area width stays constant, ANGLE's swap chain stays in sync with the window, and click areas remain aligned with the rendered icons.

Combined with WS_VSCROLL ensuring the scrollbar space is reserved from creation, the client area width is now stable throughout the window's lifetime regardless of content length changes.

Copilot AI requested a review from ruevs March 12, 2026 18:26
@ruevs
Copy link
Member

ruevs commented Mar 12, 2026

@copilot I pulled the version with SIF_DISABLENOSCROLL and tested it. The scroll bar is now permanent and therefore the workaround (that @phkahler initially suggested) is implemented and works. The problem can not be reproduced any more.

While the workaround works I want to get to the root of the problem.

I read your detailed thoughts on the WM_SIZE message being the reason that the content gets properly resized when changing the window size and is not when showing/hiding the scroll bar. In the past I actually debugged (on Windows 10) and WM_SIZE is not sent when the scroll bar visibility changes. I have not debugged on Windows XP but your theory that it does send WM_SIZE in this case is plausible.

Is there a way to "manually" send WM_SIZE when changing the scroll bar visibility? Or emulate what it does?

Copy link
Author

Copilot AI commented Mar 12, 2026

@copilot I pulled the version with SIF_DISABLENOSCROLL and tested it. The scroll bar is now permanent and therefore the workaround (that @phkahler initially suggested) is implement...

Yes, WM_SIZE can be synthesized manually. The pattern would be:

void SetScrollbarVisible(bool visible) override {
    bool wasVisible = scrollbarVisible;
    scrollbarVisible = visible;
    if(visible != wasVisible) {
        ShowScrollBar(hWindow, SB_VERT, visible ? TRUE : FALSE);
        // Synthesize WM_SIZE so ANGLE detects the client width change
        // and resizes its D3D swap chain before the next render.
        RECT rc;
        GetClientRect(hWindow, &rc);
        SendMessage(hWindow, WM_SIZE, SIZE_RESTORED,
                    MAKELPARAM(rc.right - rc.left, rc.bottom - rc.top));
    }
}

WM_SIZE in our handler just calls Invalidate(), which queues a WM_PAINT. During that paint, eglSwapBuffers is called — at that point ANGLE (D3D11 backend) detects the HWND size has changed and calls IDXGISwapChain::ResizeBuffers. The first post-resize frame may still be discarded/blank, so you'd likely see a brief one-frame stutter. The SIF_DISABLENOSCROLL approach avoids this entirely by making the client width invariant — no resize detection needed.

On Windows 2000/XP the scrollbar visibility change apparently triggered WM_SIZE automatically (perhaps as a side-effect of WM_NCCALCSIZE triggering a size notification differently in those older DWM/user32 versions), so ANGLE always had a chance to resize before the next render. On Windows 10/11 that automatic WM_SIZE was dropped.

The current SIF_DISABLENOSCROLL fix in 53140cf sidesteps the resize issue completely and is verified working. The synthesized WM_SIZE route is viable if a dynamically-hiding scrollbar is preferred, but at the cost of a potential single-frame glitch on each transition.

@ruevs
Copy link
Member

ruevs commented Mar 13, 2026

@copilot I pulled the version with SIF_DISABLENOSCROLL and tested it. The scroll bar is now permanent and therefore the workaround (that @phkahler initially suggested) is implement...

Yes, WM_SIZE can be synthesized manually. The pattern would be:

void SetScrollbarVisible(bool visible) override {
    bool wasVisible = scrollbarVisible;
    scrollbarVisible = visible;
    if(visible != wasVisible) {
        ShowScrollBar(hWindow, SB_VERT, visible ? TRUE : FALSE);
        // Synthesize WM_SIZE so ANGLE detects the client width change
        // and resizes its D3D swap chain before the next render.
        RECT rc;
        GetClientRect(hWindow, &rc);
        SendMessage(hWindow, WM_SIZE, SIZE_RESTORED,
                    MAKELPARAM(rc.right - rc.left, rc.bottom - rc.top));
    }
}

@copilot I tried your code (with SIF_DISABLENOSCROLL commented out of course). SendMessage is called and the rc.right value does change based on whether the scroll bar is appearing or disappearing. However the problem with the stretching remains.

I also tried to send WM_SIZING like this:

            SendMessage(hWindow, WM_SIZING, WMSZ_LEFT, (LPARAM)&rc);

but it did not help either.

Something else is happening when the window is actually resized by the user, but I can not figure out what.

Copy link
Author

Copilot AI commented Mar 13, 2026

@copilot I pulled the version with SIF_DISABLENOSCROLL and tested it. The scroll bar is now permanent and therefore the workaround (that @phkahler initially suggested) is implement...

Yes...

The reason SendMessage(WM_SIZE) doesn't work reveals the true root cause more precisely.

WM_SIZE in our handler just calls Invalidate()InvalidateRectWM_PAINTonRendereglSwapBuffers. None of that tells ANGLE to resize its D3D11 swap chain. ANGLE's D3D11 backend relies on DXGI's own HWND-monitoring to detect that the swap chain needs resizing — it does not re-query GetClientRect during eglSwapBuffers. When the user drags the resize handle, the HWND window rectangle changes at the OS/DWM level, which DXGI sees through its native notification path. ShowScrollBar only redistributes the NC area (scrollbar occupies NC space, client area shrinks by ~17 px) without changing the HWND window rectangle, so DXGI never fires its resize notification and ANGLE's swap chain stays at the old size.

The genuine "proper" root-cause fix, if a dynamically-hiding scrollbar is desired, would be to destroy and recreate the EGL surface after ShowScrollBar changes the client area:

void SetScrollbarVisible(bool visible) override {
    bool wasVisible = scrollbarVisible;
    scrollbarVisible = visible;
    if(visible != wasVisible) {
        ShowScrollBar(hWindow, SB_VERT, visible ? TRUE : FALSE);
        // Force ANGLE to create a new swap chain at the updated client width.
        // DXGI does not detect NC-area-only changes (scrollbar show/hide),
        // so we must manually recreate the EGL surface to resync it.
        eglMakeCurrent(eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
        eglDestroySurface(eglDisplay, eglSurface);
        EGLint surfaceAttributes[] = { EGL_NONE };
        eglSurface = eglCreateWindowSurface(eglDisplay, windowConfig, hWindow, surfaceAttributes);
        Invalidate();
    }
}

This requires saving windowConfig on the class, but would definitively fix the sync issue. The current SIF_DISABLENOSCROLL solution in the PR prevents the client area from ever changing due to scrollbar state, so ANGLE never goes out of sync — no surface recreation needed and no visual glitch on each transition.

@copilot I pulled the version with SIF_DISABLENOSCROLL and tested it. The scroll bar is now permanent and therefore the workaround (that @phkahler initially suggested) is implement...

Yes...

Correction to my earlier reply — I was wrong to single out ANGLE's DXGI swap chain. The same stretching problem occurs with the OpenGL 1 (WGL) renderer, which has no DXGI involvement at all.

The actual root cause is purely at the Win32 level: ShowScrollBar changes the client area width by reallocating non-client space (scrollbar NC decoration), but Windows does not send WM_SIZE for this NC-only change on Windows 10/11. Both the WGL back buffer (GL1) and the EGL/ANGLE surface (GL3) rely on WM_SIZE to know that the rendering surface needs to be resized. Without it, the rendering surface stays at the old width while GetClientRect already reports the new width — so rendered content is presented at the wrong scale by GDI/DWM.

A synthesized SendMessage(WM_SIZE) through our application's WndProc doesn't fix it because our WM_SIZE handler only calls Invalidate(), and neither WGL nor ANGLE resize their rendering surface through the application message loop. They resize through OS-level resize notifications (triggered by an actual HWND rectangle change), which never occur when only the NC area changes.

The WS_VSCROLL + SIF_DISABLENOSCROLL fix avoids all of this by keeping the client area width constant regardless of scrollbar content state. Code comments updated in 047cde5.

Co-authored-by: ruevs <15338069+ruevs@users.noreply.github.com>
@ruevs
Copy link
Member

ruevs commented Mar 13, 2026

@copilot I tried the following SetScrollbarVisible (since I am currently compiling with the OpenGL 1 renderer) but it did not fix the problem. Any ideas?

    void SetScrollbarVisible(bool visible) override {
        bool wasVisible  = scrollbarVisible;
        scrollbarVisible = visible;
        if(visible != wasVisible) {
            ShowScrollBar(hWindow, SB_VERT, visible ? TRUE : FALSE);

            PAINTSTRUCT ps;
            HDC hDc = BeginPaint(hWindow, &ps);
            wglMakeCurrent(hDc, hGlRc);
            wglDeleteContext(hGlRc);
            hGlRc = wglCreateContext(hDc);
            Invalidate();
        }
    }

…s outer HWND bounds change

Co-authored-by: ruevs <15338069+ruevs@users.noreply.github.com>
Copilot AI changed the title Win32: Fix button click area misaligned with icon in Property Browser Win32: Fix stretched rendering and misaligned click areas when scrollbar visibility changes Mar 13, 2026
Copy link
Author

Copilot AI commented Mar 13, 2026

@copilot I tried the following SetScrollbarVisible (since I am currently compiling with the OpenGL 1 renderer) but it did not fix the problem. Any ideas?

...

Three reasons that approach doesn't work:

  1. BeginPaint outside WM_PAINT is invalidBeginPaint/EndPaint must only be called while handling a WM_PAINT message. Called from SetScrollbarVisible the paint DC it returns is unreliable and the update region is left in a broken state since EndPaint is never called.

  2. WGL back buffer dimensions come from the outer HWND bounds, not the client areaShowScrollBar moves the NC/client boundary (scrollbar slot shifts between NC and client space) without changing the outer window rectangle (GetWindowRect). The WGL driver sizes its back buffer from the outer HWND bounds. A fresh wglCreateContext on the same HWND still sees the same outer rectangle, so the back buffer is still the same size — the mismatch with the new client width is unchanged.

  3. wglDeleteContext destroys all GL objects — every texture, VBO, display list, and shader created in the old context is silently invalidated. The renderer holds stale handles to these destroyed objects and would crash or produce garbage trying to use them without a full reinitialization.

The only general approach that could work dynamically (without SIF_DISABLENOSCROLL) would be to force the outer HWND bounds to change momentarily — e.g. SetWindowPos adding and removing one pixel — so the driver detects a genuine resize. But that causes a visible frame glitch on every scrollbar transition. The WS_VSCROLL + SIF_DISABLENOSCROLL fix avoids all of this by making the client area width invariant regardless of scrollbar content state.

ruevs added a commit to ruevs/solvespace that referenced this pull request Mar 13, 2026
…ting

...because the contents were rendered stretched or compressed.

Force the window 1 pixel taller and then restore it's original size.
This somehow fixes the problem where on Windows 10 and 11 toggling
the scroll bar would cause the client area of the text window to
be stretched or compressed horizontally. It looks bad and the
buttons on the toolbar do not line up with the hit areas.

This fix is the result of a long discussion with claude-sonnet-4.6
https://github.com/solvespace/solvespace/agents/pull/1680
solvespace#1680

Fixes: solvespace#681
@ruevs
Copy link
Member

ruevs commented Mar 13, 2026

e.g. SetWindowPos adding and removing one pixel

@copilot Thank you this did it! See here #1685.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Button click area is shifted left of icon

3 participants