Skip to content

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 geometries
end
return MyMaster

Note 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 = nmaster
local stackCount = count - masterCount
-- Calculate widths (subtract gap between master and stack)
local availableWidth = workarea.w - gapInner
local masterWidth = math.floor(availableWidth * mainRatio)
local stackWidth = availableWidth - masterWidth

Why 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 geometries
end

This 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 region
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

Step 6: Arrange stack windows

Then arrange the remaining windows in the right region:

-- Arrange stack windows vertically in right region
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 geometries

Notice 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 geometries
end
return MyMaster

Step 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

  1. Ratio adjustment: Ctrl+Alt+H shrinks master, Ctrl+Alt+L expands it
  2. NMaster adjustment: Ctrl+Alt+, decreases masters, Ctrl+Alt+. increases
  3. Edge cases: Test with 1, 2, 5, and 10 windows
  4. 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 function
end

Open 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.mainRatio controls the master/stack split (0.1 to 0.9)
  • params.nmaster controls 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 = 100
masterWidth = 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.