Part 3: Modifiers and composition
You will learn
- How modifiers transform layout output
- How to wrap layouts with built-in modifiers
- How to build custom modifiers
- How to chain multiple modifiers
Prerequisites
Complete Part 2 first. You’ll transform the master layout you built.
Why modifiers?
Imagine you want the tall layout but with the master on the right instead of the left. You could write a new layout, but that duplicates logic.
Modifiers solve this: wrap any layout to transform its output. The layout computes positions, then the modifier adjusts them. One line of code, any layout, no duplication.
Step 1: Mirror your layout
Let’s put the master area on the right using Mirror:
-- ~/.hammerspoon/init.luahs.loadSpoon("Shoji")
local Modifiers = require("layouts.modifiers")local Mirror = require("layouts.modifiers.mirror")local MyMaster = require("layouts.mymaster")
-- Wrap MyMaster with horizontal mirrorlocal MyMasterRight = Modifiers.wrap(MyMaster, Mirror.horizontal(), { name = "mymaster-right", displayName = "My Master (Right)",})
spoon.Shoji:configure({ layouts = { MyMaster, MyMasterRight }, enabled_layouts = { "mymaster", "mymaster-right" },})
spoon.Shoji:start()Without Mirror:
┌─────────────────────────────────────────┬─────────────────┐│ │ ││ │ 2 ││ │ ││ ├─────────────────┤│ │ ││ │ 3 ││ 1 │ ││ ├─────────────────┤│ │ ││ │ 4 ││ │ ││ ├─────────────────┤│ │ ││ │ 5 ││ │ ││ │ ││ │ │└─────────────────────────────────────────┴─────────────────┘With Mirror.horizontal():
┌─────────────────┬─────────────────────────────────────────┐│ │ ││ 2 │ ││ │ │├─────────────────┤ ││ │ ││ 3 │ ││ │ 1 │├─────────────────┤ ││ │ ││ 4 │ ││ │ │├─────────────────┤ ││ │ ││ 5 │ ││ │ ││ │ ││ │ │└─────────────────┴─────────────────────────────────────────┘Step 2: Add magnification
The Magnifier modifier enlarges the focused window:
local Magnifier = require("layouts.modifiers.magnifier")
local MyMasterMag = Modifiers.wrap(MyMaster, Magnifier.create({ ratio = 1.3 }), { name = "mymaster-mag", displayName = "My Master + Magnify",})Important: Enable retile_on_focus to update magnification when focus
changes:
spoon.Shoji:configure({ retile_on_focus = true, layouts = { MyMaster, MyMasterMag }, enabled_layouts = { "mymaster", "mymaster-mag" },})Without this, magnification only updates when windows are created/destroyed.
Step 3: Chain multiple modifiers
Apply multiple transformations with Modifiers.chain:
local Modifiers = require("layouts.modifiers")local Magnifier = require("layouts.modifiers.magnifier")local Mirror = require("layouts.modifiers.mirror")local MyMaster = require("layouts.mymaster")
-- Mirror first, then magnifylocal MyMasterRightMag = Modifiers.chain(MyMaster, { Mirror.horizontal(), Magnifier.create({ ratio = 1.2 }),}, { name = "mymaster-right-mag", displayName = "My Master (Right) + Magnify",})Order matters. These produce different results:
-- Mirror, then magnify: master flips to right, then focused enlarges{ Mirror.horizontal(), Magnifier.create() }
-- Magnify, then mirror: focused enlarges, then everything (including-- the enlarged window) flips horizontally{ Magnifier.create(), Mirror.horizontal() }The difference is subtle but visible. When debugging, try reversing the order to see if you get the result you expected.
Step 4: Build a custom modifier
A modifier is a function that transforms geometries. It receives the layout’s output and returns modified positions:
---@param geometries table<WindowID, Frame> -- Input from layout---@param params LayoutParams -- Same params layout received---@return table<WindowID, Frame> -- Modified geometrieslocal function myModifier(geometries, params) -- Transform and return return geometriesendLet’s build a “Padding” modifier that adds extra space around each window. We’ll build it properly from the start with metadata and edge case handling:
-- ~/.hammerspoon/modifiers/padding.lua
local Padding = {}
local MIN_DIMENSION = 50 -- Minimum window size after padding
function Padding.create(amount) amount = amount or 20
local modifier = function(geometries, _params) local result = {}
for windowID, frame in pairs(geometries) do -- Calculate effective padding for THIS window (don't exceed available space) local maxPadX = math.floor((frame.w - MIN_DIMENSION) / 2) local maxPadY = math.floor((frame.h - MIN_DIMENSION) / 2) local effectivePad = math.min(amount, maxPadX, maxPadY) effectivePad = math.max(0, effectivePad) -- Never negative
result[windowID] = { x = frame.x + effectivePad, y = frame.y + effectivePad, w = frame.w - (effectivePad * 2), h = frame.h - (effectivePad * 2), } end
return result end
-- Return modifier with metadata for auto-naming return { fn = modifier, name = "padding", displayName = "Padding", preservesActions = true, }end
return PaddingWhy calculate effective padding per window? Each window might have different dimensions. A small window in the stack might not fit the full padding amount, so we clamp it to what fits while maintaining minimum dimensions.
Use it:
local Padding = require("modifiers.padding")
local PaddedMaster = Modifiers.wrap(MyMaster, Padding.create(30), { name = "mymaster-padded", displayName = "My Master (Padded)",})Step 5: Understand modifier metadata
The metadata object tells Shoji how to handle the modifier:
return { fn = modifier, -- The actual transform function name = "padding", -- Used for auto-generated layout names displayName = "Padding", -- Used for auto-generated display names preservesActions = true, -- Whether base layout actions still work}When to set preservesActions:
-
true: The modifier only adjusts positions/sizes without changing window relationships. Actions like “focus next” or “swap with master” still make sense. Examples: Padding, Mirror, Centered, Gaps. -
false: The modifier fundamentally changes window relationships or ordering. The base layout’s actions would produce confusing results. Example: a modifier that randomizes window positions. In this case, “swap with next” would be meaningless.
Most modifiers preserve actions. Use false only when the transformation
breaks the spatial logic that actions depend on.
With metadata, names auto-generate:
local padded = Modifiers.wrap(Tall, Padding.create(20))-- padded.name = "tall+padding"-- padded.displayName = "Tall + Padding"You can still override with explicit names in the options.
Step 6: Use the Centered modifier
The built-in Centered modifier scales and centers the layout within a smaller area:
local Centered = require("layouts.modifiers.centered")
-- Use 70% of screen, centeredlocal CenteredMaster = Modifiers.wrap(MyMaster, Centered.ratio(0.7), { name = "mymaster-centered", displayName = "My Master (Centered)",})┌───────────────────────────────────────────────────────────┐│ ││ ┌───────────────────────────┬───────────────────────┐ ││ │ │ │ ││ │ │ 2 │ ││ │ │ │ ││ │ ├───────────────────────┤ ││ │ 1 │ │ ││ │ │ 3 │ ││ │ │ │ ││ │ ├───────────────────────┤ ││ │ │ │ ││ │ │ 4 │ ││ │ │ │ ││ └───────────────────────────┴───────────────────────┘ ││ ││ ││ │└───────────────────────────────────────────────────────────┘Debugging modifier chains
When a modifier chain doesn’t produce expected results, add a debug modifier to inspect intermediate geometries:
local function debugModifier(geometries, params) print("=== Debug: geometries after previous modifier ===") for id, frame in pairs(geometries) do print(string.format(" Window %d: x=%d y=%d w=%d h=%d", id, frame.x, frame.y, frame.w, frame.h)) end return geometries -- Pass through unchangedend
-- Insert debug modifier between others to see intermediate statelocal debuggedLayout = Modifiers.chain(Tall, { Mirror.horizontal(), debugModifier, -- See state after mirror Magnifier.create(),})Open the Hammerspoon console to see output when the layout arranges windows.
Common debugging questions:
- “Why aren’t windows where I expect?” Add debug modifiers between each step to see where the positions diverge from expectations.
- “Is my modifier being called?” Add a
print("modifier called")at the start of your modifier function. - “Why does focus-based magnification not update?” Check that
retile_on_focus = trueis set in your config.
When to use modifiers vs custom layouts
Use modifiers when:
- Transforming positions (mirror, scale, offset)
- Adding visual effects (magnification, gaps)
- Creating variations of existing layouts
- The transformation is independent of layout logic
Write a custom layout when:
- The arrangement logic is fundamentally different
- You need custom capabilities or actions
- The transformation depends on understanding window relationships
- Modifiers can’t express what you need
Complete example
Here’s a full init.lua with multiple modified layouts:
-- ~/.hammerspoon/init.luahs.loadSpoon("Shoji")
local Modifiers = require("layouts.modifiers")local Magnifier = require("layouts.modifiers.magnifier")local Mirror = require("layouts.modifiers.mirror")local Centered = require("layouts.modifiers.centered")local Tall = require("layouts.tall")
-- Tall with master on rightlocal TallRight = Modifiers.wrap(Tall, Mirror.horizontal(), { name = "tall-right", displayName = "Tall (Right)",})
-- Tall with magnificationlocal TallMag = Modifiers.wrap(Tall, Magnifier.create({ ratio = 1.4 }), { name = "tall-mag", displayName = "Tall + Magnify",})
-- Tall mirrored and magnifiedlocal TallRightMag = Modifiers.chain(Tall, { Mirror.horizontal(), Magnifier.create({ ratio = 1.3 }),}, { name = "tall-right-mag", displayName = "Tall (Right) + Magnify",})
-- Centered tall at 80%local TallCentered = Modifiers.wrap(Tall, Centered.ratio(0.8), { name = "tall-centered", displayName = "Tall (Centered)",})
spoon.Shoji:configure({ retile_on_focus = true, layouts = { Tall, TallRight, TallMag, TallRightMag, TallCentered }, enabled_layouts = { "tall", "tall-right", "tall-mag", "tall-right-mag", "tall-centered", },})
spoon.Shoji:start()What you learned
- Modifiers transform layout output without changing layout code
Modifiers.wrapapplies a single modifierModifiers.chainapplies multiple modifiers in sequence- Order matters when chaining modifiers
- Custom modifiers should handle edge cases (minimum dimensions)
- Metadata enables auto-generated names and action preservation
- Debug modifiers help troubleshoot chains
Common mistakes
Mutating input frames: Always create new frame tables. Do not modify the input geometries, as they may be reused.
-- BAD: mutates inputgeometries[windowID].x = geometries[windowID].x + 10
-- GOOD: creates new tableresult[windowID] = { x = frame.x + 10, y = frame.y, w = frame.w, h = frame.h,}Not handling edge cases: A modifier that shrinks windows can create zero or negative dimensions. Always clamp to minimum sizes.
Forgetting retile_on_focus: Magnifier and other focus-dependent modifiers need this to update when focus changes.
Wrong modifier order: Test your chain order. When results are unexpected, try reversing the order or adding debug modifiers to see intermediate states.
Common errors
“attempt to call a table value”: You passed a modifier object instead of
calling it. Use Magnifier.create() not Magnifier.create.
“modifier must be a function”: Your modifier is returning the wrong type.
Return either a function or an object with an fn field.
Layout name conflicts: Two layouts with the same name will conflict. Use
explicit names when creating multiple variations of the same base layout.
Challenge
Build a “Shrink” modifier that reduces each window by a percentage of its own size (not a fixed amount), centering the smaller window in its original space:
function Shrink.create(ratio) -- e.g., 0.9 for 10% smaller ratio = ratio or 0.9 -- ...endHint: For a window at (100, 100) with size (400, 300), shrunk to 90%:
- New size: 360 × 270
- Offset to center: (400-360)/2 = 20, (300-270)/2 = 15
- New position: (120, 115)
Next steps
You’ve completed the tutorials. For reference documentation, see:
- Layout protocol — Full protocol specification
- Modifiers — Built-in modifiers
- Chaining modifiers — Composition patterns