Part 2: Making it configurable
You will learn
- How to declare layout capabilities
- How to read configuration parameters
- How to create a master-stack layout
- How to debug layouts with console logging
Prerequisites
Complete Part 1 first.
Why a new layout?
In Part 1 you built a column layout. We could modify it to add configurability, but master-stack is different enough that starting fresh is cleaner. The concepts transfer directly: same protocol, same params, same return type.
Think of Part 1 as learning the mechanics. Part 2 uses those mechanics to build something more sophisticated.
What we’re building
A master-stack layout where the first window (master) takes a larger portion of the screen, and remaining windows stack on the side:
┌─────────────────────────────────────────┬─────────────────┐│ │ ││ │ 2 ││ │ ││ ├─────────────────┤│ │ ││ │ 3 ││ 1 │ ││ ├─────────────────┤│ │ ││ │ 4 ││ │ ││ ├─────────────────┤│ │ ││ │ 5 ││ │ ││ │ ││ │ │└─────────────────────────────────────────┴─────────────────┘Users can adjust:
- Main ratio: How much space the master takes (0.1 to 0.9)
- Number of masters: How many windows appear in the master area
Step 1: Understand capabilities
Capabilities tell Shoji which features a layout supports:
capabilities = { adjustable_ratio = true, -- Responds to ratio changes adjustable_nmaster = true, -- Responds to nmaster changes per_window_resize = false, -- Individual window resizing stateful = false, -- Maintains state between retiles}When adjustable_ratio is false, Shoji disables ratio adjustment hotkeys
for that layout. This prevents confusing “nothing happened” moments when users
press keys that don’t apply.
Step 2: Start the layout
Create ~/.hammerspoon/layouts/mymaster.lua:
local MyMaster = { name = "mymaster", displayName = "My Master",
capabilities = { adjustable_ratio = true, adjustable_nmaster = true, per_window_resize = false, stateful = false, },}
function MyMaster.arrange(params) local geometries = {} local windowIDs = params.windowIDs local workarea = params.workarea local gapInner = params.gap_inner or 0 local mainRatio = params.mainRatio or 0.5 local nmaster = params.nmaster or 1
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
-- TODO: handle multiple windows return geometriesend
return MyMasterNote the new parameters: mainRatio (0.0-1.0, how much space master gets) and
nmaster (how many windows in master area).
Step 3: Calculate master and stack areas
With multiple windows, we split the screen into master (left) and stack (right) regions:
-- After the count == 1 check:
-- Clamp nmaster to valid range (at least 1, at most all windows)nmaster = math.max(1, math.min(nmaster, count))
local masterCount = nmasterlocal stackCount = count - masterCount
-- Calculate widths (subtract gap between master and stack)local availableWidth = workarea.w - gapInnerlocal masterWidth = math.floor(availableWidth * mainRatio)local stackWidth = availableWidth - masterWidthWhy clamp nmaster? Without clamping, nmaster = 10 with 3 windows would
give stackCount = -7, breaking our calculations.
Step 4: Handle all-masters case
When nmaster >= count, all windows are masters and there’s no stack. Handle
this before the main layout logic:
-- If all windows are masters, stack them vertically (no stack area)if stackCount == 0 then local totalGaps = (masterCount - 1) * gapInner local masterHeight = math.floor((workarea.h - totalGaps) / masterCount)
for i, windowID in ipairs(windowIDs) do local yOffset = (i - 1) * (masterHeight + gapInner) local h = (i == count) and (workarea.h - yOffset) or masterHeight
geometries[windowID] = { x = workarea.x, y = workarea.y + yOffset, w = workarea.w, h = h, } end
return geometriesendThis is the same vertical stacking from Part 1’s challenge, just applied to masters.
Step 5: Arrange master windows
Now handle the normal case with both master and stack. First, arrange the master windows vertically in the left region:
-- Arrange master windows vertically in left regionlocal totalMasterGaps = (masterCount - 1) * gapInnerlocal masterHeight = math.floor((workarea.h - totalMasterGaps) / masterCount)
for i = 1, masterCount do local yOffset = (i - 1) * (masterHeight + gapInner) local h = (i == masterCount) and (workarea.h - yOffset) or masterHeight
geometries[windowIDs[i]] = { x = workarea.x, y = workarea.y + yOffset, w = masterWidth, h = h, }endStep 6: Arrange stack windows
Then arrange the remaining windows in the right region:
-- Arrange stack windows vertically in right regionlocal totalStackGaps = (stackCount - 1) * gapInnerlocal stackHeight = math.floor((workarea.h - totalStackGaps) / stackCount)
for i = 1, stackCount do local windowIndex = masterCount + i local yOffset = (i - 1) * (stackHeight + gapInner) local h = (i == stackCount) and (workarea.h - yOffset) or stackHeight
geometries[windowIDs[windowIndex]] = { x = workarea.x + masterWidth + gapInner, y = workarea.y + yOffset, w = stackWidth, h = h, }end
return geometriesNotice we offset the window index by masterCount to skip the master windows.
Step 7: Complete code
Here’s the full implementation:
-- ~/.hammerspoon/layouts/mymaster.lua
local MyMaster = { name = "mymaster", displayName = "My Master",
capabilities = { adjustable_ratio = true, adjustable_nmaster = true, per_window_resize = false, stateful = false, },}
function MyMaster.arrange(params) local geometries = {} local windowIDs = params.windowIDs local workarea = params.workarea local gapInner = params.gap_inner or 0 local mainRatio = params.mainRatio or 0.5 local nmaster = params.nmaster or 1
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
-- Clamp nmaster to valid range nmaster = math.max(1, math.min(nmaster, count))
local masterCount = nmaster local stackCount = count - masterCount
-- All windows in master: stack vertically, no master/stack split if stackCount == 0 then local totalGaps = (masterCount - 1) * gapInner local masterHeight = math.floor((workarea.h - totalGaps) / masterCount)
for i, windowID in ipairs(windowIDs) do local yOffset = (i - 1) * (masterHeight + gapInner) local h = (i == count) and (workarea.h - yOffset) or masterHeight
geometries[windowID] = { x = workarea.x, y = workarea.y + yOffset, w = workarea.w, h = h, } end
return geometries end
-- Calculate master and stack areas local availableWidth = workarea.w - gapInner local masterWidth = math.floor(availableWidth * mainRatio) local stackWidth = availableWidth - masterWidth
-- Arrange master windows vertically local totalMasterGaps = (masterCount - 1) * gapInner local masterHeight = math.floor((workarea.h - totalMasterGaps) / masterCount)
for i = 1, masterCount do local yOffset = (i - 1) * (masterHeight + gapInner) local h = (i == masterCount) and (workarea.h - yOffset) or masterHeight
geometries[windowIDs[i]] = { x = workarea.x, y = workarea.y + yOffset, w = masterWidth, h = h, } end
-- Arrange stack windows vertically local totalStackGaps = (stackCount - 1) * gapInner local stackHeight = math.floor((workarea.h - totalStackGaps) / stackCount)
for i = 1, stackCount do local windowIndex = masterCount + i local yOffset = (i - 1) * (stackHeight + gapInner) local h = (i == stackCount) and (workarea.h - yOffset) or stackHeight
geometries[windowIDs[windowIndex]] = { x = workarea.x + masterWidth + gapInner, y = workarea.y + yOffset, w = stackWidth, h = h, } end
return geometriesend
return MyMasterStep 8: Register and test
Update ~/.hammerspoon/init.lua:
hs.loadSpoon("Shoji")
spoon.Shoji:configure({ layouts = { require("layouts.mymaster"), }, enabled_layouts = { "tall", "mymaster", "fullscreen" },})
spoon.Shoji:start()Verify keybindings work
Open the Hammerspoon console and run:
hs.hotkey.getHotkeys()Look for entries with “shrink_main” and “expand_main” (ratio) and “inc_nmaster”
and “dec_nmaster”. If they’re missing, check your bindHotkeys configuration.
Test scenarios
- Ratio adjustment: Ctrl+Alt+H shrinks master, Ctrl+Alt+L expands it
- NMaster adjustment: Ctrl+Alt+, decreases masters, Ctrl+Alt+. increases
- Edge cases: Test with 1, 2, 5, and 10 windows
- Ratio extremes: Push ratio to minimum (0.1) and maximum (0.9)
Note on ratio limits: Shoji clamps mainRatio between 0.1 and 0.9, so your
layout never gets an extreme value that would hide a region entirely.
With 2 masters and 70% ratio:
┌─────────────────────────────────────────┬─────────────────┐│ │ ││ │ ││ │ 3 ││ │ ││ 1 │ ││ ├─────────────────┤│ │ ││ │ │├─────────────────────────────────────────┤ 4 ││ │ ││ │ ││ ├─────────────────┤│ │ ││ 2 │ ││ │ 5 ││ │ ││ │ │└─────────────────────────────────────────┴─────────────────┘Debugging with console logging
When things don’t work, add print statements:
function MyMaster.arrange(params) print("MyMaster.arrange called") print(" windows:", #params.windowIDs) print(" mainRatio:", params.mainRatio) print(" nmaster:", params.nmaster)
-- ... rest of the functionendOpen the Hammerspoon console (menu bar > Console) to see output. Every retile
triggers arrange, so you’ll see these messages when:
- Windows are created or destroyed
- You change ratio or nmaster
- You switch layouts
- Shoji initializes
What you learned
- Capabilities declare which features a layout supports
params.mainRatiocontrols the master/stack split (0.1 to 0.9)params.nmastercontrols how many windows are masters- Always clamp nmaster to prevent invalid states
- Handle edge cases: all in master, all in stack, single window
Common mistakes
Forgetting to clamp nmaster:
nmaster can be any value. Without math.max(1, math.min(nmaster, count)),
you’ll get negative stack counts or array index errors.
Not handling stackCount == 0: When all windows are masters, there’s no stack region. The math changes.
Capabilities don’t match behavior:
If you declare adjustable_ratio = true but ignore mainRatio, the hotkeys
appear to do nothing.
Assuming mainRatio is always 0.5: Users change it. Test with 0.3 and 0.7 to ensure your layout handles variation.
Common errors
“attempt to index a nil value (field ’?’)”: You’re accessing a window ID
that does not exist. Check your loop bounds. Are you iterating past count?
Windows overlap or have gaps: Check your gap calculations. The total gaps
should be (count - 1) * gapInner, not count * gapInner.
Ratio changes have no effect: Make sure you’re using params.mainRatio,
not a hardcoded value, and that adjustable_ratio = true in capabilities.
Challenge
Add a minimum width check. If masterWidth or stackWidth falls below 100
pixels, clamp it:
local MIN_WIDTH = 100masterWidth = math.max(MIN_WIDTH, masterWidth)stackWidth = math.max(MIN_WIDTH, availableWidth - masterWidth)What happens when both regions can’t fit? How would you handle that gracefully?
Next steps
In Part 3, you’ll learn to transform layouts with modifiers (mirroring, magnifying, and centering) without rewriting code.