Skip to content

Layout modifiers

Modifiers transform window positions after the base layout arranges them. The layout computes positions, then modifiers adjust the results.

This keeps layouts simple while allowing flexible customization. Want the tall layout with the master on the right? Wrap it with a horizontal mirror instead of writing a new layout.

Basic usage

Wrap a layout with a modifier using Modifiers.wrap:

local Modifiers = require("layouts.modifiers")
local Magnifier = require("layouts.modifiers.magnifier")
local Tall = require("layouts.tall")
-- Create a magnified version of the tall layout
local magnifiedTall = Modifiers.wrap(Tall, Magnifier.create({ ratio = 1.5 }))

The wrapped layout works like the original but applies the modifier after computing positions.

Available modifiers

Magnifier

Enlarges the focused window while reflowing others to minimize overlap. Keeps the active window prominent without hiding the rest.

local Magnifier = require("layouts.modifiers.magnifier")
-- Default 1.5x magnification with reflow
local modifier = Magnifier.create()
-- Custom ratio with minimum window size
local modifier = Magnifier.create({ ratio = 2.0, minSize = 100 })
-- Disable reflow (other windows may overlap)
local modifier = Magnifier.create({ ratio = 1.5, reflow = false })

Options:

  • ratio (number, default: 1.5): Magnification factor for focused window
  • reflow (boolean, default: true): Push other windows away from magnified window
  • minSize (number, default: 50): Minimum window dimension after reflow

The magnifier centers the enlarged window around its original position, clamped to screen bounds. When reflow is enabled, overlapping windows are pushed in whichever direction minimizes displacement.

Important: Enable retile_on_focus to update magnification on focus change:

spoon.Shoji:configure({
retile_on_focus = true,
})

Without this, the magnifier only updates when a retile is triggered.

Mirror

Flips window positions horizontally or vertically around the screen center.

local Mirror = require("layouts.modifiers.mirror")
-- Mirror X positions (left becomes right)
local hMirror = Mirror.horizontal()
-- Mirror Y positions (top becomes bottom)
local vMirror = Mirror.vertical()

Common uses:

  • Put master on the right instead of left (horizontal mirror)
  • Create bottom-up layouts from top-down ones (vertical mirror)

Gaps

Adds extra spacing beyond global gap settings.

local Gaps = require("layouts.modifiers.gaps")
-- Add extra inner spacing between windows
local modifier = Gaps.add({ inner = 16 })
-- Add both inner and outer spacing
local modifier = Gaps.add({ inner = 4, outer = 12 })

Options:

  • inner (number, optional): Extra gap between windows
  • outer (number, optional): Extra gap around screen edges

These values add to any global gap configuration.

Centered

Scales and centers the layout within a smaller screen area.

local Centered = require("layouts.modifiers.centered")
-- Use 80% of the screen, centered
local modifier = Centered.ratio(0.8)
-- Use 60% for a more focused view
local modifier = Centered.ratio(0.6)

Options:

  • ratio (number, 0.0-1.0): Fraction of workarea to use (e.g., 0.8 = 80%)

All windows scale proportionally and center on screen. Works with any layout and any number of windows.

Chaining modifiers

Apply multiple modifiers in sequence with Modifiers.chain:

local Modifiers = require("layouts.modifiers")
local Magnifier = require("layouts.modifiers.magnifier")
local Mirror = require("layouts.modifiers.mirror")
local Tall = require("layouts.tall")
-- Mirror horizontally, then magnify the focused window
local layout = Modifiers.chain(Tall, {
Mirror.horizontal(),
Magnifier.create({ ratio = 1.3 }),
})

Modifiers run in array order. The base layout computes geometries, then each modifier transforms them in sequence. Order matters when effects interact (mirroring before or after magnification produces different results).

Layout naming

Wrapped layouts inherit the base layout’s name and displayName by default. This can cause conflicts when registering multiple variants of the same layout.

Custom names

Set custom names in the options:

local wrapped = Modifiers.wrap(Tall, Mirror.horizontal(), {
name = "tall-right",
displayName = "Tall (Right)",
})

Auto-generated names

