Skip to content

Commit 373526f

Browse files
committed
Let hidden-title-bar windows on Windows reach the top edge
Without WS_CAPTION a resizable window keeps its sizing frame, and the top part of it is painted as a band above the content. Handle WM_NCCALCSIZE to give that band to the client area, and keep the top edge resizable: the window answers HTTOP / HTTOPLEFT / HTTOPRIGHT in a band as thick as the side frame, and its child windows (a Flutter view) return HTTRANSPARENT there so the hit test reaches it. A maximized captionless window is sized to the whole monitor; fit its client area to the work area so the content no longer runs under the taskbar or stops short on the right. The hidden state now lives in an HWND property, so GetTitleBarStyle() agrees across wrappers of the same window.
1 parent 3bad016 commit 373526f

2 files changed

Lines changed: 115 additions & 8 deletions

File tree

‎src/platform/windows/window_windows.cpp‎

Lines changed: 109 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
#include <commctrl.h>
77
#include <cmath>
88
#include <iostream>
9+
#include <optional>
910
#include "../../foundation/id_allocator.h"
1011
#include "../../window.h"
1112
#include "../../window_manager.h"
@@ -20,17 +21,111 @@ namespace nativeapi {
2021

2122
// Property name for storing window ID in HWND
2223
static const wchar_t* kWindowIdProperty = L"NativeAPIWindowId";
24+
// Set while the title bar is hidden. Kept on the HWND, like the style itself, so every
25+
// wrapper of the window agrees and the frame handling below outlives any one wrapper.
26+
static const wchar_t* kTitleBarHiddenProperty = L"NativeAPITitleBarHidden";
27+
28+
#ifndef NATIVEAPI_ENABLE_WINUI3
29+
// A window with WS_THICKFRAME but no WS_CAPTION keeps its sizing frame on all sides.
30+
// The left, right and bottom parts are invisible, but the top part is painted as a
31+
// band above the content. That band is given to the client area (WM_NCCALCSIZE), and
32+
// the top edge stays resizable through hit testing: the window answers HTTOP there,
33+
// and child windows covering the content let the hit test through to it.
34+
35+
// The resize hit code for a screen point in the top band of `root`, or 0.
36+
static LRESULT TopResizeHit(HWND root, LPARAM point) {
37+
if (!GetPropW(root, kTitleBarHiddenProperty) || IsZoomed(root) ||
38+
!(GetWindowLongPtrW(root, GWL_STYLE) & WS_THICKFRAME))
39+
return 0;
40+
RECT window;
41+
GetWindowRect(root, &window);
42+
// The sizing frame is as thick at the top as on the left, where it is still in place.
43+
POINT client_origin = {0, 0};
44+
ClientToScreen(root, &client_origin);
45+
const int frame = client_origin.x - window.left;
46+
// GET_X_LPARAM / GET_Y_LPARAM, without <windowsx.h>: its IsMaximized() and
47+
// IsMinimized() macros would rename Window's methods of the same name.
48+
const int x = static_cast<short>(LOWORD(point));
49+
const int y = static_cast<short>(HIWORD(point));
50+
if (y < window.top || y >= window.top + frame || x < window.left || x >= window.right)
51+
return 0;
52+
if (x < window.left + frame) return HTTOPLEFT;
53+
if (x >= window.right - frame) return HTTOPRIGHT;
54+
return HTTOP;
55+
}
56+
57+
// Subclass of the child windows of a window with a hidden title bar. A child that
58+
// covers the content (a Flutter view, for one) is hit-tested before its parent;
59+
// in the top resize band it steps aside so the parent can answer HTTOP.
60+
static LRESULT CALLBACK TopEdgeChildProc(HWND child, UINT message, WPARAM wp, LPARAM lp,
61+
UINT_PTR subclass_id, DWORD_PTR) {
62+
if (message == WM_NCHITTEST) {
63+
// HTTRANSPARENT passes the hit test on to windows of the same thread only.
64+
HWND root = GetAncestor(child, GA_ROOT);
65+
if (root && TopResizeHit(root, lp) != 0 &&
66+
GetWindowThreadProcessId(root, nullptr) == GetCurrentThreadId())
67+
return HTTRANSPARENT;
68+
} else if (message == WM_NCDESTROY) {
69+
RemoveWindowSubclass(child, TopEdgeChildProc, subclass_id);
70+
}
71+
return DefSubclassProc(child, message, wp, lp);
72+
}
73+
74+
static BOOL CALLBACK AttachTopEdgeChild(HWND child, LPARAM) {
75+
// Fails for windows of other threads, which cannot be subclassed; that is fine.
76+
SetWindowSubclass(child, TopEdgeChildProc, 1, 0);
77+
return TRUE;
78+
}
79+
80+
static std::optional<LRESULT> HandleHiddenTitleBarFrame(HWND hwnd, UINT message, WPARAM wp,
81+
LPARAM lp) {
82+
if (message == WM_PARENTNOTIFY && LOWORD(wp) == WM_CREATE) {
83+
if (GetPropW(hwnd, kTitleBarHiddenProperty)) AttachTopEdgeChild(reinterpret_cast<HWND>(lp), 0);
84+
return std::nullopt;
85+
}
86+
if (!GetPropW(hwnd, kTitleBarHiddenProperty)) return std::nullopt;
87+
if (message == WM_NCCALCSIZE && wp) {
88+
auto* params = reinterpret_cast<NCCALCSIZE_PARAMS*>(lp);
89+
const LONG top = params->rgrc[0].top;
90+
const LRESULT result = DefSubclassProc(hwnd, message, wp, lp);
91+
if (!IsZoomed(hwnd)) {
92+
params->rgrc[0].top = top;
93+
return result;
94+
}
95+
// Without a caption, maximizing sizes the whole window to the monitor, so the
96+
// content would run under the taskbar and stop short on the right. Fit it to
97+
// the work area instead.
98+
MONITORINFO monitor = {sizeof(monitor)};
99+
if (GetMonitorInfoW(MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST), &monitor))
100+
params->rgrc[0] = monitor.rcWork;
101+
return result;
102+
}
103+
if (message == WM_NCHITTEST) {
104+
const LRESULT hit = DefSubclassProc(hwnd, message, wp, lp);
105+
if (hit != HTCLIENT) return hit;
106+
const LRESULT top = TopResizeHit(hwnd, lp);
107+
return top != 0 ? top : hit;
108+
}
109+
return std::nullopt;
110+
}
111+
#endif
23112

