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 windowslocal 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 gapslocal modifier = Gaps.create({ outer = { left = 50, right = 50 }, inner = 10,})Options:
inner(number, default: 0): Extra gap between windowsouter(numberortable, 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, centeredlocal modifier = Centered.ratio(0.8)
-- Use 60% for a more focused viewlocal 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:
- Base layout computes geometries
- First modifier transforms the result
- Second modifier transforms that output
- And so on…
Order matters when effects interact:
-- Order: Tall -> Mirror -> CenteredModifiers.chain(Tall, { Mirror.horizontal(), -- First: flip positions Centered.ratio(0.8), -- Second: scale and center})
-- Different order: Tall -> Centered -> MirrorModifiers.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:
- Explicit
options.name/options.displayName(highest) - Auto-derived from modifier metadata (
base+modifier/Base + Modifier) - 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"endCustom 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 geometrieslocal 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 geometriesend
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.workareafor boundary calculations - Use
params.focusedWindowIDfor 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 unchangedend
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.luahs.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 namelocal tallRight = Modifiers.wrap(Tall, Mirror.horizontal(), { name = "tall-right", displayName = "Tall (Right)",})
-- Create centered wide with custom namelocal 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()