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:
- Adaptive focused — uses IfMax to show Monocle for 1-2 windows (deep work) and Tall for 3+ (side-by-side editing)
- 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.luahs.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:
| Windows | Layout | Why |
|---|---|---|
| 1 | Monocle | 1 <= 2, use below |
| 2 | Monocle | 2 <= 2, use below |
| 3 | Tall | 3 > 2, use above |
| 4 | Tall | 4 > 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:
- First split: takes
countwindows (1 in our example) - Next split: takes its
count, or all remaining ifcountis 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 splitUse 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 gapslocal 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.luahs.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 gapslocal 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 withratio,layout, andcount- 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 usinglocal ide, err = Partition.horizontal(splits)if not ide then print("Partition error: " .. err) returnendExpecting 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: