Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ Two cooperating sides, like `vim-tmux-navigator`:
into that pane with `herdr pane send-keys`; otherwise it moves herdr's focus
with `herdr pane focus --direction`.
- **editor side** (`editor/nvim.lua`, `editor/vim.vim`): maps the same keys to
`wincmd h/j/k/l`. If the window didn't change (Vim is at an edge), it calls
`herdr pane focus --direction` to cross into the neighbouring herdr pane. Vim
finds its own pane through the `$HERDR_PANE_ID` herdr injects into every pane.
`wincmd h/j/k/l`. If the window didn't change (Vim is at an edge), it crosses
into the neighbouring herdr pane — through `navigate.sh` when this file still
sits next to it in a checkout (so edge spillover, below, applies here too),
otherwise with a plain `herdr pane focus --direction`. Vim finds its own pane
through the `$HERDR_PANE_ID` herdr injects into every pane.

## Requirements

Expand Down Expand Up @@ -115,6 +117,23 @@ or, simply copy and pasta.

Unlike Vim, these apps don't cross _out_ at an edge — use `prefix+h/j/k/l` to
leave the pane.
- **Edge spillover** (opt-in). By default `Ctrl+h/j/k/l` stops at the edge of the
pane layout. Set `HERDR_NAV_SPILL=1` to have the motion carry on into the
neighbouring container instead, i3-style:

```bash
export HERDR_NAV_SPILL=1
```

`Ctrl+h`/`Ctrl+l` at the left/right edge move to the previous/next **tab** in
the workspace; `Ctrl+k`/`Ctrl+j` at the top/bottom edge move to the
previous/next **workspace**. Both wrap around, and focus then walks to the far
side of the layout you land in, so crossing rightwards puts you on the leftmost
pane and the whole thing reads as one continuous move. With a single tab (or a
single workspace) there is nowhere to go and the key does nothing, as before.

Set it where you launch herdr, so both herdr itself and the panes it starts see
it — the editor side reads it from the pane's environment.
- **`Ctrl+l` / `Ctrl+k` in shells.** Binding these globally shadows readline's
`Ctrl+L` (clear screen) and `Ctrl+K` (kill line) inside non-Vim panes. This is
the same tradeoff as `vim-tmux-navigator`. If you want them back, bind clear to
Expand Down
9 changes: 9 additions & 0 deletions editor/nvim.lua
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
-- or source it from your config after plugins load:
-- dofile("/path/to/vim-herdr-navigation/editor/nvim.lua")

-- navigate.sh, if this file still sits next to it in a plugin checkout. Going
-- through it means crossing out of Neovim behaves like crossing out of any other
-- pane (edge spillover and all). Copied elsewhere, we just move focus.
local navigate = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":p:h:h") .. "/navigate.sh"

local function nav(wincmd, dir)
local prev = vim.api.nvim_get_current_win()
vim.cmd("wincmd " .. wincmd)
Expand All @@ -19,6 +24,10 @@ local function nav(wincmd, dir)
end
-- At a split edge: cross into the surrounding multiplexer.
if vim.env.HERDR_PANE_ID and vim.env.HERDR_PANE_ID ~= "" then
if vim.fn.filereadable(navigate) == 1 then
vim.fn.system({ "bash", navigate, "--no-detect", dir })
return
end
local herdr = vim.env.HERDR_BIN_PATH
if herdr == nil or herdr == "" then
herdr = "herdr"
Expand Down
9 changes: 9 additions & 0 deletions editor/vim.vim
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,16 @@ if empty($HERDR_PANE_ID)
finish
endif

" navigate.sh, if this file still sits next to it in a plugin checkout. Going
" through it means crossing out of Vim behaves like crossing out of any other
" pane (edge spillover and all). Copied somewhere else, we just move focus.
let s:navigate = expand('<sfile>:p:h:h') . '/navigate.sh'

function! s:HerdrFocus(dir) abort
if filereadable(s:navigate)
call system('bash ' . shellescape(s:navigate) . ' --no-detect ' . a:dir)
return
endif
let l:herdr = empty($HERDR_BIN_PATH) ? 'herdr' : $HERDR_BIN_PATH
call system(shellescape(l:herdr) . ' pane focus --direction ' . a:dir . ' --current')
endfunction
Expand Down
100 changes: 93 additions & 7 deletions navigate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
#
# vim-herdr-navigation — herdr side
#
# Invoked by a herdr keybind as: navigate.sh <left|down|up|right>
# Invoked by a herdr keybind as: navigate.sh [--no-detect] <left|down|up|right>
#
# If the focused pane is running Vim/Neovim in the foreground, hand the matching
# Ctrl chord to that pane so Vim moves between its own splits (and, at a split
Expand All @@ -11,12 +11,22 @@
# HERDR_NAV_PASSTHROUGH_RE (off by default — see below). For any other foreground
# process, move herdr's pane focus directly.
#
# --no-detect skips the Vim check and goes straight to moving herdr's focus. The
# editor side calls in that way once Vim is out of splits, so that crossing out
# of Vim behaves exactly like crossing out of any other pane (spillover below).
#
# Requires `jq`. Without it, detection is skipped and every key just moves the
# herdr pane focus (no Vim awareness).

set -euo pipefail

