Add dynamic (automatic) tiling mode - #600
Conversation
The loop that finds the vacant tile nearest the centre of the screen was
seeded with a differently-parenthesised expression than the one used in
the loop body:
seed: Math.abs(0.5 - tile.x + tile.width / 2)
body: Math.abs(0.5 - (tile.x + tile.width / 2))
The seed adds half the tile's width instead of subtracting it, so the
first tile's score is inflated and it loses comparisons it should win.
On a 67/33 two-column layout the first window is placed in the narrow
right tile rather than the wide left one.
Replace the seed with Number.MAX_VALUE and start the loop at 0, so the
formula exists in exactly one place. Ties still resolve to the leftmost
tile, as the original seed-at-index-0 intended.
The same block is duplicated in the window menu's "Move to best tile"
entry; both are fixed.
First of the two pure layers behind dynamic tiling. buildLayoutTree takes the tiles of a layout and recursively looks for a full-span guillotine cut that no tile straddles, returning a binary tree of splits and leaves, or null when no such decomposition exists (a pinwheel, for instance) so the caller can fall back to static behaviour. No GNOME imports, so it is unit-testable without a shell. The repository had no test harness at all; tests run on node's built-in runner via `npm test`, which needs no new dependencies. esbuild now excludes *.test.ts from the bundle, since node:test does not exist in GJS. Ambiguous layouts such as a plain 2x2 grid admit a valid cut on either axis; x is chosen deterministically for now. The layout's `groups` field records which divider the user actually drew and can refine this later.
Second pure layer. reflow turns a split tree and a window count into one
rectangle per window:
1 window the whole area, so a lone window is fullscreen
fewer than tiles windows are shared between subtrees in proportion to
the tiles each holds; a subtree that receives fewer
windows than it has tiles collapses into its bounds
and the sibling absorbs the space
one per tile the layout exactly as the user drew it
more than tiles the roomiest rectangle is halved across its longer
side, repeatedly, so every window still gets one
Closing a window is the same call with one fewer, which is where "the
space goes back to the neighbour" comes from without any extra code.
Tested against four layout shapes at every window count from 1 to 7: the
rectangles always tile the area exactly, with no gaps and no overlaps.
…close Wires the two pure layers into TilingManager. When dynamic tiling is on, a new window claims a slot and every managed window is reflowed for the new count; when one closes it gives the slot back and the survivors reflow into the space. Placement reuses the existing easing, so gaps, scaling and animation behave exactly as they already do. A layout with no guillotine decomposition yields a null tree and is left to the existing static behaviour rather than being distorted. Enabled by a new enable-dynamic-tiling key, off by default. It takes precedence over auto-tiling, which places one window into one tile and means something different.
…rflow Dragging a managed window now drops it into whichever slot the pointer is over and exchanges places with the window living there, which is the answer to the open question on issue domferr#342. Dropping outside every slot, or back where it started, simply snaps the window home. Overflow past the last tile previously halved whichever rectangle was roomiest. It now halves the tile of the window that had focus when the new window appeared, matching what tiling window managers do, and falls back to the roomiest rectangle when there is no focus to speak of.
…r toggle Layout order is now preference. For a given number of windows dynamic tiling picks the leftmost layout with exactly that many tiles and uses it as drawn; failing that the leftmost roomier layout, collapsed to fit; failing that the roomiest layout, subdivided. Layouts with no guillotine decomposition are never candidates. This closes the other half of issue domferr#340, which asked for a different layout per window count, without a separate picker: the order the user already arranges their layouts in is the answer. The indicator menu gains a Dynamic tiling switch, so the mode can be turned on and off without opening preferences.
The editor now sorts layouts by how many tiles they hold and draws a rule between groups. The reorder arrows move a layout only within its own group, because that is the only movement that changes anything: dynamic tiling uses the leftmost layout of the group matching the window count, so position across groups is meaningless. The picker now prefers the smallest layout still large enough rather than the leftmost one in storage order, so a layout is collapsed as little as possible. With three windows and 4-tile and 8-tile layouts available, the 4-tile one is used.
Credits the original author, keeps his donation links, and explains that the fork exists because upstream review is slow rather than because the feature was rejected — he was receptive to it on issue domferr#342.
…ndow the roomiest region Nothing was being tiled at all. Windows passed the candidate check at window-created and failed it milliseconds later at reflow time, because the check disqualified maximized windows and applications such as Brave, Files and Nautilus maximize themselves as soon as they are mapped. Every window was refused and simply stayed maximized. Being maximized is no longer disqualifying. In a mode whose premise is that windows fill the screen according to a layout, refusing them would mean refusing almost everything, so they are unmaximized on placement instead, the way a tiling window manager would. Slots are now ordered by area rather than by position in the tree, so the window opened first keeps the roomiest region whichever side of the layout it is drawn on. Reversing the tree order would have worked only for layouts whose largest tile happens to come last. Two call sites need the mapping: overflow translates the focused slot into the tile it occupies before splitting it, and the drag hit test translates a rectangle back into a slot before swapping. The [dyn] debug lines are deliberately left in for one more round of testing and will be removed once the behaviour is confirmed on a real session.
A minimized window is not a placement candidate, so it gave up its slot to the windows behind it, but nothing recomputed the layout: its region was simply left empty until the next window opened, closed or was dragged. Minimize and unminimize now trigger a reflow, deferred to idle so the window's own minimized state has settled before it is read. Restoring a window reclaims its original slot, because minimizing only removes it from the eligible list and never from the slot list. The first window keeps the roomiest region across a minimize and restore.
GNOME's Extensions app links to this url, so bug reports about the dynamic tiling added here would otherwise land on domferr's tracker for code he did not write.
They were left in deliberately to diagnose why no window was being placed; the behaviour is confirmed working now.
…o go back Same UUID as upstream and a higher version number than the one published on extensions.gnome.org, so GNOME will never offer an update back. Anyone testing this deserves to know that before installing, along with the two commands that undo it.
…ic layout The keyboard move keybindings went straight to the static tiling layout, placing the window in a tile that dynamic tiling knows nothing about. The next reflow — any window opening, closing or being minimised — then moved it somewhere else, so the keypress appeared to work and then silently undid itself. For a window dynamic tiling manages, the arrow keys now swap it with the region in that direction, which is the keyboard equivalent of dragging it there. Reaching the edge of the screen does nothing rather than falling through to the static path. Windows dynamic tiling does not manage, and the span keybindings, are left to the existing behaviour untouched. neighbourIndex is pure and unit tested: a candidate must lie beyond the starting region and share the edge being crossed, so a region merely off to one side is not a neighbour. Nearest wins, ties to the topmost.
Right-clicking a titlebar offered "Move to best tile", "Move to leftmost tile", "Move to rightmost tile", a layout tile picker and four quarter placements. All of them move the window into a fixed rectangle of the static layout, which dynamic tiling undoes at the next reflow, so they appear to work and then quietly revert. While dynamic tiling is on the menu now keeps GNOME's own entries and nothing else. With it off the full menu returns unchanged.
…option Adoption. Windows were only ever picked up from window-created, so after the extension was disabled and re-enabled — which happens on every screen lock and every monitor change — nothing already on screen was managed at all. Turning the switch on mid-session had the same problem. Both now adopt every open window. Stability. reflow's subdivision target was passed only by the code path that added a window, so every later reflow re-picked the roomiest region instead and reshuffled every window on screen. Worse, the drop hit test and the directional lookup recomputed geometry without it and reasoned about rectangles the windows were not in, so a drag or Super+Arrow could swap with the wrong window. A new pure `assign` returns rectangles already indexed by slot and takes the split target, which is now persisted, so every caller sees the geometry the windows are actually in. Overflow. Splitting a region made its halves smaller, and the assignment then re-sorted by area, so the halves fell to the end of the order and the window whose region had just been split was evicted from it. `assign` keeps the owner in the first half and appends the newcomer. Workspaces. Reflows always targeted the active workspace, so a window closing elsewhere left a hole behind; and nothing listened for a window changing workspace, which left a hole at one end and an overlap at the other. Every workspace holding a managed window is now reflowed, and workspace-changed is handled. Lifecycle. The per-window unmanaged handler was never disconnected — it could not be, since SignalHandling is keyed by signal name and one entry would overwrite another — so every window kept a destroyed manager alive and threw when closed after disable. Handlers are now tracked per window and torn down, the minimize idle source id is kept and removed, repeated minimizes coalesce into one reflow, and destroy clears the slot list. Interactions. Auto-tiling and dynamic tiling could both be on: the unmaximized handler was ungated, so every window dynamic tiling deliberately unmaximized was then grabbed by auto-tiling a frame later. The explicit unmaximize before easing was redundant and made the untile path restore to the wrong size, so it is gone. Returning early from a dynamic drop no longer abandons snap-assist state, which had been leaving edge tiling blocked for every subsequent drag. Also corrects pickLayout's header comment, which described a rule the code had stopped following.
Adoption only saw the active workspace, so unlocking or a monitor change left every window on any other workspace unmanaged, and a window opened there afterwards would be the sole tracked one and take the whole screen on top of windows that were never adopted. Adoption now loops every workspace. Adoption order was most-recently-used, so every unlock or toggle promoted whichever window had last been focused into the master slot instead of whichever was opened first. Adopted windows are now sorted by get_stable_sequence(), which reproduces creation order and makes the cycle a no-op. Minimized windows were excluded from candidacy entirely, so a window minimized before dynamic tiling started, or before a lock, was never tracked and stayed unmanaged even after being restored. Candidacy is now split: trackable (may be tracked, including while minimized) and eligible (has a rectangle to be placed in). Tracking adds a window regardless of minimized state; placement still only considers eligible ones. Alongside this: the split target that decides which region overflow halves was a bare slot index applied to every workspace, so opening a window on one workspace could reshuffle another, and closing a window ahead of it in the list made it name the wrong region after the list shifted. It is now held as a window reference, resolved to an index against the correct workspace's window list at every reflow, and cleared when that window is untracked. The minimize/unminimize/workspace-changed reflows are also now routed through one coalescing method rather than duplicating the idle-queue logic inline.
Super+N was requested first, but it is already GNOME's own toggle-message-tray — checked every keybinding schema (desktop, shell, mutter, settings-daemon, and Tiling Shell's own) and every punctuation key next to Super was free. Semicolon was picked; Shift+Super+; goes backward. Both confirmed free before wiring anything up. pickLayoutIndexAt is pickLayoutIndex with a group and an offset: it finds every candidate layout sharing the tile count of the default pick and steps to another member of that group, wrapping in either direction. Offset 0 always agrees with pickLayoutIndex, which is now implemented as a call to it — one algorithm, not two to keep in sync. The offset is stored per workspace on the manager and cleared when a workspace is removed, alongside the existing cleanup for its tiling layout. _dynamicTree takes the workspace now, for the same reason splitTarget needed one: a preference set on one workspace must not leak onto another. Wired through the same KeyBindings/extension.ts pattern the other keyboard actions already use, gated on enable-move-keybindings — Super+ Arrow already implicitly depends on that toggle for the same reason, so this stays consistent rather than inventing a second gate.
… cycle offset per group Three gaps found by an independent review of the whole feature as it stands, none of them touched by the two prior rounds. Editing a layout, adding or deleting one, or reordering with the editor's arrows fires GlobalState's layouts-changed signal, which only relayed to the static TilingManager. Nothing told dynamic tiling anything had changed, so a window count that should now pick a different layout, or a layout whose geometry itself changed, sat stale until an unrelated event happened to trigger a reflow. The editor's own reorder arrows are how a user expresses layout preference under dynamic tiling, so this was the most visible of the three. The work area changing — a panel or dock appearing, a monitor resizing — updated the static layout, snap-assist and edge-tiling but left dynamic windows converting their normalized rectangles through a stale work area until, again, an unrelated event forced a reflow. The cycle offset from Super+; was a single number per workspace, applied to whichever tile-count group happened to be current at reflow time. Cycling with N windows open, then changing the window count, silently applied that offset to a different group's layouts — landing on one the user never chose. It cannot corrupt geometry, since pickLayoutIndexAt never leaves the group it was given, but it does silently pick the wrong member of a different group. The offset is now keyed by workspace and by the tile count of the default pick, so a preference set for one group never leaks into another; both _dynamicTree and cycleDynamicLayout now share one _dynamicLayoutCandidates helper so they agree on where those group boundaries are.
Enable-smart-window-border-radius chooses between detecting each window's actual corner radius asynchronously and matching it, versus a fixed fallback — but that fallback was DEFAULT_BORDER_RADIUS (11px) on every corner regardless, so turning smart mode off never actually produced a square border; it just meant "always 11px round" instead of "round dynamically." There was no way to get a square border at all. The non-smart fallback is now 0 on every corner. Smart mode's own transient pre-scan placeholder values (11px on top, 0 on bottom, both overwritten once the async pixel scan completes) are untouched.
Super+; layout cycling and the Super+Arrow swap reinterpretation existed in gschema.xml/keybindings.ts but weren't documented anywhere in the README.
There was a problem hiding this comment.
Pull request overview
Adds an opt-in “dynamic tiling” mode that continuously reflows window geometry as windows open/close/minimize/restore, preferring user-defined layouts (by tile count) and falling back to deterministic binary splits when needed. This extends the tiling system with new layout-selection/reflow core logic, new keybindings for cycling layouts within a tile-count group, and UI/editor adjustments to better reflect grouping by tile count.
Changes:
- Introduces a pure (non-GNOME) dynamic layout engine (
layoutTree,reflow,pickLayout) with Node-based unit tests. - Integrates dynamic tiling into the GNOME extension runtime (settings toggle, keybindings, tiling manager behavior, and menu behavior).
- Updates the layout editor UI to group layouts by tile count and restrict reordering to within a group.
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/styles/editor.scss | Adds styling for layout-group separators in the editor UI. |
| src/settings/settings.ts | Adds dynamic-tiling setting key + keybinding setting names and accessors. |
| src/keybindings.ts | Adds keybindings + signal for cycling dynamic layouts (forward/backward). |
| src/indicator/defaultMenu.ts | Adds an indicator-menu toggle for enabling/disabling dynamic tiling. |
| src/extension.ts | Wires the new keybinding signal to TilingManager.cycleDynamicLayout. |
| src/components/windowBorder/windowBorder.ts | Adjusts default border-radius behavior when smart radius is enabled/disabled. |
| src/components/window_menu/overriddenWindowMenu.ts | Disables custom “move to tile” menu items under dynamic tiling; refines “best tile” selection loop. |
| src/components/tilingsystem/tilingManager.ts | Implements dynamic tiling: tracking windows, reflow/coalescing, swap-on-drop, keyboard neighbor swap, layout cycling, and integration points. |
| src/components/layout/dynamic/reflow.ts | Implements pure reflow/assignment logic and neighbor selection utilities. |
| src/components/layout/dynamic/reflow.test.ts | Adds deterministic unit tests for reflow/assign/neighbour logic. |
| src/components/layout/dynamic/pickLayout.ts | Implements pure layout-index picking and group-cycling by tile count. |
| src/components/layout/dynamic/pickLayout.test.ts | Adds unit tests for layout picking/cycling behavior. |
| src/components/layout/dynamic/layoutTree.ts | Builds a guillotine split-tree decomposition from user-drawn tile rectangles. |
| src/components/layout/dynamic/layoutTree.test.ts | Adds unit tests for split-tree decomposition behavior. |
| src/components/editor/editorDialog.ts | Groups layouts by tile count visually; constrains reorder actions to within a group. |
| resources/schemas/org.gnome.shell.extensions.tilingshell.gschema.xml | Adds settings schema entries for dynamic tiling + cycling keybindings. |
| package.json | Adds an npm test script intended to run the new unit tests. |
| esbuild.mjs | Excludes *.test.ts from extension build entrypoints to keep Node-only tests out of GJS artifacts. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| private _trackDynamicWindow(window: Meta.Window): boolean { | ||
| if (window.get_monitor() !== this._monitor.index) return false; | ||
| if (!this._isDynamicTrackable(window)) return false; | ||
| if (this._dynamicWindowSignals.has(window)) return false; | ||
|
|
| private _dynamicTree(windowCount: number, ws: Meta.Workspace) { | ||
| const candidates = this._dynamicLayoutCandidates(); | ||
| const tileCounts = candidates.map((candidate) => candidate.tileCount); | ||
|
|
||
| // The offset only ever applies within the tile-count group of the |
| "vm:destroy:gnome49": "vagrant destroy gnome49", | ||
| "vm:halt:gnome49": "vagrant halt gnome49" | ||
| "vm:halt:gnome49": "vagrant halt gnome49", | ||
| "test": "node --test \"src/**/*.test.ts\"" |
…e layout candidates Addresses two Copilot review comments on PR domferr#600: - _trackDynamicWindow filtered windows by monitor only at track time, so a window dragged to another monitor after being tracked stayed in _dynamicWindows/_dynamicWindowSignals indefinitely, with its handlers still connected. Now polled on position-changed (matching the existing get_monitor() pattern in windowBorder.ts) and released via _releaseDynamicWindow, which disconnects the window's handlers before dropping it. - _dynamicLayoutCandidates() rebuilt a split tree for every saved layout on every call, and _dynamicTree() called it on every reflow. Now cached and only invalidated on GlobalState.SIGNAL_LAYOUTS_CHANGED.
Addresses a Copilot review comment on PR domferr#600: `node --test` against .test.ts files relied on Node's native TypeScript type stripping with no engines field or loader declared, so npm test would fail on Node versions where that isn't available/unflagged by default (e.g. Node 20 LTS). tsx works consistently regardless of Node version.
|
Addressed all 3 of Copilot's review comments:
All 37 unit tests still pass and the esbuild build is unaffected. |
…e layout candidates Addresses two Copilot review comments on PR domferr#600: - _trackDynamicWindow filtered windows by monitor only at track time, so a window dragged to another monitor after being tracked stayed in _dynamicWindows/_dynamicWindowSignals indefinitely, with its handlers still connected. Now polled on position-changed (matching the existing get_monitor() pattern in windowBorder.ts) and released via _releaseDynamicWindow, which disconnects the window's handlers before dropping it. - _dynamicLayoutCandidates() rebuilt a split tree for every saved layout on every call, and _dynamicTree() called it on every reflow. Now cached and only invalidated on GlobalState.SIGNAL_LAYOUTS_CHANGED.
Addresses a Copilot review comment on PR domferr#600: `node --test` against .test.ts files relied on Node's native TypeScript type stripping with no engines field or loader declared, so npm test would fail on Node versions where that isn't available/unflagged by default (e.g. Node 20 LTS). tsx works consistently regardless of Node version.
…e layout candidates Addresses two Copilot review comments on PR domferr#600: - _trackDynamicWindow filtered windows by monitor only at track time, so a window dragged to another monitor after being tracked stayed in _dynamicWindows/_dynamicWindowSignals indefinitely, with its handlers still connected. Now polled on position-changed (matching the existing get_monitor() pattern in windowBorder.ts) and released via _releaseDynamicWindow, which disconnects the window's handlers before dropping it. - _dynamicLayoutCandidates() rebuilt a split tree for every saved layout on every call, and _dynamicTree() called it on every reflow. Now cached and only invalidated on GlobalState.SIGNAL_LAYOUTS_CHANGED.
Addresses a Copilot review comment on PR domferr#600: `node --test` against .test.ts files relied on Node's native TypeScript type stripping with no engines field or loader declared, so npm test would fail on Node versions where that isn't available/unflagged by default (e.g. Node 20 LTS). tsx works consistently regardless of Node version.
An opt-in mode (
enable-dynamic-tiling, off by default — everything else behaves exactly as it does today) in which windows always fill the screen as you open and close them, following the proportions of whichever layout is selected: one window fullscreen, opening a second splits the space in half, opening a third takes half of whatever region is currently focused, closing one gives its space back to whatever was sharing it.Layout selection: always prefers a layout you've already made in the editor — if one exists with exactly as many tiles as there are windows, it's used as drawn (leftmost such layout wins, respecting your layout order). If none matches exactly, the smallest layout still roomy enough is collapsed to fit, collapsing as little as possible. Only if nothing fits does it fall back to splitting on its own — subdividing the roomiest region in a binary-tree fashion, cutting whatever the focused window occupies first, the way a tiling WM splits whatever you're looking at. Your layouts are never bypassed when one applies.
Included:
Super+Arrowswaps the focused window with its neighbor instead of moving to a static tile (no fixed grid to move to in this mode)Super+;/Shift+Super+;cycle between layouts sharing the same tile countTesting: the layout-selection/geometry core (
src/components/layout/dynamic/{layoutTree,reflow,pickLayout}.ts) is pure — no GNOME imports — and fully covered bynode --test: 37 deterministic tests, no display required. Addednpm testtopackage.jsonfor it.Status: running this as a daily driver for about a week without issues. One known gap: multi-monitor isn't fully exercised yet — dragging a tiled window between monitors isn't handled specially.
Happy to adjust anything review turns up.