diff options
| author | Igor Soarez <igor@soarez.org> | 2026-10-05 11:27:00 +0300 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-10-05 11:27:00 +0300 |
| commit | 986e760687a2d34051934d410bec71fa2efc349b (patch) | |
| tree | f57dcc4ba0d7690fdf90eeb2f0970a8ebbefc45a /tmuxrdr/plugin | |
| parent | 7b116c05bfda646af39d2524c54e70c751f57ee8 (diff) | |
tmuxrdr: tmux-mode plugin
The tmux default prefix bindings herdr has no built-in action for, as a
herdr plugin built on its CLI and socket: break-pane, swap-pane and
rotate-window by pane order (focus behaviour measured on tmux 3.7c),
mark/clear-mark, last-window and switch-client -l (from focus events, per
session), select-window and move-window by index, paste-buffer -p,
display-message in tmux's default format, select-layout -E, kill-pane and
kill-window behind tmux's y/n prompts, a command prompt taking tmux's
commands, clock-mode, show-messages, and automatic-rename.
Also a .gitignore for secrets scoped to tmuxrdr/, leaving herdr's own
untouched.
Diffstat (limited to 'tmuxrdr/plugin')
| -rwxr-xr-x | tmuxrdr/plugin/bin/automatic-rename | 37 | ||||
| -rwxr-xr-x | tmuxrdr/plugin/bin/break-pane | 14 | ||||
| -rwxr-xr-x | tmuxrdr/plugin/bin/clock-mode | 34 | ||||
| -rwxr-xr-x | tmuxrdr/plugin/bin/command-prompt | 82 | ||||
| -rwxr-xr-x | tmuxrdr/plugin/bin/confirm-kill | 20 | ||||
| -rwxr-xr-x | tmuxrdr/plugin/bin/display-message | 15 | ||||
| -rwxr-xr-x | tmuxrdr/plugin/bin/last | 25 | ||||
| -rwxr-xr-x | tmuxrdr/plugin/bin/mark | 23 | ||||
| -rwxr-xr-x | tmuxrdr/plugin/bin/move-window | 34 | ||||
| -rwxr-xr-x | tmuxrdr/plugin/bin/paste-buffer | 20 | ||||
| -rwxr-xr-x | tmuxrdr/plugin/bin/rotate-window | 34 | ||||
| -rwxr-xr-x | tmuxrdr/plugin/bin/select-window | 22 | ||||
| -rwxr-xr-x | tmuxrdr/plugin/bin/show-messages | 8 | ||||
| -rwxr-xr-x | tmuxrdr/plugin/bin/spread-layout | 25 | ||||
| -rwxr-xr-x | tmuxrdr/plugin/bin/swap-pane | 30 | ||||
| -rwxr-xr-x | tmuxrdr/plugin/bin/track-focus | 36 | ||||
| -rw-r--r-- | tmuxrdr/plugin/herdr-plugin.toml | 92 | ||||
| -rw-r--r-- | tmuxrdr/plugin/lib.sh | 95 |
18 files changed, 646 insertions, 0 deletions
diff --git a/tmuxrdr/plugin/bin/automatic-rename b/tmuxrdr/plugin/bin/automatic-rename new file mode 100755 index 00000000..f3409a5d --- /dev/null +++ b/tmuxrdr/plugin/bin/automatic-rename @@ -0,0 +1,37 @@ +#!/bin/sh +# tmux.conf: automatic-rename on, automatic-rename-format '#{b:pane_current_path}', +# status-interval 5 -- every window is named after the directory of its +# current pane. Herdr names nothing (an unnamed tab shows its place in the tab +# bar), so this runs as a tab_bar_right command entry every 5 s (config.toml) +# and renames each tab after the basename of its focused pane's foreground +# directory -- tmux's pane_current_path. Like tmux's rename-window, a manual +# rename switches it off for that tab for good: a label this did not set is +# left alone. Prints nothing, so the status entry stays empty. +H="${HERDR_BIN_PATH:-herdr}" +dir="${XDG_STATE_HOME:-$HOME/.local/state}/herdr-tmux-mode/$(basename "$(dirname "${HERDR_SOCKET_PATH:-x/default/x}")")/names" +mkdir -p "$dir" + +"$H" api snapshot | jq -r ' + .result.snapshot as $s + | $s.tabs as $tabs + | range(0; $tabs | length) as $i + | $tabs[$i] as $t + | ([$tabs[0:$i][] | select(.workspace_id == $t.workspace_id)] | length + 1) as $pos + | ([$s.layouts[] | select(.tab_id == $t.tab_id) | .focused_pane_id][0]) as $fp + | ([$s.panes[] | select(.pane_id == $fp) | (.foreground_cwd // .cwd // "")][0] // "") as $cwd + | select($cwd != "") + | [$t.tab_id, $t.label, ($pos | tostring), + (if $cwd == "/" then "/" else ($cwd | rtrimstr("/") | split("/") | last) end)] + | @tsv' \ +| while IFS="$(printf '\t')" read -r tab label pos want; do + f="$dir/$(printf '%s' "$tab" | tr -c 'A-Za-z0-9._-' '_')" + mine="$(cat "$f" 2>/dev/null || true)" + [ "$mine" = "@manual" ] && continue + if [ "$label" != "$mine" ] && [ "$label" != "$pos" ]; then + printf '@manual\n' >"$f" + continue + fi + [ "$label" = "$want" ] && continue + "$H" tab rename "$tab" "$want" >/dev/null 2>&1 && printf '%s\n' "$want" >"$f" +done +exit 0 diff --git a/tmuxrdr/plugin/bin/break-pane b/tmuxrdr/plugin/bin/break-pane new file mode 100755 index 00000000..f2683c93 --- /dev/null +++ b/tmuxrdr/plugin/bin/break-pane @@ -0,0 +1,14 @@ +#!/bin/sh +# tmux prefix+! (break-pane): the current pane becomes a tab of its own, at the +# end of the tab bar, and that tab is selected. Same message as tmux when the +# pane is alone. +. "$(dirname "$0")/../lib.sh" + +p="$(active_pane)" +[ "$(tab_panes "$p" | wc -l)" -gt 1 ] || fail "can't break with only one pane" +tab="$("$H" pane move "$p" --new-tab --workspace "$(pane_info "$p" workspace_id)" \ + --no-focus | jq -r '.result.move_result.pane.tab_id // empty')" +[ -n "$tab" ] || fail "break-pane failed" +# Focused separately: a move's own --focus emits no tab.focused event, so +# track-focus would miss it and `last window` would not lead back. +"$H" tab focus "$tab" >/dev/null diff --git a/tmuxrdr/plugin/bin/clock-mode b/tmuxrdr/plugin/bin/clock-mode new file mode 100755 index 00000000..220d53d3 --- /dev/null +++ b/tmuxrdr/plugin/bin/clock-mode @@ -0,0 +1,34 @@ +#!/bin/sh +# tmux prefix+t (clock-mode): a big 24-hour clock in clock-mode-colour (blue), +# centred, until any key. Run as a popup. +trap 'stty "$tty"; printf "\033[0m\033[?25h"' EXIT +tty="$(stty -g)" +stty -icanon -echo min 0 time 10 +printf '\033[?25l' +while :; do + set -- $(stty size) + printf '\033[H\033[2J\033[34m' + date '+%H:%M' | awk -v rows="$1" -v cols="$2" ' + BEGIN { + f["0"] = "###|#.#|#.#|#.#|###"; f["1"] = "..#|..#|..#|..#|..#" + f["2"] = "###|..#|###|#..|###"; f["3"] = "###|..#|###|..#|###" + f["4"] = "#.#|#.#|###|..#|..#"; f["5"] = "###|#..|###|..#|###" + f["6"] = "###|#..|###|#.#|###"; f["7"] = "###|..#|..#|..#|..#" + f["8"] = "###|#.#|###|#.#|###"; f["9"] = "###|#.#|###|..#|###" + f[":"] = ".|#|.|#|." + } + { + for (r = 1; r <= 5; r++) line[r] = "" + for (i = 1; i <= length($0); i++) { + split(f[substr($0, i, 1)], g, "|") + for (r = 1; r <= 5; r++) line[r] = line[r] g[r] "." + } + w = length(line[1]) * 2 + for (i = 1; i <= int((rows - 5) / 2); i++) print "" + for (r = 1; r <= 5; r++) { + s = line[r]; gsub(/#/, "██", s); gsub(/\./, " ", s) + printf "%" int((cols - w) / 2) "s%s\n", "", s + } + }' + [ -n "$(dd bs=1 count=1 2>/dev/null)" ] && break +done diff --git a/tmuxrdr/plugin/bin/command-prompt b/tmuxrdr/plugin/bin/command-prompt new file mode 100755 index 00000000..8c4a4e4f --- /dev/null +++ b/tmuxrdr/plugin/bin/command-prompt @@ -0,0 +1,82 @@ +#!/bin/sh +# tmux prefix+: (command-prompt). Herdr has no command language of its own, so +# this takes the tmux commands that have a herdr counterpart, with their usual +# aliases and flags, and passes anything else to the herdr CLI -- `:pane list` +# runs `herdr pane list` and shows the result. Run as a popup. +. "$(dirname "$0")/../lib.sh" + +action() { "$H" plugin action invoke "tmuxrdr.tmux-mode.$1" >/dev/null; } +pause() { printf '\n[press any key]'; read_key >/dev/null; } + +printf ':' +IFS= read -r line || exit 0 +[ -n "$line" ] || exit 0 +eval "set -- $line" || { pause; exit 1; } +cmd="$1" +shift + +p="$(active_pane)" +has() { case " $* " in *" $flag "*) return 0 ;; esac; return 1; } +opt() { # opt -t ARGS...: the value after -t, if any + _f="$1"; shift + while [ $# -gt 0 ]; do + [ "$1" = "$_f" ] && { printf '%s' "${2:-}"; return; } + shift + done +} + +# The case lives in a function: bash 3.2, which is macOS /bin/sh, misparses a +# case statement written inside $(...) -- its pattern parentheses end the +# substitution early. +run() { + case "$cmd" in + kill-pane | killp) "$H" pane close "$p" ;; + kill-window | killw) "$H" tab close "$(pane_info "$p" tab_id)" ;; + kill-session) "$H" workspace close "$(pane_info "$p" workspace_id)" ;; + kill-server) "$H" server stop ;; + new-window | neww) "$H" tab create --workspace "$(pane_info "$p" workspace_id)" --focus ;; + split-window | splitw | new-pane | newp) + flag=-h; dir=down; has "$@" && dir=right + "$H" pane split "$p" --direction "$dir" --focus ;; + rename-window | renamew) "$H" tab rename "$(pane_info "$p" tab_id)" "$*" ;; + rename-session | rename) "$H" workspace rename "$(pane_info "$p" workspace_id)" "$*" ;; + select-window | selectw) t="$(opt -t "$@")"; t="${t#:}"; sh "$BIN/select-window" "${t#=}" ;; + break-pane | breakp) sh "$BIN/break-pane" ;; + move-window | movew) t="$(opt -t "$@")"; sh "$BIN/move-window" "${t#:}" ;; + swap-pane | swapp) + flag=-U; if has "$@"; then sh "$BIN/swap-pane" prev + else flag=-D; if has "$@"; then sh "$BIN/swap-pane" next; else action swap-marked; fi; fi ;; + rotate-window | rotatew) flag=-D; if has "$@"; then sh "$BIN/rotate-window" down; else sh "$BIN/rotate-window" up; fi ;; + last-window | last) action last-window ;; + switch-client | switchc) flag=-l; if has "$@"; then action last-session; else echo "switch-client: only -l is supported"; exit 1; fi ;; + select-pane | selectp) + flag=-m; if has "$@"; then action mark; exit; fi + flag=-M; if has "$@"; then action clear-mark; exit; fi + for d in L:left R:right U:up D:down; do + flag="-${d%%:*}"; has "$@" && { "$H" pane focus --direction "${d#*:}" --pane "$p"; exit; } + done + echo "select-pane: only -L -R -U -D -m -M are supported"; exit 1 ;; + resize-pane | resizep) + flag=-Z; has "$@" && { "$H" pane zoom "$p" --toggle; exit; } + n="$(printf '%s\n' "$@" | grep -x '[0-9][0-9]*' | head -n 1)" + for d in L:left R:right U:up D:down; do + flag="-${d%%:*}"; has "$@" && { "$H" pane resize --direction "${d#*:}" --amount "$(awk "BEGIN { print ${n:-1} / 100 }")" --pane "$p"; exit; } + done + echo "resize-pane: only -L -R -U -D [N] and -Z are supported"; exit 1 ;; + select-layout | selectl) flag=-E; if has "$@"; then action spread-layout; else echo "select-layout: only -E is supported"; exit 1; fi ;; + display-message | display) if [ $# -gt 0 ]; then say "$*"; else sh "$BIN/display-message"; fi ;; + paste-buffer | pasteb) sh "$BIN/paste-buffer" ;; + source-file | source) "$H" server reload-config ;; + detach-client | detach) echo "detach-client: not scriptable in herdr; use prefix+d"; exit 1 ;; + *) "$H" "$cmd" "$@"; echo; exit 2 ;; + esac +} + +out="$(run "$@" 2>&1)" +status=$? +# tmux's own commands close the prompt quietly on success; errors, and +# anything handed to the herdr CLI, stay on screen until a key. +case "$status" in +0) ;; +*) printf '%s\n' "$out"; pause ;; +esac diff --git a/tmuxrdr/plugin/bin/confirm-kill b/tmuxrdr/plugin/bin/confirm-kill new file mode 100755 index 00000000..b8826bb8 --- /dev/null +++ b/tmuxrdr/plugin/bin/confirm-kill @@ -0,0 +1,20 @@ +#!/bin/sh +# tmux prefix+x and prefix+& ask before killing -- confirm-before -p +# "kill-pane #P? (y/n)" and "kill-window #W? (y/n)" -- and so does this, run +# as a popup from config.toml. y kills; any other key cancels. +. "$(dirname "$0")/../lib.sh" + +p="$(active_pane)" +case "${1:-}" in +pane) + printf 'kill-pane %s? (y/n)' "$(pane_index "$p")" + [ "$(read_key)" = y ] && "$H" pane close "$p" >/dev/null + ;; +window) + tab="$(pane_info "$p" tab_id)" + name="$("$H" tab get "$tab" | jq -r '.result.tab.label // empty')" + printf 'kill-window %s? (y/n)' "$name" + [ "$(read_key)" = y ] && "$H" tab close "$tab" >/dev/null + ;; +esac +exit 0 diff --git a/tmuxrdr/plugin/bin/display-message b/tmuxrdr/plugin/bin/display-message new file mode 100755 index 00000000..e2ff0192 --- /dev/null +++ b/tmuxrdr/plugin/bin/display-message @@ -0,0 +1,15 @@ +#!/bin/sh +# tmux prefix+i (display-message), in tmux's default template -- measured on +# tmux 3.7c: "[0] 0:zsh, current pane 0 - (10:30 05-Oct-26)", i.e. +# [#S] #I:#W, current pane #P - (%H:%M %d-%b-%y). Session is the workspace, +# window index its place in the tab bar, window name the tab label. +. "$(dirname "$0")/../lib.sh" + +p="$(active_pane)" +ws="$(pane_info "$p" workspace_id)" +tab="$(pane_info "$p" tab_id)" +session="$("$H" workspace get "$ws" | jq -r '.result.workspace.label // empty')" +line="$(workspace_tabs "$ws" | grep -n -F "$tab " | head -n 1)" +window_index="${line%%:*}" +window_name="$(printf '%s' "${line#*:}" | cut -f2)" +say "[$session] $window_index:$window_name, current pane $(pane_index "$p") - ($(date '+%H:%M %d-%b-%y'))" diff --git a/tmuxrdr/plugin/bin/last b/tmuxrdr/plugin/bin/last new file mode 100755 index 00000000..2d3e137d --- /dev/null +++ b/tmuxrdr/plugin/bin/last @@ -0,0 +1,25 @@ +#!/bin/sh +# tmux prefix+L (switch-client -l): back to the previously focused workspace -- +# herdr's workspace is tmux's session. `last window` is tmux's prefix+l +# (last-window): this machine's tmux.conf took l for select-pane -R, so it has +# no key there and none here, but the : prompt has it. Going back focuses the +# old one, which track-focus records, so repeating toggles -- as in tmux. +. "$(dirname "$0")/../lib.sh" + +dir="$(state_dir)" +case "${1:-}" in +window) + ws="$(pane_info "$(active_pane)" workspace_id)" + t="$(cat "$dir/tab.$(key_of "$ws").previous" 2>/dev/null || true)" + [ -n "$t" ] && "$H" tab get "$t" >/dev/null 2>&1 || fail "no last window" + "$H" tab focus "$t" >/dev/null + ;; +session) + w="$(cat "$dir/workspace.previous" 2>/dev/null || true)" + [ -n "$w" ] && "$H" workspace get "$w" >/dev/null 2>&1 || fail "can't find last session" + "$H" workspace focus "$w" >/dev/null + ;; +*) + fail "usage: last window|session" + ;; +esac diff --git a/tmuxrdr/plugin/bin/mark b/tmuxrdr/plugin/bin/mark new file mode 100755 index 00000000..1a3aaaad --- /dev/null +++ b/tmuxrdr/plugin/bin/mark @@ -0,0 +1,23 @@ +#!/bin/sh +# tmux prefix+m (select-pane -m) toggles the mark on the current pane; prefix+M +# (select-pane -M) clears it. tmux shows the mark as a highlighted border, +# which herdr cannot draw, so marking says so in a toast instead. Used by +# `swap-pane marked` (the : prompt's swap-pane with no target). +. "$(dirname "$0")/../lib.sh" + +f="$(state_dir)/marked" +case "${1:-toggle}" in +toggle) + p="$(active_pane)" + if [ "$(cat "$f" 2>/dev/null || true)" = "$p" ]; then + rm -f "$f" + say "pane $(pane_index "$p") unmarked" + else + printf '%s\n' "$p" >"$f" + say "pane $(pane_index "$p") marked" + fi + ;; +clear) + rm -f "$f" + ;; +esac diff --git a/tmuxrdr/plugin/bin/move-window b/tmuxrdr/plugin/bin/move-window new file mode 100755 index 00000000..a81567a9 --- /dev/null +++ b/tmuxrdr/plugin/bin/move-window @@ -0,0 +1,34 @@ +#!/bin/sh +# tmux prefix+. (command-prompt -T target { move-window -t "%%" }): move the +# current window to index N. Herdr's tabs are positions -- tmux with +# renumber-windows on -- so N is the place in the tab bar it moves to, where +# tmux would refuse an index in use. Prompts for the target (run as a popup), +# or takes N as its argument. Over the socket's tab.move, whose insert_index +# counts slots in the list as it is before the move: moving right is one more. +. "$(dirname "$0")/../lib.sh" + +n="${1:-}" +if [ -z "$n" ]; then + printf '(target) ' + IFS= read -r n || exit 0 +fi +n="${n#:}" +[ -n "$n" ] || exit 0 +case "$n" in + *[!0-9]*) fail "can't find window: $n" ;; +esac + +p="$(active_pane)" +ws="$(pane_info "$p" workspace_id)" +tab="$(pane_info "$p" tab_id)" +tabs="$(workspace_tabs "$ws" | cut -f1)" +count="$(printf '%s\n' "$tabs" | wc -l | tr -d ' ')" +[ "$n" -ge 1 ] && [ "$n" -le "$count" ] || fail "index $n out of range" +from="$(printf '%s\n' "$tabs" | grep -n -x -F "$tab" | cut -d: -f1)" +[ "$n" -eq "$from" ] && exit 0 +if [ "$n" -gt "$from" ]; then + at="$n" +else + at="$((n - 1))" +fi +api "{\"id\":\"tmux-mode\",\"method\":\"tab.move\",\"params\":{\"tab_id\":\"$tab\",\"insert_index\":$at}}" >/dev/null diff --git a/tmuxrdr/plugin/bin/paste-buffer b/tmuxrdr/plugin/bin/paste-buffer new file mode 100755 index 00000000..c5c16844 --- /dev/null +++ b/tmuxrdr/plugin/bin/paste-buffer @@ -0,0 +1,20 @@ +#!/bin/sh +# tmux prefix+] (paste-buffer -p): paste the most recent copy into the current +# pane. Herdr copies to the system clipboard, so that is the buffer. -p means +# bracketed paste when the application asked for it, and that is what herdr's +# send-text already does (src/app/api_helpers.rs: encode_api_text). +. "$(dirname "$0")/../lib.sh" + +if command -v pbpaste >/dev/null 2>&1; then + buf="$(pbpaste; printf x)" +elif command -v wl-paste >/dev/null 2>&1; then + buf="$(wl-paste --no-newline; printf x)" +elif command -v xclip >/dev/null 2>&1; then + buf="$(xclip -o -selection clipboard; printf x)" +else + fail "no buffers" +fi +# The x keeps trailing newlines that $(...) would strip; tmux pastes verbatim. +buf="${buf%x}" +[ -n "$buf" ] || fail "no buffers" +"$H" pane send-text "$(active_pane)" "$buf" >/dev/null diff --git a/tmuxrdr/plugin/bin/rotate-window b/tmuxrdr/plugin/bin/rotate-window new file mode 100755 index 00000000..2457fa3b --- /dev/null +++ b/tmuxrdr/plugin/bin/rotate-window @@ -0,0 +1,34 @@ +#!/bin/sh +# tmux prefix+C-o (rotate-window, upward) and prefix+M-o (rotate-window -D): +# every pane moves one place in pane-index order, wrapping. Measured on tmux +# 3.7c: %0 %1 %2 becomes %1 %2 %0 upward, %2 %0 %1 downward, and focus stays on +# the *position* -- the pane now there becomes current -- so this refocuses it +# by id over the socket (pane.focus, which the CLI does not wrap). +. "$(dirname "$0")/../lib.sh" + +p="$(active_pane)" +panes="$(tab_panes "$p")" +n="$(printf '%s\n' "$panes" | wc -l | tr -d ' ')" +[ "$n" -gt 1 ] || exit 0 +pos="$(printf '%s\n' "$panes" | grep -n -x -F "$p" | cut -d: -f1)" + +# Swaps move panes, ids and all: swapping the first pane with each of the +# others in turn rotates upward; the last with each, in reverse, downward. +case "${1:-up}" in +up) + first="$(printf '%s\n' "$panes" | sed -n 1p)" + printf '%s\n' "$panes" | sed 1d | while IFS= read -r t; do + swap "$first" "$t" + done + ;; +down) + last="$(printf '%s\n' "$panes" | sed -n "${n}p")" + printf '%s\n' "$panes" | sed '$d' | sed -n '1!G;h;$p' | while IFS= read -r t; do + swap "$last" "$t" + done + ;; +*) + fail "usage: rotate-window up|down" + ;; +esac +focus_pane "$(tab_panes "$p" | sed -n "${pos}p")" diff --git a/tmuxrdr/plugin/bin/select-window b/tmuxrdr/plugin/bin/select-window new file mode 100755 index 00000000..691d7c9f --- /dev/null +++ b/tmuxrdr/plugin/bin/select-window @@ -0,0 +1,22 @@ +#!/bin/sh +# tmux prefix+0 and prefix+' (select-window -t :=N). Herdr numbers tabs by +# their place in the tab bar, from 1 -- like tmux with base-index 1 and +# renumber-windows on -- so N is a position: 0 finds nothing, exactly as +# prefix+0 does under this machine's base-index 1. With no argument it prompts +# for the index, as prefix+' does (run as a popup). +. "$(dirname "$0")/../lib.sh" + +n="${1:-}" +if [ -z "$n" ]; then + printf '(index) ' + IFS= read -r n || exit 0 +fi +[ -n "$n" ] || exit 0 +case "$n" in + *[!0-9]*) fail "can't find window: $n" ;; +esac + +ws="$(pane_info "$(active_pane)" workspace_id)" +tab="$(workspace_tabs "$ws" | sed -n "${n}p" | cut -f1)" +[ "$n" -gt 0 ] && [ -n "$tab" ] || fail "can't find window: $n" +"$H" tab focus "$tab" >/dev/null diff --git a/tmuxrdr/plugin/bin/show-messages b/tmuxrdr/plugin/bin/show-messages new file mode 100755 index 00000000..c6d83958 --- /dev/null +++ b/tmuxrdr/plugin/bin/show-messages @@ -0,0 +1,8 @@ +#!/bin/sh +# tmux prefix+~ (show-messages): the server's message log. Herdr's is its +# server log, beside the session's socket; q leaves, as in tmux's view mode. +# Run as a popup. +dir="$(dirname "${HERDR_SOCKET_PATH:?not run by herdr}")" +log="$dir/herdr-server.log" +[ -r "$log" ] || log="$dir/herdr.log" +exec less -R +G "$log" diff --git a/tmuxrdr/plugin/bin/spread-layout b/tmuxrdr/plugin/bin/spread-layout new file mode 100755 index 00000000..2b40de2a --- /dev/null +++ b/tmuxrdr/plugin/bin/spread-layout @@ -0,0 +1,25 @@ +#!/bin/sh +# tmux prefix+E (select-layout -E): spread panes out evenly. tmux evens out the +# current pane and its neighbours; this evens every split in the tab -- each +# split's ratio becomes its share of the panes along that direction -- over +# the socket's layout.set_split_ratio, which keeps every pane running. herdr +# clamps ratios to 0.1-0.9, so past ten panes in a row they cannot be equal. +. "$(dirname "$0")/../lib.sh" + +tab="$(pane_info "$(active_pane)" tab_id)" +api "{\"id\":\"tmux-mode\",\"method\":\"layout.export\",\"params\":{\"tab_id\":\"$tab\"}}" \ + | jq -c --arg tab "$tab" ' + def cells($d): if .type == "split" and .direction == $d + then (.first | cells($d)) + (.second | cells($d)) else 1 end; + def splits($p): if .type == "split" then + .direction as $d + | {path: $p, ratio: ((.first | cells($d)) / ((.first | cells($d)) + (.second | cells($d))))}, + (.first | splits($p + [false])), + (.second | splits($p + [true])) + else empty end; + .result.layout.root | splits([]) + | {id: "tmux-mode", method: "layout.set_split_ratio", + params: {tab_id: $tab, path: .path, ratio: .ratio}}' \ + | while IFS= read -r req; do + api "$req" >/dev/null + done diff --git a/tmuxrdr/plugin/bin/swap-pane b/tmuxrdr/plugin/bin/swap-pane new file mode 100755 index 00000000..636c9904 --- /dev/null +++ b/tmuxrdr/plugin/bin/swap-pane @@ -0,0 +1,30 @@ +#!/bin/sh +# tmux prefix+{ / prefix+} (swap-pane -U / -D): swap the current pane with the +# previous / next one in pane-index order, wrapping; `marked` swaps with the +# pane prefix+m marked (swap-pane with no target, from the : prompt). The +# current pane keeps focus as it moves, as in tmux. +. "$(dirname "$0")/../lib.sh" + +p="$(active_pane)" +case "${1:-}" in +marked) + t="$(cat "$(state_dir)/marked" 2>/dev/null || true)" + [ -n "$t" ] && "$H" pane get "$t" >/dev/null 2>&1 || fail "no marked pane" + ;; +prev | next) + panes="$(tab_panes "$p")" + n="$(printf '%s\n' "$panes" | wc -l | tr -d ' ')" + [ "$n" -gt 1 ] || exit 0 + i="$(printf '%s\n' "$panes" | grep -n -x -F "$p" | cut -d: -f1)" + if [ "$1" = prev ]; then + j=$(( (i - 2 + n) % n + 1 )) + else + j=$(( i % n + 1 )) + fi + t="$(printf '%s\n' "$panes" | sed -n "${j}p")" + ;; +*) + fail "usage: swap-pane prev|next|marked" + ;; +esac +[ "$t" = "$p" ] || swap "$p" "$t" diff --git a/tmuxrdr/plugin/bin/track-focus b/tmuxrdr/plugin/bin/track-focus new file mode 100755 index 00000000..4132891f --- /dev/null +++ b/tmuxrdr/plugin/bin/track-focus @@ -0,0 +1,36 @@ +#!/bin/sh +# Event hook for tab.focused and workspace.focused: keeps the current and +# previous tab of each workspace, and the current and previous workspace, so +# `last window` and `last session` know where to go. Hooks run concurrently, +# so updates take a mkdir lock. +. "$(dirname "$0")/../lib.sh" + +dir="$(state_dir)" +ev="${HERDR_PLUGIN_EVENT_JSON:-}" +[ -n "$ev" ] || exit 0 + +field() { + printf '%s' "$ev" | jq -r --arg k "$1" '[.. | objects | .[$k]? | strings][0] // empty' +} + +# record NAME VALUE: VALUE becomes NAME.current, the old one NAME.previous. +record() { + [ -n "$2" ] || return 0 + cur="$(cat "$dir/$1.current" 2>/dev/null || true)" + [ "$cur" = "$2" ] && return 0 + [ -n "$cur" ] && printf '%s\n' "$cur" >"$dir/$1.previous" + printf '%s\n' "$2" >"$dir/$1.current" +} + +tries=0 +until mkdir "$dir/.lock" 2>/dev/null; do + tries=$((tries + 1)) + [ "$tries" -gt 40 ] && break + sleep 0.05 +done +trap 'rmdir "$dir/.lock" 2>/dev/null' EXIT + +case "${HERDR_PLUGIN_EVENT:-}" in +tab.focused) record "tab.$(key_of "$(field workspace_id)")" "$(field tab_id)" ;; +workspace.focused) record workspace "$(field workspace_id)" ;; +esac diff --git a/tmuxrdr/plugin/herdr-plugin.toml b/tmuxrdr/plugin/herdr-plugin.toml new file mode 100644 index 00000000..a94327fc --- /dev/null +++ b/tmuxrdr/plugin/herdr-plugin.toml @@ -0,0 +1,92 @@ +# tmux mode: the tmux default prefix bindings herdr has no built-in action +# for, built on the herdr CLI and socket. Part of tmuxrdr; the keys themselves +# are bound in tmuxrdr/config/config.toml, which also holds the bindings herdr +# does have. tmuxrdr/scripts/install links this directory as +# ~/.local/share/tmuxrdr/plugin and registers it with herdr plugin link. +# What neither config nor plugin can do is in tmuxrdr/ROADMAP.md. + +id = "tmuxrdr.tmux-mode" +name = "tmux mode" +version = "0.1.0" +min_herdr_version = "0.9.3" +description = "tmux default prefix bindings herdr lacks: break-pane, swap and rotate by pane order, marks, last window/session, paste-buffer, display-message, select-layout -E" +platforms = ["macos", "linux"] + +# Focus history for `last window` / `last session`. +[[events]] +on = "tab.focused" +command = ["sh", "bin/track-focus"] + +[[events]] +on = "workspace.focused" +command = ["sh", "bin/track-focus"] + +[[actions]] +id = "break-pane" +title = "break-pane (tmux prefix+!)" +command = ["sh", "bin/break-pane"] + +[[actions]] +id = "select-window-0" +title = "select-window -t :=0 (tmux prefix+0)" +command = ["sh", "bin/select-window", "0"] + +[[actions]] +id = "swap-prev" +title = "swap-pane -U (tmux prefix+{)" +command = ["sh", "bin/swap-pane", "prev"] + +[[actions]] +id = "swap-next" +title = "swap-pane -D (tmux prefix+})" +command = ["sh", "bin/swap-pane", "next"] + +[[actions]] +id = "swap-marked" +title = "swap-pane with the marked pane" +command = ["sh", "bin/swap-pane", "marked"] + +[[actions]] +id = "rotate-up" +title = "rotate-window (tmux prefix+C-o)" +command = ["sh", "bin/rotate-window", "up"] + +[[actions]] +id = "rotate-down" +title = "rotate-window -D (tmux prefix+M-o)" +command = ["sh", "bin/rotate-window", "down"] + +[[actions]] +id = "mark" +title = "select-pane -m (tmux prefix+m)" +command = ["sh", "bin/mark", "toggle"] + +[[actions]] +id = "clear-mark" +title = "select-pane -M (tmux prefix+M)" +command = ["sh", "bin/mark", "clear"] + +[[actions]] +id = "last-window" +title = "last-window (tmux prefix+l)" +command = ["sh", "bin/last", "window"] + +[[actions]] +id = "last-session" +title = "switch-client -l (tmux prefix+L)" +command = ["sh", "bin/last", "session"] + +[[actions]] +id = "paste-buffer" +title = "paste-buffer -p (tmux prefix+])" +command = ["sh", "bin/paste-buffer"] + +[[actions]] +id = "display-message" +title = "display-message (tmux prefix+i)" +command = ["sh", "bin/display-message"] + +[[actions]] +id = "spread-layout" +title = "select-layout -E (tmux prefix+E)" +command = ["sh", "bin/spread-layout"] diff --git a/tmuxrdr/plugin/lib.sh b/tmuxrdr/plugin/lib.sh new file mode 100644 index 00000000..c7146904 --- /dev/null +++ b/tmuxrdr/plugin/lib.sh @@ -0,0 +1,95 @@ +# Shared by the commands in bin/. POSIX sh -- macOS /bin/sh is bash 3.2. +# Sourced, never run. Every command acts through the herdr CLI, or the raw +# socket for the one method the CLI does not wrap (pane.focus by id). + +set -u + +H="${HERDR_BIN_PATH:-herdr}" +BIN="$(cd "$(dirname "$0")" && pwd)" + +# tmux shows messages and errors ("can't find window: 0") on its status line; +# here they are herdr toasts. +say() { + "$H" notification show "$1" --sound none >/dev/null 2>&1 \ + || printf '%s\n' "$1" >&2 +} + +fail() { + say "$1" + exit 1 +} + +# One newline-delimited JSON request on the session's socket. +api() { + printf '%s\n' "$1" | nc -U -w 2 "${HERDR_SOCKET_PATH:?not run by herdr}" +} + +# The pane the key was pressed in. Keybinding commands and popups get +# HERDR_ACTIVE_PANE_ID; plugin actions get it in their context JSON; anything +# else asks the server which pane is focused. +active_pane() { + _p="${HERDR_ACTIVE_PANE_ID:-}" + if [ -z "$_p" ] && [ -n "${HERDR_PLUGIN_CONTEXT_JSON:-}" ]; then + _p="$(printf '%s' "$HERDR_PLUGIN_CONTEXT_JSON" \ + | jq -r '.focused_pane_id // empty')" + fi + if [ -z "$_p" ]; then + _p="$("$H" pane current | jq -r '.result.pane.pane_id // empty')" + fi + [ -n "$_p" ] || fail "no current pane" + printf '%s' "$_p" +} + +# pane_info PANE FIELD -- e.g. workspace_id, tab_id. +pane_info() { + "$H" pane get "$1" | jq -r --arg k "$2" '.result.pane[$k] // empty' +} + +# Panes of the tab holding PANE, in layout order: tmux's pane index order. +tab_panes() { + "$H" pane layout --pane "$1" | jq -r '.result.layout.panes[].pane_id' +} + +# 0-based position of PANE in its tab: tmux's #P (pane-base-index 0). +pane_index() { + _i="$(tab_panes "$1" | grep -n -x -F "$1" | cut -d: -f1)" + printf '%s' "$((${_i:-1} - 1))" +} + +# Tabs of WORKSPACE in tab-bar order, one "tab_id<TAB>label" per line. +workspace_tabs() { + "$H" tab list --workspace "$1" | jq -r '.result.tabs[] | [.tab_id, .label] | @tsv' +} + +focus_pane() { + api "{\"id\":\"tmux-mode\",\"method\":\"pane.focus\",\"params\":{\"pane_id\":\"$1\"}}" >/dev/null +} + +swap() { + "$H" pane swap --source-pane "$1" --target-pane "$2" >/dev/null +} + +# One keypress, unbuffered, for (y/n) prompts. +read_key() { + _tty="$(stty -g)" + stty -icanon -echo min 1 time 0 + _k="$(dd bs=1 count=1 2>/dev/null)" + stty "$_tty" + printf '%s' "$_k" +} + +# Plugin state (marks, focus history) lives where herdr puts this plugin's, +# one directory per session: herdr shares that directory across sessions, +# and ids repeat between them -- every named session has its own w1. The +# session is the directory holding its socket (herdr for the default one). +state_dir() { + _d="${HERDR_PLUGIN_STATE_DIR:?run as a herdr plugin action}" + _d="$_d/$(basename "$(dirname "${HERDR_SOCKET_PATH:?not run by herdr}")")" + mkdir -p "$_d" + printf '%s' "$_d" +} + +# Tab and workspace ids hold colons; keep file names plain. +key_of() { + printf '%s' "$1" | tr -c 'A-Za-z0-9._-' '_' +} |
