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 main area on the right? Wrap it with a horizontal mirror instead of writing a new layout.

At a glance

  • Modifiers transform positions after the base layout computes them
  • Three built-in: Mirror, Gaps, Centered
  • Chain multiple with Modifiers.chain; order matters
  • Wrapped layouts inherit actions by default

Basic usage

Wrap a layout with a modifier using Modifiers.wrap:

local Modifiers = require("layouts.modifiers")
local Mirror = require("layouts.modifiers.mirror")
local Tall = require("layouts.tall")
-- Create a mirrored version of the tall layout (main area on right)
local tallRight = Modifiers.wrap(Tall, Mirror.horizontal())

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

Available modifiers

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 main area 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.create({ inner = 16 })
-- Add both inner and outer spacing (uniform)
local modifier = Gaps.create({ inner = 4, outer = 12 })
-- Per-edge outer gaps (monocle with side margins)
local modifier = Gaps.create({
outer = { left = 100, right = 100, top = 0, bottom = 0 },
})
-- Per-edge with inner gaps
local modifier = Gaps.create({
outer = { left = 50, right = 50 },
inner = 10,
})

Options:

  • inner (number, default: 0): Extra gap between windows
  • outer (number or table, default: 0): Extra gap around screen edges
    • As number: applies uniformly to all edges
    • As table with named fields: { top = n, right = n, bottom = n, left = n } for per-edge control (unspecified edges default to 0)

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)

Parameters:

  • ratio (number): Fraction of workarea to use (0.0-1.0)

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 Centered = require("layouts.modifiers.centered")
local Mirror = require("layouts.modifiers.mirror")
local Tall = require("layouts.tall")
-- Mirror horizontally, then center at 80%
local layout = Modifiers.chain(Tall, {
Mirror.horizontal(),
Centered.ratio(0.8),
})

Execution order

Modifiers execute in array order:

  1. Base layout computes geometries
  2. First modifier transforms the result
  3. Second modifier transforms that output
  4. And so on…

Order matters when effects interact:

-- Order: Tall -> Mirror -> Centered
Modifiers.chain(Tall, {
Mirror.horizontal(), -- First: flip positions
Centered.ratio(0.8), -- Second: scale and center
})
-- Different order: Tall -> Centered -> Mirror
Modifiers.chain(Tall, {
Centered.ratio(0.8), -- First: scale and center
Mirror.horizontal(), -- Second: flip centered layout
})

In the first example, positions flip before centering. In the second, centering happens first, then the centered layout flips.

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 }
-- Wrapped layout auto-generates name: "tall+mirror-h"
-- And displayName: "Tall + Mirror H"
local wrapped = Modifiers.wrap(Tall, Mirror.horizontal())

Override generated names with explicit options:

local wrapped = Modifiers.wrap(Tall, Mirror.horizontal(), {
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)

Layout actions

Wrapped layouts inherit base layout actions by default. Most modifiers only adjust positions without changing window relationships, so actions remain valid.

To strip actions, pass preserveActions = false:

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

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

local myModifier = {
name = "my-modifier",
preservesActions = false,
fn = function(geometries, params)
-- ... transform geometries
return geometries
end,
}

When to strip actions:

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

When actions are safe (the common case):

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

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

Debugging

Add a pass-through modifier to inspect intermediate geometries:

local function debugModifier(geometries, params)
print("Geometries after previous modifier:")
for id, frame in pairs(geometries) do
print(string.format(" %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
local debuggedLayout = Modifiers.chain(Tall, {
Mirror.horizontal(),
debugModifier,
Centered.ratio(0.8),
})

Common mistakes

Mutating input frames instead of creating new tables: Create new frame tables rather than modifying the input. The input geometries may be reused, and mutation causes subtle bugs that are hard to track down.

Passing constructor instead of calling it: Centered.ratio is a function — call it: Centered.ratio(0.8), not Centered.ratio. Passing the function itself triggers “attempt to call a table value”.

Registering multiple variants without distinct names: Wrapped layouts inherit the base layout name by default. Register two Tall variants without explicit names and one silently overwrites the other.

Applying outer gaps in a modifier: The workarea already accounts for gap_outer. Adding outer gaps in a modifier double-applies them, pushing windows away from screen edges.

Common errors

“attempt to call a table value”: Passed a modifier object instead of calling its constructor. Use Centered.ratio(0.8) not Centered.ratio.

“modifier must be a function or table with fn”: The 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 conflict. Use explicit names when creating multiple variations of the same base layout.

Examples

Mirrored tall

Main area on the right instead of left:

local tallRight = Modifiers.wrap(Tall, Mirror.horizontal(), {
name = "tall-right",
displayName = "Tall (Right)",
})
spoon.Shoji:configure({
layouts = { tallRight },
enabled_layouts = { "tall", "tall-right" },
})

BSP with custom gaps

BSP with tighter spacing than global defaults:

local Gaps = require("layouts.modifiers.gaps")
local BSP = require("layouts.bsp")
local tightBSP = Modifiers.chain(BSP, {
Gaps.create({ inner = 2, outer = 4 }),
}, {
name = "bsp-tight",
displayName = "BSP (Tight)",
preserveActions = true, -- Keep BSP resize/rotate actions
})

Centered tall

Scale and center at 70% of screen size:

local Centered = require("layouts.modifiers.centered")
local centeredTall = Modifiers.chain(Tall, {
Centered.ratio(0.7),
}, {
name = "tall-centered",
displayName = "Tall (Centered)",
})

Monocle with side gaps

Monocle layout with horizontal margins (useful for ultrawide monitors):

local Gaps = require("layouts.modifiers.gaps")
local Monocle = require("layouts.monocle")
local centeredMonocle = Modifiers.wrap(Monocle, Gaps.create({
outer = { left = 200, right = 200, top = 0, bottom = 0 },
}), {
name = "monocle-centered",
displayName = "Monocle (Centered)",
})

Full example

-- ~/.hammerspoon/init.lua
hs.loadSpoon("Shoji")
local Modifiers = require("layouts.modifiers")
local Centered = require("layouts.modifiers.centered")
local Mirror = require("layouts.modifiers.mirror")
local Tall = require("layouts.tall")
local Wide = require("layouts.wide")
-- Create mirrored tall (main area on right) with custom name
local tallRight = Modifiers.wrap(Tall, Mirror.horizontal(), {
name = "tall-right",
displayName = "Tall (Right)",
})
-- Create centered wide with custom name
local centeredWide = Modifiers.wrap(Wide, Centered.ratio(0.8), {
name = "wide-centered",
displayName = "Wide (Centered)",
})
spoon.Shoji:configure({
layouts = { Tall, tallRight, Wide, centeredWide },
})
spoon.Shoji:start()