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.windowIDsis always a non-nil array (may be empty)params.workareais always a valid frame with positive dimensions- Your
arrangefunction 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 MyColumnThis 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 geometriesend
return MyColumnRight 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 geometriesend
return MyColumnThe 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:
- The file is saved at the correct path
- The
return MyColumnline is present - There are no syntax errors (check for missing
endstatements)
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 geometriesendTest 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:
| Windows | Gaps | Formula |
|---|---|---|
| 2 | 1 | (workarea.w - 1*gap) / 2 |
| 3 | 2 | (workarea.w - 2*gap) / 3 |
| N | N-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 geometriesendWhy 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, }endStep 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 geometriesend
return MyColumnWhat you learned
- A layout is a table with
nameandarrange arrangereceivesparamswith 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
ipairsfor 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:
- Rename your layout file temporarily (e.g.,
mycolumn.lua.bak) - Restart Hammerspoon
- Fix the syntax error in the renamed file
- 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.