From 39eda5f08df22c8532f08545f5d9def63b1e8ae6 Mon Sep 17 00:00:00 2001 From: yiguo Date: Sun, 8 Nov 2026 13:01:24 +0800 Subject: [PATCH 1/3] fix(linux): avoid repeated shadow gutter allocations --- src/platform/linux/window_linux.cpp | 6 +- src/platform/linux/window_shadow_linux.h | 101 +++++++-- tests/CMakeLists.txt | 11 + tests/window_shadow_gutter_linux_test.cpp | 261 ++++++++++++++++++++++ 4 files changed, 360 insertions(+), 19 deletions(-) create mode 100644 tests/window_shadow_gutter_linux_test.cpp diff --git a/src/platform/linux/window_linux.cpp b/src/platform/linux/window_linux.cpp index d380a56..7d324bc 100644 --- a/src/platform/linux/window_linux.cpp +++ b/src/platform/linux/window_linux.cpp @@ -2061,7 +2061,11 @@ bool Window::SetFrameless(bool is_frameless) { SetTitleBarStyle(is_frameless ? TitleBarStyle::Hidden : TitleBarStyle::Normal); if (is_frameless) { g_object_set_data(G_OBJECT(widget), kFramelessKey, GINT_TO_POINTER(1)); - gtk_window_set_decorated(GTK_WINDOW(widget), FALSE); + // A hidden CSD header and the core's borderless decoration CSS are already + // visually frameless. Keep GTK's decoration geometry active so that it can + // reserve the custom shadow margin itself. + if (!linux_shadow::GtkOwnsGutter(widget)) + gtk_window_set_decorated(GTK_WINDOW(widget), FALSE); SetHasShadow(HasShadow()); } return true; diff --git a/src/platform/linux/window_shadow_linux.h b/src/platform/linux/window_shadow_linux.h index 30feb2f..249b8f9 100644 --- a/src/platform/linux/window_shadow_linux.h +++ b/src/platform/linux/window_shadow_linux.h @@ -2,6 +2,7 @@ #include #include +#include #include "../window_shadow_blur.h" @@ -13,6 +14,14 @@ static void ClearShadowInput(GtkWidget* widget); namespace nativeapi::linux_shadow { constexpr char kConfig[] = "NativeAPICustomShadow"; constexpr char kState[] = "NativeAPICoreShadow"; +constexpr char kReservedGutterStyleClass[] = "nativeapi-shadow-reserved"; + +// GTK rewrites a CSD window's shadow widths during every allocation. Keep its +// decoration margin as the only source of geometry rather than competing with +// that declaration from an allocation handler. +inline bool GtkOwnsGutter(GtkWidget* widget) { + return gtk_style_context_has_class(gtk_widget_get_style_context(widget), "csd"); +} // Reserve the complete supported blur/offset envelope once. Wayland cannot // compensate for changing surface gutters with gtk_window_move(), and doing so @@ -27,12 +36,12 @@ inline int ReservedMargin() { // States in which the window manager fits the whole surface to the space it gives the // window - a tiling window manager's tiles, maximized, full screen. The gutter is then // no shadow, only a band of empty window around the content, so it is taken away -// while the window is in one of them - when the window manager really sized the -// surface. Hyprland reports every toplevel as maximized and tiled, floating ones too, +// while the window is in one of them. GTK owns that transition for CSD windows. +// For the non-CSD fallback, Hyprland reports every toplevel as maximized and tiled, floating ones too, // so that it draws no client-side shadow, and leaves the size to the client: nothing // is painted in the gutter then, but the gutter stays, because GDK takes no resize // while those states stand and taking the border away would hand the content the -// whole surface. The declared window geometry excludes the gutter either way. +// whole surface. Its declared window geometry excludes the gutter either way. constexpr int kFittedStates = GDK_WINDOW_STATE_MAXIMIZED | GDK_WINDOW_STATE_FULLSCREEN | GDK_WINDOW_STATE_TILED | GDK_WINDOW_STATE_TOP_TILED | GDK_WINDOW_STATE_RIGHT_TILED | @@ -47,8 +56,13 @@ struct State { bool enabled = true; // The window manager's states say fitted: nothing is painted in the gutter. bool fitted = false; - // The surface carries the gutter: the container border is the margin. + // The surface carries the gutter, in the GTK decoration or the fallback border. bool gutter = false; + bool gtk_gutter = false; + // Only non-CSD windows need a direct GDK declaration. The cache belongs to the + // native surface, which can be replaced while this GtkWidget survives. + GdkWindow* declared_window = nullptr; + int declared_left = -1, declared_right = -1, declared_top = -1, declared_bottom = -1; // The surface size last allocated or asked for, to tell a surface the window manager // sized for a fitted state from one its states merely relabelled. int surface_width = 0, surface_height = 0; @@ -73,11 +87,14 @@ inline void Trace(const char* where, GtkWidget* widget, State* state); inline State* Get(GtkWidget* widget) { return widget ? static_cast(g_object_get_data(G_OBJECT(widget), kState)) : nullptr; } -// The gutter the surface has around the content right now. +// Extra inset for GTK size/position requests. GTK already accounts for its CSD +// decoration margin; only the container border must be added by the caller. inline int Gutter(GtkWidget* widget) { auto* state = Get(widget); if (!state) return 0; + if (state->gtk_gutter) + return static_cast(state->original_border); return state->gutter ? state->margin : static_cast(state->original_border); } // What the surface is about to be sized to (SetContentSize), so that a configure @@ -95,11 +112,18 @@ inline bool SurfaceSizedByWindowManager(GtkWidget* widget, State* state) { (width != state->surface_width || height != state->surface_height); } inline void SetGutter(GtkWidget* widget, State* state, bool gutter) { - if (state->gutter == gutter) + const bool gtk_gutter = GtkOwnsGutter(widget); + if (state->gutter == gutter && state->gtk_gutter == gtk_gutter) return; state->gutter = gutter; + state->gtk_gutter = gtk_gutter; + auto* context = gtk_widget_get_style_context(widget); + if (gtk_gutter && gutter) + gtk_style_context_add_class(context, kReservedGutterStyleClass); + else + gtk_style_context_remove_class(context, kReservedGutterStyleClass); gtk_container_set_border_width(GTK_CONTAINER(widget), - gutter ? state->margin : state->original_border); + gutter && !gtk_gutter ? state->margin : state->original_border); } inline bool IsFitted(GtkWidget* widget) { auto* window = gtk_widget_get_window(widget); @@ -184,9 +208,12 @@ inline gboolean DrawOverlay(GtkWidget*, cairo_t* cr, gpointer data) { // The gutter is shadow, not window. Tell the window manager, so that it places, // snaps and tiles the content rather than the whole surface (_GTK_FRAME_EXTENTS on // X11, the xdg_surface window geometry on Wayland). GTK declares its own -// decoration's extents while allocating the window; this runs after it and -// declares everything around the content, GTK's part included. +// decoration's extents while allocating the window, so CSD windows reserve the +// gutter in that decoration instead. Only windows without CSD declare it here. inline void DeclareGutter(GtkWidget* widget) { + auto* state = Get(widget); + if (!state || GtkOwnsGutter(widget)) + return; auto* window = gtk_widget_get_window(widget); auto* child = gtk_bin_get_child(GTK_BIN(widget)); int x = 0, y = 0; @@ -199,10 +226,27 @@ inline void DeclareGutter(GtkWidget* widget) { gtk_widget_get_allocated_height(child); if (x < 0 || y < 0 || right < 0 || bottom < 0) return; + if (state->declared_window == window && state->declared_left == x && + state->declared_right == right && state->declared_top == y && + state->declared_bottom == bottom) + return; + state->declared_window = window; + state->declared_left = x; + state->declared_right = right; + state->declared_top = y; + state->declared_bottom = bottom; gdk_window_set_shadow_width(window, x, right, y, bottom); } +inline void InvalidateDeclaration(GtkWidget*, gpointer data) { + static_cast(data)->declared_window = nullptr; +} +inline void DeclarationPropertyChanged(GObject*, GParamSpec*, gpointer data) { + static_cast(data)->declared_window = nullptr; +} inline void Allocated(GtkWidget* widget, GtkAllocation*, gpointer data) { auto* state = static_cast(data); + if (state->gtk_gutter != GtkOwnsGutter(widget)) + SetGutter(widget, state, state->gutter); gtk_window_get_size(GTK_WINDOW(widget), &state->surface_width, &state->surface_height); RefreshShadowInput(widget); DeclareGutter(widget); @@ -218,16 +262,19 @@ inline bool CanBeTranslucent(GtkWidget* widget) { return gdk_screen_is_composited(screen) && gtk_widget_get_visual(widget) == gdk_screen_get_rgba_visual(screen); } -inline void ClearGutterBackground(GtkWidget* widget) { +inline void InstallGutterStyle(GtkWidget* widget) { static bool installed = false; if (!installed) { installed = true; GtkCssProvider* provider = gtk_css_provider_new(); - gtk_css_provider_load_from_data(provider, - "window.nativeapi-shadow-gutter," - "window.nativeapi-shadow-gutter:backdrop {" - " background: none; }", - -1, nullptr); + const std::string css = + "window.nativeapi-shadow-gutter," + "window.nativeapi-shadow-gutter:backdrop { background: none; }" + "window.nativeapi-shadow-reserved.csd decoration," + "window.nativeapi-shadow-reserved.csd decoration:backdrop {" + " box-shadow: none; border: none; padding: 0; margin: " + + std::to_string(ReservedMargin()) + "px; }"; + gtk_css_provider_load_from_data(provider, css.c_str(), -1, nullptr); // KDE's GTK modules install user-priority providers. The gutter is backing // for our shadow, so its transparency must survive theme overrides (#594). gtk_style_context_add_provider_for_screen(gtk_widget_get_screen(widget), @@ -235,14 +282,16 @@ inline void ClearGutterBackground(GtkWidget* widget) { GTK_STYLE_PROVIDER_PRIORITY_USER + 1); g_object_unref(provider); } +} +inline void ClearGutterBackground(GtkWidget* widget) { gtk_style_context_add_class(gtk_widget_get_style_context(widget), kGutterStyleClass); } // Into a fitted state the window manager has already sized the surface, and changing // the border only moves the content edge to the surface edge; out of one it restores // the surface it had, gutter included, and the border moves the edge back in. A fitted // state that came with no size of the window manager's (Hyprland, see kFittedStates) -// keeps the gutter: the surface is what the client asked for, and GDK would not -// shrink it now anyway. +// keeps the fallback border gutter: the surface is what the client asked for, and +// GDK would not shrink it now anyway. CSD follows GTK's own fitted geometry. inline gboolean StateChanged(GtkWidget* widget, GdkEventWindowState* event, gpointer data) { auto* state = static_cast(data); Trace("state-event", widget, state); @@ -261,7 +310,8 @@ inline gboolean StateChanged(GtkWidget* widget, GdkEventWindowState* event, gpoi if (fitted == state->fitted) return FALSE; state->fitted = fitted; - SetGutter(widget, state, !fitted || !SurfaceSizedByWindowManager(widget, state)); + SetGutter(widget, state, + !fitted || (!state->gtk_gutter && !SurfaceSizedByWindowManager(widget, state))); gtk_widget_queue_draw(widget); return FALSE; } @@ -276,6 +326,7 @@ inline void Remove(GtkWidget* widget) { g_signal_handlers_disconnect_by_data(widget, state); ClearShadowInput(widget); gtk_style_context_remove_class(gtk_widget_get_style_context(widget), kGutterStyleClass); + gtk_style_context_remove_class(gtk_widget_get_style_context(widget), kReservedGutterStyleClass); if (state->overlay && child == state->overlay) { auto* content = gtk_bin_get_child(GTK_BIN(state->overlay)); g_object_ref(content); @@ -302,6 +353,13 @@ inline void AddGutter(GtkWidget* widget, State* state) { gtk_window_get_position(GTK_WINDOW(widget), &x, &y); gtk_window_get_size(GTK_WINDOW(widget), &width, &height); SetGutter(widget, state, true); + if (state->gtk_gutter) { + // GTK size requests already exclude its decoration margin. Keep the same + // content request while GTK grows the surface around it. + NoteRequestedSize(widget, width, height); + gtk_window_resize(GTK_WINDOW(widget), width, height); + return; + } const int extra = state->margin - state->original_border; NoteRequestedSize(widget, width + extra * 2, height + extra * 2); gtk_window_resize(GTK_WINDOW(widget), width + extra * 2, height + extra * 2); @@ -407,12 +465,19 @@ inline State* Ensure(GtkWidget* widget) { state->custom = std::make_shared(*config); } state->original_border = gtk_container_get_border_width(GTK_CONTAINER(widget)); + state->gtk_gutter = GtkOwnsGutter(widget); state->fitted = IsFitted(widget); // final once Decide() has run g_object_set_data_full(G_OBJECT(widget), kState, state, [](gpointer p) { delete static_cast(p); }); g_signal_connect_after(widget, "draw", G_CALLBACK(Draw), state); g_signal_connect_after(widget, "size-allocate", G_CALLBACK(Allocated), state); + g_signal_connect(widget, "unrealize", G_CALLBACK(InvalidateDeclaration), state); + g_signal_connect(widget, "map", G_CALLBACK(InvalidateDeclaration), state); + g_signal_connect(widget, "style-updated", G_CALLBACK(InvalidateDeclaration), state); + g_signal_connect(widget, "notify::decorated", G_CALLBACK(DeclarationPropertyChanged), state); + g_signal_connect(widget, "notify::scale-factor", G_CALLBACK(DeclarationPropertyChanged), state); g_signal_connect(widget, "window-state-event", G_CALLBACK(StateChanged), state); + InstallGutterStyle(widget); if (CanBeTranslucent(widget)) ClearGutterBackground(widget); // The gutter belongs to GTK, not the application's render tree. Keeping it diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 35e257e..fe9735a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -375,6 +375,17 @@ endif() if(CMAKE_SYSTEM_NAME STREQUAL "Linux") add_executable(window_shadow_remap_linux_test window_shadow_remap_linux_test.cpp) target_link_libraries(window_shadow_remap_linux_test PRIVATE nativeapi) + # Real GTK helper/backend regression; skips when no display is available. + # Build the internal helper in one translation unit so its GDK writes can be + # counted without changing or interposing the public nativeapi library. + find_package(PkgConfig REQUIRED) + pkg_check_modules(SHADOW_GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + add_executable(window_shadow_gutter_linux_test + window_shadow_gutter_linux_test.cpp ../src/window_shape.cpp ../src/window_shadow.cpp) + target_compile_features(window_shadow_gutter_linux_test PRIVATE cxx_std_17) + target_link_libraries(window_shadow_gutter_linux_test PRIVATE PkgConfig::SHADOW_GTK) + add_test(NAME window_shadow_gutter_linux_test COMMAND window_shadow_gutter_linux_test) + set_tests_properties(window_shadow_gutter_linux_test PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 10) endif() # Explicit desktop integration test; creates windows but sends no input. diff --git a/tests/window_shadow_gutter_linux_test.cpp b/tests/window_shadow_gutter_linux_test.cpp new file mode 100644 index 0000000..deb4f19 --- /dev/null +++ b/tests/window_shadow_gutter_linux_test.cpp @@ -0,0 +1,261 @@ +// Real GTK allocations and the production gutter helper. Only the GDK write is +// observed; the wrapper forwards every declaration to the real backend. +#include +#ifdef GDK_WINDOWING_WAYLAND +#include +#endif +#include +#include + +namespace { +struct Declaration { + GdkWindow* window; + int left, right, top, bottom; +}; +std::vector declarations; +void DeclareShadowWidth(GdkWindow* window, int left, int right, int top, int bottom) { + declarations.push_back({window, left, right, top, bottom}); + gdk_window_set_shadow_width(window, left, right, top, bottom); +} +} + +#define gdk_window_set_shadow_width DeclareShadowWidth +#include "../src/platform/linux/window_shadow_linux.h" +#undef gdk_window_set_shadow_width + +// Input-region synchronization is independent of gutter declarations. Keep the +// renderer/helper test independent of the full Window implementation. +namespace nativeapi { +static void RefreshShadowInput(GtkWidget*) {} +static void ClearShadowInput(GtkWidget*) {} +} + +namespace { +int failures = 0; +void Check(bool ok, const char* message) { + std::cout << (ok ? "PASS " : "FAIL ") << message << std::endl; + failures += !ok; +} +// Always return at the deadline, including when a regression keeps GTK pending. +void Pump(int milliseconds) { + const auto end = g_get_monotonic_time() + milliseconds * 1000; + while (g_get_monotonic_time() < end) { + g_main_context_iteration(nullptr, FALSE); + g_usleep(1000); + } +} +GtkWidget* MakeWindow(GtkWidget** content) { + auto* widget = gtk_window_new(GTK_WINDOW_TOPLEVEL); + gtk_window_set_title(GTK_WINDOW(widget), "NativeAPI shadow gutter regression"); + gtk_window_set_decorated(GTK_WINDOW(widget), FALSE); + gtk_window_set_default_size(GTK_WINDOW(widget), 480, 360); + *content = gtk_drawing_area_new(); + gtk_container_add(GTK_CONTAINER(widget), *content); + return widget; +} +void AllocateContent(GtkWidget* widget, GtkWidget* content, + int left, int right, int top, int bottom) { + GtkAllocation allocation{left, top, + gtk_widget_get_allocated_width(widget) - left - right, + gtk_widget_get_allocated_height(widget) - top - bottom}; + gtk_widget_size_allocate(content, &allocation); +} +bool LastEquals(int left, int right, int top, int bottom) { + if (declarations.empty()) return false; + const auto& last = declarations.back(); + return last.left == left && last.right == right && + last.top == top && last.bottom == bottom; +} +void RepeatedDeclarations() { + GtkWidget* content = nullptr; + auto* widget = MakeWindow(&content); + gtk_widget_show_all(widget); + Pump(200); + Check(!gtk_style_context_has_class(gtk_widget_get_style_context(widget), "csd"), + "fallback fixture has no client-side decoration"); + nativeapi::linux_shadow::Ensure(widget); + + AllocateContent(widget, content, 11, 17, 23, 29); + declarations.clear(); + for (int i = 0; i < 12; ++i) + nativeapi::linux_shadow::DeclareGutter(widget); + Check(declarations.size() == 1, "unchanged allocations declare shadow widths once"); + Check(LastEquals(11, 17, 23, 29), "asymmetric shadow extents reach real GDK unchanged"); + + // Every component must participate in the equality check. + for (const auto& extents : {Declaration{nullptr, 13, 17, 23, 29}, + Declaration{nullptr, 13, 19, 23, 29}, + Declaration{nullptr, 13, 19, 31, 29}, + Declaration{nullptr, 13, 19, 31, 37}}) { + AllocateContent(widget, content, extents.left, extents.right, + extents.top, extents.bottom); + const auto before = declarations.size(); + nativeapi::linux_shadow::DeclareGutter(widget); + nativeapi::linux_shadow::DeclareGutter(widget); + Check(declarations.size() == before + 1 && + LastEquals(extents.left, extents.right, extents.top, extents.bottom), + "changed extent is declared once, then becomes idempotent"); + } + + // A compositor/display scale change can invalidate native geometry without + // changing the logical extents. Exercise the real GObject notification hook. + g_object_notify(G_OBJECT(widget), "scale-factor"); + const auto before_scale = declarations.size(); + nativeapi::linux_shadow::DeclareGutter(widget); + nativeapi::linux_shadow::DeclareGutter(widget); + Check(declarations.size() == before_scale + 1 && LastEquals(13, 19, 31, 37), + "scale notification re-declares unchanged logical extents once"); + + const auto valid = declarations.size(); + for (const auto& extents : {Declaration{nullptr, -1, 19, 31, 37}, + Declaration{nullptr, 13, -1, 31, 37}, + Declaration{nullptr, 13, 19, -1, 37}, + Declaration{nullptr, 13, 19, 31, -1}}) { + AllocateContent(widget, content, extents.left, extents.right, + extents.top, extents.bottom); + nativeapi::linux_shadow::DeclareGutter(widget); + } + Check(declarations.size() == valid, "negative extents are not sent to GDK"); + + gtk_widget_hide(content); + const auto hidden = declarations.size(); + nativeapi::linux_shadow::DeclareGutter(widget); + Check(declarations.size() == hidden, "hidden content does not declare a gutter"); + gtk_widget_show(content); + AllocateContent(widget, content, 13, 19, 31, 37); + nativeapi::linux_shadow::DeclareGutter(widget); + Check(declarations.size() == hidden, "skipping hidden content preserves last declaration"); + + // Keep the old GdkWindow alive, so the replacement cannot reuse its address. + auto* old_window = gtk_widget_get_window(widget); + g_object_ref(old_window); + gtk_widget_hide(widget); + gtk_widget_unrealize(widget); + const auto unrealized = declarations.size(); + nativeapi::linux_shadow::DeclareGutter(widget); + Check(declarations.size() == unrealized, "unrealized window does not declare a gutter"); + gtk_widget_realize(widget); + gtk_widget_realize(content); + Check(gtk_widget_get_window(widget) != old_window, "realize creates a new GdkWindow"); + AllocateContent(widget, content, 13, 19, 31, 37); + const auto recreated = declarations.size(); + nativeapi::linux_shadow::DeclareGutter(widget); + nativeapi::linux_shadow::DeclareGutter(widget); + Check(declarations.size() == recreated + 1 && LastEquals(13, 19, 31, 37), + "new GdkWindow receives identical extents once"); + g_object_unref(old_window); + gtk_widget_destroy(widget); +} +void AllocationQuiescence() { + const auto before_csd = declarations.size(); + GtkWidget* content = nullptr; + auto* widget = MakeWindow(&content); + // Match a realized CSD host with a hidden header, as used by Flutter. + gtk_window_set_decorated(GTK_WINDOW(widget), TRUE); + auto* header = gtk_header_bar_new(); + gtk_window_set_titlebar(GTK_WINDOW(widget), header); + gtk_widget_realize(widget); + gtk_widget_hide(header); + gtk_widget_realize(content); + nativeapi::linux_shadow::Ensure(widget); + // The application requests its content dimensions after installing the + // hidden-title-bar shadow policy, before mapping the native host. + gtk_window_resize(GTK_WINDOW(widget), 480, 360); + int allocations = 0; + g_signal_connect(widget, "size-allocate", G_CALLBACK(+[](GtkWidget*, GtkAllocation*, gpointer data) { + ++*static_cast(data); + }), &allocations); + gtk_widget_show(content); + gtk_widget_show(widget); + Pump(900); + Check(declarations.size() == before_csd, + "GTK owns CSD shadow geometry without custom GDK writes"); + const auto initial_allocations = allocations; + const auto initial_declarations = declarations.size(); + Pump(300); + std::cout << "idle: allocations=" << allocations - initial_allocations + << " declarations=" << declarations.size() - initial_declarations << std::endl; + Check(allocations == initial_allocations, "static mapped window stops allocating"); + Check(declarations.size() == initial_declarations, "static mapped window stops declaring shadow widths"); + + const auto initial_width = gtk_widget_get_allocated_width(content); + const auto initial_height = gtk_widget_get_allocated_height(content); + std::cout << "initial mapped CSD content=" << initial_width << "x" << initial_height + << " border=" << gtk_container_get_border_width(GTK_CONTAINER(widget)) + << std::endl; + bool wayland = false; +#ifdef GDK_WINDOWING_WAYLAND + wayland = GDK_IS_WAYLAND_DISPLAY(gtk_widget_get_display(widget)); +#endif + if (wayland) + Check(initial_width == 480 && initial_height == 360, + "Wayland first map preserves requested content dimensions"); + else + std::cout << "INFO X11 initial CSD size is informational; exact sizing is checked after a settled resize" + << std::endl; + Check(nativeapi::linux_shadow::Gutter(widget) == 0, + "GTK content dimensions need no additional CSD gutter subtraction"); + + // X11 already has different pre-map CSD/default-size accounting. Both + // backends must honor an explicit content request once the window is settled. + gtk_window_resize(GTK_WINDOW(widget), 480, 360); + Pump(500); + const auto width = gtk_widget_get_allocated_width(content); + const auto height = gtk_widget_get_allocated_height(content); + std::cout << "settled requested CSD content=" << width << "x" << height << std::endl; + Check(width == 480 && height == 360, + "settled content resize produces the exact requested dimensions"); + gtk_widget_hide(widget); + Pump(50); + gtk_widget_show(widget); + Pump(600); + const auto remapped_allocations = allocations; + const auto remapped_declarations = declarations.size(); + Pump(300); + Check(allocations == remapped_allocations && declarations.size() == remapped_declarations, + "remapped window returns to an idle layout"); + Check(gtk_widget_get_allocated_width(content) == width && + gtk_widget_get_allocated_height(content) == height, + "hide/show preserves content geometry"); + + // GTK retains the CSD class across direct decoration toggles. It must + // continue owning the geometry even while decorations are temporarily off. + for (bool decorated : {false, true}) { + gtk_window_set_decorated(GTK_WINDOW(widget), decorated); + Pump(500); + const auto toggled_allocations = allocations; + const auto toggled_declarations = declarations.size(); + Pump(300); + Check(allocations == toggled_allocations && + declarations.size() == toggled_declarations && + declarations.size() == before_csd, + "direct decoration toggle settles without custom CSD declarations"); + } + Check(gtk_widget_get_allocated_width(content) == width && + gtk_widget_get_allocated_height(content) == height, + "restoring CSD decorations restores original content geometry"); + + nativeapi::linux_shadow::Remove(widget); + gtk_widget_show(header); + Pump(500); + const auto restored_allocations = allocations; + Pump(300); + Check(allocations == restored_allocations, + "restoring system decorations returns to an idle layout"); + Check(!nativeapi::linux_shadow::Get(widget) && + !gtk_style_context_has_class(gtk_widget_get_style_context(widget), + nativeapi::linux_shadow::kGutterStyleClass) && + !gtk_style_context_has_class(gtk_widget_get_style_context(widget), + "nativeapi-shadow-reserved"), + "restoring system decorations removes core shadow state and style"); + gtk_widget_destroy(widget); +} +} + +int main(int argc, char** argv) { + if (!gtk_init_check(&argc, &argv)) return 77; + RepeatedDeclarations(); + AllocationQuiescence(); + std::cout << (failures ? "FAILED " : "ALL PASS ") << failures << std::endl; + return failures ? 1 : 0; +} From 75306a011ec98206c93e6ea2d4f3e66b3de748b6 Mon Sep 17 00:00:00 2001 From: yiguo Date: Sun, 8 Nov 2026 14:25:53 +0800 Subject: [PATCH 2/3] fix(linux): preserve GTK titlebar and gutter geometry --- src/platform/linux/window_linux.cpp | 17 +-- src/platform/linux/window_shadow_linux.h | 12 +- tests/window_controls_test.cpp | 141 +++++++++++++++++++++- tests/window_shadow_gutter_linux_test.cpp | 27 +++++ 4 files changed, 185 insertions(+), 12 deletions(-) diff --git a/src/platform/linux/window_linux.cpp b/src/platform/linux/window_linux.cpp index 7d324bc..564061e 100644 --- a/src/platform/linux/window_linux.cpp +++ b/src/platform/linux/window_linux.cpp @@ -2104,21 +2104,24 @@ void Window::SetTitleBarStyle(TitleBarStyle style) { bool show_decorations = (style == TitleBarStyle::Normal); if (show_decorations) linux_shadow::Remove(pimpl_->widget_); - // Try to find and toggle header bar visibility - GtkWidget* header_bar = WindowHeaderBar(pimpl_->widget_); - if (header_bar) { - gtk_widget_set_visible(header_bar, show_decorations); + // A custom title bar can contain more than its header bar. Toggle the whole + // widget while preserving its children's visibility; GTK's internal header + // is not returned by gtk_window_get_titlebar(). + GtkWidget* title_bar = gtk_window_get_titlebar(gtk_window); + if (!title_bar) title_bar = WindowHeaderBar(pimpl_->widget_); + if (title_bar) { + gtk_widget_set_visible(title_bar, show_decorations); } else { - // If no header bar found, toggle window decorations + // If no title bar is found, toggle window decorations. const gchar* title = gtk_window_get_title(gtk_window); if (title != nullptr) { gtk_window_set_decorated(gtk_window, show_decorations); } } - // An owned CSD header remains hidden in Hidden mode, but GTK must regain + // An owned CSD title bar stays hidden in Hidden mode, but GTK must regain // its normal frame after an explicit frameless mode. - if (show_decorations || header_bar) { + if (show_decorations || title_bar) { gtk_window_set_decorated(gtk_window, TRUE); } SetWindowControlButtonsVisible(show_decorations); diff --git a/src/platform/linux/window_shadow_linux.h b/src/platform/linux/window_shadow_linux.h index 249b8f9..d20cb47 100644 --- a/src/platform/linux/window_shadow_linux.h +++ b/src/platform/linux/window_shadow_linux.h @@ -1,6 +1,9 @@ #pragma once #include +#ifdef GDK_WINDOWING_WAYLAND +#include +#endif #include #include @@ -354,9 +357,14 @@ inline void AddGutter(GtkWidget* widget, State* state) { gtk_window_get_size(GTK_WINDOW(widget), &width, &height); SetGutter(widget, state, true); if (state->gtk_gutter) { - // GTK size requests already exclude its decoration margin. Keep the same - // content request while GTK grows the surface around it. NoteRequestedSize(widget, width, height); +#ifdef GDK_WINDOWING_WAYLAND + // Wayland grows the surface when GTK updates its shadow widths. An explicit + // resize here expands it twice, leaving the content larger than requested. + if (GDK_IS_WAYLAND_DISPLAY(gtk_widget_get_display(widget))) + return; +#endif + // X11 only declares the extents; GTK still needs the content-size request. gtk_window_resize(GTK_WINDOW(widget), width, height); return; } diff --git a/tests/window_controls_test.cpp b/tests/window_controls_test.cpp index 2bfbb9a..9161ed6 100644 --- a/tests/window_controls_test.cpp +++ b/tests/window_controls_test.cpp @@ -36,11 +36,135 @@ void Pump(int ms = 150) { DispatchMessageW(&msg); } #else - while (gtk_events_pending()) gtk_main_iteration(); + for (int i = 0; i < 32 && gtk_events_pending() && std::chrono::steady_clock::now() < end; ++i) + g_main_context_iteration(nullptr, FALSE); #endif std::this_thread::sleep_for(std::chrono::milliseconds(5)); } while (std::chrono::steady_clock::now() < end); } +#if !defined(__APPLE__) && !defined(_WIN32) +bool GtkContentFillsFrame(const Window& window, GtkWidget* content) { + const auto frame = window.GetBounds(); + const auto bounds = window.GetContentBounds(); + const bool fills = bounds.x == frame.x && bounds.y == frame.y && + bounds.width == frame.width && bounds.height == frame.height && + bounds.width == gtk_widget_get_allocated_width(content) && + bounds.height == gtk_widget_get_allocated_height(content); + if (!fills) + std::cout << "INFO GTK frame=" << frame.x << "," << frame.y << " " << frame.width << "x" << frame.height + << " content=" << bounds.x << "," << bounds.y << " " << bounds.width << "x" << bounds.height + << " allocation=" << gtk_widget_get_allocated_width(content) << "x" + << gtk_widget_get_allocated_height(content) << std::endl; + return fills; +} +void GtkHostWithoutTitleBar() { + auto* host = gtk_window_new(GTK_WINDOW_TOPLEVEL); + auto* content = gtk_drawing_area_new(); + gtk_container_add(GTK_CONTAINER(host), content); + gtk_widget_show(content); + Window window(host); + window.SetTitle("nativeapi GTK host without an explicit title bar"); + window.SetContentSize({520, 340}); + window.ShowInactive(); + Pump(400); + const bool csd = gtk_style_context_has_class(gtk_widget_get_style_context(host), "csd"); + Check(window.SetFrameless(true) && window.IsFrameless(), + "GTK host without an explicit title bar accepts frameless policy"); + Pump(400); + if (!csd) + Check(!gtk_window_get_decorated(GTK_WINDOW(host)), "frameless non-CSD GTK disables native decorations"); + Check(gtk_widget_get_mapped(content) && GtkContentFillsFrame(window, content), + "frameless GTK host without an explicit title bar gives its frame to content"); + gtk_widget_destroy(host); +} +void CompositeGtkTitleBar() { + auto* host = gtk_window_new(GTK_WINDOW_TOPLEVEL); + auto* titlebar = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + auto* header = gtk_header_bar_new(); + auto* extra_row = gtk_label_new("Application title-bar row"); + auto* hidden_row = gtk_label_new("Initially hidden title-bar row"); + gtk_widget_set_size_request(extra_row, -1, 24); + gtk_box_pack_start(GTK_BOX(titlebar), header, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(titlebar), extra_row, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(titlebar), hidden_row, FALSE, FALSE, 0); + gtk_widget_show(header); + gtk_widget_show(extra_row); + gtk_widget_show(titlebar); + gtk_window_set_titlebar(GTK_WINDOW(host), titlebar); + auto* content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + auto* content_header = gtk_header_bar_new(); + auto* drawing = gtk_drawing_area_new(); + gtk_box_pack_start(GTK_BOX(content), content_header, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(content), drawing, TRUE, TRUE, 0); + gtk_container_add(GTK_CONTAINER(host), content); + gtk_widget_show(content_header); + gtk_widget_show(drawing); + gtk_widget_show(content); + Window window(host); + window.SetTitle("nativeapi composite GTK title-bar regression"); + window.SetContentSize({520, 340}); + window.ShowInactive(); + Pump(400); + std::cout << "INFO composite GTK decoration class=" + << (gtk_style_context_has_class(gtk_widget_get_style_context(host), "csd") ? "csd" : "non-CSD") + << std::endl; + Check(gtk_widget_get_mapped(titlebar) && gtk_widget_get_mapped(extra_row) && + !gtk_widget_get_visible(hidden_row), "composite GTK title bar starts with its original child visibility"); + auto check_hidden = [&] { + Check(!gtk_widget_get_visible(titlebar) && !gtk_widget_get_mapped(titlebar) && + !gtk_widget_get_mapped(extra_row), "GTK hides the entire composite title bar"); + Check(gtk_widget_get_visible(header) && gtk_widget_get_visible(extra_row) && + !gtk_widget_get_visible(hidden_row), "GTK title-bar transitions preserve individual child visibility"); + Check(gtk_widget_get_mapped(content_header), "GTK composite title-bar changes preserve the application content header"); + Check(GtkContentFillsFrame(window, content), "hidden composite GTK title bar consumes no frame space"); + const auto size = window.GetContentSize(); + if (size.width != 520 || size.height != 340) + std::cout << "INFO composite GTK requested=520x340 actual=" << size.width << "x" << size.height + << " frame=" << window.GetBounds().width << "x" << window.GetBounds().height << std::endl; + Check(size.width == 520 && size.height == 340, + "hidden composite GTK title bar retains the requested content size"); + }; + for (int round = 0; round < 2; ++round) { + Check(window.SetFrameless(true) && window.SetFrameless(true) && window.IsFrameless(), + "composite GTK title bar accepts repeated frameless requests"); + // GTK's CSD resize accounting depends on the new title-bar style being measured. + Pump(400); + window.SetContentSize({520, 340}); + Pump(400); + check_hidden(); + for (bool shadow : {false, true}) { + window.SetHasShadow(shadow); + Pump(); + Check(window.IsFrameless() && window.HasShadow() == shadow, + "GTK shadow changes preserve frameless policy"); + check_hidden(); + } + window.Hide(); + Pump(); + window.ShowInactive(); + Pump(400); + check_hidden(); + window.SetTitleBarStyle(TitleBarStyle::Hidden); + Pump(); + Check(!window.IsFrameless(), "explicit Hidden exits frameless for a composite GTK title bar"); + // Hidden may restore a non-CSD frame. Request content size after that style settles. + window.SetContentSize({520, 340}); + Pump(); + check_hidden(); + Check(window.SetFrameless(true) && window.SetFrameless(false), + "composite GTK title bar exits frameless through the public policy"); + Pump(400); + Check(!window.IsFrameless() && window.GetTitleBarStyle() == TitleBarStyle::Normal && + gtk_widget_get_mapped(titlebar) && gtk_widget_get_mapped(header) && + gtk_widget_get_mapped(extra_row) && !gtk_widget_get_visible(hidden_row), + "Normal restores the composite GTK title bar without showing initially hidden children"); + Check(gtk_widget_get_mapped(content_header) && + window.GetBounds().height > window.GetContentSize().height, + "restored composite GTK title bar occupies its own space above application content"); + } + gtk_widget_destroy(host); +} +#endif } int main(int argc, char** argv) { #ifdef __APPLE__ @@ -156,7 +280,11 @@ int main(int argc, char** argv) { const bool shadow = window.HasShadow(); changes.clear(); Check(native_window_set_frameless(ffi_window, true), "C ABI accepts frameless mode"); +#if !defined(__APPLE__) && !defined(_WIN32) + Pump(400); // Include the deferred GTK shadow-gutter decision. +#else Pump(); +#endif Check(alias.IsFrameless() && native_window_is_frameless(ffi_window) && window.GetTitleBarStyle() == TitleBarStyle::Hidden && !window.IsWindowControlButtonsVisible(), "C ABI frameless policy is shared and hides the native caption"); @@ -179,8 +307,13 @@ int main(int argc, char** argv) { (frame_rect.top + frame_rect.bottom) / 2)) == HTLEFT, "frameless Win32 content retains native edge resize hit testing"); #elif !defined(__APPLE__) - Check(!gtk_window_get_decorated(GTK_WINDOW(host)) && gtk_widget_get_visible(content_header), - "frameless GTK removes decorations without hiding application content"); + Check(!gtk_widget_get_visible(header) && gtk_widget_get_mapped(content_header) && + GtkContentFillsFrame(window, content_header), + "frameless GTK removes visible decorations without hiding or insetting application content"); + if (gtk_style_context_has_class(gtk_widget_get_style_context(host), "csd")) + Check(gtk_window_get_decorated(GTK_WINDOW(host)), "frameless CSD GTK retains decoration geometry for shadows"); + else + Check(!gtk_window_get_decorated(GTK_WINDOW(host)), "frameless non-CSD GTK disables native decorations"); #else NSWindow* ns_window = (__bridge NSWindow*)window.GetNativeObject(); NSView* title_view = [[ns_window standardWindowButton:NSWindowCloseButton] superview].superview; @@ -263,6 +396,8 @@ int main(int argc, char** argv) { DestroyWindow(hwnd); #else gtk_widget_destroy(host); + GtkHostWithoutTitleBar(); + CompositeGtkTitleBar(); #endif return failures ? 1 : 0; } diff --git a/tests/window_shadow_gutter_linux_test.cpp b/tests/window_shadow_gutter_linux_test.cpp index deb4f19..7195fff 100644 --- a/tests/window_shadow_gutter_linux_test.cpp +++ b/tests/window_shadow_gutter_linux_test.cpp @@ -157,6 +157,9 @@ void AllocationQuiescence() { gtk_widget_realize(widget); gtk_widget_hide(header); gtk_widget_realize(content); + Check(gdk_screen_is_composited(gtk_widget_get_screen(widget)) && + gtk_style_context_has_class(gtk_widget_get_style_context(widget), "csd"), + "CSD fixture has compositor-supported client-side shadow geometry"); nativeapi::linux_shadow::Ensure(widget); // The application requests its content dimensions after installing the // hidden-title-bar shadow policy, before mapping the native host. @@ -250,12 +253,36 @@ void AllocationQuiescence() { "restoring system decorations removes core shadow state and style"); gtk_widget_destroy(widget); } +void MappedCsdContent() { + GtkWidget* content = nullptr; + auto* widget = MakeWindow(&content); + gtk_window_set_decorated(GTK_WINDOW(widget), TRUE); + gtk_window_set_titlebar(GTK_WINDOW(widget), gtk_header_bar_new()); + gtk_widget_show(content); + gtk_widget_show(widget); + Pump(500); + gtk_window_resize(GTK_WINDOW(widget), 480, 360); + Pump(500); + Check(gtk_style_context_has_class(gtk_widget_get_style_context(widget), "csd") && + gtk_widget_get_allocated_width(content) == 480 && + gtk_widget_get_allocated_height(content) == 360, + "mapped CSD fixture starts with exact content dimensions"); + nativeapi::linux_shadow::Ensure(widget); + Pump(600); // Include the deferred gutter decision, without another resize. + std::cout << "mapped CSD after installing gutter=" << gtk_widget_get_allocated_width(content) + << "x" << gtk_widget_get_allocated_height(content) << std::endl; + Check(gtk_widget_get_allocated_width(content) == 480 && + gtk_widget_get_allocated_height(content) == 360, + "installing a gutter on mapped CSD preserves content dimensions without a resize request"); + gtk_widget_destroy(widget); +} } int main(int argc, char** argv) { if (!gtk_init_check(&argc, &argv)) return 77; RepeatedDeclarations(); AllocationQuiescence(); + MappedCsdContent(); std::cout << (failures ? "FAILED " : "ALL PASS ") << failures << std::endl; return failures ? 1 : 0; } From 1a4c41b2f141c29d494e38b53ec0c5df500a4dfc Mon Sep 17 00:00:00 2001 From: yiguo Date: Sun, 8 Nov 2026 15:00:13 +0800 Subject: [PATCH 3/3] test(linux): skip CSD cases on unsupported GTK hosts --- tests/window_shadow_gutter_linux_test.cpp | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/window_shadow_gutter_linux_test.cpp b/tests/window_shadow_gutter_linux_test.cpp index 7195fff..d6e969d 100644 --- a/tests/window_shadow_gutter_linux_test.cpp +++ b/tests/window_shadow_gutter_linux_test.cpp @@ -53,6 +53,15 @@ GtkWidget* MakeWindow(GtkWidget** content) { gtk_container_add(GTK_CONTAINER(widget), *content); return widget; } +bool SkipUnsupportedCsd(GtkWidget* widget, const char* test_name) { + if (gdk_screen_is_composited(gtk_widget_get_screen(widget)) && + gtk_style_context_has_class(gtk_widget_get_style_context(widget), "csd")) + return false; + std::cout << "SKIP " << test_name + << ": compositor-supported CSD is unavailable" << std::endl; + gtk_widget_destroy(widget); + return true; +} void AllocateContent(GtkWidget* widget, GtkWidget* content, int left, int right, int top, int bottom) { GtkAllocation allocation{left, top, @@ -157,9 +166,8 @@ void AllocationQuiescence() { gtk_widget_realize(widget); gtk_widget_hide(header); gtk_widget_realize(content); - Check(gdk_screen_is_composited(gtk_widget_get_screen(widget)) && - gtk_style_context_has_class(gtk_widget_get_style_context(widget), "csd"), - "CSD fixture has compositor-supported client-side shadow geometry"); + if (SkipUnsupportedCsd(widget, "AllocationQuiescence")) + return; nativeapi::linux_shadow::Ensure(widget); // The application requests its content dimensions after installing the // hidden-title-bar shadow policy, before mapping the native host. @@ -261,6 +269,8 @@ void MappedCsdContent() { gtk_widget_show(content); gtk_widget_show(widget); Pump(500); + if (SkipUnsupportedCsd(widget, "MappedCsdContent")) + return; gtk_window_resize(GTK_WINDOW(widget), 480, 360); Pump(500); Check(gtk_style_context_has_class(gtk_widget_get_style_context(widget), "csd") &&