Skip to content

Custom layouts

Building a custom layout is straightforward. A layout receives a list of windows and returns their positions. Shoji handles everything else: applying positions, handling events, and managing state.

At a glance

  • A layout is a table with name and arrange fields
  • arrange receives params, returns {windowID = frame}
  • workarea is pre-inset by outer gap — don’t reapply
  • Declare capabilities to enable built-in actions

Layout protocol

A layout is a Lua table with required and optional fields:

---@class Layout
---@field name LayoutName -- Required: unique identifier
---@field arrange fun( -- Required: computes window geometries
--- params: LayoutParams
---): GeometryMap, LayoutState|nil
---@field displayName string|nil -- Optional: human-readable name for alerts
---@field description string|nil -- Optional: brief description for chooser
---@field icon FilePath|nil -- Optional: path to SVG icon
---@field capabilities LayoutCapabilities|nil
---@field actions table<string, LayoutAction>|nil
---@field handleMessage fun( -- Optional: update state from messages
--- state: LayoutState,
--- msg: LayoutMessage
---): LayoutState|nil
---@field initState fun( -- Optional: initialize per-space state
--- windowIDs: WindowID[]
---): LayoutState|nil

Only name and arrange are required. Everything else has sensible defaults.

Simple example: centered layout

This layout centers a main window with smaller windows in the corners:

