Skip to content

Commit f84bf25

Browse files
committed
Linux: a hidden title bar's shadow gutter waits for the compositor's states
Hyprland reports every toplevel as tiled and maximized, floating ones too, and GDK takes no resize while those states stand. A gutter added before the compositor had answered therefore stayed: a 320x320 content area sat in a 584x584 window, which grew by two more gutters on every show. Whether to carry a gutter is now decided once the compositor's states are known (or shortly after the window is mapped, for a window that gets none), and a fitted state that came without a size of the window manager's keeps the gutter instead of handing the content the whole surface. The no-shadow CSS rule also clears the theme's decoration margin, which GTK counts as shadow width and which Hyprland made a popup lay out twice on every show. tests/window_shadow_remap_linux_test.cpp covers three timings of hiding the title bar across hides and shows; run it floating on a compositor that sends tiled states.
1 parent 104c032 commit f84bf25

4 files changed

Lines changed: 315 additions & 23 deletions

File tree

‎src/platform/linux/window_linux.cpp‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -839,6 +839,7 @@ void Window::SetContentSize(Size size) {
839839
if (pimpl_->widget_ && GTK_IS_WINDOW(pimpl_->widget_)) {
840840
size.width += linux_shadow::Gutter(pimpl_->widget_) * 2;
841841
size.height += linux_shadow::Gutter(pimpl_->widget_) * 2;
842+
linux_shadow::NoteRequestedSize(pimpl_->widget_, (gint)size.width, (gint)size.height);
842843
GtkWindow* gtk_window = GTK_WINDOW(pimpl_->widget_);
843844
if (!gtk_window_get_resizable(gtk_window)) {
844845
// GTK derives a non-resizable window's fixed geometry from its default
@@ -1310,10 +1311,14 @@ static void EnsureNoShadowRule(GtkWidget* widget) {
13101311
}
13111312
installed = true;
13121313
GtkCssProvider* provider = gtk_css_provider_new();
1314+
// The margin too: the theme's decoration node reserves one for its shadow (10 px in
1315+
// Adwaita), GTK counts it as shadow width, and drops it the moment the compositor
1316+
// reports the window tiled or maximized - on Hyprland that is every window, right
1317+
// after it maps, so a popup would lay itself out twice on every show.
13131318
gtk_css_provider_load_from_data(provider,
13141319
"window.nativeapi-no-shadow decoration,"
13151320
"window.nativeapi-no-shadow decoration:backdrop {"
1316-
" box-shadow: none; border: none; }",
1321+
" box-shadow: none; border: none; margin: 0; }",
13171322
-1, nullptr);
13181323
gtk_style_context_add_provider_for_screen(gtk_widget_get_screen(widget),
13191324
GTK_STYLE_PROVIDER(provider),

‎src/platform/linux/window_shadow_linux.h‎

Lines changed: 151 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,12 @@ inline int ReservedMargin() {
2626
// States in which the window manager fits the whole surface to the space it gives the
2727
// window - a tiling window manager's tiles, maximized, full screen. The gutter is then
2828
// no shadow, only a band of empty window around the content, so it is taken away
29-
// while the window is in one of them.
29+
// while the window is in one of them - when the window manager really sized the
30+
// surface. Hyprland reports every toplevel as maximized and tiled, floating ones too,
31+
// so that it draws no client-side shadow, and leaves the size to the client: nothing
32+
// is painted in the gutter then, but the gutter stays, because GDK takes no resize
33+
// while those states stand and taking the border away would hand the content the
34+
// whole surface. The declared window geometry excludes the gutter either way.
3035
constexpr int kFittedStates =
3136
GDK_WINDOW_STATE_MAXIMIZED | GDK_WINDOW_STATE_FULLSCREEN | GDK_WINDOW_STATE_TILED |
3237
GDK_WINDOW_STATE_TOP_TILED | GDK_WINDOW_STATE_RIGHT_TILED |
@@ -39,16 +44,31 @@ struct State {
3944
int margin = ReservedMargin();
4045
std::shared_ptr<WindowShadow> custom;
4146
bool enabled = true;
47+
// The window manager's states say fitted: nothing is painted in the gutter.
4248
bool fitted = false;
49+
// The surface carries the gutter: the container border is the margin.
50+
bool gutter = false;
51+
// The surface size last allocated or asked for, to tell a surface the window manager
52+
// sized for a fitted state from one its states merely relabelled.
53+
int surface_width = 0, surface_height = 0;
54+
// Whether to carry a gutter at all is decided once the compositor's states are
55+
// known (see Decide()); until then neither border nor size is touched.
56+
bool decided = false;
57+
gulong decide_on_map = 0;
58+
guint decide_timeout = 0;
4359
bool dirty = true;
4460
int width = 0, height = 0;
4561
WindowShape polygon;
4662
cairo_surface_t* image = nullptr;
4763
~State() {
64+
if (decide_timeout)
65+
g_source_remove(decide_timeout);
4866
if (image)
4967
cairo_surface_destroy(image);
5068
}
5169
};
70+
inline void Decide(GtkWidget* widget, State* state);
71+
inline void Trace(const char* where, GtkWidget* widget, State* state);
5272
inline State* Get(GtkWidget* widget) {
5373
return widget ? static_cast<State*>(g_object_get_data(G_OBJECT(widget), kState)) : nullptr;
5474
}
@@ -57,7 +77,28 @@ inline int Gutter(GtkWidget* widget) {
5777
auto* state = Get(widget);
5878
if (!state)
5979
return 0;
60-
return state->fitted ? static_cast<int>(state->original_border) : state->margin;
80+
return state->gutter ? state->margin : static_cast<int>(state->original_border);
81+
}
82+
// What the surface is about to be sized to (SetContentSize), so that a configure
83+
// echoing it is not taken for the window manager's own size.
84+
inline void NoteRequestedSize(GtkWidget* widget, int width, int height) {
85+
if (auto* state = Get(widget)) {
86+
state->surface_width = width;
87+
state->surface_height = height;
88+
}
89+
}
90+
inline bool SurfaceSizedByWindowManager(GtkWidget* widget, State* state) {
91+
int width = 0, height = 0;
92+
gtk_window_get_size(GTK_WINDOW(widget), &width, &height);
93+
return state->surface_width > 0 &&
94+
(width != state->surface_width || height != state->surface_height);
95+
}
96+
inline void SetGutter(GtkWidget* widget, State* state, bool gutter) {
97+
if (state->gutter == gutter)
98+
return;
99+
state->gutter = gutter;
100+
gtk_container_set_border_width(GTK_CONTAINER(widget),
101+
gutter ? state->margin : state->original_border);
61102
}
62103
inline bool IsFitted(GtkWidget* widget) {
63104
auto* window = gtk_widget_get_window(widget);
@@ -159,7 +200,9 @@ inline void DeclareGutter(GtkWidget* widget) {
159200
return;
160201
gdk_window_set_shadow_width(window, x, right, y, bottom);
161202
}
162-
inline void Allocated(GtkWidget* widget, GtkAllocation*, gpointer) {
203+
inline void Allocated(GtkWidget* widget, GtkAllocation*, gpointer data) {
204+
auto* state = static_cast<State*>(data);
205+
gtk_window_get_size(GTK_WINDOW(widget), &state->surface_width, &state->surface_height);
163206
RefreshShadowInput(widget);
164207
DeclareGutter(widget);
165208
}
@@ -191,17 +234,32 @@ inline void ClearGutterBackground(GtkWidget* widget) {
191234
}
192235
gtk_style_context_add_class(gtk_widget_get_style_context(widget), kGutterStyleClass);
193236
}
194-
// The window manager has already sized the surface for the new state; changing the
195-
// border only moves the content edge to the surface edge, or back in.
237+
// Into a fitted state the window manager has already sized the surface, and changing
238+
// the border only moves the content edge to the surface edge; out of one it restores
239+
// the surface it had, gutter included, and the border moves the edge back in. A fitted
240+
// state that came with no size of the window manager's (Hyprland, see kFittedStates)
241+
// keeps the gutter: the surface is what the client asked for, and GDK would not
242+
// shrink it now anyway.
196243
inline gboolean StateChanged(GtkWidget* widget, GdkEventWindowState* event, gpointer data) {
197244
auto* state = static_cast<State*>(data);
198-
const bool fitted = (event->new_window_state & kFittedStates) != 0;
199-
if (fitted != state->fitted) {
200-
state->fitted = fitted;
201-
gtk_container_set_border_width(GTK_CONTAINER(widget),
202-
fitted ? state->original_border : state->margin);
203-
gtk_widget_queue_draw(widget);
245+
Trace("state-event", widget, state);
246+
if (!state->decided) {
247+
// The compositor's first configure brings the states: a fitted one, or none, in
248+
// which case the focus GDK assumed on show goes away. GDK's own show-time event
249+
// (WITHDRAWN cleared, FOCUSED set) is not an answer.
250+
const bool fitted_changed = (event->changed_mask & kFittedStates) != 0;
251+
const bool focus_withdrawn = (event->changed_mask & GDK_WINDOW_STATE_FOCUSED) &&
252+
!(event->new_window_state & GDK_WINDOW_STATE_FOCUSED);
253+
if (fitted_changed || focus_withdrawn)
254+
Decide(widget, state);
255+
return FALSE;
204256
}
257+
const bool fitted = (event->new_window_state & kFittedStates) != 0;
258+
if (fitted == state->fitted)
259+
return FALSE;
260+
state->fitted = fitted;
261+
SetGutter(widget, state, !fitted || !SurfaceSizedByWindowManager(widget, state));
262+
gtk_widget_queue_draw(widget);
205263
return FALSE;
206264
}
207265
inline void Remove(GtkWidget* widget) {
@@ -229,6 +287,85 @@ inline void Remove(GtkWidget* widget) {
229287
g_object_set_data(G_OBJECT(widget), kState, nullptr);
230288
gtk_widget_queue_draw(widget);
231289
}
290+
// Grows the surface by the margin on every side, around the content as it is, and
291+
// moves it back so the content stays put. Only once the window manager's states are
292+
// known: on a compositor that reports every window as tiled (see kFittedStates) GDK
293+
// takes no resize afterwards, and a gutter added before would hand the content the
294+
// whole surface for good.
295+
inline void AddGutter(GtkWidget* widget, State* state) {
296+
int width = 0, height = 0;
297+
int x = 0, y = 0;
298+
gtk_window_get_position(GTK_WINDOW(widget), &x, &y);
299+
gtk_window_get_size(GTK_WINDOW(widget), &width, &height);
300+
SetGutter(widget, state, true);
301+
const int extra = state->margin - state->original_border;
302+
NoteRequestedSize(widget, width + extra * 2, height + extra * 2);
303+
gtk_window_resize(GTK_WINDOW(widget), width + extra * 2, height + extra * 2);
304+
gtk_window_move(GTK_WINDOW(widget), x - extra, y - extra);
305+
}
306+
inline void Trace(const char* where, GtkWidget* widget, State* state) {
307+
static const bool on = g_getenv("NATIVEAPI_SHADOW_TRACE") != nullptr;
308+
if (!on) return;
309+
auto* window = gtk_widget_get_window(widget);
310+
int w = 0, h = 0;
311+
gtk_window_get_size(GTK_WINDOW(widget), &w, &h);
312+
g_printerr("[shadow %ld] %s: state 0x%x mapped %d decided %d fitted %d gutter %d size %dx%d\n",
313+
(long)(g_get_monotonic_time() / 1000), where,
314+
window ? (unsigned)gdk_window_get_state(window) : 0u, gtk_widget_get_mapped(widget),
315+
state->decided, state->fitted, state->gutter, w, h);
316+
}
317+
inline void Decide(GtkWidget* widget, State* state) {
318+
if (state->decided)
319+
return;
320+
Trace("decide", widget, state);
321+
state->decided = true;
322+
if (state->decide_on_map) {
323+
g_signal_handler_disconnect(widget, state->decide_on_map);
324+
state->decide_on_map = 0;
325+
}
326+
if (state->decide_timeout) {
327+
g_source_remove(state->decide_timeout);
328+
state->decide_timeout = 0;
329+
}
330+
state->fitted = IsFitted(widget);
331+
if (!state->fitted)
332+
AddGutter(widget, state);
333+
gtk_widget_queue_draw(widget);
334+
}
335+
inline gboolean DecideOnTimeout(gpointer data) {
336+
auto* state = static_cast<State*>(data);
337+
state->decide_timeout = 0;
338+
Trace("timeout", state->target, state);
339+
Decide(state->target, state);
340+
return G_SOURCE_REMOVE;
341+
}
342+
// A window that is on screen but reports no state (floating, unfocused) gets no
343+
// configure of its own accord: decide for it after a moment.
344+
constexpr guint kDecideTimeoutMs = 300;
345+
inline void ArmDecideTimeout(State* state) {
346+
if (!state->decided && !state->decide_timeout)
347+
state->decide_timeout = g_timeout_add(kDecideTimeoutMs, DecideOnTimeout, state);
348+
}
349+
inline gboolean ArmDecideOnMap(GtkWidget*, GdkEvent*, gpointer data) {
350+
ArmDecideTimeout(static_cast<State*>(data));
351+
return FALSE;
352+
}
353+
// Decide now when the window already reports a fitted state; otherwise at the
354+
// window-state event that brings the compositor's answer (StateChanged), or shortly
355+
// after the window is mapped. Not at the first configure-event: GTK emits one for its
356+
// own initial size before the compositor has answered. Not on any state event either:
357+
// GDK marks a toplevel focused as it shows it, before the compositor has said so.
358+
inline void DecideWhenKnown(GtkWidget* widget, State* state) {
359+
if (IsFitted(widget)) {
360+
Decide(widget, state);
361+
return;
362+
}
363+
if (gtk_widget_get_mapped(widget))
364+
ArmDecideTimeout(state);
365+
else
366+
state->decide_on_map = g_signal_connect(widget, "map-event", G_CALLBACK(ArmDecideOnMap), state);
367+
}
368+
232369
inline State* Ensure(GtkWidget* widget) {
233370
if (auto* state = Get(widget))
234371
return state;
@@ -266,26 +403,18 @@ inline State* Ensure(GtkWidget* widget) {
266403
state->custom = std::make_shared<WindowShadow>(*config);
267404
}
268405
state->original_border = gtk_container_get_border_width(GTK_CONTAINER(widget));
269-
state->fitted = IsFitted(widget);
406+
state->fitted = IsFitted(widget); // final once Decide() has run
270407
g_object_set_data_full(G_OBJECT(widget), kState, state,
271408
[](gpointer p) { delete static_cast<State*>(p); });
272409
g_signal_connect_after(widget, "draw", G_CALLBACK(Draw), state);
273410
g_signal_connect_after(widget, "size-allocate", G_CALLBACK(Allocated), state);
274411
g_signal_connect(widget, "window-state-event", G_CALLBACK(StateChanged), state);
275412
if (CanBeTranslucent(widget))
276413
ClearGutterBackground(widget);
277-
if (state->fitted)
278-
return state;
279414
// The gutter belongs to GTK, not the application's render tree. Keeping it
280415
// reserved while disabled avoids geometry changes when toggling the shadow.
281-
int width = 0, height = 0;
282-
int x = 0, y = 0;
283-
gtk_window_get_position(GTK_WINDOW(widget), &x, &y);
284-
gtk_window_get_size(GTK_WINDOW(widget), &width, &height);
285-
gtk_container_set_border_width(GTK_CONTAINER(widget), state->margin);
286-
const int extra = state->margin - state->original_border;
287-
gtk_window_resize(GTK_WINDOW(widget), width + extra * 2, height + extra * 2);
288-
gtk_window_move(GTK_WINDOW(widget), x - extra, y - extra);
416+
Trace("ensure", widget, state);
417+
DecideWhenKnown(widget, state);
289418
return state;
290419
}
291420
inline void Configure(GtkWidget* widget, std::shared_ptr<WindowShadow> custom) {

‎tests/CMakeLists.txt‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,13 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
165165
target_link_libraries(window_restore_linux_test PRIVATE nativeapi)
166166
endif()
167167

168+
# Explicit desktop integration test; requires a running compositor (meant for one that
169+
# sends tiled states to floating windows, such as Hyprland).
170+
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
171+
add_executable(window_shadow_remap_linux_test window_shadow_remap_linux_test.cpp)
172+
target_link_libraries(window_shadow_remap_linux_test PRIVATE nativeapi)
173+
endif()
174+
168175
# Explicit desktop integration test; creates windows but sends no input.
169176
if(WIN32 OR (APPLE AND NOT CMAKE_SYSTEM_NAME STREQUAL "iOS"))
170177
if(APPLE)

0 commit comments

Comments
 (0)