Skip to content

Vim workflow

Window management with hjkl navigation and familiar Vim patterns.

Basic Vim keybindings

Directional navigation and window swapping:

hs.loadSpoon("Shoji")
spoon.Shoji:configure({
default_layout = "tall",
enabled_layouts = { "tall", "wide", "bsp", "fullscreen" },
})
spoon.Shoji:bindHotkeys({
-- Navigation hotkeys
focus_left = { { "ctrl", "alt" }, "h" },
focus_down = { { "ctrl", "alt" }, "j" },
focus_up = { { "ctrl", "alt" }, "k" },
focus_right = { { "ctrl", "alt" }, "l" },
-- Swap hotkeys
swap_left = { { "ctrl", "alt", "shift" }, "h" },
swap_down = { { "ctrl", "alt", "shift" }, "j" },
swap_up = { { "ctrl", "alt", "shift" }, "k" },
swap_right = { { "ctrl", "alt", "shift" }, "l" },
-- Layout control
cycle_layout = { { "ctrl", "alt" }, "space" },
swap_main = { { "ctrl", "alt" }, "return" },
})
spoon.Shoji:start()

Leader key pattern

Hammerspoon’s modal bindings can emulate Vim’s leader key. Press Ctrl+W to enter window mode, then a single key to act:

-- Create a modal for window management
local windowMode = hs.hotkey.modal.new({ "ctrl" }, "w")
function windowMode:entered()
hs.alert.show("Window mode")
end
function windowMode:exited()
hs.alert.show("Normal mode")
end
-- Exit modal with Escape
windowMode:bind({}, "escape", function() windowMode:exit() end)
-- Window navigation
windowMode:bind({}, "h", function()
spoon.Shoji.actions.focusLeft()
windowMode:exit()
end)
windowMode:bind({}, "j", function()
spoon.Shoji.actions.focusDown()
windowMode:exit()
end)
windowMode:bind({}, "k", function()
spoon.Shoji.actions.focusUp()
windowMode:exit()
end)
windowMode:bind({}, "l", function()
spoon.Shoji.actions.focusRight()
windowMode:exit()
end)
-- Swap with shift modifier
windowMode:bind({ "shift" }, "h", function()
spoon.Shoji.actions.swapLeft()
windowMode:exit()
end)

Ctrl+W h/j/k/l navigates. Ctrl+W Shift+h/j/k/l swaps.

Neovim integration

Navigate seamlessly between Neovim splits and Shoji windows. When a terminal is focused, navigation keys go to Neovim. When Neovim reaches the edge of its splits, it can pass focus back to Shoji.

This requires setup on both sides. The Hammerspoon configuration:

-- In ~/.hammerspoon/init.lua
-- Check if focused app is a terminal (customize for your terminal)
local function isTerminal()
local app = hs.application.frontmostApplication()
if not app then return false end
local bundleID = app:bundleID()
return bundleID == "com.apple.Terminal"
or bundleID == "com.googlecode.iterm2"
or bundleID == "io.alacritty"
or bundleID == "com.mitchellh.ghostty"
end
local directionKeys = {
left = "h",
right = "l",
up = "k",
down = "j",
}
local function neovimAwareFocus(direction)
if isTerminal() then
-- Send to terminal (Neovim handles it if running)
hs.eventtap.keyStroke({ "ctrl" }, directionKeys[direction])
else
-- Use Shoji for window navigation
if direction == "left" then spoon.Shoji.actions.focusLeft()
elseif direction == "right" then spoon.Shoji.actions.focusRight()
elseif direction == "up" then spoon.Shoji.actions.focusUp()
elseif direction == "down" then spoon.Shoji.actions.focusDown()
end
end
end
-- Bind navigation keys
for direction, _ in pairs(directionKeys) do
hs.hotkey.bind({ "ctrl", "alt" }, directionKeys[direction], function()
neovimAwareFocus(direction)
end)
end

For the Neovim side, use a plugin like vim-tmux-navigator or write custom mappings that call hs -c 'spoon.Shoji:cmd("focus", "left")' via jobstart() when at a split boundary.

Quick layout switching

Number keys for direct layout access:

spoon.Shoji:bindHotkeys({
set_layout_tall = { { "ctrl", "alt" }, "1" },
set_layout_wide = { { "ctrl", "alt" }, "2" },
set_layout_bsp = { { "ctrl", "alt" }, "3" },
set_layout_fullscreen = { { "ctrl", "alt" }, "4" },
set_layout_floating = { { "ctrl", "alt" }, "0" },
})

Ratio adjustments

Brackets for main area ratio and master count:

spoon.Shoji:bindHotkeys({
increase_main_ratio = { { "ctrl", "alt" }, "]" },
decrease_main_ratio = { { "ctrl", "alt" }, "[" },
increase_nmaster = { { "ctrl", "alt", "shift" }, "]" },
decrease_nmaster = { { "ctrl", "alt", "shift" }, "[" },
})

Magnified tall for focus

The magnifier modifier enlarges the focused window. Combined with retile_on_focus, this creates a dwm-style zooming effect:

local Modifiers = spoon.Shoji.Modifiers
local Tall = spoon.Shoji.Layouts.Tall
local Magnifier = Modifiers.Magnifier
local magTall = Modifiers.wrap(Tall, Magnifier.create({ ratio = 1.3 }), {
name = "tall-mag",
displayName = "Tall + Magnify",
})
spoon.Shoji:configure({
layouts = { magTall },
enabled_layouts = { "tall", "tall-mag", "bsp" },
retile_on_focus = true, -- Retile when focus changes to update magnifier
})

With retile_on_focus enabled, changing focus triggers a retile that recalculates the magnifier for the newly focused window.