Skip to content

fix: resize cursor behavior for frameless windows on Windows#46703

Open
hotdogee wants to merge 6 commits into
electron:mainfrom
hotdogee:fix/windows-frameless-resize-hit-test
Open

fix: resize cursor behavior for frameless windows on Windows#46703
hotdogee wants to merge 6 commits into
electron:mainfrom
hotdogee:fix/windows-frameless-resize-hit-test

Conversation

@hotdogee

@hotdogee hotdogee commented Apr 21, 2025

Copy link
Copy Markdown

Description of Change

Closes: #40505
Related: microsoft/vscode#185249

Problem Description

Currently, on Windows, frameless Electron windows (titleBarStyle: 'hidden' or frame: false) only show the resize cursor and allow resizing when the mouse cursor is exactly on the window border or slightly inside it. This differs from standard framed windows (including Electron's default framed windows) and other applications like Chrome or Edge, where the resize cursor also appears when the cursor is slightly outside the window border. This discrepancy makes resizing frameless windows less intuitive and user-friendly, grabbing the top-right resize handle is especially hard as there is only 1 pixel to grab.

This PR aligns the resizing behavior of frameless windows on Windows with that of standard windows by enabling resizing when the cursor is positioned slightly outside the left, right, and bottom edges.

Current and Expected Behavior

frameless-bug-vscode-v2

Root Cause Analysis

The issue stems from how Windows handles hit-testing for window resizing and how Electron's frameless window implementation interacts with it:

  1. Windows Hit-Testing (WM_NCHITTEST): Standard window resizing relies on Windows processing the WM_NCHITTEST message within the non-client area (the window frame). When the cursor is slightly outside the client area but within the frame's designated resize borders, Windows returns hit-test codes like HTLEFT, HTRIGHT, HTBOTTOM, etc., triggering the resize cursor and behavior.
  2. Frameless Window Non-Client Area: For frameless windows, Electron needs to explicitly define the non-client area geometry, typically during the WM_NCCALCSIZE message. The previous implementation in ElectronDesktopWindowTreeHostWin::GetClientAreaInsets only applied system-defined border thicknesses (SM_CXSIZEFRAME + SM_CXPADDEDBORDER) when the window was maximized. In the normal (non-maximized) state, no such non-client border insets were defined for the left, right, and bottom edges. Consequently, Windows did not receive the necessary geometry information to perform hit-testing just outside the client rectangle for these edges.
  3. Internal Views Hit-Testing: Separately, Electron's FramelessView performs its own hit-testing (ResizingBorderHitTestImpl using GetHTComponentForFrame). This logic used a fixed internal inset (kResizeInsideBoundsSize) to detect resize handles inside the window bounds.
  4. Usability issue at the top-right corner: A significant usability issue arose from the internal hit-testing approach, particularly in the top-right corner. The resize region defined within the window bounds (kResizeInsideBoundsSize) directly overlapped with the caption buttons (e.g., the Close button). In this overlapping zone, the button's hit-test typically took priority, preventing users from initiating a resize from the top-right corner. The previous implementation attempted to mitigate this with a 1-pixel offset for the caption buttons in WinFrameView::LayoutCaptionButtons, essentially carving out a small, dedicated space for resizing. As previously mentioned, this 1 pixel area is extreamly hard to grab for the user.

Proposed Changes

This pull request modifies the way non-client area calculations and hit-testing are handled for frameless windows on Windows:

  1. electron_desktop_window_tree_host_win.cc:

    • The primary change is in GetClientAreaInsets. Previously, insets (using GetSystemMetrics(SM_CXSIZEFRAME) + GetSystemMetrics(SM_CXPADDEDBORDER)) were only applied when the frameless window was maximized.
    • Now, when the window is not maximized, insets are applied to the left, bottom, and right sides (gfx::Insets::TLBR(0, thickness, thickness, thickness)). The top inset remains 0 to align with the frameless window behavior.
    • When the window is maximized, the previous behavior of applying insets to all sides is retained (gfx::Insets::TLBR(thickness, thickness, thickness, thickness)). This prevents the maximized window from bleeding onto other monitors.
    • These insets are used during the WM_NCCALCSIZE message processing. By defining a non-client area border around the client area (even if visually transparent), Windows' default WM_NCHITTEST processing can correctly identify HTLEFT, HTRIGHT, HTBOTTOM, etc., when the cursor is in these border regions slightly outside the client rectangle.
  2. frameless_view.cc:

    • The call to ResizingBorderHitTestImpl within ResizingBorderHitTest is modified. Instead of passing gfx::Insets(kResizeInsideBoundsSize) (which applied the inset uniformly), it now passes gfx::Insets::TLBR(kResizeInsideBoundsSize, 0, 0, 0).
    • This change adjusts the resize detection insets so that the top edge uses the traditional inside-window detection (which works well for the title bar area), while the left, bottom, and right edges get zero inset, allowing the standard Windows behavior of detecting outside the window.
  3. win_frame_view.cc:

    • Removed the 1px margin adjustment that was previously used as a workaround. With the proper inset handling in the frameless view, this margin is no longer necessary.

Testing

  1. Built Electron with these code changes on Windows 10, Windows 11, and Ubuntu 24.04.
  2. Integrated the custom Electron build into a VS Code OSS build 1.99.3.
  3. Tested resizing:
    • Moved the cursor slightly outside the left window edge. Result: Resize cursor (↔) appears, and dragging resizes the window horizontally.
    • Moved the cursor slightly outside the right window edge. Result: Resize cursor (↔) appears, and dragging resizes the window horizontally.
    • Moved the cursor slightly outside the bottom window edge. Result: Resize cursor (↕) appears, and dragging resizes the window vertically.
    • Tested corner resizing (e.g., bottom-right) slightly outside the corner. Result: Diagonal resize cursor appears, and dragging resizes correctly.
    • Tested top border resizing. Result: Behaves as before (resize cursor appears on/just inside the edge).
    • Maximized the window. Result: Resizing is correctly disabled and the maximized window extends to all sides correctly.
    • Restored the window. Result: Resizing from outside the borders works again.
    • Tested interaction with Window Controls Overlay (caption buttons): Buttons remain functional and correctly positioned.
2025-04-21.09-58-03.mp4

2025-04-21 193245

Benefits

  • Improved user experience that matches standard Windows application behavior
  • Consistent resizing behavior between framed and frameless windows
  • No negative impact on other platforms as these changes only affect Windows-specific code

Checklist

Release Notes

Notes: Aligned the resizing behavior of frameless windows with system standards to improve usability on Windows.

@welcome

welcome Bot commented Apr 21, 2025

Copy link
Copy Markdown

💖 Thanks for opening this pull request! 💖

Semantic PR titles

We use semantic commit messages to streamline the release process. Before your pull request can be merged, you should update your pull request title to start with a semantic prefix.

Examples of commit messages with semantic prefixes:

  • fix: don't overwrite prevent_default if default wasn't prevented
  • feat: add app.isPackaged() method
  • docs: app.isDefaultProtocolClient is now available on Linux

Commit signing

This repo enforces commit signatures for all incoming PRs.
To sign your commits, see GitHub's documentation on Telling Git about your signing key.

PR tips

Things that will help get your PR across the finish line:

  • Follow the JavaScript, C++, and Python coding style.
  • Run npm run lint locally to catch formatting errors earlier.
  • Document any user-facing changes you've made following the documentation styleguide.
  • Include tests when adding/changing behavior.
  • Include screenshots and animated GIFs whenever possible.

We get a lot of pull requests on this repo, so please be patient and we will get back to you as soon as we can.

@electron-cation electron-cation Bot added the new-pr 🌱 PR opened recently label Apr 21, 2025
@codebytere codebytere self-requested a review April 21, 2025 13:51
@hotdogee hotdogee changed the title fix: Fix resize cursor behavior for frameless windows on Windows fix: resize cursor behavior for frameless windows on Windows Apr 21, 2025
@hotdogee hotdogee force-pushed the fix/windows-frameless-resize-hit-test branch from a82cf89 to 34269d0 Compare April 21, 2025 14:47
This aligns the resizing behavior of frameless windows with system
standards and improves usability.

On Windows, frameless windows (titleBarStyle: 'hidden' or frame: false)
currently only trigger resize cursors and actions when the mouse is
exactly on or slightly inside the border. This differs from standard
framed windows and other applications where resizing is also possible
when the cursor is slightly outside the border, leading to a less
intuitive user experience.

This commit modifies ElectronDesktopWindowTreeHostWin::GetClientAreaInsets
to apply standard system border insets (SM_CXSIZEFRAME + SM_CXPADDEDBORDER)
to the left, right, and bottom of the non-client area via WM_NCCALCSIZE
when the window is not maximized. This allows the default Windows message
handling (WM_NCHITTEST) to correctly identify resize regions (HTLEFT,
HTRIGHT, HTBOTTOM, etc.) in the area just outside the client rectangle.

Internal Views hit-testing in FramelessView::ResizingBorderHitTest is
also adjusted to prevent conflicts with the OS-level hit-testing for
these outer borders. The caption button layout in WinFrameView is
slightly adjusted for cleaner alignment.

Fixes electron#40505.

Related to microsoft/vscode#185249.

Signed-off-by: Han Lin <hotdogee@gmail.com>
@hotdogee hotdogee force-pushed the fix/windows-frameless-resize-hit-test branch from 34269d0 to f6872f7 Compare April 21, 2025 14:48

@felixrieseberg felixrieseberg left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a really good PR, we can put this one up on the fridge.

I appreciate your detailed explanation and the simplicity of the code. I think we can mark this one up as a pure bugfix, I don't think it needs to be semver/minor.

@VerteDinde VerteDinde added the semver/patch backwards-compatible bug fixes label Apr 21, 2025

@codebytere codebytere left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice work! There's a small lint issue but once that's addressed we can get this moving.

@electron-cation electron-cation Bot removed the new-pr 🌱 PR opened recently label Apr 22, 2025
Wrap the custom non-client insets and hit‑test adjustments in OS_WIN
guards so that Ubuntu and macOS still use the original inside‑bounds
resize logic. Other platforms (Linux/macOS) will continue to apply
the full kResizeInsideBoundsSize inset, avoiding any unintended UX
changes outside of Windows.
@hotdogee

Copy link
Copy Markdown
Author

Thank you for your feedback! I’ve updated the PR to fix the regression on non‑Windows platforms—my original Ubuntu testing was flawed and has now been corrected. The linting errors have also been resolved.
image

@codebytere codebytere added target/34-x-y PR should also be added to the "34-x-y" branch. target/35-x-y PR should also be added to the "35-x-y" branch. target/36-x-y PR should also be added to the "36-x-y" branch. labels Apr 23, 2025
@codebytere

Copy link
Copy Markdown
Member

@hotdogee it looks like some of the failing tests might be relevant - can you validate those locally?

@ckerr

ckerr commented Apr 25, 2025

Copy link
Copy Markdown
Member

The failing tests look relevant to this PR:

not ok 194 BrowserWindow module sizing BrowserWindow.setContentSize(width, height) works for a frameless window
  expected [ 440, 559 ] to deeply equal [ 456, 567 ]
  AssertionError: expected [ 440, 559 ] to deeply equal [ 456, 567 ]
      at Context.<anonymous> (electron\spec\api-browser-window-spec.ts:1705:31)
ok 195 BrowserWindow module sizing BrowserWindow.setContentBounds(bounds) sets the content size and position
not ok 196 BrowserWindow module sizing BrowserWindow.setContentBounds(bounds) works for a frameless window
  expected { x: 18, y: 10, width: 234, height: 242 } to deeply equal { x: 10, y: 10, width: 250, height: 250 }
  AssertionError: expected { x: 18, y: 10, width: 234, height: 242 } to deeply equal { x: 10, y: 10, width: 250, height: 250 }
      at expectBoundsEqual (electron\spec\api-browser-window-spec.ts:38:28)
      at Context.<anonymous> (electron\spec\api-browser-window-spec.ts:1731:9)

@ckerr ckerr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The failing tests need to be resolved, but LGTM in principle

This resolves test failures caused by the updated resize hit areas
for frameless windows. The changes in GetClientAreaInsets() modified
how content and window sizes relate to each other by adding
system border insets to the non-client area.

- Updates ContentBoundsToWindowBounds and WindowBoundsToContentBounds
  to properly account for these insets
- Only applies insets in GetClientAreaInsets for resizable windows
- Modifies useContentSize handling for frameless windows on Windows
- Updates test expectations for Windows platform tests

Fixes failing tests while maintaining the improved resize experience.
@hotdogee

Copy link
Copy Markdown
Author

Thanks for the feedback on the failing tests! 🙏
I've updated the pull request to fix failing tests related to BrowserWindow.setContentSize and BrowserWindow.setContentBounds for frameless windows on Windows.

Changes Made:

  1. Size Conversion: I've updated NativeWindowViews::ContentBoundsToWindowBounds and WindowBoundsToContentBounds. Now, specifically for frameless, resizable, non-maximized windows on Windows, these functions correctly add/subtract the standard border thickness (SM_CXSIZEFRAME + SM_CXPADDEDBORDER) during conversion.
  2. Resizable Check: I also added a check in ElectronDesktopWindowTreeHostWin::GetClientAreaInsets so that these non-client borders are only added if the frameless window is actually IsResizable(). Non-resizable windows shouldn't get the resize borders.
  3. Test Updates: I've updated the tests in api-browser-window-spec.ts that were failing. They now correctly expect that on Windows, the overall window size (getSize()) will be larger than the content size (getContentSize()) for frameless/hidden title bar windows due to these borders. The expected values reflect the standard border thickness.

These updates resolve the test failures by ensuring accurate bounds calculations on Windows, while maintaining the expected behavior on Linux and macOS.

Please let me know if additional tweaks are needed 🙂

@github-actions github-actions Bot added the target/37-x-y PR should also be added to the "37-x-y" branch. label Apr 29, 2025
@hotdogee hotdogee requested a review from ckerr May 3, 2025 21:06
@github-actions github-actions Bot added the target/38-x-y PR should also be added to the "38-x-y" branch. label Jun 24, 2025
@github-actions github-actions Bot added the target/39-x-y PR should also be added to the "39-x-y" branch. label Sep 3, 2025
const size = w.getSize();
expect(size).to.deep.equal([400, 400]);
if (process.platform === 'win32') {
expect(size).to.deep.equal([416, 408]);

@mitchchn mitchchn Oct 22, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@hotdogee this PR caught my eye as I'm looking into similar issues with decorations and resize insets on Linux.

I think it's important for Electron to preserve logical bounds: if a caller sets window or content bounds, they should get back the same values back that they set. (And the window/content should visually have the correct measurements, even if there are invisible dimensions that extend further out.) Can you think of a way to keep the resize handle math hidden from the public APIs?

@jkleinsc jkleinsc removed the target/34-x-y PR should also be added to the "34-x-y" branch. label Oct 23, 2025
@jkleinsc jkleinsc removed the target/35-x-y PR should also be added to the "35-x-y" branch. label Oct 23, 2025
@github-actions github-actions Bot added the target/40-x-y PR should also be added to the "40-x-y" branch. label Oct 28, 2025
@github-actions github-actions Bot added the target/41-x-y PR should also be added to the "41-x-y" branch. label Jan 19, 2026
@codebytere

Copy link
Copy Markdown
Member

@hotdogee do you wish to pursue this?

@f1multiviewer

Copy link
Copy Markdown

We're experiencing the same issue, and although we're not able to confirm this PR fixes our issue (as our app is dependent on castLabs' electron), we're patiently awaiting the release of this fix. Thank you for all the effort thus far 🙏

@github-actions github-actions Bot added the target/42-x-y PR should also be added to the "42-x-y" branch. label Mar 13, 2026
@mitchchn

mitchchn commented Apr 6, 2026

Copy link
Copy Markdown
Member

FYI @hotdogee I took another shot at solving the problem using some of the new abstractions for frameless windows and CSD: #50706. Thanks so much for your work on this one, it helped inform those abstractions and this new attempt.

@github-actions github-actions Bot added the target/43-x-y PR should also be added to the "43-x-y" branch. label May 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

semver/patch backwards-compatible bug fixes target/36-x-y PR should also be added to the "36-x-y" branch. target/37-x-y PR should also be added to the "37-x-y" branch. target/38-x-y PR should also be added to the "38-x-y" branch. target/39-x-y PR should also be added to the "39-x-y" branch. target/40-x-y PR should also be added to the "40-x-y" branch. target/41-x-y PR should also be added to the "41-x-y" branch. target/42-x-y PR should also be added to the "42-x-y" branch. target/43-x-y PR should also be added to the "43-x-y" branch.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants