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", "monocle" },
})
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_forward = { { "ctrl", "alt" }, "space" },
swap_main = { { "ctrl", "alt", "shift" }, "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_monocle = { { "ctrl", "alt" }, "4" },
set_layout_floating = { { "ctrl", "alt" }, "0" },
})

Ratio adjustments

Brackets for main area ratio and main window count:

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