-- ~/.hammerspoon/layouts/centered.lua
local Centered = {
name = "centered",
displayName = "Centered",
description = "Main window centered, others in corners",
capabilities = {
adjustable_ratio = true,
adjustable_main_count = false,
per_window_resize = false,
stateful = false,
},
}
function Centered.arrange(params)
local geometries = {}
local windowIDs = params.windowIDs
local workarea = params.workarea -- Already inset by outer gap
local gapInner = params.gap_inner or 0
local mainRatio = params.mainRatio or 0.6
if #windowIDs == 0 then
return geometries
end
-- workarea is ready to use (outer gap already applied by tiling layer)
-- Only apply gap_inner between windows
-- Single window: fill workarea
if #windowIDs == 1 then
geometries[windowIDs[1]] = {
x = workarea.x,
y = workarea.y,
w = workarea.w,
h = workarea.h,
}
return geometries
end
-- Main window centered
local mainW = math.floor(workarea.w * mainRatio)
local mainH = math.floor(workarea.h * mainRatio)
local mainX = workarea.x + math.floor((workarea.w - mainW) / 2)
local mainY = workarea.y + math.floor((workarea.h - mainH) / 2)
geometries[windowIDs[1]] = {
x = mainX,
y = mainY,
w = mainW,
h = mainH,
}
-- Remaining windows in corners (with inner gap for spacing)
local cornerW = math.floor((workarea.w - mainW) / 2) - gapInner
local cornerH = math.floor((workarea.h - mainH) / 2) - gapInner
local corners = {
{ x = workarea.x, y = workarea.y },
{ x = workarea.x + workarea.w - cornerW, y = workarea.y },
{ x = workarea.x, y = workarea.y + workarea.h - cornerH },
{ x = workarea.x + workarea.w - cornerW,
y = workarea.y + workarea.h - cornerH },
}
for i = 2, math.min(#windowIDs, 5) do
local corner = corners[i - 1]
geometries[windowIDs[i]] = {
x = corner.x,
y = corner.y,
w = cornerW,
h = cornerH,
}
end
return geometries
end
return Centered

Registering the layout

-- ~/.hammerspoon/init.lua
hs.loadSpoon("Shoji")
spoon.Shoji:configure({
layouts = {
require("layouts.centered"),
},
enabled_layouts = { "tall", "centered", "monocle" },
default_layout = "centered",
})
spoon.Shoji:start()

The arrange function

The arrange function contains the layout logic. It receives parameters and returns a map of window positions.

Input parameters

---@class LayoutParams
---@field windowIDs WindowID[] -- Window IDs to arrange (non-nil, non-sparse)
---@field workarea Frame -- Screen area already inset by outer gap
---@field gap number -- Always 0 (outer gap pre-applied to workarea)
---@field gap_inner number -- Inner gap between windows (use for spacing)
---@field mainRatio number -- Main area ratio (0.1-0.9)
---@field mainCount number -- Number of main windows
---@field layoutState table -- Layout-specific state (for stateful layouts)

The workarea is already inset by gap_outer, so use it directly. Apply gap_inner for spacing between windows. The gap field is always 0 (kept for backward compatibility).

Output

Return a table mapping window IDs to frames:

---@return table<WindowID, Frame>
-- Example:
return {
[12345] = { x = 0, y = 0, w = 800, h = 600 },
[12346] = { x = 800, y = 0, w = 400, h = 600 },
}

Requirements

  1. Return a geometry for every window ID in windowIDs (don’t drop windows)
  2. All frame fields (x, y, w, h) must be number values
  3. Width and height must be positive (> 0)
  4. Return {} only when windowIDs is empty

Capabilities

Declare which features the layout supports:

capabilities = {
adjustable_ratio = true, -- Responds to main_ratio changes
adjustable_main_count = true, -- Responds to main_count changes
per_window_resize = false, -- Supports individual window resize
stateful = false, -- Maintains state between retiles
},

Capabilities tell Shoji which built-in actions make sense. For example, adjustable_ratio = false means ratio-changing actions won’t do anything.

Layout actions and messages

Layouts can expose actions and apply them through handleMessage. Action handlers should return a LayoutMessage, and the layout updates its own state in handleMessage. This keeps actions side-effect-free and lets the engine schedule retiling.

local FocusedResize = {
name = "focused-resize",
capabilities = { per_window_resize = true, stateful = true },
actions = {
grow = {
description = "Grow focused window",
handler = function(context)
return {
action = "grow",
payload = {
windowID = context.windowID,
spaceID = context.spaceID,
},
}
end,
},
},
}
function FocusedResize.handleMessage(state, msg)
if msg.action == "grow" and msg.payload then
local nextState = {}
for k, v in pairs(state) do
nextState[k] = v
end
nextState.lastGrow = msg.payload.windowID
return nextState
end
return state
end

Stateful layouts

Some layouts need to remember information between retiles. BSP, for example, tracks per-window resize ratios. Here’s the pattern:

local MyStateful = {
name = "mystateful",
displayName = "My Stateful",
capabilities = {
stateful = true,
},
}
-- Initialize state when layout first used on a space
function MyStateful.initState(windowIDs)
return {
customRatios = {}, -- Per-window ratios
}
end
-- arrange receives layoutState and can return updated state
function MyStateful.arrange(params)
local geometries = {}
local state = params.layoutState
-- Use state.customRatios to adjust geometries...
-- Return both geometries AND updated state
return geometries, state
end
return MyStateful

When arrange returns a second value, Shoji stores it as the new state for that space. State is per-space, so different spaces can have different state.

Layout actions

Layouts can expose custom actions for hotkey binding:

MyLayout.actions = {
custom_action = {
description = "Does something custom",
handler = function(context)
-- context.spaceID: current space
-- context.windowID: focused window (or nil)
-- context.layoutState: current state (mutable)
-- context.windows: all tiling windows
-- Modify context.layoutState to change state
context.layoutState.someValue = 42
-- Return true to apply changes and retile
-- Return false to indicate action couldn't complete
-- Return nil for success without explicit signal
return true
end,
},
}

Users bind the action as layoutname_actionname:

spoon.Shoji:bindHotkeys({
mylayout_custom_action = { { "ctrl", "alt" }, "x" },
})

Utility functions

Helpers for common geometry operations:

local Geometry = require("layouts.geometry")
local Constants = require("constants")
-- Inset a frame by gap amount
local canvas = Geometry.inset(workarea, gap)
-- Clamp frame to minimum dimensions
local frame = Geometry.clampSize(frame, Constants.MIN_WINDOW_DIMENSION)

Testing

  1. Create the layout file
  2. Register it in init.lua
  3. Reload Hammerspoon from the menu bar (Reload config) or run hs.reload() in the console
  4. Cycle to the layout and test with different window counts
  5. Check the Hammerspoon console for errors

Reference implementations

Study built-in layouts for patterns:

  • layouts/monocle.lua (simplest possible layout)
  • layouts/tall.lua (main-stack with ratio/main_count)
  • layouts/grid.lua (dynamic grid calculation)
  • layouts/bsp.lua (stateful with custom actions)

Common mistakes

Double-applying outer gap: The workarea already accounts for gap_outer. Applying it again in your layout pushes windows away from screen edges and makes them too small.

Dropping windows from the return map: Every window ID in params.windowIDs must have a geometry in the return table. Missing windows cause the layout to appear frozen.

Using pairs instead of ipairs for window iteration: Window order matters. pairs iterates in arbitrary order; ipairs preserves the array order that determines which window is “main”.

Returning zero or negative dimensions: Width and height must be positive. Zero or negative values cause rendering errors or invisible windows.

Omitting description field: Without description, custom layouts show blank subtext in the command palette. Always provide a brief description.

Common errors

“attempt to index a nil value”: Wrong param field name. Check spelling — the field is workarea, not work_area.

“attempt to perform arithmetic on a nil value”: Using mainRatio or mainCount without the corresponding capability declared. Set adjustable_ratio = true or adjustable_main_count = true in capabilities.

Layout doesn’t appear in cycle: The layout must be in both layouts and enabled_layouts in the config. Missing from either list means it won’t appear.