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 layoutlocal 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 reflowlocal modifier = Magnifier.create()
-- Custom ratio with minimum window sizelocal 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 windowreflow(boolean, default: true): Push other windows away from magnified windowminSize(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 windowslocal modifier = Gaps.add({ inner = 16 })
-- Add both inner and outer spacinglocal modifier = Gaps.add({ inner = 4, outer = 12 })Options:
inner(number, optional): Extra gap between windowsouter(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, centeredlocal modifier = Centered.ratio(0.8)
-- Use 60% for a more focused viewlocal 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 windowlocal 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:
- Explicit
options.name/options.displayName(highest) - Auto-derived from modifier metadata (
base+modifier/Base + Modifier) - 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"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
Full example
-- ~/.hammerspoon/init.luahs.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 namelocal tallRight = Modifiers.wrap(Tall, Mirror.horizontal(), { name = "tall-right", displayName = "Tall (Right)",})
-- Create magnified wide with custom namelocal 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()