Part 2: Making it configurable
You will learn
- How to declare layout capabilities
- How to read configuration parameters
- How to create a main-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 main-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 main-stack layout where the first window (main) 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 main area takes (0.1 to 0.9)
- Number of main windows: How many windows appear in the main area
Step 1: Understand capabilities
Capabilities tell Shoji which features a layout supports:
capabilities = { adjustable_ratio = true, -- Responds to ratio changes adjustable_main_count = true, -- Responds to main_count 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/mymain.lua:
local MyMain = { name = "mymain", displayName = "My Main", description = "Main area left, stack right",
capabilities = { adjustable_ratio = true, adjustable_main_count = true, per_window_resize = false, stateful = false, },}
function MyMain.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 mainCount = params.mainCount 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 MyMainNote the new parameters: mainRatio (0.0-1.0, how much space main area gets)
and mainCount (how many windows in main area).
Step 3: Calculate main and stack areas
With multiple windows, we split the screen into main (left) and stack (right) regions:
-- After the count == 1 check:
-- Clamp mainCount to valid range (at least 1, at most all windows)mainCount = math.max(1, math.min(mainCount, count))
local mainWindowCount = mainCountlocal stackCount = count - mainWindowCount
-- Calculate widths (subtract gap between main and stack)local availableWidth = workarea.w - gapInnerlocal mainWidth = math.floor(availableWidth * mainRatio)local stackWidth = availableWidth - mainWidthWhy clamp mainCount? Without clamping, mainCount = 10 with 3 windows would
give stackCount = -7, breaking our calculations.
Step 4: Handle all-main-windows case
When mainCount >= count, all windows are in the main area and there’s no
stack. Handle this before the main layout logic:
-- If all windows are in main area, stack them vertically (no stack area)if stackCount == 0 then local totalGaps = (mainWindowCount - 1) * gapInner local mainHeight = math.floor((workarea.h - totalGaps) / mainWindowCount)
for i, windowID in ipairs(windowIDs) do local yOffset = (i - 1) * (mainHeight + gapInner) local h = (i == count) and (workarea.h - yOffset) or mainHeight
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 main windows.
Step 5: Arrange main windows
Now handle the normal case with both main and stack. First, arrange the main windows vertically in the left region:
-- Arrange main windows vertically in left regionlocal totalMainGaps = (mainWindowCount - 1) * gapInnerlocal mainHeight = math.floor((workarea.h - totalMainGaps) / mainWindowCount)
for i = 1, mainWindowCount do local yOffset = (i - 1) * (mainHeight + gapInner) local h = (i == mainWindowCount) and (workarea.h - yOffset) or mainHeight
geometries[windowIDs[i]] = { x = workarea.x, y = workarea.y + yOffset, w = mainWidth, 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 = mainWindowCount + i local yOffset = (i - 1) * (stackHeight + gapInner) local h = (i == stackCount) and (workarea.h - yOffset) or stackHeight
geometries[windowIDs[windowIndex]] = { x = workarea.x + mainWidth + gapInner, y = workarea.y + yOffset, w = stackWidth, h = h, }end
return geometriesNotice we offset the window index by mainWindowCount to skip the main windows.
Step 7: Complete code
Here’s the full implementation:
-- ~/.hammerspoon/layouts/mymain.lua
local MyMain = { name = "mymain", displayName = "My Main", description = "Main area left, stack right",
capabilities = { adjustable_ratio = true, adjustable_main_count = true, per_window_resize = false, stateful = false, },}
function MyMain.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 mainCount = params.mainCount 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 mainCount to valid range mainCount = math.max(1, math.min(mainCount, count))
local mainWindowCount = mainCount local stackCount = count - mainWindowCount
-- All windows in main: stack vertically, no main/stack split if stackCount == 0 then local totalGaps = (mainWindowCount - 1) * gapInner local mainHeight = math.floor((workarea.h - totalGaps) / mainWindowCount)
for i, windowID in ipairs(windowIDs) do local yOffset = (i - 1) * (mainHeight + gapInner) local h = (i == count) and (workarea.h - yOffset) or mainHeight
geometries[windowID] = { x = workarea.x, y = workarea.y + yOffset, w = workarea.w, h = h, } end
return geometries end
-- Calculate main and stack areas local availableWidth = workarea.w - gapInner local mainWidth = math.floor(availableWidth * mainRatio) local stackWidth = availableWidth - mainWidth
-- Arrange main windows vertically local totalMainGaps = (mainWindowCount - 1) * gapInner local mainHeight = math.floor((workarea.h - totalMainGaps) / mainWindowCount)
for i = 1, mainWindowCount do local yOffset = (i - 1) * (mainHeight + gapInner) local h = (i == mainWindowCount) and (workarea.h - yOffset) or mainHeight
geometries[windowIDs[i]] = { x = workarea.x, y = workarea.y + yOffset, w = mainWidth, 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 = mainWindowCount + i local yOffset = (i - 1) * (stackHeight + gapInner) local h = (i == stackCount) and (workarea.h - yOffset) or stackHeight
geometries[windowIDs[windowIndex]] = { x = workarea.x + mainWidth + gapInner, y = workarea.y + yOffset, w = stackWidth, h = h, } end
return geometriesend
return MyMainStep 8: Register and test
Update ~/.hammerspoon/init.lua:
hs.loadSpoon("Shoji")
spoon.Shoji:configure({ layouts = { require("layouts.mymain"), }, enabled_layouts = { "tall", "mymain", "monocle" },})
spoon.Shoji:start()Verify keybindings work
Open the Hammerspoon console and run:
hs.hotkey.getHotkeys()Look for entries with increase_main_ratio and
decrease_main_ratio (ratio) and increase_main_count and
decrease_main_count. If they’re missing, check your
bindHotkeys configuration.
Test scenarios
- Ratio adjustment: Ctrl+Alt+- shrinks main area, Ctrl+Alt+= expands it
- Main count adjustment: Ctrl+Alt+, increases main windows, Ctrl+Alt+. decreases
- 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 main windows and 70% ratio:
┌─────────────────────────────────────────┬─────────────────┐│ │ ││ │ ││ │ 3 ││ │ ││ 1 │ ││ ├─────────────────┤│ │ ││ │ │├─────────────────────────────────────────┤ 4 ││ │ ││ │ ││ ├─────────────────┤│ │ ││ 2 │ ││ │ 5 ││ │ ││ │ │└─────────────────────────────────────────┴─────────────────┘Debugging with console logging
When things don’t work, add print statements:
function MyMain.arrange(params) print("MyMain.arrange called") print(" windows:", #params.windowIDs) print(" mainRatio:", params.mainRatio) print(" mainCount:", params.mainCount)
-- ... 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 main_count
- You switch layouts
- Shoji initializes
What you learned
- Capabilities declare which features a layout supports
params.mainRatiocontrols the main/stack split (0.1 to 0.9)params.mainCountcontrols how many windows are in the main area- Always clamp mainCount to prevent invalid states
- Handle edge cases: all in main, all in stack, single window
Common mistakes
Forgetting to clamp mainCount:
mainCount can be any value. Without math.max(1, math.min(mainCount, count)),
you’ll get negative stack counts or array index errors.
Not handling stackCount == 0: When all windows are in the main area, 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 mainWidth or stackWidth falls below 100
pixels, clamp it:
local MIN_WIDTH = 100mainWidth = math.max(MIN_WIDTH, mainWidth)stackWidth = math.max(MIN_WIDTH, availableWidth - mainWidth)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.