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.

Layout protocol

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

---@class Layout
---@field name string -- Required: unique identifier
---@field arrange function -- Required: computes window geometries
---@field displayName string|nil -- Optional: human-readable name for alerts
---@field icon string|nil -- Optional: path to SVG icon
---@field capabilities table|nil -- Optional: declares supported features
---@field actions table|nil -- Optional: layout-specific actions
---@field initState function|nil -- Optional: initialize per-space state

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",
capabilities = {
adjustable_ratio = true,
adjustable_nmaster = 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:start({
layouts = {
require("layouts.centered"),
},
enabled_layouts = { "tall", "centered", "fullscreen" },
default_layout = "centered",
})

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 nmaster 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_nmaster = true, -- Responds to nmaster 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.

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 or {}
-- 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/fullscreen.lua (simplest possible layout)
  • layouts/tall.lua (master-stack with ratio/nmaster)
  • layouts/grid.lua (dynamic grid calculation)
  • layouts/bsp.lua (stateful with custom actions)