Skip to content

Part 1: Your first layout

You will learn

  • The layout protocol (name + arrange)
  • How Shoji passes window information to layouts
  • How to return window positions
  • How to register and test a layout

What we’re building

A column layout that arranges windows side by side in equal-width columns:

┌───────────────────┬───────────────────┬───────────────────┐
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ 1 │ 2 │ 3 │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
└───────────────────┴───────────────────┴───────────────────┘

The layout contract

Think of this as an agreement between Shoji and your layout. Each side has responsibilities:

Shoji guarantees:

  • params.windowIDs is always a non-nil array (may be empty)
  • params.workarea is always a valid frame with positive dimensions
  • Your arrange function is called whenever windows need repositioning

Your layout must:

  • Return a geometry for every window ID in params.windowIDs
  • Return valid frames (positive width and height)
  • Be a pure function (no side effects, no Hammerspoon API calls)

Why pure functions? Shoji may call arrange multiple times to preview changes or calculate layouts for different spaces. Side effects would cause bugs.

Step 1: Create the layout file

Create a new file at ~/.hammerspoon/layouts/mycolumn.lua:

local MyColumn = {
name = "mycolumn",
}
return MyColumn

This is the minimum: a table with a name. The name identifies the layout and must be unique across all registered layouts.

Step 2: Add the arrange function

The arrange function is where the layout logic lives. It receives parameters and returns window positions.

local MyColumn = {
name = "mycolumn",
}
function MyColumn.arrange(params)
local geometries = {}
return geometries
end
return MyColumn

Right now it returns an empty table. Let’s understand what params contains.

Step 3: Understand the parameters

Shoji calls arrange with a params table containing:

params = {
windowIDs = { 12345, 12346, 12347 }, -- Windows to arrange
workarea = { x = 0, y = 25, w = 1440, h = 875 }, -- Screen area
gap_inner = 8, -- Gap between windows
-- ... other fields we'll use later
}
  • windowIDs: Array of window IDs to position (never nil, may be empty)
  • workarea: Screen area already inset by outer gaps
  • gap_inner: Pixels between windows

Step 4: Handle edge cases first

Start with the edge cases: zero windows and one window.

local MyColumn = {
name = "mycolumn",
}
function MyColumn.arrange(params)
local geometries = {}
local windowIDs = params.windowIDs
local workarea = params.workarea
-- No windows: nothing to do
if #windowIDs == 0 then
return geometries
end
-- Single window fills the workarea
geometries[windowIDs[1]] = {
x = workarea.x,
y = workarea.y,
w = workarea.w,
h = workarea.h,
}
return geometries
end
return MyColumn

The return value is a table mapping window IDs to frames. Each frame has x, y, w (width), and h (height).

Step 5: Register and test

Update ~/.hammerspoon/init.lua:

hs.loadSpoon("Shoji")
spoon.Shoji:configure({
layouts = {
require("layouts.mycolumn"),
},
enabled_layouts = { "tall", "mycolumn", "fullscreen" },
})
spoon.Shoji:start()

Reload Hammerspoon from the menu bar (Reload config) or run hs.reload() in the console, then cycle to your layout (Ctrl+Alt+Space).

If you see an error like attempt to index a nil value, check that:

  1. The file is saved at the correct path
  2. The return MyColumn line is present
  3. There are no syntax errors (check for missing end statements)

With one window, it should fill the screen. But open a second window and you’ll see the problem: only the first window gets positioned. Let’s fix that.

Step 6: Handle two windows

Before generalizing to N windows, let’s handle exactly two:

function MyColumn.arrange(params)
local geometries = {}
local windowIDs = params.windowIDs
local workarea = params.workarea
local gapInner = params.gap_inner or 0
local count = #windowIDs
if count == 0 then
return geometries
end
if count == 1 then
geometries[windowIDs[1]] = {
x = workarea.x,
y = workarea.y,
w = workarea.w,
h = workarea.h,
}
return geometries
end
-- Two windows: split the screen in half
local halfWidth = math.floor((workarea.w - gapInner) / 2)
geometries[windowIDs[1]] = {
x = workarea.x,
y = workarea.y,
w = halfWidth,
h = workarea.h,
}
geometries[windowIDs[2]] = {
x = workarea.x + halfWidth + gapInner,
y = workarea.y,
w = halfWidth,
h = workarea.h,
}
return geometries
end

Test with two windows. They should split evenly:

┌─────────────────────────────┬─────────────────────────────┐
│ │ │
│ │ │
│ │ │
│ │ │
│ │ │
│ │ │
│ │ │
│ 1 │ 2 │
│ │ │
│ │ │
│ │ │
│ │ │
│ │ │
│ │ │
│ │ │
│ │ │
│ │ │
└─────────────────────────────┴─────────────────────────────┘

Step 7: Generalize to N windows

Look at the two-window code: halfWidth = (workarea.w - gapInner) / 2. That’s just the N=2 case of a general formula:

WindowsGapsFormula
21(workarea.w - 1*gap) / 2
32(workarea.w - 2*gap) / 3
NN-1(workarea.w - (N-1)*gap) / N

