├── LICENSE.MIT ├── README.md ├── benchmark.sh ├── mighty-scroll.tmux ├── pscheck.c ├── pscheck.sh ├── scripts ├── helpers.sh └── variables.sh ├── test.sh └── tmux.conf /LICENSE.MIT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (C) 2020 Sergey Vlasov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tmux Mighty Scroll 2 | 3 | Ultimate solution to enable seamless mouse scroll in tmux. 4 | 5 | When no process running, it will scroll over the pane content. Otherwise, 6 | depending on process name, it will pass / or 7 | Page Up / Page Down keys or pass-through mouse scroll events as is. 8 | 9 | ## Features 10 | 11 | * Works in scenarios like `$ git log`, `$ find | less`, etc. 12 | * Works in other applications like `fzf`, `mc`, `man`, `ranger`, `vim`, etc. 13 | * Works with nested environments like `chroot`. 14 | * Starts copy-mode automatically when no process running. 15 | 16 | ## Limitations 17 | 18 | Does not work in panes with open remote connection, since there is no way to 19 | relay back to tmux which processes are running in remote shell. 20 | See `@mighty-scroll-fallback-mode`. 21 | 22 | ## Requirements 23 | 24 | * Mouse mode enabled (`set -g mouse on`). 25 | * C compiler (Linux only. Optional, but highly recommended). 26 | 27 | ## Installation with [Tmux Plugin Manager](https://github.com/tmux-plugins/tpm) (recommended) 28 | 29 | Add the plugin to the list of TPM plugins in `.tmux.conf`: 30 | 31 | ``` 32 | set -g @plugin 'noscript/tmux-mighty-scroll' 33 | ``` 34 | 35 | Hit `prefix + I` to fetch the plugin and source it. 36 | 37 | ## Manual Installation 38 | 39 | Clone the repo: 40 | 41 | ``` 42 | $ git clone https://github.com/noscript/tmux-mighty-scroll ~/clone/path 43 | ``` 44 | 45 | Add this line to the bottom of `.tmux.conf`: 46 | 47 | ``` 48 | run '~/clone/path/mighty-scroll.tmux' 49 | ``` 50 | 51 | Reload tmux environment: 52 | 53 | ``` 54 | $ tmux source ~/.tmux.conf 55 | ``` 56 | 57 | ## Configuration 58 | 59 | |Option|Default value|Supported values|Description| 60 | |---|---|---|---| 61 | |`@mighty-scroll-interval`|`2`|Number|How many lines to scroll in `by-line` and `history` modes.| 62 | |`@mighty-scroll-select-pane`|`on`|`on`, `off`|If enabled, the pane being scrolled becomes automatically selected.| 63 | |`@mighty-scroll-by-line`|`'man less pager fzf'`|List|Space separated list of processes that will be scrolled by line.| 64 | |`@mighty-scroll-by-page`|`'irssi vi'`|List|Space separated list of processes that will be scrolled by page.| 65 | |`@mighty-scroll-pass-through`|`'vim nvim'`|List|Space separated list of processes that will receive mouse scroll events as is.| 66 | |`@mighty-scroll-fallback-mode`|`'history'`|`'history'`, `'by-line'`, `'by-page'`, `'pass-through'`|Scroll mode when in alternate screen but the process didn't match the lists from above.| 67 | |`@mighty-scroll-show-indicator`|`off`|`on`, `off`|If enabled, shows the position indicator in the top right with the current position and the number of lines in the history.| 68 | 69 | Scrolling modes: 70 | 71 | * `history` - enter copy mode and scroll over the pane content by line. 72 | * `by-line` - scroll by line, the running process will receive / keys. 73 | * `by-page` - scroll by page, the running process will receive Page Up / Page Down keys. 74 | * `pass-through` - the running process will receive mouse scroll events as is. 75 | 76 | Example configuration: 77 | 78 | ``` 79 | set -g mouse on 80 | set -g @mighty-scroll-interval 3 81 | set -g @mighty-scroll-by-line 'man fzf' 82 | set -g @mighty-scroll-select-pane off 83 | ``` 84 | 85 | ## Performance caveats 86 | 87 | On Linux, make sure to have a C compiler (`gcc`, `clang`) available (check with 88 | `$ cc -v`), otherwise a Shell implementation of the process checker will be 89 | used, which is about 400% slower! 90 | 91 | On macOS there is only a Shell implementation of the process checker at the moment. 92 | 93 | ## License 94 | [MIT](LICENSE.MIT) 95 | -------------------------------------------------------------------------------- /benchmark.sh: -------------------------------------------------------------------------------- 1 | set -e 2 | 3 | case "$OSTYPE" in 4 | "darwin"*) 5 | echo "macOS is not supported" 6 | exit 1 7 | ;; 8 | esac 9 | 10 | cd "$(dirname "$0")" 11 | 12 | TARGET_PID=$$ # PID of benchmark.sh 13 | RUN_NUM=100 14 | PAGER_CMD="man ascii" 15 | 16 | clean() { 17 | if [ ! -z $SCREEN_PID ]; then 18 | kill $SCREEN_PID 19 | fi 20 | } 21 | trap clean 0 1 2 3 6 15 22 | 23 | run_benchmark() { 24 | echo Running: \"$@\" 25 | TOTAL=0 26 | i=1 27 | while [ "$i" -le $RUN_NUM ]; do 28 | START=$(date +%s.%N) 29 | eval "$@" >/dev/null 30 | END=$(date +%s.%N) 31 | TOTAL=$(echo "$TOTAL + $END - $START" | bc -l) 32 | echo -n "\r$(( $i * 100 / $RUN_NUM ))%" 33 | i=$((i + 1)) 34 | done 35 | echo -e "\rAverage per execution (seconds): $(echo "scale=5; $TOTAL / $RUN_NUM" | bc -l | sed 's/^\./0./')" 36 | echo 37 | } 38 | 39 | echo Execution count: $RUN_NUM 40 | 41 | echo Pager command: \"$PAGER_CMD\" 42 | screen -Dm $PAGER_CMD & 43 | SCREEN_PID=$! 44 | sleep 1 # give processes time to start 45 | 46 | echo Process tree: 47 | pstree -g $TARGET_PID 48 | echo 49 | 50 | run_benchmark "pstree $TARGET_PID | grep 'man\|less\|pager'" 51 | run_benchmark "./pscheck.sh $TARGET_PID 'man' 'less' 'pager'" 52 | run_benchmark "./pscheck $TARGET_PID 'man' 'less' 'pager'" 53 | 54 | kill $SCREEN_PID 55 | SCREEN_PID= 56 | -------------------------------------------------------------------------------- /mighty-scroll.tmux: -------------------------------------------------------------------------------- 1 | CURRENT_DIR="$(cd "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P)" 2 | 3 | . "$CURRENT_DIR/scripts/helpers.sh" 4 | . "$CURRENT_DIR/scripts/variables.sh" 5 | 6 | case "$OSTYPE" in 7 | "darwin"*) 8 | set_tmux_environment "PSCHECK" "$CURRENT_DIR/pscheck.sh" 9 | ;; 10 | *) 11 | CC="${CC:-cc}" 12 | if which "$CC" >/dev/null 2>&1; then 13 | SRC="$CURRENT_DIR/pscheck.c" 14 | OUT="$CURRENT_DIR/pscheck" 15 | if [ ! -e "$OUT" ] || [ "$SRC" -nt "$OUT" ]; then 16 | CFLAGS="-O3 $CFLAGS" 17 | "$CC" -Wall -Wextra -Werror -Wconversion -pedantic -std=c99 $CFLAGS "$SRC" -o "$OUT" 2>&1 18 | fi 19 | set_tmux_environment "PSCHECK" "$OUT" 20 | else 21 | set_tmux_environment "PSCHECK" "$CURRENT_DIR/pscheck.sh" 22 | fi 23 | ;; 24 | esac 25 | 26 | scroll_interval="$(get_tmux_option "$interval_option" "$interval_default")" 27 | 28 | set_tmux_environment "MIGHTY_SCROLL_BY_LINE" "$(get_tmux_option "$by_line_option" "$by_line_default")" 29 | set_tmux_environment "MIGHTY_SCROLL_BY_PAGE" "$(get_tmux_option "$by_page_option" "$by_page_default")" 30 | set_tmux_environment "MIGHTY_SCROLL_PASS_THROUGH" "$(get_tmux_option "$pass_through_option" "$pass_through_default")" 31 | set_tmux_environment "MIGHTY_SCROLL_FALLBACK_MODE" "$(get_tmux_option "$fallback_mode_option" "$fallback_mode_default")" 32 | 33 | if [ "$(get_tmux_option "$select_pane_option" "$select_pane_default")" = "on" ]; then 34 | select_pane_cmd='select-pane -t "{mouse}"' 35 | else 36 | select_pane_cmd='' 37 | fi 38 | 39 | if [ "$(get_tmux_option "$show_indicator_option" "$show_indicator_default")" = "on" ]; then 40 | show_indicator_arg='' 41 | else 42 | show_indicator_arg='-H' 43 | fi 44 | 45 | # FIXME: move to tmux.conf after https://github.com/tmux/tmux/issues/3791 46 | tmux set-option -g command-alias[630] mighty_scroll_select_pane="$select_pane_cmd" 47 | tmux set-option -g command-alias[631] mighty_scroll_with_interval="send-keys -t '{mouse}' -N $scroll_interval" 48 | tmux set-option -g command-alias[632] mighty_scroll_exit_mode_if_bottom='if-shell -F -t "{mouse}" "#{&&:#{pane_in_mode},#{==:#{scroll_position},0}}" {send-keys -t "{mouse}" -X cancel}' 49 | tmux set-option -g command-alias[633] mighty_scroll_enter_copy_mode="copy-mode $show_indicator_arg -t '{mouse}'" 50 | 51 | tmux source-file "$CURRENT_DIR/tmux.conf" 52 | -------------------------------------------------------------------------------- /pscheck.c: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Sergey Vlasov 2 | // MIT License 3 | 4 | #define _GNU_SOURCE 5 | #include 6 | #include 7 | #include 8 | 9 | #define BUF_LEN 512 10 | char path_buf[BUF_LEN]; 11 | 12 | void read_file(char *path, char *buf) 13 | { 14 | buf[0] = '\0'; 15 | 16 | FILE *f = fopen(path, "r"); 17 | if (!f) { // process no longer exists or something else 18 | return; 19 | } 20 | 21 | size_t size = fread(buf, sizeof(char), BUF_LEN, f); 22 | if (size > 0) { 23 | buf[size - 1] = '\0'; 24 | } 25 | fclose(f); 26 | } 27 | 28 | void walk(char *pids, int namesc, char *namesv[]) 29 | { 30 | char read_buf[BUF_LEN]; 31 | char *save_ptr = pids; 32 | char *pid = strtok_r(pids, " ", &save_ptr); 33 | while (pid) { 34 | // read process name: 35 | snprintf(path_buf, BUF_LEN, "/proc/%s/comm", pid); 36 | read_file(path_buf, read_buf); 37 | 38 | if (read_buf[0] != '\0') { 39 | for (int i = 0; i < namesc; ++i) { 40 | if (!strcmp(read_buf, namesv[i])) { // it's a match 41 | // read process state: 42 | snprintf(path_buf, BUF_LEN, "/proc/%s/status", pid); 43 | read_file(path_buf, read_buf); 44 | char *line = strtok(read_buf, "\n"); 45 | while (line) { 46 | const size_t state_pos = 7; 47 | if (!strncmp(line, "State:\t", state_pos)) { 48 | // stopped (suspended): 49 | if (line[state_pos] == 'T') { 50 | exit(1); 51 | } 52 | break; 53 | } 54 | line = strtok(NULL, "\n"); 55 | } 56 | 57 | printf("%s\n", namesv[i]); 58 | exit(0); 59 | } 60 | } 61 | 62 | snprintf(path_buf, BUF_LEN, "/proc/%s/task/%s/children", pid, pid); 63 | read_file(path_buf, read_buf); 64 | if (read_buf[0] != '\0') { 65 | walk(read_buf, namesc, namesv); 66 | } 67 | } 68 | 69 | pid = strtok_r(NULL, " ", &save_ptr); 70 | } 71 | } 72 | 73 | int main(int argc, char *argv[]) 74 | { 75 | if (argc < 3) { 76 | printf("%s: too few arguments\n", argv[0]); 77 | printf("usage: %s PID NAME...\n", argv[0]); 78 | return 2; 79 | } 80 | // command names start from 3rd argument 81 | walk(argv[1], argc - 2, argv + 2); 82 | 83 | return 1; 84 | } 85 | -------------------------------------------------------------------------------- /pscheck.sh: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Sergey Vlasov 2 | # MIT License 3 | 4 | if [ $# -lt 2 ]; then 5 | BASENAME=$(basename $0) 6 | echo "$BASENAME: too few arguments" 7 | echo "usage: $BASENAME PID NAME..." 8 | exit 2 9 | fi 10 | 11 | PID=$1; shift 12 | NAMES=$@ 13 | 14 | process_name() { 15 | case "$OSTYPE" in 16 | "darwin"*) 17 | ps -p $1 -o comm= 18 | ;; 19 | *) 20 | if [ -f /proc/$1/comm ]; then 21 | cat /proc/$P/comm 22 | fi 23 | ;; 24 | esac 25 | } 26 | 27 | is_process_stopped() { 28 | case "$OSTYPE" in 29 | "darwin"*) 30 | [ "$(ps -p $1 -o state=)" = "T" ] 31 | ;; 32 | *) 33 | [ "$(sed -n '/^State:/s/State:\t\(.\).*/\1/p' /proc/$1/status)" = "T" ] 34 | ;; 35 | esac 36 | } 37 | 38 | process_children() { 39 | case "$OSTYPE" in 40 | "darwin"*) 41 | pgrep -P $1 -a 42 | ;; 43 | *) 44 | cat /proc/$1/task/$1/children 45 | ;; 46 | esac 47 | } 48 | 49 | walk() { 50 | for P in $@; do 51 | CMD_NAME="$(process_name $P)" 52 | if [ -z "$CMD_NAME" ]; then # process no longer exists or something else 53 | continue 54 | fi 55 | 56 | for N in $NAMES; do 57 | if [ "$N" = "$CMD_NAME" ]; then # it's a match 58 | if (is_process_stopped $P); then 59 | exit 1 60 | fi 61 | echo "$N" 62 | exit 0 63 | fi 64 | done 65 | 66 | CHILDREN=$(process_children $P) 67 | if [ ! -z "$CHILDREN" ]; then 68 | walk $CHILDREN 69 | fi 70 | done 71 | } 72 | 73 | walk $PID 74 | exit 1 75 | -------------------------------------------------------------------------------- /scripts/helpers.sh: -------------------------------------------------------------------------------- 1 | get_tmux_option() { 2 | local option="$1" 3 | local default_value="$2" 4 | local option_value="$(tmux show-option -gqv "$option")" 5 | if [ -z "$option_value" ]; then 6 | echo "$default_value" 7 | else 8 | echo "$option_value" 9 | fi 10 | } 11 | 12 | set_tmux_environment() { 13 | local option="$1" 14 | local value="$2" 15 | tmux set-environment -g "$option" "$value" 16 | } 17 | -------------------------------------------------------------------------------- /scripts/variables.sh: -------------------------------------------------------------------------------- 1 | interval_option="@mighty-scroll-interval" 2 | interval_default="2" 3 | 4 | select_pane_option="@mighty-scroll-select-pane" 5 | select_pane_default="on" 6 | 7 | by_line_option="@mighty-scroll-by-line" 8 | by_line_default="man less pager fzf" 9 | 10 | by_page_option="@mighty-scroll-by-page" 11 | by_page_default="irssi vi" 12 | 13 | pass_through_option="@mighty-scroll-pass-through" 14 | pass_through_default="vim nvim" 15 | 16 | fallback_mode_option="@mighty-scroll-fallback-mode" 17 | fallback_mode_default="history" 18 | 19 | show_indicator_option="@mighty-scroll-show-indicator" 20 | show_indicator_default="off" 21 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | set -e 2 | 3 | case "$OSTYPE" in 4 | "darwin"*) 5 | echo "macOS is not supported" 6 | exit 1 7 | ;; 8 | esac 9 | 10 | cd "$(dirname "$0")" 11 | 12 | TARGET_PID=$$ # PID of benchmark.sh 13 | 14 | clean() { 15 | if [ ! -z $PID ]; then 16 | kill $PID 17 | fi 18 | } 19 | trap clean 0 1 2 3 6 15 20 | 21 | run_test() { 22 | EXPECTED_EXIT_CODE=$1; shift 23 | echo 24 | echo Running: \"$@\" 25 | ( 26 | set +e 27 | eval "$@" 28 | EXIT_CODE=$? 29 | echo "Exit code $EXIT_CODE, expected $EXPECTED_EXIT_CODE" 30 | if [ "$EXIT_CODE" != "$EXPECTED_EXIT_CODE" ]; then 31 | echo "FAILED" 32 | exit 1 33 | fi 34 | echo "PASSED" 35 | ) 36 | } 37 | 38 | run_suit() { 39 | EXPECTED_EXIT_CODE=$1; shift 40 | COMMAND="$1"; shift 41 | echo ---------------------------- 42 | echo Command: \"$COMMAND\" 43 | echo Process names to match: $@ 44 | echo Expected exit code: $EXPECTED_EXIT_CODE 45 | 46 | eval "$COMMAND" & 47 | PID=$! 48 | sleep 1 # give processes time to start 49 | 50 | echo Process tree: 51 | pstree -g $TARGET_PID 52 | echo 53 | 54 | run_test $EXPECTED_EXIT_CODE "./pscheck.sh $TARGET_PID $@" 55 | run_test $EXPECTED_EXIT_CODE "./pscheck $TARGET_PID $@" 56 | 57 | kill -KILL $PID 58 | PID= 59 | echo 60 | } 61 | 62 | run_suit 1 'timeout 5 tail -f /dev/null' 'dummy' 63 | run_suit 0 'timeout 5 tail -f /dev/null' 'dummy' 'tail' 64 | run_suit 1 'timeout -s STOP -k 5 0.5 tail -f /dev/null' 'dummy' 'tail' 65 | -------------------------------------------------------------------------------- /tmux.conf: -------------------------------------------------------------------------------- 1 | bind-key -T root WheelUpPane { 2 | mighty_scroll_select_pane 3 | if-shell -t "{mouse}" "$PSCHECK #{pane_pid} $MIGHTY_SCROLL_PASS_THROUGH" { 4 | send-keys -t "{mouse}" -M 5 | } { 6 | if-shell -t "{mouse}" "$PSCHECK #{pane_pid} $MIGHTY_SCROLL_BY_LINE || ( [ '$MIGHTY_SCROLL_FALLBACK_MODE' = 'by-line' ] && #{?alternate_on,true,false} )" { 7 | mighty_scroll_with_interval Up 8 | } { 9 | if-shell -t "{mouse}" "$PSCHECK #{pane_pid} $MIGHTY_SCROLL_BY_PAGE || ( [ '$MIGHTY_SCROLL_FALLBACK_MODE' = 'by-page' ] && #{?alternate_on,true,false} )" { 10 | send-keys -t "{mouse}" PageUp 11 | } { 12 | if-shell -t "{mouse}" "[ '$MIGHTY_SCROLL_FALLBACK_MODE' = 'pass-through' ] && #{?alternate_on,true,false}" { 13 | send-keys -t "{mouse}" -M 14 | } { 15 | mighty_scroll_enter_copy_mode 16 | mighty_scroll_with_interval -X scroll-up 17 | } 18 | } 19 | } 20 | } 21 | } 22 | 23 | bind-key -T root WheelDownPane { 24 | mighty_scroll_select_pane 25 | if-shell -t "{mouse}" "$PSCHECK #{pane_pid} $MIGHTY_SCROLL_PASS_THROUGH" { 26 | send-keys -t "{mouse}" -M 27 | } { 28 | if-shell -t "{mouse}" "$PSCHECK #{pane_pid} $MIGHTY_SCROLL_BY_LINE || ( [ '$MIGHTY_SCROLL_FALLBACK_MODE' = 'by-line' ] && #{?alternate_on,true,false} )" { 29 | mighty_scroll_with_interval Down 30 | } { 31 | if-shell -t "{mouse}" "$PSCHECK #{pane_pid} $MIGHTY_SCROLL_BY_PAGE || ( [ '$MIGHTY_SCROLL_FALLBACK_MODE' = 'by-page' ] && #{?alternate_on,true,false} )" { 32 | send-keys -t "{mouse}" PageDown 33 | } { 34 | if-shell -t "{mouse}" "[ '$MIGHTY_SCROLL_FALLBACK_MODE' = 'pass-through' ] && #{?alternate_on,true,false}" { 35 | send-keys -t "{mouse}" -M 36 | } 37 | } 38 | } 39 | } 40 | } 41 | 42 | bind-key -T copy-mode WheelUpPane { 43 | mighty_scroll_select_pane 44 | send-keys -X stop-selection 45 | mighty_scroll_with_interval -X scroll-up 46 | } 47 | 48 | bind-key -T copy-mode WheelDownPane { 49 | mighty_scroll_select_pane 50 | send-keys -X stop-selection 51 | mighty_scroll_with_interval -X scroll-down 52 | mighty_scroll_exit_mode_if_bottom 53 | } 54 | 55 | bind-key -T copy-mode-vi WheelUpPane { 56 | mighty_scroll_select_pane 57 | send-keys -X stop-selection 58 | mighty_scroll_with_interval -X scroll-up 59 | } 60 | 61 | bind-key -T copy-mode-vi WheelDownPane { 62 | mighty_scroll_select_pane 63 | send-keys -X stop-selection 64 | mighty_scroll_with_interval -X scroll-down 65 | mighty_scroll_exit_mode_if_bottom 66 | } 67 | --------------------------------------------------------------------------------