Skip to content

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.lua
hs.loadSpoon("Shoji")
local Modifiers = require("layouts.modifiers")
local Mirror = require("layouts.modifiers.mirror")
local MyMaster = require("layouts.mymaster")
-- Wrap MyMaster with horizontal mirror
local 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 magnify
local 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 geometries
local function myModifier(geometries, params)
-- Transform and return
return geometries
end

Let’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 Padding

Why 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, centered
local 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 unchanged
end
-- Insert debug modifier between others to see intermediate state
local 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 = true is 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.lua
hs.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 right
local TallRight = Modifiers.wrap(Tall, Mirror.horizontal(), {
name = "tall-right",
displayName = "Tall (Right)",
})
-- Tall with magnification
local TallMag = Modifiers.wrap(Tall, Magnifier.create({ ratio = 1.4 }), {
name = "tall-mag",
displayName = "Tall + Magnify",
})
-- Tall mirrored and magnified
local 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.wrap applies a single modifier
  • Modifiers.chain applies 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 input
geometries[windowID].x = geometries[windowID].x + 10
-- GOOD: creates new table
result[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
-- ...
end

Hint: 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: