Skip to content

Add dynamic (automatic) tiling mode - #600

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

Add dynamic (automatic) tiling mode#600
J4KE-B wants to merge 43 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)
  • 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)
  • Dropping a window onto another swaps them; while dragging, the slot the window will land in is previewed
  • The indicator menu and snap assist highlight the layout dynamic tiling is currently using and dim the ones it can't switch to
  • Past the tiles of the selected layout, a new window takes half of the focused window's tile and nothing else moves (the arrangement is kept per workspace as a split tree whose leaves are the windows); when a window closes, or on any other change, the layout is rebuilt with the oldest windows in the roomiest tiles
  • Reflow on minimize/restore, on window-count changes, and when a window is dragged to another monitor
  • The layout editor groups layouts by tile count, with reordering confined to a group, since that's what dynamic tiling cycles through

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 _easeWindowRect path: Main.wm._prepareAnimationInfo freezes the window actor and only size-changed thaws it, but on Wayland a move_resize_frame that 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 one Error in size change accounting. because completed_size_change is 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 plain scale/translation transitions 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 by npm test (99 deterministic tests via tsx --test, no display required). test/mutter-sim/ is a small model of mutter 50's Wayland configure/ack cycle and GNOME Shell's windowManager.js freeze/thaw bookkeeping: legacy.test.ts reproduces the frozen-actor bug against the previous _easeWindowRect verbatim, placement.test.ts proves the new placer against min-size-clamping, ignoring, delayed and self-resizing clients. Only src/ is built, so nothing from test/ 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.

J4KE-B added 22 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 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

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 2 commits August 10, 2026 19:37
…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 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.
…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().
… 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.
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