Built-in modifiers include metadata that auto-generates names:

-- Mirror.horizontal() returns:
-- { fn = ..., name = "mirror-h", displayName = "Mirror H",
-- preservesActions = true }
-- Mirror.vertical() returns:
-- { fn = ..., name = "mirror-v", displayName = "Mirror V",
-- preservesActions = true }
-- Magnifier.create() returns:
-- { fn = ..., name = "magnify", displayName = "Magnify" }
-- Wrapped layout auto-generates name: "tall+mirror-h+magnify"
-- And displayName: "Tall + Mirror H + Magnify"
local wrapped = Modifiers.chain(Tall, {
Mirror.horizontal(),
Magnifier.create(),
})

Override generated names with explicit options:

local wrapped = Modifiers.chain(Tall, {
Mirror.horizontal(),
Magnifier.create(),
}, { name = "my-layout", displayName = "My Custom Layout" })

Naming priority:

  1. Explicit options.name / options.displayName (highest)
  2. Auto-derived from modifier metadata (base+modifier / Base + Modifier)
  3. Base layout name/displayName (fallback)

Preserving layout actions

Wrapped layouts do not inherit base layout actions by default. This prevents modifiers from breaking action handlers that depend on specific geometry assumptions.

To preserve actions, pass preserveActions:

local wrapped = Modifiers.wrap(baseLayout, modifier, { preserveActions = true })

Or set preservesActions = true on a modifier object to make it the default (options still override):

local magnifier = {
name = "magnifier",
preservesActions = true,
fn = Magnifier.create(),
}

When to preserve actions:

  • Modifier only affects visual presentation (magnifier, mirror, gaps)
  • Layout actions (resizing, etc.) work correctly with modified positions

When NOT to preserve:

  • Modifier changes structure in ways that break action assumptions
  • Actions depend on geometry relationships that modifiers change

Error handling

Both wrap and chain return nil and an error string on invalid input:

local wrapped, err = Modifiers.wrap(nil, function() end)
if not wrapped then
print("Error: " .. err) -- "layout must be a table"
end
local wrapped, err = Modifiers.wrap(layout, "not a function")
if not wrapped then
print("Error: " .. err) -- "modifier must be a function"
end
local wrapped, err = Modifiers.chain(layout, {})
if not wrapped then
print("Error: " .. err) -- "at least one modifier required"
end

Custom modifiers

A modifier is a function that receives geometries and returns modified ones:

---@param geometries GeometryMap -- Window ID to frame mapping
---@param params LayoutParams -- Layout params (workarea, focusedWindowID)
---@return GeometryMap -- Modified geometries
local function myModifier(geometries, params)
for windowID, frame in pairs(geometries) do
-- Create new frame tables, don't mutate in place
geometries[windowID] = {
x = frame.x + 10,
y = frame.y,
w = frame.w,
h = frame.h,
}
end
return geometries
end
local wrapped = Modifiers.wrap(Tall, myModifier)

Guidelines:

  • Keep modifiers pure (no Hammerspoon API calls inside the function)
  • Create new frame tables rather than mutating the input
  • Use params.workarea for boundary calculations
  • Use params.focusedWindowID for focus-aware effects
  • Always return the geometries table, even if unchanged

Full example

-- ~/.hammerspoon/init.lua
hs.loadSpoon("Shoji")
local Modifiers = require("layouts.modifiers")
local Magnifier = require("layouts.modifiers.magnifier")
local Mirror = require("layouts.modifiers.mirror")
local Tall = require("layouts.tall")
local Wide = require("layouts.wide")
-- Create mirrored tall (master on right) with custom name
local tallRight = Modifiers.wrap(Tall, Mirror.horizontal(), {
name = "tall-right",
displayName = "Tall (Right)",
})
-- Create magnified wide with custom name
local magnifiedWide = Modifiers.wrap(Wide, Magnifier.create({ ratio = 1.4 }), {
name = "wide-mag",
displayName = "Wide + Magnifier",
})
spoon.Shoji:configure({
layouts = { Tall, tallRight, Wide, magnifiedWide },
})
spoon.Shoji:start()