Skip to content

fix: tiling bugs on GNOME Shell 51 (Wayland, multiple monitors) - #589

Open
douxev wants to merge 11 commits into
forge-ext:mainfrom
douxev:fix/gnome-51-tiling-bugs
Open

douxev wants to merge 11 commits into
forge-ext:mainfrom
douxev:fix/gnome-51-tiling-bugs

Conversation

@douxev

@douxev douxev commented Oct 28, 2026

Copy link
Copy Markdown

fix: tiling bugs on GNOME Shell 51 (Wayland, multiple monitors)

Summary

This PR fixes several bugs seen with Forge on GNOME Shell 51 under Wayland, mostly with two monitors:

  • windows jumping between monitors;
  • flickering;
  • drag-and-drop dropping windows in the wrong place;
  • the float toggle failing intermittently;
  • gnome-shell crashing when a window is closed.

Each fix is a separate commit, so they can be reviewed, reordered or dropped independently.

Fixes

  1. Only unmaximize windows that are maximized. On Mutter 51, unmaximizing a window that is not maximized moves it back to its last configured rect. Forge did this on every move, so windows restored by session management (Brave, Chromium) bounced between that rect and their tile, often across monitors.
  2. Don't touch windows being unmanaged on Mutter 51. Moving or unmaximizing a window that is being unmanaged can crash gnome-shell. Such windows are now skipped, and a disabled instance stops rendering.
  3. Keep tracking new windows when auto-split cannot split. Splitting a focused window that was never tiled threw "rect is null" (Bug: unhandled TypeError: rect is null in Node.set rect via split() during workareas-changed #540), and the new window was then never tiled.
  4. Focus on hover only when the pointer enters another window. The 16 ms loop refocused and raised the window under the pointer on every tick, fighting Mutter's own focus changes, and it also matched hidden windows and menus.
  5. Coalesce queued focus-update and raise-float events. Bursts of focus changes queued hundreds of identical events, which then re-rendered the tree for minutes.
  6. Don't re-apply geometry to windows already in place. A window within one pixel of its tile is left alone (fractional scaling rounds client sizes), and the move_frame() call before move_resize_frame() is dropped, as on Wayland it moved the window twice.
  7. Fix the float toggle on several windows of the same class. A rule for one window no longer blocks the others, per-window rules from previous sessions are purged at startup, and a reload from the preferences no longer drops the current ones.
  8. Support "workspaces on primary display only" (the GNOME default). Windows on non-primary monitors are on all workspaces. They now share a single layout instead of one overlapping layout per workspace, and drag-and-drop sees them.
  9. Only tile Wayland windows once they are shown. Mutter creates a MetaWindow before a toplevel's first frame, and again when a client hides its window. Tiling these left empty slots and caused thousands of priv->is_ready warnings.
  10. Keep tiled windows on their monitor while they are in transit. A Wayland resize applies the new position before the new size. Forge followed that transient position to the other monitor, and the window bounced back and forth.
  11. Keep a single tree node per window. Tree.split() replaced the window's node with a new one. When the detached old node was later re-attached, removeChild() spliced at a null index, removing another window and leaving a duplicate node.

Testing

  • Ubuntu 26.10, GNOME Shell 51.0, Mutter 51.0-1ubuntu3, Wayland session.
  • Two 4K monitors at 150% scaling, "workspaces on primary display only", focus on hover enabled, auto-split enabled.
  • Brave 156.1.98.60, Signal, Vesktop and Claude (Electron), Ptyxis, Nautilus.

How it was tested:

  • The same changes were applied to the Forge 89 build from extensions.gnome.org, with temporary diagnostic logging, and used daily between October 21 and 28.
  • They were added one after the other, so fixes 10, 11 and 1 have had the least testing.
  • The journal was used to confirm each fix: no more priv->is_ready warnings, no windows bouncing between monitors, and no JS errors.
  • This branch, based on main, was syntax-checked but not run as-is.

Related

These fixes were proposed by Claude Code, an AI coding assistant, and tested on the setup above.

Generated with Claude Code

douxev and others added 11 commits October 28, 2026 11:53
On Mutter 51, unmaximizing a window that is not maximized is no longer a
no-op: no maximize state changes, so meta_window_process_config() falls
through to meta_window_move_resize() with the window's configured rect.

Forge unmaximizes every window it moves, on every render. A tiled window
whose configured rect lies elsewhere is therefore sent back there before
each move to its tile. This shows with Wayland clients restored by session
management (xdg_session_manager_v1, used by Chromium-based browsers):
Mutter restores the window tiled on the monitor it was saved on, and the
window then keeps bouncing between that tile and its Forge tile, often
across monitors.

Only unmaximize windows that are maximized on at least one axis.
Side-tiled windows are maximized vertically, so they are still untiled,
once. On GNOME 49+, unmaximize() is set_unmaximize_flags(BOTH), so a
single call is enough.

Tested on Ubuntu 26.10 with GNOME Shell 51.0 and Mutter 51.0 (Wayland),
two 4K monitors at 150% scaling, Brave 156.

This fix was proposed by Claude Code, an AI coding assistant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On GNOME Shell 51 (Wayland), unmaximizing or moving a window that Mutter
is unmanaging can crash gnome-shell: its surface is already gone, so the
toplevel lookup returns NULL.

Flag windows on "unmanaging" and skip them in move() and in the delayed
window-create callback, skip windows that have no workspace, and stop
rendering once the window manager is disabled, since leaked signal
handlers can still reach a disabled instance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With auto-split enabled, trackWindow() splits the focused window before
attaching the new one. When the focused window was never tiled (no
rect yet), Tree.split() threw "rect is null" (forge-ext#540) and the new window
was never added to the tree: it stayed untiled, and toggling it floating
then threw "focusNodeWindow is null".

Only auto-split a focused node that has a rect and a parent, and stop the
float toggle after moving the window when it has no node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The focus-on-hover loop runs every 16 ms and focused and raised the
window under the pointer on every tick, even when it already had focus.
Whenever Mutter focused another window (a newly opened one, a dialog,
Alt+Tab), the loop took the focus back at the frame rate; each focus
change re-rendered the tree, so windows flickered. The hit test also
considered windows from other workspaces, minimized or not yet mapped
windows, and menus, whose frame rects can still cover the pointer.

Act only when the window under the pointer changes, like sloppy focus,
skip windows that are not shown on the active workspace, focus only
windows that can be tiled (menus still occlude what is below them), and
do not refocus a window that already has focus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
queueEvent() runs a single event every 220 ms. Every focus change queued
a "focus-update" event, and a "raise-float" one for floating windows, so
a burst of focus changes piled up hundreds of identical events that kept
re-rendering the tree for minutes afterwards.

Add Queue.has() and let events opt in to coalescing: a "focus-update" or
"raise-float" event is not queued again while one is pending. Their
callbacks read the current focus when they run, so the pending one covers
the latest change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every render calls move() on every tiled window, on every workspace. For
a window already in its tile this still unmaximized it, removed its
actor transitions (cutting open and close animations short) and sent the
client a new configure. With fractional scaling, clients round their
size (1281 for a 1280 px tile), so such windows never matched exactly
and were configured again on every render.

Return early when the window is not maximized and its frame is within
one pixel of the target rect. Also drop the move_frame() call before
move_resize_frame(): on Wayland it applied the new position with the old
size while the resize waited for the client, so the window moved twice
and could briefly sit mostly on the neighbouring monitor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The float toggle (window-toggle-float) saves a per-window rule, with the
window id, in windows.json. Three problems made it fail intermittently:

- addFloatOverride() returned early when any float rule existed for the
  window class, including a rule for another window of that class, so a
  second terminal, say, could not be floated.
- Window ids only hold for a session, but the per-window rules stayed in
  windows.json across sessions, where they blocked the case above and
  could match an unrelated window.
- reloadWindowOverrides() dropped the per-window rules from the cache,
  so a reload triggered from the preferences tiled back the windows
  floated with the toggle.

Only treat a class-wide rule, or this window's own rule, as "already
floating"; purge per-window rules left by previous sessions at startup;
and keep the current session's per-window rules when reloading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With org.gnome.mutter workspaces-only-on-primary (the GNOME default),
Mutter keeps the windows of the non-primary monitors on all workspaces,
and get_workspace() returns the active workspace for them. Forge keeps
one layout per monitor and per workspace, so the windows of a secondary
monitor were spread over several layouts that all tile the same screen:
they overlapped, and each window joined the layout of whichever
workspace was active when it opened, moved or changed monitor. Switching
workspaces or dragging a window to that monitor then moved windows
around, and drag-and-drop only saw the windows of the active
workspace's layout.

Add monWsKey(), which maps every non-primary monitor to a single layout
stored under the first workspace when the setting is on, and use it
wherever a monitor node is looked up. The drag-and-drop targets now come
from the monitor nodes shown on the active workspace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mutter emits window-created for a Wayland toplevel before its first
frame, and again each time a client unmaps its window: the toplevel is
reset and gets a new, unmapped MetaWindow, which may never be shown.
Forge tiled these windows right away, so:

- they took an empty slot in the layout;
- every render tried to move them, which Mutter refuses until the
  client commits again ("meta_window_move_resize_internal: runtime check
  failed: (priv->is_ready)", thousands of times per session here);
- once shown, the window sat in the slot reserved where the pointer was
  when it was created, possibly on another monitor.

Track a Wayland window without a buffer only once it gets mapped
(notify::mapped), both for new windows and when tracking the existing
ones, and never move an unmapped Wayland window. X11 windows are tracked
as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
updateMetaWorkspaceMonitor() moves a window's node to the monitor the
window is on whenever Mutter reports a monitor or workspace change. On
Wayland a resize applies the new position before the new size, and
Mutter may also put a window back at a saved position, so a tiled window
can briefly sit mostly on the neighbouring monitor while Forge is
placing it. Forge then re-tiled it on that monitor, the next resize
crossed back, and the window bounced between monitors on each render.

When a tiled window, not being dragged, shows up on another monitor than
its node's, keep the node where it is: the next render puts the window
back in its tile. Floating windows, windows being dragged, windows sent
to another workspace and windows whose monitor is gone still follow the
window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tree.split() pushed a window down into a new container by creating a new
node for it, leaving the old node detached but with its parentNode still
set. The new node lost the window's mode (a window being dragged turned
back from GRAB_TILE to TILE) and state, and the old one stayed
referenced, e.g. as the drag-and-drop target.

When such a detached node was appended elsewhere, removeChild() looked it
up by value, found the window's other node, and spliced at node.index,
which is null for a detached node: splice(null, 1) removed the parent's
first child, another window. The detached node then joined the tree as
a second node of the same window, and every render moved the window to
both tiles.

Move the window's own node into the new container in split(), make
removeChild() detach exactly the given node and clear its parentNode,
and drop any duplicate window node before rendering.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant