Zones by keyboard
Left, right and center each cycle their own zones on every press: quarter and half at the sides, half and two thirds in the middle.
GNOME Shell extension
Keyboard-driven zone tiling with a Quick Settings tile, no overlay and no timers.
Press a direction repeatedly and the focused window cycles through the zones on that side — quarters, halves and a center column split into thirds. No overlay, no grid picker, no timers, and a Quick Settings tile that lists every shortcut.
01
QuickTiler places the focused window into zones from the keyboard. Three direction keys cycle through seven zones; there is nothing to drag, no grid to pick from and no layout mode to switch.
Left, right and center each cycle their own zones on every press: quarter and half at the sides, half and two thirds in the middle.
The current zone is read back from the window’s own geometry on every press, so cycling works on windows something else placed.
Move focus or exchange places with the neighbor to either side, across monitors, or send a window to the next monitor in the same zone.
Lists every action with the shortcut it holds, so the panel teaches the keyboard. Click it to pause every shortcut at once.
What it holds between keypresses. No timers. While the tile is shown, its actors and one global signal — the display’s focus-window change, so a menu row knows which window to act on. Turn the tile off and it holds nothing but its keybindings and settings watches.
02
gnome-extensions ships with
GNOME Shell itself.
$ curl -LO https://github.com/Ghost-Assembly/quicktiler/releases/latest/download/[email protected] $ gnome-extensions install --force [email protected] # log out and back in, then: $ gnome-extensions enable [email protected]
gnome-extensions install unpacks the extension and
compiles its settings schema, so there is no separate
glib-compile-schemas step.
$ just setup $ just install $ just enable
just prefs opens the preferences window,
just logs follows the extension’s output, and
just disable turns it off without uninstalling.
Log out and back in. A newly installed extension is only picked up when the Shell next starts. On Wayland that means a new session.
03
Both common ultrawide layouts — 1/4 + 1/2 + 1/4 and
1/2 + 1/2 — are span-merges of the same four-column
grid, so QuickTiler has no “current layout” to switch between.
It has one flat list of seven zones and three cycles. Pressing a
key again moves to the next zone in its cycle and wraps; a
window anywhere else enters at the first.
Tile left Super+Ctrl+←
Tile right Super+Ctrl+→
Tile center Super+Ctrl+↑
The center thirds exist for the common arrangement of a video call above a terminal. Maximize is not a zone: it is Mutter’s own maximize, so restoring returns the window to where it was and the rest of GNOME agrees on its state.
Which zone a window is in is read from its geometry on every keypress, within a few pixels for terminals that snap to whole character cells. Nothing is stored per window, so there is no state to leak or go stale. A window too big for a zone — Mutter enlarges it to its minimum size and keeps the zone’s corner — is recognized by that corner, so it keeps cycling instead of starting over on every press.
The gap (8 pixels by default) is applied by insetting the work area by one gap and splitting each internal boundary, so neighbors end up exactly one gap apart. Each fraction is rounded once, so two zones that share a boundary resolve to the same pixel: no seam between thirds, and no overlap on an odd gap.
04
The tile sits in the system menu, beside the volume and network
controls. It is a QuickMenuToggle in a
SystemIndicator, added through
addExternalIndicator() so the Shell places it.
There is no top bar icon: one that never changes is noise, and
there is no status to report between keypresses.
Why a row does not read the focused window. Opening the menu takes a Clutter grab, and Mutter’s focus window can be null while it is held. So the tile remembers the last window it saw focused — that is the one global signal it watches — and a row acts on that one, through the same checks as a keypress.
05
Open them from the Extensions app, from the tile’s Settings row,
or with just prefs from a clone.
| Setting | Default | Notes |
|---|---|---|
| Show the tile | On | Off builds no actor and watches no global signal. |
| Gap | 8 pixels | 0 to 200, between windows and at the screen edge. |
| Keyboard shortcuts | On | The same pause the tile writes. With the tile hidden, it is the only way back. |
| Each action | See Keyboard | Click a row and press the new shortcut. Backspace clears it, Escape cancels. |
Assigning a shortcut another QuickTiler action already holds clears it from that action first, whichever order its modifiers are written in. Leaving both set would not work: Mutter registers both and indexes the combination to only one, so the other would show as bound and do nothing.
While it waits for a key, the capture dialog asks the Shell to pass it every key, so a combination that is already bound can still be recorded. The first time, GNOME asks whether to allow that.
06
Every default was checked against the 90 Super accelerators GNOME 50 binds out of the box, so none collide and no GNOME default has to be unbound.
| Action | Default |
|---|---|
| Tile left | Super+Ctrl+← |
| Tile right | Super+Ctrl+→ |
| Tile center | Super+Ctrl+↑ |
| Maximize | Super+Ctrl+↓ |
| Focus left | Super+[ |
| Focus right | Super+] |
| Swap left | Super+Ctrl+[ |
| Swap right | Super+Ctrl+] |
| Move to next monitor | Super+Ctrl+M |
| Move to previous monitor | Super+Ctrl+Shift+M |
The split is deliberate: Super+Ctrl moves windows, and bare Super+bracket moves focus without touching anything. Change any of them in the preferences window.
Focus and swap both cross monitors. Frame rectangles are absolute, so the monitor to your right is simply where the windows further right are. Swapping exchanges the two windows’ places; if one of them is maximized, the other is maximized in its place rather than given its size.
Neighbors are strictly horizontal. Two windows sharing a horizontal center — the center top and bottom thirds, for instance — are not neighbors of each other, and there is no vertical navigation yet.
07
Every decision is testable off the Shell. The pure modules
import nothing — not gi://, not
resource:/// — so Vitest runs them on plain Node,
and the three files that touch a toolkit only read facts off it
and act on the answer.
extension.js is the entry point GNOME Shell loads.
It is deliberately thin: it owns a settings object, a
QuickTiler and a Panel, and its only
real job is pairing each construction with a teardown, so
disable() is exactly enable() in
reverse.
| Pure module | Owns |
|---|---|
zones.js |
The zone table, the projection to pixels, matching a window to a zone, and cycling. |
windows.js
|
Which windows may be placed and which may take focus. Getting one wrong is silent — the shortcut does nothing and nothing is logged. |
neighbors.js
|
The window next to another in a direction. Ties
break on Mutter’s stable sequence, because
list_windows() has no documented order.
|
actions.js
|
The one list of actions, shared with the preferences process and checked against the gschema. |
shortcuts.js
|
What may be bound, what the capture dialog does with a keypress, and which action already holds a shortcut. |
accelerator.js
|
How a shortcut is spelled:
<Super><Control>Left
becomes Super+Ctrl+←. The Shell has no Gtk, so the
menu cannot ask Gtk; the tests check this page
against it.
|
settings.js
|
The names of the settings that are not keybindings, written once. |
prefs.js is the only file that touches Adw and Gtk.
It runs in its own process, so it may import the pure modules
but never the Shell layer.
08
The Shell layer is unit-tested too.
vitest.config.js aliases the gi:// and
resource:/// imports to stubs, and
tests/support is a fake Mutter that models what the
extension depends on:
just test-live adds what CI cannot have. It boots a
throwaway headless GNOME Shell and checks that the extension
enables, disables and enables again with no JavaScript error and
no leaked signal; it is a lifetime check, not a geometry one. It
then runs the packaging check below. just test-docs
holds this site to its rules in Chromium and Firefox:
accessibility in both color schemes, no JavaScript, no other
origin, and no sideways scrolling on a phone.
prefs.js has no coverage, by
design. Everything it used to decide lives in
shortcuts.js; what is left is widget construction,
which a unit test could only check against stubs of the toolkit.
The exclusion is written in both
vitest.config.js and
sonar-project.properties so the two agree.
09
An extension ships as a plain zip, conventionally named
<uuid>.shell-extension.zip, with
metadata.json at the archive root.
just build makes it with zip rather
than GNOME’s gnome-extensions pack, because that
tool comes with the gnome-shell package and CI has
no GNOME.
The official packer stays the authority all the same:
just pack-check runs it and fails on any difference
from the built zip, so the two cannot drift.
Every shipped icon is decoded, not just packed.
An icon that fails to load is silent — the Shell draws nothing
and logs nothing. A comment placed before the
<svg> element is enough to break gdk-pixbuf’s
format sniffing, so the pack check loads each icon at 16×16 and
fails if it does not decode to something visible.
10
Set version-name in metadata.json and
version in package.json, commit, then
tag and push. Run just test-live first: neither the
headless check nor the packer check runs in CI.
$ git tag -a v0.2.3 -m 'release v0.2.3' $ git push origin v0.2.3
The release workflow checks the tag against both files and stops
before building if they disagree — a release whose zip tells
GNOME it is another version is worse than none. It then runs
just ci and attaches the zip to a GitHub release.
Uploading to extensions.gnome.org is deliberately left out of
CI. gnome-extensions upload authenticates with the
account password rather than a scoped token, and every upload
goes to human review anyway.
11
just # list every recipe just setup # tools, dependencies and test browsers just fmt # prettier and eslint --fix, in place just test # unit suite, on Node just test-docs # this site, in Chromium and Firefox just coverage # the unit suite with a coverage report just lint # eslint, prettier, gschema, shellcheck just security # osv-scanner, gitleaks, trivy, actionlint, zizmor just build # the installable zip just ci # what CI runs: lint, test, test-docs, security, build just test-live # headless Shell check, then the packer check just run # a Shell in a window, via mutter-devkit just docs # serve this site on localhost:8000
mise.toml pins the toolchain, so
just ci behaves the same on a laptop and on a
runner. gjs, glib-compile-schemas,
gnome-shell and gnome-extensions come
from the system instead, because they have to match the Shell
being targeted; just setup checks for them.