The pattern:

  • Total gap space: (count - 1) * gapInner
  • Each column width: (workarea.w - totalGaps) / count
  • Each column’s x position: (i - 1) * (columnWidth + gapInner)
function MyColumn.arrange(params)
local geometries = {}
local windowIDs = params.windowIDs
local workarea = params.workarea
local gapInner = params.gap_inner or 0
local count = #windowIDs
if count == 0 then
return geometries
end
if count == 1 then
geometries[windowIDs[1]] = {
x = workarea.x,
y = workarea.y,
w = workarea.w,
h = workarea.h,
}
return geometries
end
-- Multiple windows: divide width equally
local totalGaps = (count - 1) * gapInner
local columnWidth = math.floor((workarea.w - totalGaps) / count)
for i, windowID in ipairs(windowIDs) do
local xOffset = (i - 1) * (columnWidth + gapInner)
geometries[windowID] = {
x = workarea.x + xOffset,
y = workarea.y,
w = columnWidth,
h = workarea.h,
}
end
return geometries
end

Why ipairs instead of pairs? The ipairs function iterates in order (1, 2, 3…) and stops at the first nil. The pairs function iterates in arbitrary order. Window order matters for layouts, so always use ipairs.

Step 8: Fix the last window

There’s a subtle bug: math.floor can leave a gap at the right edge due to rounding. Fix it by giving the last window the remaining space:

for i, windowID in ipairs(windowIDs) do
local xOffset = (i - 1) * (columnWidth + gapInner)
-- Last window takes remaining space to fill exactly
local w = (i == count) and (workarea.w - xOffset) or columnWidth
geometries[windowID] = {
x = workarea.x + xOffset,
y = workarea.y,
w = w,
h = workarea.h,
}
end

Step 9: Add display name

Add a human-readable name shown in alerts when cycling layouts:

local MyColumn = {
name = "mycolumn",
displayName = "My Column",
}

Complete code

Here’s the finished layout:

-- ~/.hammerspoon/layouts/mycolumn.lua
local MyColumn = {
name = "mycolumn",
displayName = "My Column",
}
function MyColumn.arrange(params)
local geometries = {}
local windowIDs = params.windowIDs
local workarea = params.workarea
local gapInner = params.gap_inner or 0
local count = #windowIDs
if count == 0 then
return geometries
end
-- Single window fills the workarea
if count == 1 then
geometries[windowIDs[1]] = {
x = workarea.x,
y = workarea.y,
w = workarea.w,
h = workarea.h,
}
return geometries
end
-- Multiple windows: divide width equally
local totalGaps = (count - 1) * gapInner
local columnWidth = math.floor((workarea.w - totalGaps) / count)
for i, windowID in ipairs(windowIDs) do
local xOffset = (i - 1) * (columnWidth + gapInner)
local w = (i == count) and (workarea.w - xOffset) or columnWidth
geometries[windowID] = {
x = workarea.x + xOffset,
y = workarea.y,
w = w,
h = workarea.h,
}
end
return geometries
end
return MyColumn

What you learned

  • A layout is a table with name and arrange
  • arrange receives params with window IDs and screen area
  • Return a table mapping window IDs to frames (x, y, w, h)
  • Handle edge cases first (0 windows, 1 window)
  • Use ipairs for ordered iteration over window IDs
  • Give the last window remaining space to avoid rounding gaps

Common mistakes

Forgetting to return geometries for all windows: Every window ID in params.windowIDs needs a geometry. Missing windows won’t be positioned and may overlap others.

Mutating the workarea: Create new tables for frames. Do not modify params.workarea directly, as it may be reused.

Using pairs instead of ipairs: Window order matters. pairs iterates in arbitrary order; ipairs preserves the array order.

Not handling empty arrays: Always check #windowIDs == 0 first. Accessing windowIDs[1] on an empty array returns nil, causing errors.

Common errors

“attempt to index a nil value”: Usually means params.windowIDs or params.workarea is being accessed incorrectly. Check your parameter names.

“attempt to perform arithmetic on a nil value”: You’re using a parameter that doesn’t exist. Check spelling (gap_inner not gapInner in params).

Layout doesn’t appear in cycle: Check that your layout is in both layouts and enabled_layouts in the config.

Hammerspoon won’t start after adding layout: Your layout has a syntax error that crashes Hammerspoon on startup. To recover:

  1. Rename your layout file temporarily (e.g., mycolumn.lua.bak)
  2. Restart Hammerspoon
  3. Fix the syntax error in the renamed file
  4. Rename it back and reload

Challenge

Modify your layout to arrange windows in rows instead of columns. Windows should stack vertically with equal heights:

┌───────────────────────────────────────────────────────────┐
│ │
│ │
│ 1 │
│ │
│ │
├───────────────────────────────────────────────────────────┤
│ │
│ │
│ 2 │
│ │
│ │
├───────────────────────────────────────────────────────────┤
│ │
│ │
│ 3 │
│ │
│ │
└───────────────────────────────────────────────────────────┘

Hint: Replace w calculations with h, and x offsets with y offsets.

Next steps

In Part 2, you’ll extend this layout to respond to user actions, adjusting the split ratio and number of main windows.