Skip to content

Guide 2: Composing layouts with combinators

You will learn

  • How combinators compose layouts into new ones
  • How to use IfMax to switch layouts based on window count
  • How to use Partition to divide the screen into regions
  • How to compose combinators with modifiers
  • How to build purpose-specific layouts declaratively

Prerequisites

Shoji installed and running. Familiarity with built-in layouts (Tall, Monocle, Column). The layout tutorials are recommended but not required.

What we’re building

Two purpose-specific layouts that you register alongside the built-in ones and cycle between:

  1. Adaptive focused — uses IfMax to show Monocle for 1-2 windows (deep work) and Tall for 3+ (side-by-side editing)
  2. IDE layout — uses Partition to split the screen: editor left (65%, Tall with 1 main window), references and terminals right (35%, Column). Then wraps the result with Gaps for breathing room.

By the end you’ll have four layouts in your cycle — Tall, Monocle, and the two you build here — each serving a distinct purpose.

Step 1: The problem IfMax solves

You use Monocle for focused work on a single document. When a second window appears you want Tall for side-by-side editing. Today you cycle manually every time. IfMax automates this: when the window count crosses a threshold, it switches layouts for you.

Start by creating the layout in your init.lua:

-- ~/.hammerspoon/init.lua
hs.loadSpoon("Shoji")
local IfMax = require("layouts.combinators.if_max")
local Monocle = require("layouts.monocle")
local Tall = require("layouts.tall")
local adaptive = IfMax.create(2, Monocle, Tall, {
name = "adaptive",
displayName = "Adaptive",
})
spoon.Shoji:configure({
layouts = { adaptive },
enabled_layouts = { "adaptive" },
})
spoon.Shoji:start()

Reload Hammerspoon, then open and close windows. With 1 or 2 windows you see Monocle. Open a third and Tall takes over automatically.

Step 2: Understand the threshold

IfMax.create(threshold, belowLayout, aboveLayout) works like this:

  • Window count <= threshold -> belowLayout
  • Window count > threshold -> aboveLayout

With threshold = 2:

WindowsLayoutWhy
1Monocle1 <= 2, use below
2Monocle2 <= 2, use below
3Tall3 > 2, use above
4Tall4 > 2, use above

With 1-2 windows, Monocle fills the screen:

┌───────────────────────────────────────────────────────────┐
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ 1 or 2 │
│ (Monocle) │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└───────────────────────────────────────────────────────────┘

Open a third window and Tall arranges them:

┌─────────────────────────────┬─────────────────────────────┐
│ │ │
│ │ 2 │
│ │ │
│ ├─────────────────────────────┤
│ │ │
│ │ 3 │
│ │ │
│ 1 ├─────────────────────────────┤
│ │ │
│ (Tall) │ 4 │
│ │ │
│ ├─────────────────────────────┤
│ │ │
│ │ 5 │
│ │ │
│ │ │
│ │ │
└─────────────────────────────┴─────────────────────────────┘

Step 3: Register and test capabilities

Add the adaptive layout alongside Tall and Monocle so you can cycle between them:

hs.loadSpoon("Shoji")
local IfMax = require("layouts.combinators.if_max")
local Monocle = require("layouts.monocle")
local Tall = require("layouts.tall")
local adaptive = IfMax.create(2, Monocle, Tall, {
name = "adaptive",
displayName = "Adaptive",
})
spoon.Shoji:configure({
layouts = { adaptive },
enabled_layouts = { "tall", "monocle", "adaptive" },
})
spoon.Shoji:start()

Cycle to the adaptive layout (Ctrl+Alt+Space) and open 3+ windows. Now try the ratio adjustment hotkeys (Ctrl+Alt+= / Ctrl+Alt+-). They work because Tall has adjustable_ratio = true and IfMax aggregates capabilities from its children. You get Tall’s ratio controls for free.

Step 4: Child state persists

Open 3+ windows so Tall is active. Adjust the ratio to 70%. Now close windows until only 2 remain — Monocle activates. Open a third window again. Tall returns with the 70% ratio you set earlier.

Each child layout maintains its own state independently. Switching between them preserves their individual settings. This even survives Hammerspoon restarts.

Step 5: The problem Partition solves

The adaptive layout handles window count changes automatically. But sometimes you want a fixed spatial arrangement: editor on the left, references and terminals on the right. This is a different kind of problem — dividing the screen into regions with dedicated layouts for each.

Partition splits the workarea into regions and assigns windows to each:

local Partition = require("layouts.combinators.partition")
local Column = require("layouts.column")
local Tall = require("layouts.tall")
local ide = Partition.horizontal({
{ ratio = 0.65, layout = Tall, count = 1 },
{ ratio = 0.35, layout = Column },
}, {
name = "ide",
displayName = "IDE",
})

This creates two regions:

  • Left 65%: The first window in Tall layout (your editor)
  • Right 35%: All remaining windows stacked in Column (references, terminals)
┌─────────────────────────────────────────┬─────────────────┐
│ │ │
│ │ 2 │
│ │ │
│ ├─────────────────┤
│ │ │
│ │ 3 │
│ │ │
│ 1 ├─────────────────┤
│ │ │
│ (Tall) │ 4 │
│ │ │
│ ├─────────────────┤
│ │ │
│ │ │
│ │ 5 │
│ │ │
│ │ │
└─────────────────────────────────────────┴─────────────────┘

Step 6: Understand window assignment

Each split has a count field that controls how many windows it receives. Windows are assigned in order:

  1. First split: takes count windows (1 in our example)
  2. Next split: takes its count, or all remaining if count is omitted

The last split (or any split without count) acts as a catch-all for remaining windows. This is why the right region in our IDE layout always gets whatever is left after the editor takes its one window.

{ ratio = 0.65, layout = Tall, count = 1 }, -- Window 1
{ ratio = 0.35, layout = Column }, -- Windows 2+

Step 7: Ratio normalization

Ratios don’t need to sum to 1.0. Partition normalizes them proportionally. These are all equivalent:

-- Explicit fractions
{ ratio = 0.65 }, { ratio = 0.35 }
-- Proportional integers
{ ratio = 65 }, { ratio = 35 }
-- Any proportional values
{ ratio = 13 }, { ratio = 7 } -- Same 65/35 split

Use whichever style is clearest. Fractions make the percentages obvious; integers work well for simple ratios like { ratio = 2 }, { ratio = 1 } (two-thirds / one-third).

Step 8: Add Gaps with Modifiers.wrap

The IDE layout works, but the windows feel cramped. Wrap it with Gaps to add breathing room:

local Modifiers = require("layouts.modifiers")
local Gaps = require("layouts.modifiers.gaps")
local Partition = require("layouts.combinators.partition")
local Column = require("layouts.column")
local Tall = require("layouts.tall")
local ide = Partition.horizontal({
{ ratio = 0.65, layout = Tall, count = 1 },
{ ratio = 0.35, layout = Column },
}, {
name = "ide",
displayName = "IDE",
})
local ideGapped = Modifiers.wrap(ide, Gaps.create({
outer = 8,
inner = 8,
}), {
name = "ide-gapped",
displayName = "IDE (Gapped)",
})

Combinators produce standard layouts, so modifiers work on them exactly as they do on built-in layouts. You can also use Modifiers.chain to apply multiple modifiers (Mirror, Gaps, Centered) in sequence.

Step 9: Register everything

Put it all together. Register both custom layouts alongside the built-ins:

hs.loadSpoon("Shoji")
local IfMax = require("layouts.combinators.if_max")
local Partition = require("layouts.combinators.partition")
local Modifiers = require("layouts.modifiers")
local Gaps = require("layouts.modifiers.gaps")
local Monocle = require("layouts.monocle")
local Tall = require("layouts.tall")
local Column = require("layouts.column")
-- Adaptive: Monocle for 1-2 windows, Tall for 3+
local adaptive = IfMax.create(2, Monocle, Tall, {
name = "adaptive",
displayName = "Adaptive",
})
-- IDE: editor left, references right, with gaps
local ide = Partition.horizontal({
{ ratio = 0.65, layout = Tall, count = 1 },
{ ratio = 0.35, layout = Column },
}, {
name = "ide",
displayName = "IDE",
})
local ideGapped = Modifiers.wrap(ide, Gaps.create({
outer = 8,
inner = 8,
}), {
name = "ide-gapped",
displayName = "IDE (Gapped)",
})
spoon.Shoji:configure({
layouts = { adaptive, ideGapped },
enabled_layouts = {
"tall",
"monocle",
"adaptive",
"ide-gapped",
},
})
spoon.Shoji:start()

Cycle through the four layouts. Each serves a different purpose:

  • Tall: General split with main area and stack
  • Monocle: Single focused window
  • Adaptive: Automatically picks Monocle or Tall
  • IDE (Gapped): Fixed editor/references split

This is the key idea: build purpose-specific layouts for how you work, then cycle to the one that fits your current task. Two focused layouts beat one complex layout that tries to handle everything.

Complete code

Here’s the final init.lua:

-- ~/.hammerspoon/init.lua
hs.loadSpoon("Shoji")
local IfMax = require("layouts.combinators.if_max")
local Partition = require("layouts.combinators.partition")
local Modifiers = require("layouts.modifiers")
local Gaps = require("layouts.modifiers.gaps")
local Monocle = require("layouts.monocle")
local Tall = require("layouts.tall")
local Column = require("layouts.column")
-- Adaptive: Monocle for 1-2 windows, Tall for 3+
local adaptive = IfMax.create(2, Monocle, Tall, {
name = "adaptive",
displayName = "Adaptive",
})
-- IDE: editor left, references right, with gaps
local ide = Partition.horizontal({
{ ratio = 0.65, layout = Tall, count = 1 },
{ ratio = 0.35, layout = Column },
}, {
name = "ide",
displayName = "IDE",
})
local ideGapped = Modifiers.wrap(ide, Gaps.create({
outer = 8,
inner = 8,
}), {
name = "ide-gapped",
displayName = "IDE (Gapped)",
})
spoon.Shoji:configure({
layouts = { adaptive, ideGapped },
enabled_layouts = {
"tall",
"monocle",
"adaptive",
"ide-gapped",
},
})
spoon.Shoji:start()

What you learned

  • Combinators compose layouts into new ones declaratively
  • IfMax.create(threshold, below, above) switches layouts based on window count (<= uses below, > uses above)
  • Partition.horizontal(splits) divides the screen into regions with ratio, layout, and count
  • Capabilities aggregate from children automatically
  • Child layout state persists independently
  • Combinators produce standard layouts, so modifiers work on them (Gaps, Mirror, Centered)
  • Purpose-specific layouts beat one-size-fits-all

Common mistakes

Forgetting count on non-last splits: Without count, a split receives all remaining windows. If you omit count on the first split, every window goes to the first region and the second region stays empty.

-- BAD: all windows go to the first region
{ ratio = 0.65, layout = Tall },
{ ratio = 0.35, layout = Column },
-- GOOD: first region gets 1, rest flows to second
{ ratio = 0.65, layout = Tall, count = 1 },
{ ratio = 0.35, layout = Column },

Setting count on the last split: If you set count on every split, windows beyond the total count have nowhere to go. The last split should always omit count to collect remaining windows.

Not handling the nil, error return: Both IfMax.create and Partition.horizontal return nil, errorString on invalid input. If you pass the result directly to Modifiers.wrap or another combinator without checking, you’ll get a confusing error about a nil layout.

-- SAFE: check before using
local ide, err = Partition.horizontal(splits)
if not ide then
print("Partition error: " .. err)
return
end

Expecting ratio adjustment with no capable child: If you build IfMax.create(2, Monocle, Monocle), neither child supports ratio adjustment. The ratio hotkeys do nothing. Capabilities only aggregate what the children actually support.

Common errors

“threshold must be a positive integer”: You passed 0, a negative number, or a non-integer to IfMax.create. The threshold must be a positive whole number like 1, 2, or 3.

“IfMax.create(): belowLayout is nil”: One of the layouts passed to IfMax.create is nil. This raises a hard error because a nil layout always means an upstream step failed. Check that your require calls return valid layouts and that you haven’t misspelled the module path.

“ratio at index N must be a positive number”: A split in your Partition has ratio = 0 or a negative ratio. All ratios must be positive.

“layout at index N is invalid”: A split in your Partition has a layout that doesn’t conform to the layout protocol (missing name or arrange). Check that the layout table is correct.

Layout doesn’t appear in cycle: Check that the layout’s name is in both layouts and enabled_layouts in your config. The name must match exactly.

Challenge

Build a “presentation mode” layout using Partition.vertical: top 85% is Monocle (the slides), bottom 15% is Column (speaker notes, timer, chat). Then wrap it with Centered.ratio(0.9) so the presentation doesn’t touch the screen edges:

local Centered = require("layouts.modifiers.centered")
-- Build Partition.vertical with Monocle and Column
-- Then wrap with Modifiers.wrap and Centered.ratio(0.9)

The result should look like this:

┌───────────────────────────────────────────────────────────┐
│ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ 1 (slides) │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ ├───────────────┬─────────────────┬─────────────────┤ │
│ │ │ │ │ │
│ │ 2 (notes) │ 3 (timer) │ 4 (chat) │ │
│ │ │ │ │ │
│ └───────────────┴─────────────────┴─────────────────┘ │
│ │
│ │
└───────────────────────────────────────────────────────────┘

Next steps

In Guide 3, you’ll register custom actions and IPC commands to control Shoji from hotkeys and shell scripts.

For more combinator examples, see: