Skip to content

Commit 144fca5

Browse files
committed
Honor title-bar double-click preferences in custom chrome (#48)
Hidden macOS title bars restore the background double-click action.
1 parent fd1750f commit 144fca5

14 files changed

Lines changed: 560 additions & 1 deletion

‎src/capi/window_c.cpp‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1390,6 +1390,19 @@ bool native_window_is_system_menu_supported(void) {
13901390
}
13911391
}
13921392

1393+
bool native_window_perform_title_bar_double_click(native_window_t window) {
1394+
auto self = nativeapi::HandleTable::GetInstance().Resolve<nativeapi::Window>(window);
1395+
if (!self) {
1396+
return false;
1397+
}
1398+
try {
1399+
return self->PerformTitleBarDoubleClick();
1400+
} catch (...) {
1401+
fprintf(stderr, "[nativeapi] %s: unexpected exception\n", "native_window_perform_title_bar_double_click");
1402+
return false;
1403+
}
1404+
}
1405+
13931406
void native_window_start_dragging(native_window_t window) {
13941407
auto self = nativeapi::HandleTable::GetInstance().Resolve<nativeapi::Window>(window);
13951408
if (!self) {

‎src/capi/window_c.h‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,9 @@ bool native_window_show_system_menu(native_window_t window, native_point_t posit
426426
FFI_PLUGIN_EXPORT
427427
bool native_window_is_system_menu_supported(void);
428428

429+
FFI_PLUGIN_EXPORT
430+
bool native_window_perform_title_bar_double_click(native_window_t window);
431+
429432
FFI_PLUGIN_EXPORT
430433
void native_window_start_dragging(native_window_t window);
431434

‎src/platform/android/window_android.cpp‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,10 @@ bool Window::IsSystemMenuSupported() {
454454
return false;
455455
}
456456

457+
bool Window::PerformTitleBarDoubleClick() {
458+
return false;
459+
}
460+
457461
void Window::StartDragging() {
458462
ALOGW("StartDragging not supported on Android");
459463
}

‎src/platform/ios/window_ios.mm‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,10 @@
497497
return false;
498498
}
499499

500+
bool Window::PerformTitleBarDoubleClick() {
501+
return false;
502+
}
503+
500504
void Window::StartDragging() {
501505
// Not applicable to iOS
502506
}

‎src/platform/linux/window_linux.cpp‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1919,6 +1919,15 @@ bool Window::IsSystemMenuSupported() {
19191919
return false;
19201920
}
19211921

1922+
bool Window::PerformTitleBarDoubleClick() {
1923+
GdkWindow* window = pimpl_->gdk_window_;
1924+
if (!window || gdk_window_is_destroyed(window) || IsFullScreen() || IsMinimized() ||
1925+
!IsMaximizable()) return false;
1926+
if (IsMaximized()) Unmaximize();
1927+
else Maximize();
1928+
return true;
1929+
}
1930+
19221931
void Window::StartDragging() {
19231932
if (!pimpl_->gdk_window_) {
19241933
return;

‎src/platform/macos/window_macos.mm‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,91 @@ static void NativeApiUpdateWindowMovable(NSWindow* window) {
184184
[window setMovable:requested && !NativeApiWindowIsTitleBarHidden(window)];
185185
}
186186

187+
static bool NativeApiPerformTitleBarDoubleClick(NSWindow* window) {
188+
if (!window) return false;
189+
NSString* action = [NSUserDefaults.standardUserDefaults stringForKey:@"AppleActionOnDoubleClick"];
190+
if ([action isEqualToString:@"None"]) return true;
191+
if ((window.styleMask & NSWindowStyleMaskFullScreen) || window.isMiniaturized) return false;
192+
if ([action isEqualToString:@"Minimize"]) {
193+
if (!(window.styleMask & NSWindowStyleMaskMiniaturizable)) return false;
194+
[window miniaturize:nil];
195+
return true;
196+
}
197+
if ([action isEqualToString:@"Fill"]) {
198+
if (!(window.styleMask & NSWindowStyleMaskResizable)) return false;
199+
// AppKit exposes no public Fill action. Ask for the native action only
200+
// when this OS provides it, rather than substituting standard-state zoom.
201+
SEL selector = NSSelectorFromString(@"_zoomFill:");
202+
if (![window respondsToSelector:selector]) return false;
203+
using Action = void (*)(id, SEL, id);
204+
reinterpret_cast<Action>([window methodForSelector:selector])(window, selector, nil);
205+
return true;
206+
}
207+
if (!action || [action isEqualToString:@"Maximize"] || [action isEqualToString:@"Zoom"]) {
208+
if (!(window.styleMask & NSWindowStyleMaskResizable)) return false;
209+
// Use the action itself so custom chrome does not require a native zoom box.
210+
[window zoom:nil];
211+
return true;
212+
}
213+
return false;
214+
}
215+
216+
// A plain hidden window still has a conventional title-bar band. Only empty
217+
// backgrounds inherit its double-click action: controls and views that handle
218+
// mouse presses/releases own their gestures (Flutter uses the explicit action).
219+
static bool NativeApiIsEmptyTitleBarBackground(NSWindow* window, NSEvent* event) {
220+
if (!NativeApiWindowIsTitleBarHidden(window) || event.window != window ||
221+
event.type != NSEventTypeLeftMouseUp || event.clickCount != 2) return false;
222+
NSView* content = window.contentView;
223+
if (!content) return false;
224+
const NSWindowStyleMask mask = (window.styleMask | NSWindowStyleMaskTitled) &
225+
~NSWindowStyleMaskFullSizeContentView;
226+
const NSRect frame = NSMakeRect(0, 0, window.frame.size.width, window.frame.size.height);
227+
const CGFloat height = NSHeight(frame) - NSHeight([NSWindow contentRectForFrameRect:frame styleMask:mask]);
228+
const NSPoint point = [content convertPoint:event.locationInWindow fromView:nil];
229+
const NSRect band = NSMakeRect(NSMinX(content.bounds),
230+
content.isFlipped ? NSMinY(content.bounds) : NSMaxY(content.bounds) - height,
231+
NSWidth(content.bounds), height);
232+
if (!NSPointInRect(point, band)) return false;
233+
const NSPoint hit_point = content.superview
234+
? [content.superview convertPoint:event.locationInWindow fromView:nil] : event.locationInWindow;
235+
NSView* target = [content hitTest:hit_point];
236+
if (!target) return false;
237+
const IMP default_down = class_getMethodImplementation([NSView class], @selector(mouseDown:));
238+
const IMP default_up = class_getMethodImplementation([NSView class], @selector(mouseUp:));
239+
for (NSView* view = target; view; view = view.superview) {
240+
if ([view isKindOfClass:[NSControl class]] ||
241+
class_getMethodImplementation(object_getClass(view), @selector(mouseDown:)) != default_down ||
242+
class_getMethodImplementation(object_getClass(view), @selector(mouseUp:)) != default_up)
243+
return false;
244+
if (view == content) return true;
245+
}
246+
return false;
247+
}
248+
249+
// Preserve the live runtime class (including AppKit's KVO bookkeeping). The
250+
// associated hidden flag makes this override inert on other class instances.
251+
static void NativeApiInstallTitleBarDoubleClick(NSWindow* window) {
252+
static const void* installed_key = &installed_key;
253+
Class cls = object_getClass(window);
254+
if (objc_getAssociatedObject(cls, installed_key)) return;
255+
SEL selector = @selector(sendEvent:);
256+
Method method = class_getInstanceMethod(cls, selector);
257+
auto original = reinterpret_cast<void (*)(id, SEL, NSEvent*)>(method_getImplementation(method));
258+
// A host with its own window-level event dispatch owns the gesture as well.
259+
const IMP implementation = method_getImplementation(method);
260+
if (implementation != class_getMethodImplementation([NSWindow class], selector) &&
261+
implementation != class_getMethodImplementation([NSPanel class], selector)) return;
262+
IMP replacement = imp_implementationWithBlock(^(NSWindow* instance, NSEvent* event) {
263+
if (NativeApiIsEmptyTitleBarBackground(instance, event) &&
264+
NativeApiPerformTitleBarDoubleClick(instance)) return;
265+
original(instance, selector, event);
266+
});
267+
if (!class_addMethod(cls, selector, replacement, method_getTypeEncoding(method)))
268+
class_replaceMethod(cls, selector, replacement, method_getTypeEncoding(method));
269+
objc_setAssociatedObject(cls, installed_key, @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
270+
}
271+
187272
static BOOL NativeApiWindowIsFocusable(NSWindow* window) {
188273
NSNumber* value = objc_getAssociatedObject(window, kWindowFocusableKey);
189274
return value ? [value boolValue] : YES;
@@ -258,6 +343,7 @@ static void NativeApiUpdateWindowClass(NSWindow* window, bool non_activating) {
258343
// helper window away as soon as the user clicks into another app.
259344
panel.hidesOnDeactivate = NO;
260345
panel.becomesKeyOnlyIfNeeded = NO;
346+
if (NativeApiWindowIsTitleBarHidden(window)) NativeApiInstallTitleBarDoubleClick(window);
261347
return;
262348
}
263349
if (NativeApiWindowIsNonActivating(window)) {
@@ -268,6 +354,7 @@ static void NativeApiUpdateWindowClass(NSWindow* window, bool non_activating) {
268354
(NativeApiWindowIsTitleBarHidden(window) && objc_getAssociatedObject(window, kWindowShapeFrameKey))) {
269355
NativeApiInstallFocusOverride(window);
270356
}
357+
if (NativeApiWindowIsTitleBarHidden(window)) NativeApiInstallTitleBarDoubleClick(window);
271358
}
272359

273360
#include "window_shadow_macos.h"
@@ -1072,6 +1159,10 @@ static void NativeApiSendMouseUp(NSWindow* window, NSEvent* mouse_up) {
10721159
return false;
10731160
}
10741161

1162+
bool Window::PerformTitleBarDoubleClick() {
1163+
return NativeApiPerformTitleBarDoubleClick(pimpl_->ns_window_);
1164+
}
1165+
10751166
void Window::StartDragging() {
10761167
NSWindow* window = pimpl_->ns_window_;
10771168
NSEvent* event = window.currentEvent;

‎src/platform/ohos/window_ohos.cpp‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,10 @@ bool Window::IsSystemMenuSupported() {
438438
return false;
439439
}
440440

441+
bool Window::PerformTitleBarDoubleClick() {
442+
return false;
443+
}
444+
441445
void Window::StartDragging() {
442446
// StartDragging not supported on OpenHarmony
443447
}

‎src/platform/windows/window_windows.cpp‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2194,6 +2194,14 @@ bool Window::IsSystemMenuSupported() {
21942194
return true;
21952195
}
21962196

2197+
bool Window::PerformTitleBarDoubleClick() {
2198+
HWND hwnd = pimpl_->hwnd_;
2199+
if (!hwnd || !IsWindow(hwnd) || IsFullScreen() || IsIconic(hwnd) || !IsMaximizable())
2200+
return false;
2201+
SendMessageW(hwnd, WM_SYSCOMMAND, IsZoomed(hwnd) ? SC_RESTORE : SC_MAXIMIZE, 0);
2202+
return true;
2203+
}
2204+
21972205
void Window::StartDragging() {
21982206
if (pimpl_->hwnd_) {
21992207
StartSystemFrameDrag(pimpl_->hwnd_, HTCAPTION);

‎src/window.h‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,10 @@ enum class TitleBarStyle {
4949
*
5050
* The content owns the area where the title bar was: dragging there does
5151
* not move the window. Move it from custom chrome with
52-
* Window::StartDragging() (or a WindowDragSession).
52+
* Window::StartDragging() (or a WindowDragSession). Handle a double-click
53+
* in that custom chrome with Window::PerformTitleBarDoubleClick(). On macOS,
54+
* empty backgrounds in the original title-bar band automatically perform that
55+
* action; controls, custom mouse handlers and host window dispatchers keep it.
5356
* - macOS: the content extends under a title bar that is transparent and
5457
* empty; the window buttons are hidden. The system is kept from moving
5558
* the window, even while IsMovable() is true. To keep the buttons over
@@ -1391,6 +1394,35 @@ class Window : public NativeObjectProvider, public std::enable_shared_from_this<
13911394
*/
13921395
static bool IsSystemMenuSupported();
13931396

1397+
/**
1398+
* @brief Performs the double-click action for a custom title bar.
1399+
* @return true if an action was requested, or the macOS preference explicitly
1400+
* disables it; false if unsupported, unavailable or rejected.
1401+
*
1402+
* Call from your custom title bar's double-click handler. Hidden title bars
1403+
* leave hit testing to the application. On macOS, empty backgrounds within the
1404+
* original title-bar band also invoke this action automatically; controls and
1405+
* custom mouse handlers and host window event dispatchers are excluded.
1406+
* The macOS preference is read on each call, so changes take effect immediately.
1407+
* A successful request does not guarantee the window manager applies it.
1408+
*
1409+
* @note Platform availability:
1410+
* - macOS: ✅ Uses the user's AppleActionOnDoubleClick preference: Maximize
1411+
* (or unset) zooms, Minimize miniaturizes, None deliberately does nothing.
1412+
* Legacy Zoom is also accepted. Fill requests the OS native Fill action when
1413+
* available; this currently requires a runtime-checked private AppKit action.
1414+
* Unsupported or unknown actions return false.
1415+
* - Windows: ✅ Toggles maximize/restore using the native system command.
1416+
* - Linux: ⚠️ Requests maximize/unmaximize; the window manager may ignore it.
1417+
* - Android: ❌ Not applicable - Always returns false
1418+
* - iOS: ❌ Not applicable - Always returns false
1419+
* - OpenHarmony: ❌ Not applicable - Always returns false
1420+
*
1421+
* Full-screen and minimized windows reject state changes. Disabled maximize or
1422+
* minimize controls reject their corresponding action.
1423+
*/
1424+
bool PerformTitleBarDoubleClick();
1425+
13941426
/**
13951427
* @brief Initiates a user drag operation for moving the window.
13961428
*

‎tests/CMakeLists.txt‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ add_test(NAME window_shadow_blur_test COMMAND window_shadow_blur_test)
3535

3636
if(APPLE AND NOT CMAKE_SYSTEM_NAME STREQUAL "iOS")
3737
enable_language(OBJCXX)
38+
# Explicit guarded GUI integration; no automatic CTest registration.
39+
add_executable(window_title_bar_double_click_gui_macos_test window_title_bar_double_click_gui_macos_test.mm)
40+
target_link_libraries(window_title_bar_double_click_gui_macos_test PRIVATE nativeapi)
41+
add_executable(window_title_bar_double_click_macos_test window_title_bar_double_click_macos_test.mm)
42+
target_link_libraries(window_title_bar_double_click_macos_test PRIVATE nativeapi)
43+
add_test(NAME window_title_bar_double_click_macos_test COMMAND window_title_bar_double_click_macos_test)
3844
add_executable(window_mouse_forwarding_macos_test window_mouse_forwarding_macos_test.mm)
3945
target_link_libraries(window_mouse_forwarding_macos_test PRIVATE nativeapi)
4046
add_test(NAME window_mouse_forwarding_macos_test COMMAND window_mouse_forwarding_macos_test)
@@ -127,6 +133,8 @@ if(WIN32 AND NATIVEAPI_ENABLE_WINUI3)
127133
endif()
128134

129135
if(WIN32)
136+
add_executable(window_title_bar_double_click_windows_test window_title_bar_double_click_windows_test.cpp)
137+
target_link_libraries(window_title_bar_double_click_windows_test PRIVATE nativeapi)
130138
add_executable(window_mouse_forwarding_windows_test window_mouse_forwarding_windows_test.cpp)
131139
target_link_libraries(window_mouse_forwarding_windows_test PRIVATE nativeapi)
132140
add_test(NAME window_mouse_forwarding_windows_test COMMAND window_mouse_forwarding_windows_test)
@@ -169,6 +177,8 @@ endif()
169177
# Explicit desktop integration test; requires a running window manager.
170178
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
171179
# Inspect real server input regions without injecting input into the desktop.
180+
add_executable(window_title_bar_double_click_linux_test window_title_bar_double_click_linux_test.cpp)
181+
target_link_libraries(window_title_bar_double_click_linux_test PRIVATE nativeapi)
172182
add_executable(window_mouse_forwarding_linux_test window_mouse_forwarding_linux_test.cpp)
173183
target_link_libraries(window_mouse_forwarding_linux_test PRIVATE nativeapi)
174184
add_test(NAME window_mouse_forwarding_linux_test COMMAND window_mouse_forwarding_linux_test)

0 commit comments

Comments
 (0)