diff --git a/README.md b/README.md index e78d93d..0661fcf 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/editor/nvim.lua b/editor/nvim.lua index 0e667dc..80d73ea 100644 --- a/editor/nvim.lua +++ b/editor/nvim.lua @@ -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) @@ -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" diff --git a/editor/vim.vim b/editor/vim.vim index 6b3bac9..c459f6f 100644 --- a/editor/vim.vim +++ b/editor/vim.vim @@ -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(':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 diff --git a/navigate.sh b/navigate.sh index 730da04..af366f3 100755 --- a/navigate.sh +++ b/navigate.sh @@ -2,7 +2,7 @@ # # vim-herdr-navigation — herdr side # -# Invoked by a herdr keybind as: navigate.sh +# Invoked by a herdr keybind as: navigate.sh [--no-detect] # # 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 @@ -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 }" +detect=1 +if [ "${1:-}" = --no-detect ]; then + detect=0 + shift +fi + +dir="${1:?usage: navigate.sh [--no-detect] }" herdr="${HERDR_BIN_PATH:-herdr}" pane="${HERDR_PANE_ID:-}" @@ -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 @@ -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