Ghost Assembly QuickTiler

GNOME Shell extension

QuickTiler

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.

  • GNOME Shell 49–50
  • Seven zones, three keys
  • GPL-3.0-or-later

Install Source on GitHub

On this page
  1. 01Overview
  2. 02Install
  3. 03Zones
  4. 04Quick Settings
  5. 05Preferences
  6. 06Keyboard
  7. 07Architecture
  8. 08Testing
  9. 09Packaging
  10. 10Releasing
  11. 11Development

01

Overview

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.

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.

Nothing to go stale

The current zone is read back from the window’s own geometry on every press, so cycling works on windows something else placed.

Focus, swap and monitors

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.

Quick Settings tile

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

Install

Requires

  • GNOME Shell 49 or 50.
  • Nothing else. gnome-extensions ships with GNOME Shell itself.

From a release

Shell
$ 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.

From a clone

Shell
$ 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

Zones

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+←

    1. left quarter
    2. left half
  • Tile right Super+Ctrl+→

    1. right quarter
    2. right half
  • Tile center Super+Ctrl+↑

    1. center half
    2. top third
    3. bottom two thirds

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.

Read back, not remembered

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

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

Quick Settings

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.

  • Click the tile to pause the shortcuts. Every accelerator is released to whatever else claims it; click again to take them back. The subtitle says which state it is in.
  • Click the arrow for the menu: the four groups — tile, focus, swap and monitor — each row showing the shortcuts it currently holds, then Settings.
  • Click a row to perform it. Rows work while the shortcuts are paused; the pause is about the keyboard only.

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

Preferences

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

Keyboard

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.

Neighbors and monitors

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

Architecture

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

Testing

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:

  • A maximized window reports the work area as its frame, and cannot be resized.
  • On request, frames that change only when the client commits, as on Wayland, and a minimum size Mutter enlarges a window to.
  • Signal handlers tracked per emitter, as gnome-shell tracks them, and a Quick Settings menu the Shell never destroys — so a leak shows up as a failing test.

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

Packaging

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

Releasing

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.

Shell
$ 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

Development

justfile
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.