Skip to content

Add dynamic (automatic) tiling mode - #600

Open
J4KE-B wants to merge 25 commits into
domferr:mainfrom
J4KE-B:pr/dynamic-tiling
Open

Add dynamic (automatic) tiling mode#600
J4KE-B wants to merge 25 commits into
domferr:mainfrom
J4KE-B:pr/dynamic-tiling

Conversation

@J4KE-B

@J4KE-B J4KE-B commented Aug 9, 2026

Copy link
Copy Markdown

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+Arrow swaps 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 count
  • Dropping a window onto another swaps them
  • Reflow on minimize/restore and on window-count changes
  • The layout editor groups layouts by tile count, with reordering confined to a group, since that's what dynamic tiling cycles through

Testing: the layout-selection/geometry core (src/components/layout/dynamic/{layoutTree,reflow,pickLayout}.ts) is pure — no GNOME imports — and fully covered by node --test: 37 deterministic tests, no display required. Added npm test to package.json for 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.

J4KE-B added 23 commits August 2, 2026 01:18
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.
Copilot AI lite review requested due to automatic review settings August 9, 2026 16:46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +1437 to +1441
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;

Comment on lines +1547 to +1551
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
Comment thread package.json Outdated
"vm:destroy:gnome49": "vagrant destroy gnome49",
"vm:halt:gnome49": "vagrant halt gnome49"
"vm:halt:gnome49": "vagrant halt gnome49",
"test": "node --test \"src/**/*.test.ts\""
J4KE-B added 2 commits August 10, 2026 16:03
…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.
@J4KE-B

J4KE-B commented Aug 10, 2026

Copy link
Copy Markdown
Author

Addressed all 3 of Copilot's review comments:

  • Monitor leak (tilingManager.ts): a window dragged to another monitor after being tracked stayed in _dynamicWindows/_dynamicWindowSignals indefinitely, handlers still connected. Now polled on position-changed (same pattern windowBorder.ts already uses for monitor changes) and released via a new _releaseDynamicWindow, which disconnects the window's handlers before dropping it. — 217faba
  • Redundant layout tree rebuild (tilingManager.ts): _dynamicLayoutCandidates() was rebuilding a split tree for every saved layout on every reflow. Now cached, invalidated only on GlobalState.SIGNAL_LAYOUTS_CHANGED. — same commit as above
  • Test runner reproducibility (package.json): npm test relied on Node's native TS type stripping with no engines field or loader, so it could fail on Node versions without that support (e.g. Node 20 LTS). Switched to tsx --test, which works consistently regardless of Node version. — 5c71a19

All 37 unit tests still pass and the esbuild build is unaffected.

J4KE-B added a commit to J4KE-B/tilingshell that referenced this pull request Aug 10, 2026
…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.
J4KE-B added a commit to J4KE-B/tilingshell that referenced this pull request Aug 10, 2026
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.
J4KE-B added a commit to J4KE-B/tilingshell that referenced this pull request Aug 10, 2026
…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.
J4KE-B added a commit to J4KE-B/tilingshell that referenced this pull request Aug 10, 2026
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.
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.

2 participants