Skip to content

fix: hiddenInset frameless window on Windows#363

Open
255doesnotexist wants to merge 3 commits intoblackboardsh:mainfrom
255doesnotexist:main
Open

fix: hiddenInset frameless window on Windows#363
255doesnotexist wants to merge 3 commits intoblackboardsh:mainfrom
255doesnotexist:main

Conversation

@255doesnotexist
Copy link
Copy Markdown

@255doesnotexist 255doesnotexist commented Apr 5, 2026

problem

on windows, `titleBarStyle: "hiddenInset"` currently uses `WS_POPUP | WS_THICKFRAME`. this leaves a ~2-4px caption bar residue at the top. and another choice `titleBarStyle: "hidden"` drops the DWM shadow, while resize borders behave inconsistently.

root cause

`WS_POPUP | WS_THICKFRAME` tells the window manager to draw a thickframe without a caption, but windows still reserves a small non-client area at the top. without handling `WM_NCCALCSIZE`, that residue stays. `WS_POPUP` also disables the standard DWM frame entirely.

comparison with other frameworks

framework windows hiddenInset approach
electron uses `WS_THICKFRAME
flutter sets `WS_POPUP` and draws its own shadow/resize handles via skia
photino relies on `DwmExtendFrameIntoClientArea` with a full-glass effect
tauri/wry delegates to webview2 defaults; custom chrome is typically borderless with os_shadow disabled

this patch aligns with the electron approach: keep the system frame for shadow and resize, then surgically remove only the caption area in `WM_NCCALCSIZE`.

fix

  • switch `hiddenInset` style to `WS_CAPTION | WS_THICKFRAME`. this keeps the system frame (shadow + resize borders) while giving us a hook to remove the caption area.
  • handle `WM_NCCALCSIZE` in `WindowProc`: call `DefWindowProc`, then reset `rgrc[0].top` to the original window top. this strips the caption bar height without losing the frame.
  • send `SWP_FRAMECHANGED` after `SetWindowLongPtr` so the non-client area recalculates with the new handler active.

verification

  • built and tested in a real app (yandu): window is fully frameless, no top residue, four-edge resize works, DWM shadow is present.
  • `bun test src/shared` passes (48/48).

- Use WS_CAPTION | WS_THICKFRAME instead of WS_POPUP to preserve
  system resize borders and DWM shadow.
- Handle WM_NCCALCSIZE to remove the caption bar area while keeping
  the frame for resizing.
- Trigger SWP_FRAMECHANGED after SetWindowLongPtr so the frame
  recalculates with the new handler in place.
Copilot AI review requested due to automatic review settings April 5, 2026 01:41
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Updates Windows titleBarStyle: "hiddenInset" handling to achieve a truly frameless look while keeping standard DWM shadow and resize behavior.

Changes:

  • Adds an isHiddenInset flag to per-window state to enable conditional non-client handling.
  • Switches hiddenInset window styles to WS_CAPTION | WS_THICKFRAME and customizes WM_NCCALCSIZE to remove the caption area.
  • Forces a non-client recalculation (SWP_FRAMECHANGED) after userdata is attached so the new WM_NCCALCSIZE logic applies.

Comment on lines +4771 to +4779
case WM_NCCALCSIZE:
if (wParam == TRUE) {
WindowData* data = (WindowData*)GetWindowLongPtr(hwnd, GWLP_USERDATA);
if (data && data->isHiddenInset) {
NCCALCSIZE_PARAMS* p = (NCCALCSIZE_PARAMS*)lParam;
RECT original = p->rgrc[0];
LRESULT ret = DefWindowProc(hwnd, msg, wParam, lParam);
p->rgrc[0].top = original.top;
return ret;
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

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

WM_NCCALCSIZE: resetting rgrc[0].top to the original window top removes not only the caption height but also the top resize border (and in maximized state can push the client area into the hidden/negative frame bounds, causing clipped content at the top). Consider preserving the top resize border thickness and only removing the caption portion, and special-casing maximized windows so the client rect stays within the monitor work area.

Copilot uses AI. Check for mistakes.

case WM_NCCALCSIZE:
if (wParam == TRUE) {
WindowData* data = (WindowData*)GetWindowLongPtr(hwnd, GWLP_USERDATA);
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

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

WM_NCCALCSIZE handler re-fetches and re-declares WindowData* data, shadowing the variable already loaded at the top of WindowProc. This is redundant and makes the control flow harder to follow; reuse the existing data pointer.

Suggested change
WindowData* data = (WindowData*)GetWindowLongPtr(hwnd, GWLP_USERDATA);

Copilot uses AI. Check for mistakes.
@255doesnotexist
Copy link
Copy Markdown
Author

updated: maximized state now clamps the client area to the monitor work area instead of letting the caption bar show through.

  • normal/restored: strips the caption bar completely (behavior unchanged)
  • maximized: uses MonitorFromWindow + GetMonitorInfo to align rgrc[0].top with rcWork.top, so the webview fills the screen without clipping above the visible area.

verified in a real app: no top residue in normal mode and no caption bar leak when maximized.

@255doesnotexist
Copy link
Copy Markdown
Author

255doesnotexist commented Apr 5, 2026

remind the implementation refered in this reply is not active now. we use ChromeStyle enum instead. regarding `isHiddenInset` in `WindowData`:

it's a per-window bool flag used inside the message loop to decide whether WM_NCCALCSIZE should strip the caption bar. we don't reuse the titleBarStyle string here for two practical reasons:

  1. lifetime safety - titleBarStyle comes in as a transient const char* from the zig FFI boundary. storing it as a long-lived pointer in WindowData risks a dangling reference once the caller frees the string.
  2. performance - WM_NCCALCSIZE fires repeatedly while the user drags a resize border. a strcmp on every message is unnecessary overhead; a bool flag is zero-cost.

during window creation (createWindowWithFrameAndStyleFromWorker) we derive the flag from the incoming titleBarStyle, then throw away the string. only windows with isHiddenInset == true get the custom non-client calculation; all others fall through untouched.

moves the per window chrome style flag from a single use bool
to an explicit enum class. this matches the ts titleBarStyle type
and makes it trivial to add new styles later without adding more
booleans to WindowData.

ChromeStyle lives in shared/callbacks.h since it is cross platform
and already included by all three native wrappers.
@255doesnotexist
Copy link
Copy Markdown
Author

follow up refactor on top of the fix:

replaced the one off bool isHiddenInset in WindowData with ChromeStyle chromeStyle, an enum class with values Default, Hidden, and HiddenInset. this lines up with the typescript TitleBarStyle type and means future chrome variants do not require adding more bools to the struct.

ChromeStyle lives in shared/callbacks.h because that header is already imported by all three platform native wrappers, so the type is available everywhere without extra includes.

@255doesnotexist
Copy link
Copy Markdown
Author

this pr partially resolves #320 and #226.

#320 (reported by @Andreas-Froyland): the hiddenInset residue and broken frame behavior on Windows is addressed by keeping the system DWM frame (WS_CAPTION | WS_THICKFRAME) and stripping only the caption bar in WM_NCCALCSIZE. four edge resize, shadow, and window snapping all work. the maximize/fullscreen confusion mentioned in #226 (reported by @deeivihh) is also fixed: maximized windows now clamp to the monitor work area instead of covering the taskbar.

cc @YoavCodes for review.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants