Skip to content

Commit 286a96e

Browse files
committed
Add native window system menus for custom title bars (#66)
1 parent fad74aa commit 286a96e

13 files changed

Lines changed: 567 additions & 1 deletion

‎src/capi/window_c.cpp‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1346,6 +1346,29 @@ bool native_window_is_focusable(native_window_t window) {
13461346
}
13471347
}
13481348

1349+
bool native_window_show_system_menu(native_window_t window, native_point_t position) {
1350+
auto self = nativeapi::HandleTable::GetInstance().Resolve<nativeapi::Window>(window);
1351+
if (!self) {
1352+
return false;
1353+
}
1354+
try {
1355+
auto position_cpp = to_cpp_point(position);
1356+
return self->ShowSystemMenu(position_cpp);
1357+
} catch (...) {
1358+
fprintf(stderr, "[nativeapi] %s: unexpected exception\n", "native_window_show_system_menu");
1359+
return false;
1360+
}
1361+
}
1362+
1363+
bool native_window_is_system_menu_supported(void) {
1364+
try {
1365+
return nativeapi::Window::IsSystemMenuSupported();
1366+
} catch (...) {
1367+
fprintf(stderr, "[nativeapi] %s: unexpected exception\n", "native_window_is_system_menu_supported");
1368+
return false;
1369+
}
1370+
}
1371+
13491372
void native_window_start_dragging(native_window_t window) {
13501373
auto self = nativeapi::HandleTable::GetInstance().Resolve<nativeapi::Window>(window);
13511374
if (!self) {

‎src/capi/window_c.h‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,12 @@ void native_window_set_focusable(native_window_t window, bool is_focusable);
414414
FFI_PLUGIN_EXPORT
415415
bool native_window_is_focusable(native_window_t window);
416416

417+
FFI_PLUGIN_EXPORT
418+
bool native_window_show_system_menu(native_window_t window, native_point_t position);
419+
420+
FFI_PLUGIN_EXPORT
421+
bool native_window_is_system_menu_supported(void);
422+
417423
FFI_PLUGIN_EXPORT
418424
void native_window_start_dragging(native_window_t window);
419425

‎src/platform/android/window_android.cpp‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,14 @@ bool Window::IsFocusable() const {
446446
return true;
447447
}
448448

449+
bool Window::ShowSystemMenu(Point /*position*/) {
450+
return false;
451+
}
452+
453+
bool Window::IsSystemMenuSupported() {
454+
return false;
455+
}
456+
449457
void Window::StartDragging() {
450458
ALOGW("StartDragging not supported on Android");
451459
}

‎src/platform/ios/window_ios.mm‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,14 @@
489489
return true;
490490
}
491491

492+
bool Window::ShowSystemMenu(Point /*position*/) {
493+
return false;
494+
}
495+
496+
bool Window::IsSystemMenuSupported() {
497+
return false;
498+
}
499+
492500
void Window::StartDragging() {
493501
// Not applicable to iOS
494502
}

‎src/platform/linux/window_linux.cpp‎

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ struct Decorations {
110110
struct Layout {
111111
GdkRectangle frame = {}; // what the user sees as the window: title bar and content
112112
GdkRectangle content = {}; // the client area the application draws into
113+
Point content_origin_in_surface = {}; // independent of global Wayland coordinates
113114
};
114115

115116
// A window the window manager decorates is simple: its GdkWindow is the content and
@@ -168,6 +169,8 @@ static Layout GetLayout(GtkWidget* widget, GdkWindow* gdk_window) {
168169
}
169170
layout.frame = {origin_x + side, origin_y + shadow_top, width, height + title_height};
170171
layout.content = {origin_x + side, origin_y + shadow_top + title_height, width, height};
172+
layout.content_origin_in_surface = {static_cast<double>(side),
173+
static_cast<double>(shadow_top + title_height)};
171174
#ifdef GDK_WINDOWING_WAYLAND
172175
if (GDK_IS_WAYLAND_DISPLAY(gdk_window_get_display(gdk_window))) {
173176
layout.content.x -= layout.frame.x;
@@ -192,6 +195,7 @@ static Layout GetLayout(GtkWidget* widget, GdkWindow* gdk_window) {
192195

193196
layout.content = {origin_x + child_x, origin_y + child_y, child_allocation.width,
194197
child_allocation.height};
198+
layout.content_origin_in_surface = {static_cast<double>(child_x), static_cast<double>(child_y)};
195199
// The frame is the content plus the title bar above it; the shadow is not part of it.
196200
gint frame_top = child_y;
197201
GtkWidget* titlebar = gtk_window_get_titlebar(GTK_WINDOW(widget));
@@ -466,7 +470,12 @@ class Window::Impl {
466470
: widget_(widget),
467471
gdk_window_(gdk_window),
468472
title_bar_style_(TitleBarStyle::Normal),
469-
background_color_(Color::White) {}
473+
background_color_(Color::White) {
474+
if (widget_)
475+
g_object_add_weak_pointer(G_OBJECT(widget_), reinterpret_cast<gpointer*>(&widget_));
476+
if (gdk_window_)
477+
g_object_add_weak_pointer(G_OBJECT(gdk_window_), reinterpret_cast<gpointer*>(&gdk_window_));
478+
}
470479

471480
~Impl() {
472481
if (hints_refresh_source_) {
@@ -477,6 +486,10 @@ class Window::Impl {
477486
g_object_remove_weak_pointer(G_OBJECT(hints_widget_),
478487
reinterpret_cast<gpointer*>(&hints_widget_));
479488
}
489+
if (gdk_window_)
490+
g_object_remove_weak_pointer(G_OBJECT(gdk_window_), reinterpret_cast<gpointer*>(&gdk_window_));
491+
if (widget_)
492+
g_object_remove_weak_pointer(G_OBJECT(widget_), reinterpret_cast<gpointer*>(&widget_));
480493
}
481494

482495
void ApplyGeometryHints() {
@@ -1666,6 +1679,64 @@ bool Window::IsFocusable() const {
16661679
return AcceptsFocus(pimpl_->widget_, pimpl_->gdk_window_);
16671680
}
16681681

1682+
bool Window::ShowSystemMenu(Point position) {
1683+
// GTK can replace its surface when decorations are installed. Read the live
1684+
// widget's surface instead of keeping a menu request on the previous one.
1685+
GdkWindow* surface = pimpl_->widget_ ? gtk_widget_get_window(pimpl_->widget_) : pimpl_->gdk_window_;
1686+
if (!surface || gdk_window_is_destroyed(surface) || !gdk_window_is_viewable(surface) ||
1687+
gdk_window_get_window_type(surface) != GDK_WINDOW_TOPLEVEL ||
1688+
!std::isfinite(position.x) || !std::isfinite(position.y) || !IsSystemMenuSupported()) return false;
1689+
GdkDisplay* display = gdk_window_get_display(surface);
1690+
GdkSeat* seat = gdk_display_get_default_seat(display);
1691+
GdkDevice* pointer = seat ? gdk_seat_get_pointer(seat) : nullptr;
1692+
if (!pointer) return false;
1693+
#ifdef GDK_WINDOWING_WAYLAND
1694+
if (GDK_IS_WAYLAND_DISPLAY(display)) {
1695+
GdkModifierType state = static_cast<GdkModifierType>(0);
1696+
gdk_window_get_device_position(surface, pointer, nullptr, nullptr, &state);
1697+
GdkWindow* under_pointer = gdk_device_get_window_at_position(pointer, nullptr, nullptr);
1698+
if (!under_pointer || gdk_window_get_effective_toplevel(under_pointer) != surface ||
1699+
!(state & (GDK_BUTTON1_MASK | GDK_BUTTON2_MASK | GDK_BUTTON3_MASK))) return false;
1700+
}
1701+
#endif
1702+
const Layout layout = GetLayout(pimpl_->widget_, surface);
1703+
const double x = position.x + layout.content_origin_in_surface.x;
1704+
const double y = position.y + layout.content_origin_in_surface.y;
1705+
gint origin_x = 0, origin_y = 0;
1706+
gdk_window_get_origin(surface, &origin_x, &origin_y);
1707+
const int scale = gdk_window_get_scale_factor(surface);
1708+
if (std::abs((x + origin_x) * scale) > G_MAXINT ||
1709+
std::abs((y + origin_y) * scale) > G_MAXINT) return false;
1710+
GdkEvent* event = gdk_event_new(GDK_BUTTON_PRESS);
1711+
event->button.window = GDK_WINDOW(g_object_ref(surface));
1712+
event->button.time = gtk_get_current_event_time();
1713+
event->button.button = GDK_BUTTON_SECONDARY;
1714+
event->button.x = x;
1715+
event->button.y = y;
1716+
event->button.x_root = x + origin_x;
1717+
event->button.y_root = y + origin_y;
1718+
gdk_event_set_device(event, pointer);
1719+
gdk_event_set_source_device(event, pointer);
1720+
const bool accepted = gdk_window_show_window_menu(surface, event);
1721+
gdk_event_free(event);
1722+
return accepted;
1723+
}
1724+
1725+
bool Window::IsSystemMenuSupported() {
1726+
GdkDisplay* display = gdk_display_get_default();
1727+
if (!display) return false;
1728+
#ifdef GDK_WINDOWING_X11
1729+
if (GDK_IS_X11_DISPLAY(display)) {
1730+
return gdk_x11_screen_supports_net_wm_hint(gdk_display_get_default_screen(display),
1731+
gdk_atom_intern_static_string("_GTK_SHOW_WINDOW_MENU"));
1732+
}
1733+
#endif
1734+
#ifdef GDK_WINDOWING_WAYLAND
1735+
if (GDK_IS_WAYLAND_DISPLAY(display)) return true;
1736+
#endif
1737+
return false;
1738+
}
1739+
16691740
void Window::StartDragging() {
16701741
if (!pimpl_->gdk_window_) {
16711742
return;

‎src/platform/macos/window_macos.mm‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1051,6 +1051,14 @@ static void NativeApiSendMouseUp(NSWindow* window, NSEvent* mouse_up) {
10511051
[window sendEvent:mouse_up];
10521052
}
10531053

1054+
bool Window::ShowSystemMenu(Point /*position*/) {
1055+
return false;
1056+
}
1057+
1058+
bool Window::IsSystemMenuSupported() {
1059+
return false;
1060+
}
1061+
10541062
void Window::StartDragging() {
10551063
NSWindow* window = pimpl_->ns_window_;
10561064
NSEvent* event = window.currentEvent;

‎src/platform/ohos/window_ohos.cpp‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,14 @@ bool Window::IsFocusable() const {
430430
return true;
431431
}
432432

433+
bool Window::ShowSystemMenu(Point /*position*/) {
434+
return false;
435+
}
436+
437+
bool Window::IsSystemMenuSupported() {
438+
return false;
439+
}
440+
433441
void Window::StartDragging() {
434442
// StartDragging not supported on OpenHarmony
435443
}

‎src/platform/windows/window_windows.cpp‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2021,6 +2021,65 @@ static void StartSystemFrameDrag(HWND hwnd, WPARAM hit_test) {
20212021
PostMessage(hwnd, WM_NCLBUTTONDOWN, hit_test, MAKELPARAM(cursor.x, cursor.y));
20222022
}
20232023

2024+
namespace {
2025+
struct SystemMenuSession {
2026+
HMENU menu;
2027+
bool initialized = false;
2028+
};
2029+
2030+
LRESULT CALLBACK ObserveSystemMenu(HWND hwnd, UINT message, WPARAM wp, LPARAM lp,
2031+
UINT_PTR, DWORD_PTR data) {
2032+
auto* session = reinterpret_cast<SystemMenuSession*>(data);
2033+
if (message == WM_INITMENUPOPUP && reinterpret_cast<HMENU>(wp) == session->menu)
2034+
session->initialized = true;
2035+
return DefSubclassProc(hwnd, message, wp, lp);
2036+
}
2037+
} // namespace
2038+
2039+
bool Window::ShowSystemMenu(Point position) {
2040+
HWND hwnd = pimpl_->hwnd_;
2041+
if (!IsWindow(hwnd) || !IsWindowVisible(hwnd) ||
2042+
(GetWindowLongPtrW(hwnd, GWL_STYLE) & WS_CHILD) ||
2043+
!std::isfinite(position.x) || !std::isfinite(position.y)) return false;
2044+
HMENU menu = GetSystemMenu(hwnd, FALSE);
2045+
if (!menu || !(GetWindowLongPtrW(hwnd, GWL_STYLE) & WS_SYSMENU)) return false;
2046+
POINT origin = {};
2047+
if (!ClientToScreen(hwnd, &origin)) return false;
2048+
const double scale = GetScaleFactorForWindow(hwnd);
2049+
const double x = origin.x + position.x * scale, y = origin.y + position.y * scale;
2050+
if (x < LONG_MIN || x > LONG_MAX || y < LONG_MIN || y > LONG_MAX) return false;
2051+
const bool minimized = IsIconic(hwnd), maximized = IsZoomed(hwnd), full_screen = IsFullScreen();
2052+
auto enable = [menu](UINT command, bool enabled) {
2053+
EnableMenuItem(menu, command, MF_BYCOMMAND | (enabled ? MF_ENABLED : MF_GRAYED));
2054+
};
2055+
enable(SC_RESTORE, minimized || maximized || full_screen);
2056+
enable(SC_MOVE, !maximized && !full_screen && IsMovable());
2057+
enable(SC_SIZE, !minimized && !maximized && !full_screen && IsResizable());
2058+
enable(SC_MINIMIZE, !minimized && !full_screen && IsMinimizable());
2059+
enable(SC_MAXIMIZE, !maximized && !full_screen && IsMaximizable());
2060+
// Keep the host's SC_CLOSE state and any custom items. GetSystemMenu(hwnd,
2061+
// TRUE) would discard them, so never reset or destroy the native menu.
2062+
SystemMenuSession session{menu};
2063+
const UINT_PTR observer_id = reinterpret_cast<UINT_PTR>(&session);
2064+
if (!SetWindowSubclass(hwnd, ObserveSystemMenu, observer_id,
2065+
reinterpret_cast<DWORD_PTR>(&session))) return false;
2066+
const UINT command = TrackPopupMenuEx(menu, TPM_RETURNCMD | TPM_RIGHTBUTTON,
2067+
static_cast<int>(std::lround(x)),
2068+
static_cast<int>(std::lround(y)), hwnd, nullptr);
2069+
if (IsWindow(hwnd)) RemoveWindowSubclass(hwnd, ObserveSystemMenu, observer_id);
2070+
if (command && IsWindow(hwnd)) {
2071+
if (command == SC_RESTORE && full_screen) SetFullScreen(false);
2072+
else PostMessageW(hwnd, WM_SYSCOMMAND, command, 0);
2073+
}
2074+
// With TPM_RETURNCMD, zero means both cancellation and failure. LastError
2075+
// is not reliable across the host callbacks in the nested menu loop.
2076+
return command != 0 || session.initialized;
2077+
}
2078+
2079+
bool Window::IsSystemMenuSupported() {
2080+
return true;
2081+
}
2082+
20242083
void Window::StartDragging() {
20252084
if (pimpl_->hwnd_) {
20262085
StartSystemFrameDrag(pimpl_->hwnd_, HTCAPTION);

‎src/window.h‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1333,6 +1333,39 @@ class Window : public NativeObjectProvider, public std::enable_shared_from_this<
13331333

13341334
// === User Interaction ===
13351335

1336+
/**
1337+
* @brief Shows the native window menu at a content-relative position.
1338+
* @param position Logical pixels from the top-left of the content area.
1339+
* The operating system may reposition the menu to fit the display.
1340+
* @return true if the menu was displayed or the window manager accepted the
1341+
* request, including cancellation; false if unsupported or rejected.
1342+
*
1343+
* Use this from a secondary-button press in a custom title bar. Windows runs
1344+
* a nested native menu loop until selection or cancellation; selected items
1345+
* are delivered as system window commands. Linux asks the window manager to
1346+
* show its own menu. Its contents and actions remain the manager's choice.
1347+
*
1348+
* @note Platform availability:
1349+
* - macOS: ❌ No equivalent - Always returns false
1350+
* - Windows: ✅ Native system menu with current restore/move/size/minimize/
1351+
* maximize states. Requires a visible top-level window with a system menu.
1352+
* - Linux: ⚠️ X11 requires the _GTK_SHOW_WINDOW_MENU window-manager hint;
1353+
* Wayland requires an active pointer button press in this window, and the
1354+
* compositor may ignore the request. No GTK fallback menu is substituted.
1355+
* - Android: ❌ Not applicable - Always returns false
1356+
* - iOS: ❌ Not applicable - Always returns false
1357+
* - OpenHarmony: ❌ Not applicable - Always returns false
1358+
*/
1359+
bool ShowSystemMenu(Point position);
1360+
1361+
/**
1362+
* @brief Checks if the active platform can request a native window menu.
1363+
* @return true on Windows, supported X11 managers, or Wayland; false elsewhere.
1364+
* A particular window or input gesture may still be rejected.
1365+
* @see ShowSystemMenu() for platform availability.
1366+
*/
1367+
static bool IsSystemMenuSupported();
1368+
13361369
/**
13371370
* @brief Initiates a user drag operation for moving the window.
13381371
*

‎tests/CMakeLists.txt‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ 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+
add_executable(window_system_menu_unsupported_test window_system_menu_unsupported_test.cpp)
39+
target_link_libraries(window_system_menu_unsupported_test PRIVATE nativeapi)
40+
add_test(NAME window_system_menu_unsupported_test COMMAND window_system_menu_unsupported_test)
3841
add_executable(window_shape_macos_test window_shape_macos_test.mm)
3942
target_link_libraries(window_shape_macos_test PRIVATE nativeapi)
4043
add_test(NAME window_shape_macos_test COMMAND window_shape_macos_test)
@@ -120,6 +123,8 @@ if(WIN32 AND NATIVEAPI_ENABLE_WINUI3)
120123
endif()
121124

122125
if(WIN32)
126+
add_executable(window_system_menu_windows_test window_system_menu_windows_test.cpp)
127+
target_link_libraries(window_system_menu_windows_test PRIVATE nativeapi)
123128
# Explicit desktop regression; captures only a pixel inside its own windows.
124129
add_executable(window_content_protection_windows_test window_content_protection_windows_test.cpp)
125130
target_link_libraries(window_content_protection_windows_test PRIVATE nativeapi)
@@ -151,6 +156,9 @@ endif()
151156

152157
# Explicit desktop integration test; requires a running window manager.
153158
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
159+
# Explicit integration in a private Xvfb session or Wayland negative path.
160+
add_executable(window_system_menu_linux_test window_system_menu_linux_test.cpp)
161+
target_link_libraries(window_system_menu_linux_test PRIVATE nativeapi)
154162
add_executable(window_size_constraints_linux_test window_size_constraints_linux_test.cpp)
155163
target_link_libraries(window_size_constraints_linux_test PRIVATE nativeapi)
156164
endif()

0 commit comments

Comments
 (0)