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.
…ement bug Models mutter 50.4's Wayland move/resize (configure dedupe, client ack, MOVED/RESIZED results), MetaWindowActor freeze/geometry sync and size-change accounting, and ports GNOME Shell 50.4's windowManager.js bookkeeping. Characterises _easeWindowRect at HEAD and in the Aug-14 working tree: an identical re-request after the client clamped leaves the actor frozen.
WindowPlacer replaces the Main.wm._prepareAnimationInfo dance: it never freezes the actor, remembers the last request and the frame the client settled on so a refused rect is not re-requested (mutter drops equivalent configures and never emits size-changed for them), and animates by easing the real actor's transform once mutter reports the new geometry. ReflowScheduler defers a reflow requested from inside a running reflow to one idle follow-up. Both are pure and covered by the mutter simulation.
_easeWindowRect froze the window actor via GNOME Shell's private
_prepareAnimationInfo and waited for a size-changed that Wayland does not
send when the client already refused the size and mutter drops the
equivalent configure: the actor stayed frozen at its old geometry, visibly
outside its tile. Every such placement also paid an unpaired
completed_size_change ('Error in size change accounting.').
Route all placements through WindowPlacer; skip mutter's own unmaximize
effect before tiling a maximized window; treat a window without a
compositor actor as not placeable (a grab ending during unmanage crashed
the reflow loop with 'windowActor is null'); run the reflow under
ReflowScheduler and queue the event-driven callers so a reflow can no
longer re-enter itself from a synchronous position-changed.
- ease() keys must be snake_case: gnome-shell maps them with
replaceAll('_','-') to find the Clutter transitions, so camelCase keys
were set but never animated. One adapter (placementAdapter.ts) now
serves both the extension and the simulation, whose ease() follows the
same mapping, so this cannot regress silently.
- a request for the current frame supersedes a conflicting pending one
- animate:false no longer cancels the actor's transitions (kept cutting
gnome-shell's map animation short for auto-tiled windows)
- re-asking for the rect mutter last sent, after the client changed on
its own, is preceded by a one-pixel nudge so the configure is not
dropped as equivalent
- the animation start accounts for CSD extents around the frame
- onSettled is per request, reporting requested vs actual rects
- keyboard moves unmaximize with skipNextEffect too (shared helper)
- ReflowScheduler flushes its deferred reflow even if the reflow threw
- sim: synchronous (X11-style) ack policy and scenario
… the client refused Brave answers the first configure after start-up with a larger size than asked (820x512 or 628x634 for a ~500-wide tile) and accepts the very same request a moment later; some windows also resize themselves seconds after being placed. Both left windows overlapping their neighbours until the next reflow. The placer now retries a refused size after 1 s and 3 s (nudged so mutter sends the configure), then gives up, and for 10 s after a request settled puts a window back once if it changes its own size. A reflow re-asking for the same rect keeps the pending retry instead of dropping it.
Brave accepts the re-asked size well within a second; the 1 s first retry made the correction visibly slow.
…iling is using With dynamic tiling on, the panel menu and the snap-assist popup used to highlight the static per-monitor selection, which has nothing to do with what is on screen. They now show the saved layout dynamic tiling actually resolved for the active workspace's window count (including a Super+; offset), clicking a layout in that tile-count group switches to it, and layouts outside the group are greyed out instead of silently doing nothing. Snap assist only highlights; the drop is still owned by dynamic tiling's swap.
…r the dynamic toggle settles St has no CSS opacity property, so the .layout-button-disabled rule never rendered; the dimming is now the actor's opacity, and can_focus is only touched while a button is disabled. The highlight refresh on the dynamic tiling toggle is deferred to an idle so the TilingManagers (which listen to the same setting) have adopted the open windows first. The snap-assist layouts leave room for their 2px selection ring instead of drawing the tiles over it.
Super+; is GNOME's emoji picker; the forward cycle now defaults to Shift+Super+; and the backward binding is unset by default (still configurable in the preferences).
…pped into With dynamic tiling the drop swaps the window into the slot under the pointer, but the blue preview still showed the hovered tile of the saved layout, which matches neither a collapsed nor a subdivided tree. The preview now follows the dynamic slot (own slot when the pointer is outside all of them), the drop and the preview share one slotUnderPoint lookup, and the snap-assist popup's tiles no longer drive the preview during such a drag (the popup keeps highlighting the layout in use).
Brave will not go below 500 px wide; in a 492 px slot it stayed 8 px too wide and ate the gap. Once the placer's retries are spent (onSettled now says whether an answer is final), the window's refused size is recorded as a pin; at every reflow the slot rects from assign() are rebuilt into a split tree and the pinned leaves are grown with pinLeaf (ported from feature/forced-window-resize, with its tests), so the neighbours give way and every gap stays exact. A pin is dropped again when the client accepts a smaller size, or when the window is untracked. Reflow, drop and drag preview all read the pinned slots from one place (_dynamicSlots).
- a drift-watch correction is a single attempt: its refusal is no longer reported final, so it can never create a pin on its own - pinLeaf's cascade asks the ancestor for what is missing (request + sibling floor) instead of the sibling's whole extent, and applyPins re-applies pins for a few passes so one pin cannot silently unmet another; both covered by exact-extent tests - keyboard moves read the pinned slots too
…f fighting over one divider Two windows in the same row both refusing to shrink (two Brave windows at their 500 px minimum) each moved the divider between them, undoing the other's pin; pinLeaf only escalated when a sibling hit the 5 % floor, so the row never grew and the last pin lost its gap. pinLeaf now takes a leafFloor and treats the sibling subtree's pinned minimums (summed along the axis, max across it) as the divider's floor; when the request cannot be met without breaking a sibling's pin it asks the parent for requested + siblingFloor. applyPins feeds it the other pins' extents by path.
…d for A request under the 5 % floor was granted more than it asked and still escalated, with a request smaller than the node, so the parent shrank the node and inflated its sibling. applyPins also skips a pin whose slot does not exist instead of throwing, matching unsatisfied().
5c71a19 to
4e468a1
Compare
… rebuilds by seniority Past the tiles of the template layout the arrangement was recomputed from scratch on every reflow: only the first overflow split looked at the focused window (and not at all when it sat in an overflow half), every later split took the roomiest slot, and the newcomer received whichever half was pushed last. Opening an 8th window with focus in an overflow half rebuilt the whole screen and stacked four windows in one column. The overflow arrangement is now remembered per workspace as a split tree whose leaves are the windows (arrangement.ts, pure). A window _dynamicAdd just tracked cuts the focused window's leaf along its longer side and takes the second half; nothing else moves. Any other change — a close, minimise, workspace move, layout cycle — rebuilds from the layout in creation order, so the oldest windows get the roomiest tiles again. assign() now halves the nominated slot last (so a rebuild also gives the newest window the focused tile's half) and accepts an overflow half as the nominee. Swaps trade leaves as well as slots.
…s holding a closed window
4e468a1 to
d3c2bbb
Compare
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)Shift+Super+;cycles between layouts sharing the same tile count (a backward binding is available in the preferences but unbound by default —Super+;is GNOME's emoji picker)Window placement (
src/components/tilingsystem/windowPlacer.ts): while running this daily I hit windows that stopped repainting or sat outside their tile after a reflow. The cause turned out to be the existing_easeWindowRectpath:Main.wm._prepareAnimationInfofreezes the window actor and onlysize-changedthaws it, but on Wayland amove_resize_framethat matches the configure mutter last sent is deduped and never acked (meta-window-wayland.c,should_configure), so a client that clamps to its minimum size (Brave, WhatsApp, Files…) leaves the actor frozen forever at its old geometry — and every animated placement also logs oneError in size change accounting.becausecompleted_size_changeis called for a size change mutter never counted. Dynamic tiling re-requests the same rect far more often than manual tiling does, which is why it surfaced here. The fix is a small placement layer that never freezes the actor: it dedupes against the last request itself, nudges by a pixel when it must re-ask the exact rect mutter already sent, animates from the previous frame to the new one with plainscale/translationtransitions on the actor, waits for the client's answer (settle on geometry match / 40 ms quiet / 300 ms timeout), retries a refused placement three times (200/400/600 ms), and watches for the client resizing itself afterwards. Only the dynamic-tiling paths use it; the static tiling paths are untouched.Windows that refuse to shrink: when a client's answer is still larger than its slot after the retries (a 500 px-wide Brave window in a 492 px column), the slot is pinned to what the client accepted and the layout tree moves the divider — including the parent's divider when two pinned siblings share one — so neighbours shrink and the gaps hold instead of the window overlapping the next tile. Pins are dropped as soon as the client accepts a smaller size and when the window goes away.
Testing: the layout-selection/geometry core (
src/components/layout/dynamic/{layoutTree,reflow,pickLayout,pins,arrangement}.ts) and the placement layer are pure — no GNOME imports — and covered bynpm test(99 deterministic tests viatsx --test, no display required).test/mutter-sim/is a small model of mutter 50's Wayland configure/ack cycle and GNOME Shell'swindowManager.jsfreeze/thaw bookkeeping:legacy.test.tsreproduces the frozen-actor bug against the previous_easeWindowRectverbatim,placement.test.tsproves the new placer against min-size-clamping, ignoring, delayed and self-resizing clients. Onlysrc/is built, so nothing fromtest/ships.Status: daily driver on GNOME 50.4 / Fedora 44 (Wayland) since early August; the placement and pinning changes are recent (traced and fixed this week), verified live with min-size clients — no accounting warnings in the journal, windows stay in their tiles with their gaps. Multi-monitor is exercised only lightly (single-monitor machine).
Happy to adjust anything review turns up.