dir="${1:?usage: navigate.sh <left|down|up|right>}"
detect=1
if [ "${1:-}" = --no-detect ]; then
detect=0
shift
fi

dir="${1:?usage: navigate.sh [--no-detect] <left|down|up|right>}"
herdr="${HERDR_BIN_PATH:-herdr}"
pane="${HERDR_PANE_ID:-}"

Expand All @@ -37,7 +47,7 @@ vim_re='^g?(view|l?n?vim?x?)(diff)?$'
passthrough_re="${HERDR_NAV_PASSTHROUGH_RE:-}"

forward=0
if [ -n "$pane" ] && command -v jq >/dev/null 2>&1; then
if [ "$detect" -eq 1 ] && [ -n "$pane" ] && command -v jq >/dev/null 2>&1; then
if "$herdr" pane process-info --pane "$pane" 2>/dev/null \
| jq -e --arg vim "$vim_re" --arg pass "$passthrough_re" \
'.result.process_info.foreground_processes[]?.name
Expand All @@ -49,9 +59,85 @@ fi

if [ "$forward" -eq 1 ]; then
exec "$herdr" pane send-keys "$pane" "$key"
elif [ -n "$pane" ]; then
exec "$herdr" pane focus --direction "$dir" --pane "$pane"
fi

# Target the invoking pane; --current is the server's globally focused pane,
# which is not necessarily this one. Without $HERDR_PANE_ID there is nothing to
# name, so fall back to global focus.
if [ -n "$pane" ]; then
from=(--pane "$pane")
else
from=(--current)
fi

# --- edge spillover (opt-in: HERDR_NAV_SPILL=1) -----------------------------
# Off by default: pane focus stops dead at the edge of the layout, as before.
# Turned on, the motion carries on into the neighbouring container, i3-style:
#
# left/right at the horizontal edge -> previous/next TAB in this workspace
# up/down at the vertical edge -> previous/next WORKSPACE
#
# Both wrap around. After the jump, focus walks to the far side of the new
# layout (crossing rightwards lands on the leftmost pane, and so on) so the
# motion reads as one continuous move.
#
# Needs jq; without it this degrades to plain focus, same as Vim detection above.

plain_focus() { exec "$herdr" pane focus --direction "$dir" "${from[@]}"; }

spill="${HERDR_NAV_SPILL:-}"
[ -n "$spill" ] && [ "$spill" != 0 ] || plain_focus
command -v jq >/dev/null 2>&1 || plain_focus

# Just try the move. The reply says whether focus actually changed and carries
# the tab/workspace it happened in, so there is nothing to probe beforehand.
moved=$("$herdr" pane focus --direction "$dir" "${from[@]}" 2>/dev/null) || exit 0
[ "$(jq -r '.result.focus.changed' <<<"$moved")" = false ] || exit 0

# At the edge. Pick the container to jump to, wrapping at either end.
case "$dir" in
left) scope=tab; step=-1; land=right ;;
right) scope=tab; step=1; land=left ;;
up) scope=workspace; step=-1; land=down ;;
down) scope=workspace; step=1; land=up ;;
esac

workspace_id=$(jq -r '.result.focus.layout.workspace_id' <<<"$moved")
tab_id=$(jq -r '.result.focus.layout.tab_id' <<<"$moved")

# Step through the list in the order herdr returns it: that is the on-screen
# order. Do NOT index by .number -- for tabs that is an id, not a position: it
# stays put when a tab is reordered, and closing a tab leaves a hole in the
# sequence (workspace numbers do get compacted, but list order is right there).
# Returns empty when there is nowhere to go (a single tab / workspace), a no-op.
if [ "$scope" = tab ]; then
target=$("$herdr" tab list 2>/dev/null | jq -r --arg ws "$workspace_id" \
--arg cur "$tab_id" --argjson step "$step" '
[ .result.tabs[] | select(.workspace_id == $ws) ] as $list
| ($list | map(.tab_id) | index($cur)) as $i
| if ($list | length) < 2 or $i == null then empty
else $list[(($i + $step) % ($list | length) + ($list | length)) % ($list | length)].tab_id
end') || exit 0
else
# Invoked outside a pane (no $HERDR_PANE_ID): fall back to global focus.
exec "$herdr" pane focus --direction "$dir" --current
target=$("$herdr" workspace list 2>/dev/null | jq -r --arg cur "$workspace_id" \
--argjson step "$step" '
.result.workspaces as $list
| ($list | map(.workspace_id) | index($cur)) as $i
| if ($list | length) < 2 or $i == null then empty
else $list[(($i + $step) % ($list | length) + ($list | length)) % ($list | length)].workspace_id
end') || exit 0
fi

[ -n "$target" ] || exit 0

"$herdr" "$scope" focus "$target" >/dev/null || exit 0

# Walk to the far side of the layout we just entered, one pane at a time, until
# nothing moves. --current is deliberate here, unlike above: the tab/workspace
# focus landed us on a pane in the new container, and that is what we walk from
# -- the invoking pane is back in the container we just left.
# Bounded so a layout that always reports a move cannot spin.
for _ in {1..32}; do
[ "$("$herdr" pane focus --direction "$land" --current 2>/dev/null \
| jq -r '.result.focus.changed')" = true ] || break
done