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

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

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

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

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 = 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 geometries

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

Step 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

  1. Ratio adjustment: Ctrl+Alt+- shrinks main area, Ctrl+Alt+= expands it
  2. Main count adjustment: Ctrl+Alt+, increases main windows, Ctrl+Alt+. decreases
  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 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 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 main_count
  • You switch layouts
  • Shoji initializes

What you learned

  • Capabilities declare which features a layout supports
  • params.mainRatio controls the main/stack split (0.1 to 0.9)
  • params.mainCount controls 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 = 100
mainWidth = 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.