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 stateOnly 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 geometriesend
return CenteredRegistering the layout
-- ~/.hammerspoon/init.luahs.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
- Return a geometry for every window ID in
windowIDs(don’t drop windows) - All frame fields (x, y, w, h) must be
numbervalues - Width and height must be positive (> 0)
- Return
{}only whenwindowIDsis 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 spacefunction MyStateful.initState(windowIDs) return { customRatios = {}, -- Per-window ratios }end
-- arrange receives layoutState and can return updated statefunction MyStateful.arrange(params) local geometries = {} local state = params.layoutState or {}
-- Use state.customRatios to adjust geometries...
-- Return both geometries AND updated state return geometries, stateend
return MyStatefulWhen 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 amountlocal canvas = Geometry.inset(workarea, gap)
-- Clamp frame to minimum dimensionslocal frame = Geometry.clampSize(frame, Constants.MIN_WINDOW_DIMENSION)Testing
- Create the layout file
- Register it in
init.lua - Reload Hammerspoon from the menu bar (Reload config) or run
hs.reload()in the console - Cycle to the layout and test with different window counts
- 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)