Skip to content

Commit 45b0cbb

Browse files
committed
Let a custom maximize button bring back snap layouts (#70)
Window::SetMaximizeButtonBounds(Rectangle) takes the app's own maximize button in content-area logical coordinates (the space of ShowSystemMenu); GetMaximizeButtonBounds() and IsMaximizeButtonBoundsSupported() go with it. Windows: the window answers WM_NCHITTEST with HTMAXBUTTON over the button, so Windows 11 opens the snap layouts when the pointer rests on it; content child windows (a Flutter view) step aside there, and the resize border still wins where it overlaps. The non-client move, press, release and leave over the button are passed on to the content window under the pointer as client messages, so the app's button draws its states and handles the click itself; DefWindowProc's caption-button loop never runs. Not with NATIVEAPI_ENABLE_WINUI3. Every other platform returns false. tests/window_maximize_button_test: 22 checks on Windows (messages sent to the windows, no input); unsupported elsewhere.
1 parent 1097ca6 commit 45b0cbb

11 files changed

Lines changed: 448 additions & 1 deletion

File tree

‎src/capi/window_c.cpp‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1437,6 +1437,45 @@ bool native_window_is_system_menu_supported(void) {
14371437
}
14381438
}
14391439

1440+
bool native_window_set_maximize_button_bounds(native_window_t window, native_rectangle_t bounds) {
1441+
auto self = nativeapi::HandleTable::GetInstance().Resolve<nativeapi::Window>(window);
1442+
if (!self) {
1443+
return false;
1444+
}
1445+
try {
1446+
auto bounds_cpp = to_cpp_rectangle(bounds);
1447+
return self->SetMaximizeButtonBounds(bounds_cpp);
1448+
} catch (...) {
1449+
fprintf(stderr, "[nativeapi] %s: unexpected exception\n", "native_window_set_maximize_button_bounds");
1450+
return false;
1451+
}
1452+
}
1453+
1454+
native_rectangle_t native_window_get_maximize_button_bounds(native_window_t window) {
1455+
auto self = nativeapi::HandleTable::GetInstance().Resolve<nativeapi::Window>(window);
1456+
if (!self) {
1457+
native_rectangle_t result = {};
1458+
return result;
1459+
}
1460+
try {
1461+
const auto cpp_result = self->GetMaximizeButtonBounds();
1462+
return to_c_rectangle(cpp_result);
1463+
} catch (...) {
1464+
fprintf(stderr, "[nativeapi] %s: unexpected exception\n", "native_window_get_maximize_button_bounds");
1465+
native_rectangle_t result = {};
1466+
return result;
1467+
}
1468+
}
1469+
1470+
bool native_window_is_maximize_button_bounds_supported(void) {
1471+
try {
1472+
return nativeapi::Window::IsMaximizeButtonBoundsSupported();
1473+
} catch (...) {
1474+
fprintf(stderr, "[nativeapi] %s: unexpected exception\n", "native_window_is_maximize_button_bounds_supported");
1475+
return false;
1476+
}
1477+
}
1478+
14401479
bool native_window_perform_title_bar_double_click(native_window_t window) {
14411480
auto self = nativeapi::HandleTable::GetInstance().Resolve<nativeapi::Window>(window);
14421481
if (!self) {

‎src/capi/window_c.h‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,15 @@ bool native_window_show_system_menu(native_window_t window, native_point_t posit
473473
FFI_PLUGIN_EXPORT
474474
bool native_window_is_system_menu_supported(void);
475475

476+
FFI_PLUGIN_EXPORT
477+
bool native_window_set_maximize_button_bounds(native_window_t window, native_rectangle_t bounds);
478+
479+
FFI_PLUGIN_EXPORT
480+
native_rectangle_t native_window_get_maximize_button_bounds(native_window_t window);
481+
482+
FFI_PLUGIN_EXPORT
483+
bool native_window_is_maximize_button_bounds_supported(void);
484+
476485
FFI_PLUGIN_EXPORT
477486
bool native_window_perform_title_bar_double_click(native_window_t window);
478487

‎src/platform/android/window_android.cpp‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,18 @@ bool Window::IsSystemMenuSupported() {
464464
return false;
465465
}
466466

467+
bool Window::SetMaximizeButtonBounds(Rectangle) {
468+
return false;
469+
}
470+
471+
Rectangle Window::GetMaximizeButtonBounds() const {
472+
return {0, 0, 0, 0};
473+
}
474+
475+
bool Window::IsMaximizeButtonBoundsSupported() {
476+
return false;
477+
}
478+
467479
bool Window::PerformTitleBarDoubleClick() {
468480
return false;
469481
}

‎src/platform/ios/window_ios.mm‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,18 @@
507507
return false;
508508
}
509509

510+
bool Window::SetMaximizeButtonBounds(Rectangle) {
511+
return false;
512+
}
513+
514+
Rectangle Window::GetMaximizeButtonBounds() const {
515+
return {0, 0, 0, 0};
516+
}
517+
518+
bool Window::IsMaximizeButtonBoundsSupported() {
519+
return false;
520+
}
521+
510522
bool Window::PerformTitleBarDoubleClick() {
511523
return false;
512524
}

‎src/platform/linux/window_linux.cpp‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2242,6 +2242,18 @@ bool Window::IsSystemMenuSupported() {
22422242
return false;
22432243
}
22442244

2245+
bool Window::SetMaximizeButtonBounds(Rectangle) {
2246+
return false;
2247+
}
2248+
2249+
Rectangle Window::GetMaximizeButtonBounds() const {
2250+
return {0, 0, 0, 0};
2251+
}
2252+
2253+
bool Window::IsMaximizeButtonBoundsSupported() {
2254+
return false;
2255+
}
2256+
22452257
bool Window::PerformTitleBarDoubleClick() {
22462258
GdkWindow* window = pimpl_->gdk_window_;
22472259
if (!window || gdk_window_is_destroyed(window) || IsFullScreen() || IsMinimized() ||

‎src/platform/macos/window_macos.mm‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1229,6 +1229,18 @@ static void NativeApiSendMouseUp(NSWindow* window, NSEvent* mouse_up) {
12291229
return false;
12301230
}
12311231

1232+
bool Window::SetMaximizeButtonBounds(Rectangle) {
1233+
return false;
1234+
}
1235+
1236+
Rectangle Window::GetMaximizeButtonBounds() const {
1237+
return {0, 0, 0, 0};
1238+
}
1239+
1240+
bool Window::IsMaximizeButtonBoundsSupported() {
1241+
return false;
1242+
}
1243+
12321244
bool Window::PerformTitleBarDoubleClick() {
12331245
return NativeApiPerformTitleBarDoubleClick(pimpl_->ns_window_);
12341246
}

‎src/platform/ohos/window_ohos.cpp‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,18 @@ bool Window::IsSystemMenuSupported() {
448448
return false;
449449
}
450450

451+
bool Window::SetMaximizeButtonBounds(Rectangle) {
452+
return false;
453+
}
454+
455+
Rectangle Window::GetMaximizeButtonBounds() const {
456+
return {0, 0, 0, 0};
457+
}
458+
459+
bool Window::IsMaximizeButtonBoundsSupported() {
460+
return false;
461+
}
462+
451463
bool Window::PerformTitleBarDoubleClick() {
452464
return false;
453465
}

‎src/platform/windows/window_windows.cpp‎

Lines changed: 149 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,13 @@ static const wchar_t* kFocusReturnWindowProperty = L"NativeAPIFocusReturnWindow"
121121
// hands the foreground back to it. Distinct from the focus-policy property above,
122122
// which only a no-activate window uses and its policy change clears.
123123
static const wchar_t* kBlurReturnWindowProperty = L"NativeAPIBlurReturnWindow";
124+
// The app-drawn maximize button (MaximizeButtonArea), owned by the HWND.
125+
static const wchar_t* kMaximizeButtonProperty = L"NativeAPIMaximizeButton";
126+
struct MaximizeButtonArea {
127+
Rectangle bounds; // Content-area logical coordinates.
128+
bool tracking_leave = false; // TME_NONCLIENT | TME_LEAVE is armed.
129+
HWND hovered = nullptr; // Content window that was sent the last move.
130+
};
124131
// The translucent background color of the window, as 0x1AARRGGBB (the leading 1 tells
125132
// a transparent black from "no property"). Set while the window is see-through.
126133
static const wchar_t* kTranslucentBackgroundProperty = L"NativeAPITranslucentBackground";
@@ -379,6 +386,98 @@ static LRESULT HiddenFrameResizeHit(HWND root, LPARAM point) {
379386
return 0;
380387
}
381388

389+
// Whether a screen point (WM_NCHITTEST's lParam) is on the app-drawn maximize
390+
// button of |root|, and where in its content area, in physical pixels.
391+
static bool IsOverMaximizeButton(HWND root, LPARAM point, POINT* client = nullptr) {
392+
const auto* area = static_cast<MaximizeButtonArea*>(GetPropW(root, kMaximizeButtonProperty));
393+
if (!area) return false;
394+
POINT local = {static_cast<short>(LOWORD(point)), static_cast<short>(HIWORD(point))};
395+
if (!ScreenToClient(root, &local)) return false;
396+
const double scale = GetScaleFactorForWindow(root);
397+
const double x = local.x / scale, y = local.y / scale;
398+
const Rectangle& b = area->bounds;
399+
if (x < b.x || y < b.y || x >= b.x + b.width || y >= b.y + b.height) return false;
400+
if (client) *client = local;
401+
return true;
402+
}
403+
404+
// The innermost visible, enabled content window under a client point of |root|.
405+
static HWND ContentWindowAt(HWND root, POINT client, POINT* local) {
406+
HWND target = root;
407+
POINT at = client;
408+
for (int depth = 0; depth < 32; ++depth) {
409+
HWND child = ChildWindowFromPointEx(target, at, CWP_SKIPINVISIBLE | CWP_SKIPDISABLED |
410+
CWP_SKIPTRANSPARENT);
411+
if (!child || child == target) break;
412+
MapWindowPoints(target, child, &at, 1);
413+
target = child;
414+
}
415+
*local = at;
416+
return target;
417+
}
418+
419+
// Passes a non-client mouse message over the maximize button on to the content
420+
// as the client message it would have been, so the app's button sees it.
421+
static void ForwardToContent(HWND root, MaximizeButtonArea* area, UINT message, LPARAM point) {
422+
POINT client = {static_cast<short>(LOWORD(point)), static_cast<short>(HIWORD(point))};
423+
ScreenToClient(root, &client);
424+
POINT local = {};
425+
HWND target = ContentWindowAt(root, client, &local);
426+
WPARAM keys = 0;
427+
if (GetKeyState(VK_LBUTTON) < 0) keys |= MK_LBUTTON;
428+
if (GetKeyState(VK_RBUTTON) < 0) keys |= MK_RBUTTON;
429+
if (GetKeyState(VK_MBUTTON) < 0) keys |= MK_MBUTTON;
430+
if (GetKeyState(VK_SHIFT) < 0) keys |= MK_SHIFT;
431+
if (GetKeyState(VK_CONTROL) < 0) keys |= MK_CONTROL;
432+
if (message == WM_LBUTTONDOWN) keys |= MK_LBUTTON;
433+
if (message == WM_LBUTTONUP) keys &= ~static_cast<WPARAM>(MK_LBUTTON);
434+
area->hovered = target;
435+
SendMessageW(target, message, keys, MAKELPARAM(local.x, local.y));
436+
}
437+
438+
// The maximize button's share of the window procedure: answer the hit test,
439+
// and hand hover, press and release to the content instead of letting
440+
// DefWindowProc run its caption-button loop (it would draw a classic button
441+
// and maximize on its own).
442+
static std::optional<LRESULT> HandleMaximizeButtonArea(HWND hwnd, UINT message, WPARAM wp,
443+
LPARAM lp) {
444+
auto* area = static_cast<MaximizeButtonArea*>(GetPropW(hwnd, kMaximizeButtonProperty));
445+
if (!area) return std::nullopt;
446+
switch (message) {
447+
case WM_NCHITTEST:
448+
if (!IsOverMaximizeButton(hwnd, lp)) return std::nullopt;
449+
// A resize border overlapping the button keeps working.
450+
if (GetPropW(hwnd, kTitleBarHiddenProperty) && HiddenFrameResizeHit(hwnd, lp) != 0)
451+
return std::nullopt;
452+
return HTMAXBUTTON;
453+
case WM_NCMOUSEMOVE:
454+
if (wp != HTMAXBUTTON) break;
455+
if (!area->tracking_leave) {
456+
TRACKMOUSEEVENT track = {sizeof(track), TME_LEAVE | TME_NONCLIENT, hwnd, 0};
457+
area->tracking_leave = TrackMouseEvent(&track) != FALSE;
458+
}
459+
ForwardToContent(hwnd, area, WM_MOUSEMOVE, lp);
460+
return 0;
461+
case WM_NCMOUSELEAVE:
462+
area->tracking_leave = false;
463+
if (area->hovered && IsWindow(area->hovered)) SendMessageW(area->hovered, WM_MOUSELEAVE, 0, 0);
464+
area->hovered = nullptr;
465+
break; // DefWindowProc has its own bookkeeping for it.
466+
case WM_NCLBUTTONDOWN:
467+
case WM_NCLBUTTONDBLCLK:
468+
if (wp != HTMAXBUTTON) break;
469+
ForwardToContent(hwnd, area, WM_LBUTTONDOWN, lp);
470+
return 0;
471+
case WM_NCLBUTTONUP:
472+
if (wp != HTMAXBUTTON) break;
473+
ForwardToContent(hwnd, area, WM_LBUTTONUP, lp);
474+
return 0;
475+
default:
476+
break;
477+
}
478+
return std::nullopt;
479+
}
480+
382481
// Subclass of the child windows of a window with a hidden title bar. A child that
383482
// covers the content (a Flutter view, for one) is hit-tested before its parent;
384483
// on a resize edge it steps aside so the parent can answer it.
@@ -387,7 +486,7 @@ static LRESULT CALLBACK TopEdgeChildProc(HWND child, UINT message, WPARAM wp, LP
387486
if (message == WM_NCHITTEST) {
388487
// HTTRANSPARENT passes the hit test on to windows of the same thread only.
389488
HWND root = GetAncestor(child, GA_ROOT);
390-
if (root && HiddenFrameResizeHit(root, lp) != 0 &&
489+
if (root && (HiddenFrameResizeHit(root, lp) != 0 || IsOverMaximizeButton(root, lp)) &&
391490
GetWindowThreadProcessId(root, nullptr) == GetCurrentThreadId())
392491
return HTTRANSPARENT;
393492
} else if (message == WM_NCDESTROY) {
@@ -618,6 +717,7 @@ static LRESULT WindowLifetimeMessage(HWND hwnd, UINT message, WPARAM wp, LPARAM
618717
RemovePropW(hwnd, kNonActivatingProperty);
619718
RemovePropW(hwnd, kFocusReturnWindowProperty);
620719
RemovePropW(hwnd, kBlurReturnWindowProperty);
720+
delete static_cast<MaximizeButtonArea*>(RemovePropW(hwnd, kMaximizeButtonProperty));
621721
RemovePropW(hwnd, kVisualEffectProperty);
622722
RemovePropW(hwnd, kCornerPreferenceProperty);
623723
RemovePropW(hwnd, kBackgroundColorProperty);
@@ -652,6 +752,10 @@ static LRESULT WindowLifetimeMessage(HWND hwnd, UINT message, WPARAM wp, LPARAM
652752
return result;
653753
}
654754
#ifndef NATIVEAPI_ENABLE_WINUI3
755+
if (auto handled = HandleMaximizeButtonArea(hwnd, message, wp, lp)) return *handled;
756+
if (message == WM_PARENTNOTIFY && LOWORD(wp) == WM_CREATE &&
757+
GetPropW(hwnd, kMaximizeButtonProperty))
758+
AttachTopEdgeChild(reinterpret_cast<HWND>(lp), 0);
655759
if (message == WM_NCCALCSIZE || message == WM_NCHITTEST || message == WM_PARENTNOTIFY ||
656760
message == WM_NCACTIVATE || message == WM_NCPAINT || message == 0x00AE ||
657761
message == 0x00AF || message == WM_SETTEXT || message == WM_SETICON) {
@@ -2373,6 +2477,50 @@ bool Window::IsSystemMenuSupported() {
23732477
return true;
23742478
}
23752479

2480+
bool Window::SetMaximizeButtonBounds(Rectangle bounds) {
2481+
#ifdef NATIVEAPI_ENABLE_WINUI3
2482+
(void)bounds;
2483+
return false;
2484+
#else
2485+
HWND hwnd = pimpl_->hwnd_;
2486+
if (!hwnd || !IsWindow(hwnd) || !std::isfinite(bounds.x) || !std::isfinite(bounds.y) ||
2487+
!std::isfinite(bounds.width) || !std::isfinite(bounds.height))
2488+
return false;
2489+
auto* area = static_cast<MaximizeButtonArea*>(GetPropW(hwnd, kMaximizeButtonProperty));
2490+
if (bounds.width <= 0 || bounds.height <= 0) {
2491+
delete static_cast<MaximizeButtonArea*>(RemovePropW(hwnd, kMaximizeButtonProperty));
2492+
return true;
2493+
}
2494+
if (!area) {
2495+
area = new MaximizeButtonArea();
2496+
if (!SetPropW(hwnd, kMaximizeButtonProperty, area)) {
2497+
delete area;
2498+
return false;
2499+
}
2500+
// Content windows (a Flutter view) are hit-tested first: let them step
2501+
// aside over the button so this window can answer HTMAXBUTTON.
2502+
EnumChildWindows(hwnd, AttachTopEdgeChild, 0);
2503+
}
2504+
area->bounds = bounds;
2505+
return true;
2506+
#endif
2507+
}
2508+
2509+
Rectangle Window::GetMaximizeButtonBounds() const {
2510+
HWND hwnd = pimpl_->hwnd_;
2511+
const auto* area =
2512+
hwnd ? static_cast<MaximizeButtonArea*>(GetPropW(hwnd, kMaximizeButtonProperty)) : nullptr;
2513+
return area ? area->bounds : Rectangle{0, 0, 0, 0};
2514+
}
2515+
2516+
bool Window::IsMaximizeButtonBoundsSupported() {
2517+
#ifdef NATIVEAPI_ENABLE_WINUI3
2518+
return false;
2519+
#else
2520+
return true;
2521+
#endif
2522+
}
2523+
23762524
bool Window::PerformTitleBarDoubleClick() {
23772525
HWND hwnd = pimpl_->hwnd_;
23782526
if (!hwnd || !IsWindow(hwnd) || IsFullScreen() || IsIconic(hwnd) || !IsMaximizable())

0 commit comments

Comments
 (0)