24113
// Registry entries follow the HWND lifetime, not any one C++ wrapper.
25114
static LRESULT CALLBACK WindowLifetimeProc(HWND hwnd, UINT message, WPARAM wp, LPARAM lp,
26115
UINT_PTR subclass_id, DWORD_PTR reference) {
27116
if (message == WM_NCDESTROY) {
28117
RemoveWindowSubclass(hwnd, WindowLifetimeProc, subclass_id);
29118
RemovePropW(hwnd, kWindowIdProperty);
119+
RemovePropW(hwnd, kTitleBarHiddenProperty);
30120
const auto result = DefSubclassProc(hwnd, message, wp, lp);
31121
WindowRegistry::GetInstance().Remove(static_cast<WindowId>(reference));
32122
return result;
33123
}
124+
#ifndef NATIVEAPI_ENABLE_WINUI3
125+
if (message == WM_NCCALCSIZE || message == WM_NCHITTEST || message == WM_PARENTNOTIFY) {
126+
if (auto handled = HandleHiddenTitleBarFrame(hwnd, message, wp, lp)) return *handled;
127+
}
128+
#endif
34129
return DefSubclassProc(hwnd, message, wp, lp);
35130
}
36131
static void TrackWindowLifetime(HWND hwnd, WindowId id) {
@@ -44,13 +139,9 @@ static LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM l
44139
class Window::Impl {
45140
public:
46141
Impl(HWND hwnd, WindowId id)
47-
: hwnd_(hwnd),
48-
window_id_(id),
49-
title_bar_style_(TitleBarStyle::Normal),
50-
visual_effect_(VisualEffect::None) {}
142+
: hwnd_(hwnd), window_id_(id), visual_effect_(VisualEffect::None) {}
51143
HWND hwnd_;
52144
WindowId window_id_;
53-
TitleBarStyle title_bar_style_;
54145
VisualEffect visual_effect_;
55146
Size min_size_{0, 0};
56147
Size max_size_{0, 0};
@@ -963,7 +1054,16 @@ void Window::SetTitleBarStyle(TitleBarStyle style) {
9631054
else flags |= WS_CAPTION;
9641055
SetWindowLongPtrW(pimpl_->hwnd_, GWL_STYLE, flags);
9651056
#endif
966-
pimpl_->title_bar_style_ = style;
1057+
// Read by WM_NCCALCSIZE during the frame change below.
1058+
if (style == TitleBarStyle::Hidden) {
1059+
SetPropW(pimpl_->hwnd_, kTitleBarHiddenProperty, reinterpret_cast<HANDLE>(1));
1060+
#ifndef NATIVEAPI_ENABLE_WINUI3
1061+
// Existing children; later ones are attached as they are created (WM_PARENTNOTIFY).
1062+
EnumChildWindows(pimpl_->hwnd_, AttachTopEdgeChild, 0);
1063+
#endif
1064+
} else {
1065+
RemovePropW(pimpl_->hwnd_, kTitleBarHiddenProperty);
1066+
}
9671067

9681068
// Get current window rect
9691069
RECT rect;
@@ -980,7 +1080,9 @@ void Window::SetTitleBarStyle(TitleBarStyle style) {
9801080
}
9811081

9821082
TitleBarStyle Window::GetTitleBarStyle() const {
983-
return pimpl_->title_bar_style_;
1083+
if (pimpl_->hwnd_ && GetPropW(pimpl_->hwnd_, kTitleBarHiddenProperty))
1084+
return TitleBarStyle::Hidden;
1085+
return TitleBarStyle::Normal;
9841086
}
9851087

9861088
void Window::SetHasShadow(bool has_shadow) {

‎src/window.h‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ typedef IdAllocator::IdType WindowId;
2626
* title text and window control buttons (minimize, maximize, close).
2727
*
2828
* @note Platform behavior may vary:
29-
* - Windows: Hidden style removes the title bar but may retain window borders
29+
* - Windows: Hidden style removes the title bar; the resize border stays on the sides and bottom
3030
* - macOS: Hidden style creates a borderless window with transparent title bar
3131
* - Linux: Hidden style removes window decorations entirely
3232
*/
@@ -47,6 +47,11 @@ enum class TitleBarStyle {
4747
* - macOS: the content extends under a transparent title bar; the
4848
* window buttons stay. The system is kept from moving the window, even
4949
* while IsMovable() is true.
50+
* - Windows: the content reaches the top edge of the window; the resize
51+
* border stays on the other sides. A band as thick as that border along
52+
* the top of the content still resizes the window, also over child
53+
* windows of the same thread (such as a Flutter view), so content there
54+
* does not receive the mouse.
5055
*
5156
* Switching between styles keeps the window's frame (position and outer
5257
* size); the content area grows or shrinks by the title bar instead.

0 commit comments

Comments
 (0)