├── LICENSE ├── copytk.tmux ├── _regex_builds.py ├── README.md └── copytk.py /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Chris Breneman 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /copytk.tmux: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | CURRENT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 3 | 4 | get_tmux_option() { 5 | tmux show-options -g | grep "^${1} " | head -n1 | cut -d ' ' -f 2- 6 | } 7 | NOBINDS=0 8 | NOMATCHES=0 9 | if [ "`get_tmux_option '@copytk-no-default-binds'`" = 'on' ]; then NOBINDS=1; fi 10 | if [ "`get_tmux_option '@copytk-no-default-matches'`" = 'on' ]; then NOMATCHES=1; fi 11 | 12 | if [ $NOBINDS -eq 0 ]; then 13 | 14 | # copytk prefix: easymotion action bindings 15 | tmux bind-key -T copytk s run-shell -b "python3 $CURRENT_DIR/copytk.py easymotion-search --search-nkeys 1" 16 | tmux bind-key -T copytk S run-shell -b "python3 $CURRENT_DIR/copytk.py easymotion-search --search-nkeys 2" 17 | tmux bind-key -T copytk k run-shell -b "python3 $CURRENT_DIR/copytk.py easymotion-lines --search-direction backward" 18 | tmux bind-key -T copytk j run-shell -b "python3 $CURRENT_DIR/copytk.py easymotion-lines --search-direction forward" 19 | tmux bind-key -T copytk n run-shell -b "python3 $CURRENT_DIR/copytk.py easymotion-lines" 20 | 21 | # copy mode: easymotion action bindings 22 | tmux bind-key -T copy-mode-vi s run-shell -b "python3 $CURRENT_DIR/copytk.py easymotion-search --search-nkeys 1" 23 | tmux bind-key -T copy-mode s run-shell -b "python3 $CURRENT_DIR/copytk.py easymotion-search --search-nkeys 1" 24 | 25 | # copytk prefix: easycopy action bindings 26 | tmux bind-key -T copytk y run-shell -b "python3 $CURRENT_DIR/copytk.py easycopy --search-nkeys 1" 27 | tmux bind-key -T copytk Y run-shell -b "python3 $CURRENT_DIR/copytk.py easycopy --search-nkeys 2" 28 | 29 | # tmux prefix: easycopy action bindings 30 | tmux bind-key -T prefix S run-shell -b "python3 $CURRENT_DIR/copytk.py easycopy --search-nkeys 1" 31 | tmux bind-key -T prefix C-s run-shell -b "python3 $CURRENT_DIR/copytk.py easycopy --search-nkeys 1" 32 | 33 | # tmux prefix: linecopy action bindings 34 | tmux bind-key -T prefix W run-shell -b "python3 $CURRENT_DIR/copytk.py linecopy" 35 | tmux bind-key -T prefix C-w run-shell -b "python3 $CURRENT_DIR/copytk.py linecopy" 36 | 37 | # tmux prefix: quickcopy action bindings 38 | tmux bind-key -T prefix Q run-shell -b "python3 $CURRENT_DIR/copytk.py quickcopy" 39 | tmux bind-key -T prefix C-q run-shell -b "python3 $CURRENT_DIR/copytk.py quickcopy" 40 | 41 | # tmux prefix: quickopen action bindings 42 | tmux bind-key -T prefix P run-shell -b "python3 $CURRENT_DIR/copytk.py quickopen" 43 | tmux bind-key -T prefix C-p run-shell -b "python3 $CURRENT_DIR/copytk.py quickopen" 44 | 45 | # bindings to enter copytk prefix 46 | tmux bind-key -T copy-mode-vi S switch-client -T copytk 47 | tmux bind-key -T copy-mode S switch-client -T copytk 48 | 49 | fi 50 | 51 | 52 | if [ $NOMATCHES -eq 0 ]; then 53 | 54 | # Match URLs 55 | tmux set -g @copytk-quickcopy-match-0-0 urls 56 | # Match paths and filenames 57 | tmux set -g @copytk-quickcopy-match-0-1 abspaths 58 | tmux set -g @copytk-quickcopy-match-1-0 paths 59 | tmux set -g @copytk-quickcopy-match-1-1 filenames 60 | # Match IP addrs 61 | tmux set -g @copytk-quickcopy-match-1-2 '(?:^|\W)([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})(?:$|\W)' 62 | # Match commands after the prompt 63 | tmux set -g @copytk-quickcopy-match-2-0 '(?m)^[^\n]{0,80}\$ ([a-zA-Z][a-zA-Z0-9_-]*(?: [^\n]*)?)$' 64 | # Match numbers 65 | tmux set -g @copytk-quickcopy-match-3-0 '-?[0-9]+(?:\.[0-9]+)?(?:[eE]-?[0-9]+)?' 66 | # Match quote-enclosed strings 67 | tmux set -g @copytk-quickcopy-match-3-1 '"([^"\n]*)"' 68 | tmux set -g @copytk-quickcopy-match-3-2 ''\''([^'\'\\'n]*)'\' 69 | # Match whole lines 70 | tmux set -g @copytk-quickcopy-match-4-0 lines 71 | 72 | # Matches for quickopen 73 | tmux set -g @copytk-quickopen-match-0-0 urls 74 | tmux set -g @copytk-quickopen-match-0-1 abspaths 75 | 76 | 77 | fi 78 | 79 | -------------------------------------------------------------------------------- /_regex_builds.py: -------------------------------------------------------------------------------- 1 | # Note: The regexes in this file are not intended to correctly validate 2 | # all instances of the given type. They are designed to match the most 3 | # common variants while specifically excluding less-common ones that could 4 | # introduce false matches. 5 | 6 | import re 7 | 8 | def make_path_regexes(): 9 | # This is not intended to match all valid paths - that would result in too many matches. 10 | # Note: Spaces, as a common delimiter, are handled specially. They are only allowed in 11 | # paths with at least 3 elements, and never in the first or last element. There cannot 12 | # be more than one consecutive space, and it cannot be at the beginning or end of the 13 | # element. 14 | edge_delimiters = r'[][\s:=,#$"{}<>()`' + "'" + ']' 15 | edge_delimiters_w_slash = r'[][\s:=,#$"{}<>()`/' + "'" + ']' 16 | leader = r'(?:^|' + edge_delimiters + ')' 17 | leader_w_slash = r'(?:^|' + edge_delimiters_w_slash + ')' 18 | follower = r'(?:$|' + edge_delimiters + ')' 19 | path_el = r'(?:[a-zA-Z0-9_-]{1,80}|\.|\.\.)' 20 | filename_core = r'[a-zA-Z0-9_-]{1,80}' 21 | inner_path_el = r'[a-zA-Z0-9_-]{1,60}\\? [a-zA-Z0-9_-]{1,60}' 22 | either_path_el = r'(?:' + path_el + '|' + inner_path_el + ')' 23 | basic_filename = filename_core + r'\.' + r'[a-zA-Z][a-zA-Z0-9]{0,5}' 24 | path_filename = either_path_el + r'\.' + r'[a-zA-Z0-9]{1,6}' 25 | sep = r'[\\/]' 26 | root = r'(?:/|~/|[A-Z]:' + sep + ')' 27 | abspath = root + r'(?:' + either_path_el + sep + ')*' + r'(?:' + path_filename + '|' + path_el + sep + '?)' 28 | relpath2 = path_el + sep + r'(?:' + path_el + sep + '?|' + path_filename + ')' 29 | relpath3 = path_el + sep + r'(?:' + either_path_el + sep + r')+' + r'(?:' + path_el + sep + '?|' + path_filename + ')' 30 | anypath = r'(?:' + abspath + '|' + relpath2 + '|' + relpath3 + ')' 31 | rabspath = leader + '(' + abspath + ')' + follower 32 | ranypath = leader + '(' + anypath + ')' + follower 33 | rfilename = leader_w_slash + '(' + basic_filename + ')' + follower 34 | return rabspath, ranypath, rfilename 35 | 36 | def test_path_regexes(): 37 | testpaths = r''' 38 | foo 39 | foo/bar 40 | foo/bar/baz 41 | baz.mp3 42 | 123.456 43 | C:\fooo 44 | C:\fooo.bar 45 | C:/fooo.bar 46 | /fooo 47 | /fooo.bar 48 | /abc/def/ghi 49 | foo bar/baz 50 | foo/bar baz/fiz 51 | foo/bar baz/fiz.buz 52 | ./copytk.tmux 53 | ././copytk.tmux 54 | '''.split('\n') 55 | testpaths = [ p.strip() for p in testpaths if len(p.strip()) ] 56 | 57 | abspath, anypath, filename = make_path_regexes() 58 | 59 | print('abspath: ' + abspath) 60 | print('anypath: ' + anypath) 61 | print('filename: ' + filename) 62 | 63 | for p in testpaths: 64 | m1 = re.fullmatch(abspath, p) 65 | m2 = re.fullmatch(anypath, p) 66 | m3 = re.fullmatch(filename, p) 67 | print(f'{p} - {"ABS" if m1 else ""} {"PATH" if m2 else ""} {"FILE" if m3 else ""}') 68 | 69 | 70 | # Note: This url regex is not a validator, nor is it intended to be. It is intended 71 | # to match the most common kinds of URLs that are used and avoid unintended matches. 72 | # Notably, spaces in urls are not matched; with spaces there's too great a chance 73 | # of false positives (without a more complicated algorithm for matching urls) 74 | 75 | def make_url_regex(): 76 | # Match only at beginning of line or after whitespace or a common delimiting character 77 | edge_delimiters = r'[][\s:=,#"{}()' + "'" + ']' 78 | leader = r'(?:^|' + edge_delimiters + ')' 79 | proto = r'[a-zA-Z][a-zA-Z0-9]{1,5}://' 80 | creds = r'[a-zA-Z0-9_]+(?::[a-zA-Z0-9_-]+)?@' 81 | ipaddr = r'(?:[0-2]?[0-9]{1,2}\.){3}[0-2]?[0-9]{1,2}' 82 | hostname = r'(?:[a-zA-Z0-9][\w-]*\.)*[a-zA-Z][\w-]*' 83 | servname = r'(?:' + hostname + '|' + ipaddr + r')(?::[0-9]{1,5})?' 84 | # to match parts of path and qs, we also want to match parens (some websites 85 | # unfortunately use them) but doing do will likely result in false positives. 86 | # To reduce the chance of false positives, ensure matching parens here. 87 | token = r'(?:[\w.~%/&-]+|(?:[\w.~%/&-]*\([\w.~%/&-]*\)[\w.~%/&-]*)+)' 88 | urlpath = '/' + token + r'?/?' 89 | querystringkv = token + r'+(?:=' + token + '?)?' 90 | querystring = r'\?(?:' + querystringkv + r'&)*(?:' + querystringkv + r')?' 91 | fragment = r'#(?:' + querystringkv + r'&)*(?:' + querystringkv + r')?' 92 | follower = r'(?:$|' + edge_delimiters + ')' 93 | url = leader + '(' + proto + '(?:' + creds + ')?' + servname + '(?:' + urlpath + ')?' + '(?:' + querystring + ')?' + '(?:' + fragment + ')?' + ')' + follower 94 | return url 95 | 96 | def test_url_regex(): 97 | # https://mathiasbynens.be/demo/url-regex 98 | # note: note all are intended to match exactly for usability with matching 99 | testurls = ''' 100 | http://foo.com/blah_blah 101 | http://foo.com/blah_blah/ 102 | http://foo.com/blah_blah_(wikipedia) 103 | http://foo.com/blah_blah_(wikipedia)_(again) 104 | http://1.2.3.4/blah_blah_(wikipedia)_(again) 105 | http://www.example.com/wpstyle/?p=364 106 | https://www.example.com/foo/?bar=baz&inga=42&quux 107 | http://✪df.ws/123 108 | http://userid:password@example.com:8080 109 | http://userid:password@example.com:8080/ 110 | http://userid@example.com 111 | http://userid@example.com/ 112 | http://userid@example.com:8080 113 | http://userid@example.com:8080/ 114 | http://userid:password@example.com 115 | http://userid:password@example.com/ 116 | http://142.42.1.1/ 117 | http://142.42.1.1:8080/ 118 | http://➡.ws/䨹 119 | http://⌘.ws 120 | http://⌘.ws/ 121 | http://foo.com/blah_(wikipedia)#cite-1 122 | http://foo.com/blah_(wikipedia)_blah#cite-1 123 | http://foo.com/unicode_(✪)_in_parens 124 | http://foo.com/(something)?after=parens 125 | http://☺.damowmow.com/ 126 | http://code.google.com/events/#&product=browser 127 | http://j.mp 128 | ftp://foo.bar/baz 129 | http://foo.bar/?q=Test%20URL-encoded%20stuff 130 | http://مثال.إختبار 131 | http://例子.测试 132 | http://उदाहरण.परीक्षा 133 | http://-.~_!$&'()*+,;=:%40:80%2f::::::@example.com 134 | http://1337.net 135 | http://a.b-c.de 136 | http://223.255.255.254 137 | http:// 138 | http://. 139 | http://.. 140 | http://../ 141 | http://? 142 | http://?? 143 | http://??/ 144 | http://# 145 | http://## 146 | http://##/ 147 | http://foo.bar?q=Spaces should be encoded 148 | // 149 | //a 150 | ///a 151 | /// 152 | http:///a 153 | foo.com 154 | rdar://1234 155 | h://test 156 | http:// shouldfail.com 157 | :// should fail 158 | http://foo.bar/foo(bar)baz quux 159 | ftps://foo.bar/ 160 | http://-error-.invalid/ 161 | http://a.b--c.de/ 162 | http://-a.b.co 163 | http://a.b-.co 164 | http://0.0.0.0 165 | http://10.1.1.0 166 | http://10.1.1.255 167 | http://224.1.1.1 168 | http://1.1.1.1.1 169 | http://123.123.123 170 | http://3628126748 171 | http://.www.foo.bar/ 172 | http://www.foo.bar./ 173 | http://.www.foo.bar./ 174 | http://10.1.1.1% 175 | '''.split('\n') 176 | testurls = [ u.strip() for u in testurls if len(u.strip()) ] 177 | 178 | url = make_url_regex() 179 | 180 | print(url) 181 | 182 | for u in testurls: 183 | m = re.fullmatch(url, u) 184 | if m: 185 | print(f'{u} - Match: {m.group(1)}') 186 | else: 187 | print(f'{u} - No Match') 188 | 189 | def print_rex(name, r, comment=None): 190 | rxstr = "r'" + r.replace("'", "'+\"'\"+r'") + "'" 191 | if comment: 192 | print(f"\t# {comment}") 193 | print(f"\t'{name}': {rxstr},") 194 | 195 | #test_url_regex() 196 | 197 | #print('URL:') 198 | print_rex('urls', make_url_regex(), 'matches common types of urls and things that look like urls') 199 | print 200 | 201 | #test_path_regexes() 202 | 203 | absp, anyp, fn = make_path_regexes() 204 | #print('ABS PATH:') 205 | print_rex('abspaths', absp, 'Unix and window style absolute paths') 206 | #print('PATH:') 207 | print_rex('paths', anyp, 'Absolute or relative paths') 208 | #print('FILE:') 209 | print_rex('filenames', fn, 'Isolated filenames without paths') 210 | 211 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tmux Rapid Copy Toolkit 2 | 3 | This tmux plugin is intended to allow copying on-screen data as efficiently and 4 | in as few keystrokes as possible. It is inspired by several other tmux plugins 5 | including [tmux-jump](https://github.com/schasse/tmux-jump), [tmux-fingers](https://github.com/Morantron/tmux-fingers), 6 | [tmux-open](https://github.com/tmux-plugins/tmux-open), and other similar ones. 7 | This plugin combines, improves, and adds functionality on top of a common interface, 8 | with particular attention paid to stability and reliability with respect to wrapped 9 | lines, panes, zooming, etc. 10 | 11 | * [Tmux Rapid Copy Toolkit](#tmux-rapid-copy-toolkit) 12 | * [Features](#features) 13 | * [easymotion](#easymotion) 14 | * [easycopy](#easycopy) 15 | * [quickcopy](#quickcopy) 16 | * [quickopen](#quickopen) 17 | * [linecopy](#linecopy) 18 | * [Requirements](#requirements) 19 | * [Installation](#installation) 20 | * [Manual Install](#manual-install) 21 | * [Keybinds](#keybinds) 22 | * [Options](#options) 23 | * [quickcopy/quickopen matches](#quickcopyquickopen-matches) 24 | * [Custom quickcopy example](#custom-quickcopy-example) 25 | 26 | 27 | ## Features 28 | 29 | There are 5 major modes/features: 30 | 31 | ### easymotion 32 | 33 | This is a reimplementation of the classic vim-style easymotion movement. It allows 34 | rapid seeking to anywhere in copy-mode in just a few keystrokes (typically 3 or 4). 35 | 36 | In tmux copy-mode, hit 37 | the easymotion key (by default `s`), then enter the character at the desired 38 | seek destination. Each instance of that character will then be highlighted 39 | and replaced with one or more letters. Enter the letter(s) corresponding 40 | to the desired seek destination to seek there. The j, k, and n line-based 41 | actions are also supported. 42 | 43 | This mode has several variants that can be configured using options. 44 | 45 | ### easycopy 46 | 47 | This is effectively two easymotion movements in series to set the start and end 48 | positions of a block of text to copy. This can be used outside of copy-mode. 49 | With default keybinds, it allows copying nearly any arbitrary block of text 50 | on screen in 8 keystrokes or less (including prefix). 51 | 52 | To use, hit `Ctrl-b` `S` to activate easycopy mode. Then enter the character 53 | at the start of the region to copy, and key in the corresponding label 54 | that appears. Immediately after, enter the character at the end (inclusive) 55 | of the region to copy, then the corresponding label. The block will 56 | then flash to confirm and will be copied. 57 | 58 | ### quickcopy 59 | 60 | This mode allows copying some of the most common elements of text in even 61 | fewer keystrokes; as few as 3 (including prefix) for very commonly-copied 62 | things like URLs. 63 | A set of patterns for commonly-copied elements such as 64 | paths, filenames, URLs, etc. is configured in tmux.conf (with a reasonable 65 | set of defaults) and these are used to select copyable regions. 66 | 67 | To use, hit `Ctrl-b` `Q` to active quickcopy mode. All matched elements on 68 | screen will be highlighted with associated labels. Key in the label to 69 | copy the region of text. Shorter labels are assigned to the higher-priority 70 | matches. If there are overlapping matches (there usually are), matches 71 | are displayed sequentially in batches - press `n` to advance to the next 72 | batch. Batches are arranged by configured priority, but also optimized 73 | to fit as many matches on-screen as possible in a batch. 74 | 75 | ### quickopen 76 | 77 | This mode allows quickly opening URLs in a browser or absolute paths with their 78 | respective applications. 79 | 80 | It is activated with `Ctrl-b` `P` and behaves identially to quickcopy mode, 81 | except that only openable things are highlighted. Selecting one invokes 82 | `xdg-open` (on Linux) or `open` (on Mac). It also includes a mechanism 83 | to import X environment variables from external source to ensure the command 84 | can reach X. 85 | 86 | ### linecopy 87 | 88 | This mode allows copying blocks of full lines, or a single line; essentially a 89 | faster version of easycopy when the start and end points are both line boundaries. 90 | 91 | To use, hit `Ctrl-b` `W` to activate linecopy mode. The beginning of each terminal 92 | line is replaced by the label string. Key in a label string to select the first line 93 | to copy, then the label string for the last line to copy (inclusive). The block of 94 | lines will then be copied. 95 | 96 | To copy a single line, key in that line's label using uppercase letters. In this 97 | case, do not enter a second label. This will not work if there are any capital 98 | letters defined in the label chars. Single-line copies can also be performed 99 | using quickcopy. 100 | 101 | Line beginnings are assigned for all terminal lines, including wrapped lines; but 102 | the end of a line always goes to the end of a "logical" line (a hard line break). 103 | This allows both easily copying wrapped lines and copying some partial lines. 104 | 105 | ## Requirements 106 | 107 | - python3 108 | - bash 109 | 110 | ## Installation 111 | 112 | Using [tmux plugin manager](https://github.com/tmux-plugins/tpm), add the following 113 | to your tmux.conf: 114 | 115 | ``` 116 | set -g @plugin 'CrispyConductor/tmux-copy-toolkit' 117 | ``` 118 | 119 | Then reload tmux with `Ctrl-b` `:source ~/.tmux.conf` and install TPM plugins 120 | with `Ctrl-b` `I`. 121 | 122 | ### Manual Install 123 | 124 | To install without TPM: 125 | 126 | ``` 127 | git clone https://github.com/CrispyConductor/tmux-copy-toolkit/ ~/path/to/somewhere/ 128 | ``` 129 | 130 | Then add to `tmux.conf`: 131 | 132 | ``` 133 | run-shell ~/path/to/somewhere/copytk.tmux 134 | ``` 135 | 136 | ## Keybinds 137 | 138 | This table specifies the default keybinds. Additional keybinds can be added to 139 | tmux.conf. The defaults can be suppressed by adding this to tmux.conf: 140 | 141 | ``` 142 | set -g @copytk-no-default-binds on 143 | ``` 144 | 145 | You can find the commands corresponding to each binding in [copytk.tmux](copytk.tmux). 146 | 147 | Mode | Action | Default Key 148 | ---- | ------ | ----------- 149 | copy | easymotion seek | `s` 150 | copy | easymotion seek 2-char | `S` `S` 151 | copy | easymotion "j" action (lines below cursor) | `S` `j` 152 | copy | easymotion "k" action (lines above cursor) | `S` `k` 153 | copy | easymotion lines action (all lines) | `S` `n` 154 | any | easycopy | `` `S` 155 | any | quickcopy | `` `Q` 156 | any | quickopen | `` `P` 157 | any | linecopy | `` `W` 158 | 159 | ## Options 160 | 161 | These options can be configured in your tmux.conf like: 162 | 163 | ``` 164 | set -g @copytk-copy-command "VALUE" 165 | ``` 166 | 167 | Option | Default | Description 168 | ------ | ------- | ----------- 169 | `@copytk-copy-command` | `tmux load-buffer -` | Command to run to copy data. The command is run in a shell and data is piped to its stdin. 170 | `@copytk-label-chars` | `asdghklqwertyuiopzxcvbnmfj;` | Characters to use for labels. 171 | `@copytk-cancel-key` | `Escape Enter ^C` | Key(s) to use to cancel out of a mode. These are curses-style key names (with the exception of a few that are mapped). 172 | `@copytk-flash-time` | `0.5` | Seconds to flash copied text on screen. 173 | `@copytk-preflash-time` | `0.05` | Seconds to blank screen before flash. 174 | `@copytk-case-sensitive-search` | `upper` | Case sensitivity for easymotion search char. on=case sensitive; off=not case sensitive; upper=case sensitive only for uppercase search char 175 | `@copytk-min-match-spacing` | `2` | Minimum distance between easymotion search matches. 176 | `@copytk-quickcopy-match-*` | | quickcopy patterns; see below 177 | `@copytk-quickopen-match-*` | | 178 | `@copytk-quickcopy-next-batch-char` | `n` | Key to assign to switching to next batch in quickcopy mode. 179 | `@copytk-quickopen-next-batch-char` | `n` | 180 | `@copytk-quickcopy-min-match-len` | `4` | Minimum length of matching blocks for quickcopy. 181 | `@copytk-quickopen-min-match-len` | `4` | 182 | `@copytk-quickcopy-pack-tiers` | `on` | Whether to allow mixing match tiers in the same batch to pack more in. 183 | `@copytk-flash-only-one` | `on` | In quickcopy mode, if there is more than one instance of the copied text on-screen, this is whether to flash all occurrences of the text or just one. 184 | `@copytk-quickopen-env-file` | `~/.tmux-copytk-env` | Path to a file containing newlike-separated `KEY=VALUE` environment variables. These are added to the environment for running the open command. Generation of this file can be automated in your shellrc. 185 | `@copytk-quickopen-open-command` | `xdg-open` on Linux, `open` on Mac | Command to run to open selected blocks in quickopen. The selected text is passed as an argument. 186 | `@copytk-color-highlight` | `green:yellow` | The color to use for highlighted matches, in the form `foreground`:`background`. Valid names are: `none`, `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white` 187 | `@copytk-color-labelchar` | `red:none` | The color to use for the first/active label character. 188 | `@copytk-color-labelchar2` | `yellow:none` | The color to use for the second and subsequent label characters. 189 | `@copytk-color-message` | `red:none` | The color to use for the status message. 190 | 191 | ### quickcopy/quickopen matches 192 | 193 | Match patterns for quickcopy and quickopen can be configured as well. Patterns for the 2 modes are tracked separately; all quickcopy match options have quickopen equivalents. 194 | 195 | Patterns are divided into numbered tiers starting at 0. Tiers define the priorities for matches to be displayed when there are 196 | overlaps. Organizing patterns into tiers can be important for quickly finding the desired match. Lower-numbered 197 | tiers are higher priority. 198 | 199 | Patterns are entered as options in the form `@copytk-quickcopy-match--` . The value is either 200 | a regular expression or a built-in pattern key. Tier numbers start at 0 and must be contiguous. 201 | Pattern numbers also start as 0 (for each tier) and also must be contiguous. 202 | 203 | Any duplicate matches (within or across tiers) are removed; only the highest priority one is kept. 204 | 205 | For an example of what this looks like, take a look at the defaults in [copytk.tmux](copytk.tmux). 206 | 207 | To suppress the default patterns and completely redefine your own: 208 | 209 | ``` 210 | set -g @copytk-no-default-matches on 211 | ``` 212 | 213 | The built-in pattern keys that can be used in place of a regex are: 214 | 215 | - `urls` - Match a subset of URLs that are likely to occur in a delimited terminal environment. 216 | - `abspaths` - Match absolute paths, either UNIX or Windows style. 217 | - `paths` - Match all paths, absolute and relative. 218 | - `filenames` - Match isolated filenames that occur without a path separator. 219 | - `lines` - Match each individual whole line. 220 | 221 | In addition to these, the defaults include patterns for: 222 | 223 | - IP addresses 224 | - Commands entered after a $ prompt 225 | - Numbers longer than a threshold 226 | - Quote-enclosed strings 227 | 228 | #### Custom quickcopy example 229 | 230 | As an example, here's how one might define a simple quickcopy regex to match basic SQL-query-like things: 231 | 232 | ``` 233 | set -g @copytk-quickcopy-match-0-1 '(SELECT|INSERT) .+;' 234 | ``` 235 | 236 | This adds the regex as a tier 0 (high priority) match. It's added as index 1 in tier 0 because quickcopy-match-0-0 is already used by the defaults (but can be changed). 237 | 238 | 239 | 240 | 241 | -------------------------------------------------------------------------------- /copytk.py: -------------------------------------------------------------------------------- 1 | # Tmux Copy Toolkit 2 | # (C) Chris Breneman 2021 3 | 4 | import os 5 | import os.path 6 | import sys 7 | import re 8 | import argparse 9 | import traceback 10 | import curses 11 | import itertools 12 | import math 13 | import subprocess 14 | import shutil 15 | from datetime import datetime 16 | import time 17 | import platform 18 | 19 | #logdir = '/tmp/copytklog' 20 | logdir = None 21 | 22 | python_command = 'python3' 23 | # Find full path to tmux command so it can be invoked without a shell 24 | def find_command_path(c): 25 | cmd = 'command -V ' + c 26 | f = os.popen(cmd) 27 | result = f.read() 28 | estatus = f.close() 29 | if estatus: 30 | raise Exception('Error finding tmux: ' + cmd) 31 | r = ' '.join(result.split('\n')[0].split(' ')[2:]) 32 | if r[0] != '/': 33 | raise Exception('Got unexpected result from tmux path finding: ' + cmd) 34 | return r 35 | tmux_command = find_command_path('tmux') 36 | 37 | # strings that can be used to map to regexes in the quickcopy matches 38 | match_expr_presets = { 39 | # matches common types of urls and things that look like urls 40 | 'urls': r'(?:^|[][\s:=,#"{}()'+"'"+r'])([a-zA-Z][a-zA-Z0-9]{1,5}://(?:[a-zA-Z0-9_]+(?::[a-zA-Z0-9_-]+)?@)?(?:(?:[a-zA-Z0-9][\w-]*\.)*[a-zA-Z][\w-]*|(?:[0-2]?[0-9]{1,2}\.){3}[0-2]?[0-9]{1,2})(?::[0-9]{1,5})?(?:/(?:[\w.~%/&-]+|(?:[\w.~%/&-]*\([\w.~%/&-]*\)[\w.~%/&-]*)+)?/?)?(?:\?(?:(?:[\w.~%/&-]+|(?:[\w.~%/&-]*\([\w.~%/&-]*\)[\w.~%/&-]*)+)+(?:=(?:[\w.~%/&-]+|(?:[\w.~%/&-]*\([\w.~%/&-]*\)[\w.~%/&-]*)+)?)?&)*(?:(?:[\w.~%/&-]+|(?:[\w.~%/&-]*\([\w.~%/&-]*\)[\w.~%/&-]*)+)+(?:=(?:[\w.~%/&-]+|(?:[\w.~%/&-]*\([\w.~%/&-]*\)[\w.~%/&-]*)+)?)?)?)?(?:#(?:(?:[\w.~%/&-]+|(?:[\w.~%/&-]*\([\w.~%/&-]*\)[\w.~%/&-]*)+)+(?:=(?:[\w.~%/&-]+|(?:[\w.~%/&-]*\([\w.~%/&-]*\)[\w.~%/&-]*)+)?)?&)*(?:(?:[\w.~%/&-]+|(?:[\w.~%/&-]*\([\w.~%/&-]*\)[\w.~%/&-]*)+)+(?:=(?:[\w.~%/&-]+|(?:[\w.~%/&-]*\([\w.~%/&-]*\)[\w.~%/&-]*)+)?)?)?)?)(?:$|[][\s:=,#"{}()'+"'"+r'])', 41 | # Unix and window style absolute paths 42 | 'abspaths': r'(?:^|[][\s:=,#$"{}<>()`'+"'"+r'])((?:/|~/|[A-Z]:[\\/])(?:(?:(?:[a-zA-Z0-9_-]{1,80}|\.|\.\.)|[a-zA-Z0-9_-]{1,60}\\? [a-zA-Z0-9_-]{1,60})[\\/])*(?:(?:(?:[a-zA-Z0-9_-]{1,80}|\.|\.\.)|[a-zA-Z0-9_-]{1,60}\\? [a-zA-Z0-9_-]{1,60})\.[a-zA-Z0-9]{1,6}|(?:[a-zA-Z0-9_-]{1,80}|\.|\.\.)[\\/]?))(?:$|[][\s:=,#$"{}<>()`'+"'"+r'])', 43 | # Absolute or relative paths 44 | 'paths': r'(?:^|[][\s:=,#$"{}<>()`'+"'"+r'])((?:(?:/|~/|[A-Z]:[\\/])(?:(?:(?:[a-zA-Z0-9_-]{1,80}|\.|\.\.)|[a-zA-Z0-9_-]{1,60}\\? [a-zA-Z0-9_-]{1,60})[\\/])*(?:(?:(?:[a-zA-Z0-9_-]{1,80}|\.|\.\.)|[a-zA-Z0-9_-]{1,60}\\? [a-zA-Z0-9_-]{1,60})\.[a-zA-Z0-9]{1,6}|(?:[a-zA-Z0-9_-]{1,80}|\.|\.\.)[\\/]?)|(?:[a-zA-Z0-9_-]{1,80}|\.|\.\.)[\\/](?:(?:[a-zA-Z0-9_-]{1,80}|\.|\.\.)[\\/]?|(?:(?:[a-zA-Z0-9_-]{1,80}|\.|\.\.)|[a-zA-Z0-9_-]{1,60}\\? [a-zA-Z0-9_-]{1,60})\.[a-zA-Z0-9]{1,6})|(?:[a-zA-Z0-9_-]{1,80}|\.|\.\.)[\\/](?:(?:(?:[a-zA-Z0-9_-]{1,80}|\.|\.\.)|[a-zA-Z0-9_-]{1,60}\\? [a-zA-Z0-9_-]{1,60})[\\/])+(?:(?:[a-zA-Z0-9_-]{1,80}|\.|\.\.)[\\/]?|(?:(?:[a-zA-Z0-9_-]{1,80}|\.|\.\.)|[a-zA-Z0-9_-]{1,60}\\? [a-zA-Z0-9_-]{1,60})\.[a-zA-Z0-9]{1,6})))(?:$|[][\s:=,#$"{}<>()`'+"'"+r'])', 45 | # Isolated filenames without paths 46 | 'filenames': r'(?:^|[][\s:=,#$"{}<>()`/'+"'"+r'])([a-zA-Z0-9_-]{1,80}\.[a-zA-Z][a-zA-Z0-9]{0,5})(?:$|[][\s:=,#$"{}<>()`'+"'"+r'])', 47 | } 48 | 49 | 50 | 51 | def log_clear(): 52 | if not logdir: return 53 | shutil.rmtree(logdir, ignore_errors=True) 54 | os.makedirs(logdir) 55 | 56 | def log(message, fn=None, time=False): 57 | if not logdir: return 58 | if fn == None: fn = 'main.log' 59 | if time: 60 | message = str(datetime.now()) + ': ' + message 61 | with open(os.path.join(logdir, fn), 'a') as f: 62 | f.write(message + '\n') 63 | 64 | # We need to replace the current pane with a pane running the plugin script. 65 | # Ideally this would be done without messing with any application currently running within the pane. 66 | # So, create a new window and pane for that application. If the created window/pane 67 | # is larger than the current pane, divide it into sections and set the pane to the proper size. 68 | 69 | 70 | def runcmd(command, one=False, lines=False, noblanklines=False): 71 | # runs command in shell via popen 72 | f = os.popen(command) 73 | data = f.read() 74 | estatus = f.close() 75 | if estatus: 76 | raise Exception(f'Command "{command}" exited with status {estatus}') 77 | if one or lines: # return list of lines 78 | dlines = data.split('\n') 79 | if not one and noblanklines: 80 | dlines = [ l for l in dlines if len(l) > 0 ] 81 | if one: # single-line 82 | return dlines[0] if len(dlines) > 0 else '' 83 | return dlines if lines else data 84 | 85 | def runtmux(args, one=False, lines=False, noblanklines=False, sendstdin=None): 86 | args = [ str(a) for a in args ] 87 | log('run tmux: ' + ' '.join(args), time=True) 88 | with subprocess.Popen( 89 | [ tmux_command ] + args, 90 | shell=False, 91 | stdin=subprocess.PIPE if sendstdin != None else subprocess.DEVNULL, 92 | stdout=subprocess.PIPE 93 | ) as proc: 94 | if sendstdin != None and isinstance(sendstdin, str): 95 | sendstdin = bytearray(sendstdin, 'utf8') 96 | recvstdout, _ = proc.communicate(input=sendstdin) 97 | if proc.returncode != 0: 98 | raise Exception(f'tmux {" ".join(args)} exited with status {proc.returncode}') 99 | log('tmux returned', time=True) 100 | data = recvstdout.decode('utf8') 101 | if one or lines: # return list of lines 102 | dlines = data.split('\n') 103 | if not one and noblanklines: 104 | dlines = [ l for l in dlines if len(l) > 0 ] 105 | if one: # single-line 106 | return dlines[0] if len(dlines) > 0 else '' 107 | return dlines if lines else data 108 | 109 | def runshellcommand(command, sendstdin=None, raisenonzero=True): 110 | log('run shell command: ' + command, time=True) 111 | with subprocess.Popen( 112 | command, 113 | shell=True, 114 | executable='/bin/bash', 115 | stdin=subprocess.PIPE if sendstdin != None else subprocess.DEVNULL, 116 | stdout=subprocess.DEVNULL 117 | ) as proc: 118 | if sendstdin != None and isinstance(sendstdin, str): 119 | sendstdin = bytearray(sendstdin, 'utf8') 120 | proc.communicate(input=sendstdin) 121 | if proc.returncode != 0 and raisenonzero: 122 | raise Exception(f'Command {command} returned exit code {proc.returncode}') 123 | 124 | 125 | def runtmuxmulti(argsets): 126 | if len(argsets) < 1: return 127 | allargs = [] 128 | for argset in argsets: 129 | if len(allargs) > 0: 130 | allargs.append(';') 131 | allargs.extend(argset) 132 | runtmux(allargs) 133 | 134 | tmux_options_cache = {} 135 | def fetch_tmux_options(optmode='g'): 136 | if optmode in tmux_options_cache: 137 | return tmux_options_cache[optmode] 138 | tmuxargs = [ 'show-options' ] 139 | if optmode: 140 | tmuxargs += [ '-' + optmode ] 141 | rows = runtmux(tmuxargs, lines=True, noblanklines=True) 142 | opts = {} 143 | for row in rows: 144 | i = row.find(' ') 145 | if i == -1: 146 | opts[row] = 'on' 147 | continue 148 | name = row[:i] 149 | val = row[i+1:] 150 | # need to process val for quoting and backslash-escapes 151 | if len(val) > 1 and val[0] == '"': 152 | assert(val[-1] == '"') 153 | val = val[1:-1] 154 | elif len(val) > 1 and val[0] == "'": 155 | assert(val[-1] == "'") 156 | val = val[1:-1] 157 | if val.find('\\') != -1: 158 | rval = '' 159 | esc = False 160 | for c in val: 161 | if esc: 162 | rval += c 163 | esc = False 164 | elif c == '\\': 165 | esc = True 166 | else: 167 | rval += c 168 | val = rval 169 | opts[name] = val 170 | tmux_options_cache[optmode] = opts 171 | return opts 172 | 173 | def get_tmux_option(name, default=None, optmode='g', aslist=False, userlist=False): 174 | opts = fetch_tmux_options(optmode) 175 | if aslist: 176 | ret = [] 177 | if name in opts: 178 | ret.append(opts[name]) 179 | i = 0 180 | while True: 181 | if userlist: 182 | lname = name + '-' + str(i) 183 | else: 184 | lname = name + '[' + str(i) + ']' 185 | if lname not in opts: break 186 | ret.append(opts[lname]) 187 | i += 1 188 | if len(ret) == 0 and default != None: 189 | if isinstance(default, list): 190 | return default 191 | else: 192 | return [ default ] 193 | return ret 194 | else: 195 | return opts.get(name, default) 196 | 197 | def str2bool(s): 198 | return str(s).lower() not in ( '', 'off', 'no', 'false', '0' ) 199 | 200 | def get_tmux_option_key_curses(name, default=None, optmode='g', aslist=False): 201 | remap = { 202 | 'Escape': '\x1b', 203 | 'Enter': '\n', 204 | 'Space': ' ' 205 | } 206 | v = get_tmux_option(name, default=default, optmode=optmode, aslist=aslist) 207 | if aslist: 208 | # also allow space-separated list 209 | return [ remap.get(s, s) for k in v for s in k.split(' ') ] 210 | else: 211 | return remap.get(v, v) 212 | 213 | def get_tmux_option_color_pair_curses(name, default_fg=-1, default_bg=-1): 214 | strmap = { 215 | 'none': -1, 216 | 'black': curses.COLOR_BLACK, 217 | 'red': curses.COLOR_RED, 218 | 'green': curses.COLOR_GREEN, 219 | 'yellow': curses.COLOR_YELLOW, 220 | 'blue': curses.COLOR_BLUE, 221 | 'magenta': curses.COLOR_MAGENTA, 222 | 'cyan': curses.COLOR_CYAN, 223 | 'white': curses.COLOR_WHITE 224 | } 225 | v = get_tmux_option(name) 226 | if v == None: 227 | return (default_fg, default_bg) 228 | parts = v.lower().split(':') 229 | if parts[0] not in strmap: 230 | raise Exception(f'Invalid color {parts[0]}') 231 | fg = strmap[parts[0]] 232 | if len(parts) > 1: 233 | if parts[1] not in strmap: 234 | raise Exception(f'Invalid color {parts[1]}') 235 | bg = strmap[parts[1]] 236 | else: 237 | bg = default_bg 238 | return (fg, bg) 239 | 240 | def capture_pane_contents(target=None, opts=None): 241 | args = [ 'capture-pane', '-p' ] 242 | if opts: 243 | args += opts 244 | if target != None: 245 | args += [ '-t', target ] 246 | return runtmux(args)[:-1] 247 | 248 | def get_pane_info(target=None, capture=False, capturej=False): 249 | args = [ 'display-message', '-p' ] 250 | if target != None: 251 | args += [ '-t', target ] 252 | args += [ '#{session_id} #{window_id} #{pane_id} #{pane_width} #{pane_height} #{window_zoomed_flag} #{cursor_x} #{cursor_y} #{copy_cursor_x} #{copy_cursor_y} #{pane_mode} #{scroll_position}' ] 253 | r = runtmux(args, one=True).split(' ') 254 | try: 255 | cursorpos = (int(r[6]), int(r[7])) 256 | except: 257 | cursorpos = (0, 0) 258 | try: 259 | copycursorpos = (int(r[8]), int(r[9])) 260 | except: 261 | copycursorpos = (0, 0) 262 | mode = r[10] 263 | rdict = { 264 | 'session_id': r[0], 265 | 'window_id': r[1], 266 | 'window_id_full': r[0] + ':' + r[1], 267 | 'pane_id': r[2], 268 | 'pane_id_full': r[0] + ':' + r[1] + '.' + r[2], 269 | 'pane_size': (int(r[3]), int(r[4])), # (width, height) 270 | 'zoomed': bool(int(r[5])), 271 | 'cursor': copycursorpos if mode == 'copy-mode' else cursorpos, 272 | 'scroll_position': int(r[11]) if r[11] != '' else None, 273 | 'mode': mode 274 | } 275 | capture_opts = [] 276 | if mode == 'copy-mode' and rdict['scroll_position'] != None and rdict['scroll_position'] > 0: 277 | capture_opts += [ '-S', str(-rdict['scroll_position']), '-E', str(-rdict['scroll_position'] + rdict['pane_size'][1] - 1) ] 278 | if capture: 279 | # The "normal" pane capture includes "hard" newlines at line wraps and truncates trailing spaces 280 | rdict['contents'] = capture_pane_contents(rdict['pane_id_full'], capture_opts) 281 | if capturej: 282 | # The "-J" pane capture includes trailing spaces and does not have newlines for wrapping 283 | rdict['contentsj'] = capture_pane_contents(rdict['pane_id_full'], [ '-J' ] + capture_opts) 284 | return rdict 285 | 286 | def create_window_pane_of_size(size): 287 | # Create a new window in the background 288 | window_id_full = runtmux([ 'new-window', '-dP', '-F', '#{session_id}:#{window_id}', '/bin/cat' ], one=True) 289 | # Get the information about the new pane just created 290 | pane = get_pane_info(window_id_full) 291 | # If the width is greater than the target width, do a vertical split. 292 | # Note that splitting reduces width by at least 2 due to the separator 293 | tmuxcmds = [] 294 | resize = False 295 | if pane['pane_size'][0] > size[0] + 1: 296 | tmuxcmds.append([ 'split-window', '-t', pane['pane_id_full'], '-hd', '/bin/cat' ]) 297 | resize = True 298 | # If too tall, do a horizontal split 299 | if pane['pane_size'][1] > size[1] + 1: 300 | tmuxcmds.append([ 'split-window', '-t', pane['pane_id_full'], '-vd', '/bin/cat' ]) 301 | resize = True 302 | # Resize the pane to desired size 303 | if resize: 304 | tmuxcmds.append([ 'resize-pane', '-t', pane['pane_id_full'], '-x', size[0], '-y', size[1] ]) 305 | if len(tmuxcmds) > 0: 306 | runtmuxmulti(tmuxcmds) 307 | # Return info 308 | pane['pane_size'] = size 309 | return pane 310 | 311 | swap_count = 0 312 | def swap_hidden_pane(show_hidden=None): 313 | global swap_count 314 | if show_hidden == True and swap_count % 2 == 1: 315 | return 316 | if show_hidden == False and swap_count % 2 == 0: 317 | return 318 | 319 | if args.swap_mode == 'pane-swap': 320 | # Swap target pane and hidden pane 321 | t1 = args.t 322 | t2 = args.hidden_t 323 | runtmux([ 'swap-pane', '-s', t2, '-t', t1 ]) 324 | else: 325 | # Switch to either the hidden window or the orig window 326 | if swap_count % 2 == 0: 327 | selectwin = args.hidden_window 328 | else: 329 | selectwin = args.orig_window 330 | runtmux([ 'select-window', '-t', selectwin ]) 331 | swap_count += 1 332 | 333 | def move_tmux_cursor(pos, target, gotocopy=True): # (x, y) 334 | log('move cursor to: ' + str(pos), time=True) 335 | tmuxcmds = [] 336 | if gotocopy: 337 | tmuxcmds.append([ 'copy-mode', '-t', target ]) 338 | tmuxcmds.append([ 'send-keys', '-X', '-t', target, 'top-line' ]) 339 | if pos[1] > 0: 340 | tmuxcmds.append([ 'send-keys', '-X', '-t', target, '-N', str(pos[1]), 'cursor-down' ]) 341 | #tmuxcmds.append([ 'send-keys', '-X', '-t', target, 'start-of-line' ]) # Was breaking when on a wrapped line 342 | if pos[0] > 0: 343 | tmuxcmds.append([ 'send-keys', '-X', '-t', target, '-N', str(pos[0]), 'cursor-right' ]) 344 | runtmuxmulti(tmuxcmds) 345 | 346 | def cleanup_internal_process(): 347 | if swap_count % 2 == 1: 348 | swap_hidden_pane() 349 | runtmux([ 'kill-window', '-t', args.hidden_window ]) 350 | 351 | def gen_em_labels(n, chars=None, min_nchars=1, max_nchars=None): 352 | # Generates easy-motion letter abbreviation sequences 353 | all_chars = chars or 'asdghklqwertyuiopzxcvbnmfj;' 354 | # Determine how many chars per label are needed 355 | need_label_len = max(math.ceil(math.log(n, len(all_chars))), 1) 356 | if min_nchars > need_label_len: 357 | need_label_len = min_nchars 358 | if max_nchars and need_label_len > max_nchars: 359 | need_label_len = max_nchars 360 | # Determine how many letters are actually needed at such a length 361 | at_len_need_chars = math.ceil(n ** (1 / need_label_len)) 362 | # If there are free letters, then there are some available lower on the stack. Evenly divide the 363 | # remaininder among the lower tiers. 364 | n_remaining_chars = len(all_chars) - at_len_need_chars 365 | nchars_per_tier = [ at_len_need_chars ] 366 | for i in range(need_label_len - 1): 367 | nc = n_remaining_chars // (need_label_len - 1 - i) 368 | if i+1 < min_nchars: 369 | nc = 0 370 | nchars_per_tier.append(nc) 371 | n_remaining_chars -= nc 372 | nchars_per_tier.reverse() 373 | 374 | # Construct the labels 375 | remaining_chars = all_chars 376 | for tier in range(need_label_len): 377 | tierchars = remaining_chars[:nchars_per_tier[tier]] 378 | remaining_chars = remaining_chars[nchars_per_tier[tier]:] 379 | for label in itertools.product(*[tierchars for i in range(tier + 1)]): 380 | yield ''.join(label) 381 | 382 | def process_pane_capture_lines(data, nlines=None): 383 | """Given the string blob of data from `tmux capture-pane`, returns an array of line strings. 384 | 385 | Arguments: 386 | data -- String blob of data from `tmux capture-pane` 387 | nlines -- Maximum number of lines to return 388 | 389 | Returns: 390 | An array of line strings. Each line string corresponds to a single visible line such that 391 | a wrapped line is returned as multiple visible lines. Nonprintable characters are removed 392 | and tabs are converted to spaces. 393 | """ 394 | # processes pane capture data into an array of lines 395 | # also handles nonprintables 396 | lines = [ 397 | ''.join([ 398 | ' ' if c == '\t' else ( 399 | c if c.isprintable() else '' 400 | ) 401 | for c in line 402 | ]) 403 | for line in data.split('\n') 404 | ] 405 | if nlines != None: 406 | lines = lines[:nlines] 407 | return lines 408 | 409 | def process_pane_capture_line(line): 410 | return ''.join([ 411 | ' ' if c == '\t' else ( 412 | c if c.isprintable() else '' 413 | ) 414 | for c in line 415 | ]) 416 | 417 | 418 | 419 | # Aligns display capture data to actual data that doesn't include wraps. 420 | # Returns a dict mapping each (x, y) in disp_data to an index in j_data. 421 | # If alignment fails, returns None. 422 | # size is x, y (column, lineno) 423 | def align_capture_data(disp_data, j_data, size): 424 | # TODO: Add checks for if arguments are 0-length or otherwise invalid 425 | jidx = 0 426 | didx = 0 427 | charmap = [] # map from index in disp_data to index in j_data 428 | jcharmap = [] # map from index in j_data to index in disp_data 429 | while didx < len(disp_data): 430 | if jidx >= len(j_data): 431 | charmap.append(len(j_data) - 1) 432 | didx += 1 433 | continue 434 | jc = j_data[jidx] 435 | dc = disp_data[didx] 436 | if jc == dc: # usual case - characters match 437 | charmap.append(jidx) 438 | jcharmap.append(didx) 439 | didx += 1 440 | jidx += 1 441 | elif dc == '\t' and jc == ' ': 442 | for i in range(8): 443 | if jidx < len(j_data) and j_data[jidx] == ' ': 444 | jcharmap.append(didx) 445 | jidx += 1 446 | else: 447 | break 448 | elif jc == '\t' and dc == ' ': 449 | for i in range(8): 450 | if didx < len(disp_data) and disp_data[didx] == ' ': 451 | charmap.append(jidx) 452 | didx += 1 453 | else: 454 | break 455 | elif dc == '\n' or dc == ' ' or dc == '\t': 456 | charmap.append(max(jidx - 1, 0)) 457 | didx += 1 458 | elif jc == ' ' or jc == '\t': 459 | jcharmap.append(didx) 460 | jidx += 1 461 | else: 462 | return None 463 | # Pad maps to full length if necessary 464 | while len(charmap) < len(disp_data): 465 | charmap.append(len(j_data) - 1) 466 | while len(jcharmap) < len(j_data): 467 | jcharmap.append(len(disp_data) - 1) 468 | # Convert character mapping to mapping indexed by disp_data (x, y) 469 | xymap = { 470 | xy : charmap[didx] if didx < len(charmap) and didx < len(disp_data) else len(j_data) - 1 471 | for xy, didx in get_data_xy_idx_map(disp_data, size).items() 472 | } 473 | # Convert j character mapping to a mapping from j char index to display (x, y) 474 | didx_rev_coord_map = get_data_xy_idx_rev_map(disp_data, size) 475 | xymapj = [ 476 | didx_rev_coord_map[min(didx, len(disp_data) - 1)] 477 | for didx in jcharmap 478 | ] 479 | # Return values are: 480 | # 0. Mapping dict from tuple (x, y) display position to index into j_data 481 | # 1. Mapping list from index into j_data to (x, y) display position 482 | # 2. Mapping list from index into disp_data to index into j_data 483 | # 3. Mapping list from index into j_data to index into disp_data 484 | return xymap, xymapj, charmap, jcharmap 485 | 486 | # Returns a mapping array from index in data (the pane capture data) to the (x, y) coordinates on screen 487 | def get_data_xy_idx_rev_map(data, size): 488 | revmap = [] 489 | lineno = 0 490 | col = 0 491 | for dchar in data: 492 | if dchar == '\n': 493 | revmap.append((col, lineno)) 494 | lineno += 1 495 | col = 0 496 | continue 497 | if col >= size[0]: 498 | lineno += 1 499 | col = 0 500 | revmap.append((col, lineno)) 501 | if dchar == '\t': 502 | col = min(col + 8, size[0]) 503 | else: 504 | col += 1 505 | return revmap 506 | 507 | # Return a map from (x,y) to index into data 508 | def get_data_xy_idx_map(data, size): 509 | xymap = {} 510 | didx = 0 511 | for lineno in range(size[1]): 512 | lineended = False 513 | for col in range(size[0]): 514 | if didx >= len(data): 515 | xymap[(col, lineno)] = max(len(data) - 1, 0) 516 | continue 517 | dc = data[didx] 518 | if lineended or dc == '\n': 519 | lineended = True 520 | xymap[(col, lineno)] = max(didx - 1, 0) 521 | else: 522 | xymap[(col, lineno)] = didx if didx < len(data) else len(data) - 1 523 | didx += 1 524 | if didx < len(data) and data[didx] == '\n': 525 | didx += 1 526 | return xymap 527 | 528 | 529 | def execute_copy(data): 530 | command = os.path.expanduser(get_tmux_option('@copytk-copy-command', 'tmux load-buffer -')) 531 | runshellcommand(command, sendstdin=data) 532 | log('Copied data.') 533 | 534 | #n = 10000 535 | #ls = gen_em_labels(n) 536 | #for i in range(n): 537 | # print(next(ls)) 538 | #exit(0) 539 | 540 | class ActionCanceled(Exception): 541 | def __init__(self): 542 | super().__init__('Action Canceled') 543 | 544 | class PaneJumpAction: 545 | 546 | def __init__(self, stdscr): 547 | self.stdscr = stdscr 548 | log('start run easymotion internal', time=True) 549 | 550 | # Fetch information about the panes and capture original contents 551 | self.orig_pane = get_pane_info(args.t, capture=True, capturej=True) 552 | self.overlay_pane = get_pane_info(args.hidden_t) 553 | 554 | # Fetch options 555 | self.em_label_chars = get_tmux_option('@copytk-label-chars', 'asdghklqwertyuiopzxcvbnmfj;') 556 | self.has_capital_label_chars = bool(re.search(r'[A-Z]', self.em_label_chars)) 557 | 558 | # Sanitize the J capture data by removing trailing spaces on each line 559 | self.copy_data = '\n'.join(( line.rstrip() for line in self.orig_pane['contentsj'].split('\n') )) 560 | log(self.copy_data, 'copy_data') 561 | 562 | # Create a mapping from display coordinates to indexes into the copy data 563 | aligninfo = align_capture_data(self.orig_pane['contents'], self.copy_data, self.orig_pane['pane_size']) 564 | if aligninfo == None: 565 | log('alignment failed') 566 | # raise Exception('alignment failed') 567 | # Fall back to just mapping the display data to itself. Will break wrapped lines. 568 | self.copy_data = self.orig_pane['contents'] 569 | self.disp_copy_map = get_data_xy_idx_map(self.copy_data, self.orig_pane['pane_size']) 570 | self.copy_disp_map = get_data_xy_idx_rev_map(self.copy_data, self.orig_pane['pane_size']) 571 | else: 572 | self.disp_copy_map = aligninfo[0] 573 | self.copy_disp_map = aligninfo[1] 574 | 575 | # Fetch options 576 | self.cancel_keys = get_tmux_option_key_curses('@copytk-cancel-key', default='Escape Enter ^C', aslist=True) 577 | 578 | # Initialize curses stuff 579 | curses.curs_set(False) 580 | curses.start_color() 581 | curses.use_default_colors() 582 | def init_color(index, optname, default_fg, default_bg): 583 | pair = get_tmux_option_color_pair_curses(optname, default_fg, default_bg) 584 | curses.init_pair(index, pair[0], pair[1]) 585 | init_color(1, '@copytk-color-labelchar', curses.COLOR_RED, -1) # first label char 586 | init_color(2, '@copytk-color-labelchar2', curses.COLOR_YELLOW, -1) # second+ label char 587 | init_color(3, '@copytk-color-highlight', curses.COLOR_GREEN, curses.COLOR_YELLOW) # highlight 588 | init_color(4, '@copytk-color-message', curses.COLOR_RED, -1) # status message 589 | self.stdscr.clear() 590 | 591 | # Track the size as known by curses 592 | self.curses_size = stdscr.getmaxyx() # note: in (y,x) not (x,y) 593 | 594 | # Set the contents to display 595 | self.display_content_lines = process_pane_capture_lines(self.orig_pane['contents'], self.orig_pane['pane_size'][1]) 596 | self.reset() 597 | 598 | def reset(self, keep_highlight=False): 599 | # Initialize properties for later 600 | self.cur_label_pos = 0 # how many label chars have been keyed in 601 | self.match_locations = None # the currently valid search results [ (x, y, label) ] 602 | self.status_msg = None # Message in bottom-right of screen 603 | 604 | # Highlighted location 605 | if not keep_highlight: 606 | self.highlight_ranges = None # range is inclusive 607 | 608 | # display current contents 609 | log('\n'.join(self.display_content_lines), 'display_content_lines') 610 | self.redraw() 611 | 612 | def flash_highlight_range(self, hlrange, noredraw=False, preflash=False): 613 | if not self.highlight_ranges: 614 | self.highlight_ranges = [] 615 | if preflash: 616 | delayt = float(get_tmux_option('@copytk-preflash-time', '0.05')) 617 | self.redraw() 618 | time.sleep(delayt) 619 | if isinstance(hlrange, list): 620 | self.highlight_ranges.extend(hlrange) 621 | else: 622 | self.highlight_ranges.append(hlrange) 623 | self._redraw_highlight_ranges() 624 | self.stdscr.refresh() 625 | delayt = float(get_tmux_option('@copytk-flash-time', '0.5')) 626 | time.sleep(delayt) 627 | if isinstance(hlrange, list): 628 | self.highlight_ranges = self.highlight_ranges[:-len(hlrange)] 629 | else: 630 | self.highlight_ranges.pop() 631 | if not noredraw: 632 | self._redraw_contents() 633 | self.stdscr.refresh() 634 | 635 | def addstr(self, y, x, s, a=None): 636 | if len(s) == 0: return 637 | try: 638 | if a == None: 639 | self.stdscr.addstr(y, x, s) 640 | else: 641 | self.stdscr.addstr(y, x, s, a) 642 | except Exception as err: 643 | pass 644 | # note: errors are expected in writes to bottom-right 645 | #log(f'Error writing str to screen. curses_size={self.curses_size} linelen={len(line)} i={i} err={str(err)}') 646 | 647 | def _redraw_contents(self): 648 | line_width = min(self.curses_size[1], self.orig_pane['pane_size'][0]) 649 | max_line = min(self.curses_size[0], len(self.display_content_lines)) 650 | for i in range(max_line): 651 | line = self.display_content_lines[i][:line_width].ljust(self.curses_size[0]) 652 | self.addstr(i, 0, line) 653 | 654 | def _redraw_highlight_ranges(self): 655 | if not self.highlight_ranges: return 656 | line_width = min(self.curses_size[1], self.orig_pane['pane_size'][0]) 657 | hlattr = curses.color_pair(3) 658 | for rng in self.highlight_ranges: 659 | for i in range(rng[0][1], rng[1][1] + 1): 660 | line = self.display_content_lines[i] 661 | if i < rng[0][1] or i > rng[1][1]: # whole line not hl 662 | continue 663 | elif i > rng[0][1] and i < rng[1][1]: # whole line hl 664 | self.addstr(i, 0, line.ljust(line_width), hlattr) 665 | elif i == rng[0][1] and i == rng[1][1]: # range starts and stops on this line 666 | self.addstr(i, rng[0][0], line[rng[0][0]:rng[1][0]+1], hlattr) 667 | elif i == rng[0][1]: # range starts on this line 668 | self.addstr(i, rng[0][0], line.ljust(line_width)[rng[0][0]:], hlattr) 669 | elif i == rng[1][1]: # range ends on this line 670 | self.addstr(i, 0, line[0:rng[1][0]+1], hlattr) 671 | else: 672 | assert(False) 673 | 674 | def _redraw_labels(self): 675 | line_width = min(self.curses_size[1], self.orig_pane['pane_size'][0]) 676 | if self.match_locations: 677 | for col, row, label in self.match_locations: 678 | if col + len(label) > line_width: 679 | label = label[:line_width - col] 680 | if len(label) > self.cur_label_pos: 681 | try: 682 | self.stdscr.addstr(row, col, label[self.cur_label_pos], curses.color_pair(1)) 683 | except Exception as err: 684 | pass 685 | #log(f'Error writing str to screen. curses_size={self.curses_size} linelen={len(line)} i={i} err={str(err)}') 686 | if len(label) > self.cur_label_pos + 1: 687 | try: 688 | self.stdscr.addstr(row, col+1, label[self.cur_label_pos+1:], curses.color_pair(2)) 689 | except Exception as err: 690 | pass 691 | #log(f'Error writing str to screen. curses_size={self.curses_size} linelen={len(line)} i={i} err={str(err)}') 692 | 693 | def redraw(self): 694 | self._redraw_contents() 695 | self._redraw_labels() 696 | # highlight ranges 697 | self._redraw_highlight_ranges() 698 | # status message 699 | if self.status_msg: 700 | try: 701 | self.stdscr.addstr(self.curses_size[0] - 1, self.curses_size[1] - len(self.status_msg), self.status_msg, curses.color_pair(4)) 702 | except: 703 | pass 704 | # refresh 705 | self.stdscr.refresh() 706 | 707 | def setstatus(self, msg): 708 | self.status_msg = msg 709 | 710 | def cancel(self): 711 | raise ActionCanceled() 712 | 713 | def getkey(self, valid=None): 714 | if valid == None: 715 | valid = lambda k: len(k) == 1 and k.isprintable() 716 | while True: 717 | try: 718 | key = self.stdscr.getkey() 719 | except: # fix occasional weird curses bug where this behaves as non-blocking 720 | key = 'none' 721 | #if key in ('^[', '^C', '\n', '\x1b'): 722 | if key in self.cancel_keys: 723 | self.cancel() 724 | if key == 'KEY_RESIZE': 725 | self.curses_size = self.stdscr.getmaxyx() 726 | self.redraw() 727 | continue 728 | if valid(key): 729 | return key 730 | #key = ' '.join([str(hex(ord(c))) for c in key]) 731 | #self.stdscr.addstr(0, 0, key) 732 | 733 | 734 | def run(self): 735 | pass 736 | 737 | 738 | class EasyMotionAction(PaneJumpAction): 739 | 740 | def __init__(self, stdscr, search_len=1): 741 | super().__init__(stdscr) 742 | self.search_len = search_len 743 | self.case_sensitive_search = get_tmux_option('@copytk-case-sensitive-search', 'upper') # value values: on, off, upper 744 | self.min_match_spacing = int(get_tmux_option('@copytk-min-match-spacing', '2')) 745 | self.loc_label_mapping = {} # override mapping from match loc tuples to labels 746 | 747 | def _em_filter_locs(self, locs): 748 | d = args.search_direction 749 | cursor = self.orig_pane['cursor'] 750 | if d == 'forward' or d == 'down': 751 | return [ 752 | loc 753 | for loc in locs 754 | if loc[1] > cursor[1] or (loc[1] == cursor[1] and loc[0] >= cursor[0]) 755 | ] 756 | elif d == 'reverse' or d == 'up' or d == 'backward': 757 | return [ 758 | loc 759 | for loc in locs 760 | if loc[1] < cursor[1] or (loc[1] == cursor[1] and loc[0] < cursor[0]) 761 | ] 762 | else: 763 | return locs 764 | 765 | def _em_sort_locs_cursor_proximity(self, locs, cursor=None): 766 | # Sort locations by proximity to cursor 767 | if cursor == None: 768 | cursor = self.orig_pane['cursor'] 769 | locs.sort(key=lambda pos: abs(cursor[0] - pos[0]) + abs(cursor[1] - pos[1]) * self.orig_pane['pane_size'][0]) 770 | 771 | def _em_search_lines(self, datalines, srch, min_match_spacing=2, matchcase=False): 772 | if not matchcase: srch = srch.lower() 773 | results = [] # (x, y) 774 | for linenum, line in reversed(list(enumerate(datalines))): 775 | if not matchcase: line = line.lower() 776 | pos = 0 777 | while True: 778 | r = line.find(srch, pos) 779 | if r == -1: break 780 | results.append((r, linenum)) 781 | pos = r + len(srch) + min_match_spacing 782 | return results 783 | 784 | def _em_input_search_chars(self): 785 | search_str = '' 786 | self.setstatus('INPUT CHAR') 787 | self.redraw() 788 | for i in range(self.search_len): 789 | search_str += self.getkey() 790 | self.setstatus(None) 791 | self.redraw() 792 | return search_str 793 | 794 | def get_locations(self, action): 795 | """Returns a list of (x, y) locations of potential match locations. 796 | 797 | Arguments: 798 | action -- Either 'search' (to input a search char) or 'lines' (to use each line as a location) 799 | 800 | Returns: 801 | A list of (x, y) tuples where y is the line number and x is the character in the line. The lines 802 | represent "physical" lines (eg. a single wrapped line is treated as multiple physical lines here.) 803 | """ 804 | pane_search_lines = self.display_content_lines 805 | log('\n'.join(pane_search_lines), 'pane_search_lines') 806 | 807 | if action == 'search': 808 | search_str = self._em_input_search_chars() 809 | return self._em_search_lines( 810 | pane_search_lines, 811 | search_str, 812 | self.min_match_spacing, 813 | self.case_sensitive_search == 'on' or (self.case_sensitive_search == 'upper' and search_str.lower() != search_str) 814 | ) 815 | elif action == 'lines': 816 | return [ (0, y) for y in range(self.orig_pane['pane_size'][1]) ] 817 | else: 818 | raise Exception('Invalid copytk easymotion action') 819 | 820 | def _input_easymotion_keys(self): 821 | """Waits for easymotion keypresses to select match; filters possible matches as is executed.""" 822 | # Wait for label presses 823 | keyed_label = '' 824 | while True: # loop over each key/char in the label 825 | keyed_label += self.getkey() 826 | self.cur_label_pos += 1 827 | self.match_locations = [ m for m in self.match_locations if m[2].startswith(keyed_label) ] 828 | if len(self.match_locations) < 2: 829 | break 830 | self.redraw() 831 | log('keyed label: ' + keyed_label, time=True) 832 | 833 | 834 | def do_easymotion(self, action, filter_locs=None, sort_close_to=None, save_labels=False): 835 | # Get possible jump locations sorted by proximity to cursor 836 | locs = self.get_locations(action) 837 | locs = self._em_filter_locs(locs) 838 | if filter_locs: 839 | locs = [ l for l in locs if filter_locs(l) ] 840 | if len(locs) == 0: 841 | raise ActionCanceled() 842 | self._em_sort_locs_cursor_proximity(locs, sort_close_to) 843 | 844 | # Assign each match a label 845 | label_it = gen_em_labels(len(locs), self.em_label_chars) 846 | self.match_locations = [] 847 | used_labels = { label : loc for loc, label in self.loc_label_mapping.items() } 848 | for ml in locs: 849 | if ml in self.loc_label_mapping: 850 | label = self.loc_label_mapping[ml] 851 | else: 852 | while True: 853 | label = next(label_it) 854 | if label not in used_labels: 855 | break 856 | used_labels[label] = ml 857 | self.match_locations.append(( ml[0], ml[1], label )) 858 | 859 | # If save_labels is true, preserve labels for locations across batches 860 | if save_labels: 861 | self.loc_label_mapping.update({ loc : label for label, loc in used_labels.items() }) 862 | 863 | # Draw labels 864 | self.redraw() 865 | 866 | # Wait for keypresses 867 | self._input_easymotion_keys() 868 | 869 | if len(self.match_locations) == 0: 870 | return None 871 | else: 872 | return (self.match_locations[0][0], self.match_locations[0][1]) 873 | 874 | def run(self, action): 875 | log('easymotion swapping in hidden pane', time=True) 876 | swap_hidden_pane(True) 877 | 878 | loc = self.do_easymotion(action) 879 | 880 | # If a location was found, move cursor there in original pane 881 | if loc: 882 | log('match location: ' + str(loc), time=True) 883 | move_tmux_cursor((loc[0], loc[1]), self.orig_pane['pane_id']) 884 | 885 | 886 | class EasyCopyAction(EasyMotionAction): 887 | 888 | def __init__(self, stdscr, search_len=1): 889 | super().__init__(stdscr, search_len) 890 | 891 | def run(self): 892 | log('easycopy swapping in hidden pane', time=True) 893 | swap_hidden_pane(True) 894 | 895 | # Input searches to get bounds 896 | pos1 = self.do_easymotion('search') 897 | if not pos1: return 898 | self.highlight_ranges = [ (pos1, pos1) ] 899 | self.reset(keep_highlight=True) 900 | # restrict second search to after first position 901 | pos2 = self.do_easymotion( 902 | 'search', 903 | filter_locs=lambda loc: loc[1] > pos1[1] or (loc[1] == pos1[1] and loc[0] > pos1[0]), 904 | sort_close_to=pos1 905 | ) 906 | if not pos2: return 907 | 908 | # since typing last n letters of word, advance end position by n-1 (-1 because range is inclusive) 909 | pos2 = (pos2[0] + self.search_len - 1, pos2[1]) 910 | 911 | # Find the data associated with this range and run the copy command 912 | selected_data = self.copy_data[self.disp_copy_map[pos1] : self.disp_copy_map[pos2] + 1] 913 | log('Copied: ' + selected_data) 914 | execute_copy(selected_data) 915 | 916 | # Flash selected range as confirmation 917 | self.flash_highlight_range((pos1, pos2)) 918 | 919 | 920 | class LineCopyAction(EasyMotionAction): 921 | 922 | def __init__(self, stdscr): 923 | super().__init__(stdscr) 924 | 925 | # override from EasyMotionAction to support single-char line selection 926 | def _input_easymotion_keys(self): 927 | """Waits for easymotion keypresses to select match; filters possible matches as is executed.""" 928 | keyed_label = '' 929 | while True: # loop over each key/char in the label 930 | k = self.getkey() 931 | if not self.has_capital_label_chars and re.fullmatch('[A-Z]', k) and self.easymotion_phase == 0: 932 | # Enable single-line-copy mode 933 | k = k.lower() 934 | self.single_line_copy = True 935 | keyed_label += k 936 | self.cur_label_pos += 1 937 | self.match_locations = [ m for m in self.match_locations if m[2].startswith(keyed_label) ] 938 | if len(self.match_locations) < 2: 939 | break 940 | self.redraw() 941 | log('keyed label: ' + keyed_label, time=True) 942 | 943 | def run(self): 944 | log('easycopy linecopy swapping in hidden pane', time=True) 945 | swap_hidden_pane(True) 946 | 947 | # Input searches to get bounds 948 | self.easymotion_phase = 0 949 | self.single_line_copy = False 950 | pos1 = self.do_easymotion('lines', save_labels=True) 951 | if not pos1: return 952 | 953 | if self.single_line_copy: 954 | # Copy single logical line starting at pos1. 955 | # Find end of line as a copy data index 956 | startidx = self.disp_copy_map[pos1] 957 | endidx = self.copy_data.find('\n', startidx) 958 | if endidx == -1: 959 | endidx = len(self.copy_data) 960 | pos2 = self.copy_disp_map[len(self.copy_data) - 1] 961 | selected_data = self.copy_data[startidx:] 962 | else: 963 | pos2 = self.copy_disp_map[endidx-1] 964 | selected_data = self.copy_data[startidx:endidx] 965 | else: 966 | self.highlight_ranges = [ (pos1, pos1) ] 967 | self.reset(keep_highlight=True) 968 | # restrict second search to after first position 969 | self.easymotion_phase = 1 970 | pos2 = self.do_easymotion( 971 | 'lines', 972 | filter_locs=lambda loc: loc[1] > pos1[1] or (loc[1] == pos1[1] and loc[0] >= pos1[0]), 973 | sort_close_to=pos1 974 | ) 975 | if not pos2: return 976 | 977 | # since typing last n letters of word, advance end position by n-1 (-1 because range is inclusive) 978 | pos2 = (self.orig_pane['pane_size'][0] - 1, pos2[1]) 979 | 980 | # Find the data associated with this range and run the copy command 981 | selected_data = self.copy_data[self.disp_copy_map[pos1] : self.disp_copy_map[pos2] + 1] 982 | 983 | log('Copied: ' + selected_data) 984 | execute_copy(selected_data) 985 | 986 | # Flash selected range as confirmation 987 | self.flash_highlight_range((pos1, pos2)) 988 | 989 | 990 | class QuickCopyAction(PaneJumpAction): 991 | 992 | def __init__(self, stdscr, options_prefix='@copytk-quickcopy-'): 993 | super().__init__(stdscr) 994 | self.options_prefix = options_prefix 995 | self._load_options(options_prefix) 996 | self.em_label_chars = ''.join(( c for c in self.em_label_chars if c not in self.next_batch_char )) 997 | 998 | def _load_options(self, prefix='@copytk-quickcopy-'): 999 | # Load in the tiers of match expressions. 1000 | # Options for this are in the form: @copytk-quickcopy-match-- 1001 | # Each tier list is terminated by a missing option at the index. 1002 | # The set of tiers is terminated by a missing 0 index for the tier. 1003 | tier_exprs = [] # list (of tiers) of lists of strings 1004 | tier_ctr = 0 1005 | while True: 1006 | l = get_tmux_option(prefix + 'match-' + str(tier_ctr), aslist=True, userlist=True) 1007 | if l == None or len(l) == 0: 1008 | break 1009 | tier_exprs.append(l) 1010 | tier_ctr += 1 1011 | self.tier_exprs = tier_exprs 1012 | self.next_batch_char = get_tmux_option_key_curses(prefix + 'next-batch-char', ' n', aslist=True) 1013 | self.min_match_len = get_tmux_option(prefix + 'min-match-len', 4) 1014 | self.pack_tiers = str2bool(get_tmux_option(prefix + 'pack-tiers', 'on')) 1015 | 1016 | def _matchobj(self, start, end, tier=0): 1017 | return ( 1018 | tier, 1019 | end-start, 1020 | self.copy_data[start:end], 1021 | (start, end), 1022 | self.copy_disp_map[start] if start < len(self.copy_data) else len(self.copy_data) - 1, 1023 | self.copy_disp_map[end - 1] 1024 | ) 1025 | 1026 | def _matchobjs(self, tuplist, tier=0): 1027 | return [ self._matchobj(start, end, tier=tier) for start, end in tuplist ] 1028 | 1029 | def _find_lines_matches(self): 1030 | start = 0 1031 | for i, c in enumerate(self.copy_data): 1032 | if c == '\n': 1033 | if i > start: 1034 | yield (start, i) 1035 | start = i + 1 1036 | if len(self.copy_data) > start + 1: 1037 | yield (start, len(self.copy_data)) 1038 | 1039 | # Returns an iterator over (start, end) tuples 1040 | def find_expr_matches(self, expr): 1041 | if expr in match_expr_presets: 1042 | expr = match_expr_presets[expr] 1043 | if expr == 'lines': 1044 | for m in self._find_lines_matches(): 1045 | yield m 1046 | return 1047 | # regex expr 1048 | log('Matching against expr ' + expr) 1049 | flags = 0 1050 | if expr.startswith('(?m)'): 1051 | flags = re.MULTILINE 1052 | expr = expr[4:] 1053 | for match in re.finditer(expr, self.copy_data, flags): 1054 | try: 1055 | d = ( match.start(1), match.end(1) ) 1056 | except IndexError: 1057 | d = ( match.start(0), match.end(0) ) 1058 | log('Found match: ' + str(d) + ': ' + self.copy_data[d[0]:d[1]]) 1059 | if d[0] < 0 or d[1] < 0: 1060 | d = ( 0, 0 ) 1061 | yield d 1062 | 1063 | def find_matches(self): 1064 | # Produce a list of matches where each entry is in this format: 1065 | # ( tiernum, matchlen, data, ( copy data start, copy data end ), ( disp start x, disp start y ), ( disp end x, disp end y ) ) 1066 | allmatches = [] 1067 | for tier, exprs in enumerate(self.tier_exprs): 1068 | for expr in exprs: 1069 | allmatches.extend(self._matchobjs(self.find_expr_matches(expr), tier)) 1070 | # Filter out matches shorter than the minimum 1071 | return [ m for m in allmatches if m[1] >= self.min_match_len ] 1072 | 1073 | def arrange_matches(self, matches, pack_tiers=True): 1074 | # Arrange the set of matches into batches of non-overlapping ones, by tier, and by shortness (shorter preferred) 1075 | # Do this by "writing" each match's range onto a virtual screen, marking each char, and pushing overlapping ones 1076 | # to the next batch. 1077 | # Sort tuples (first by tier then length) 1078 | matches.sort() 1079 | # Dedup matches 1080 | c_match_set = set() 1081 | newmatches = [] 1082 | for match in matches: 1083 | if match[3] not in c_match_set: 1084 | c_match_set.add(match[3]) 1085 | newmatches.append(match) 1086 | matches = newmatches 1087 | # Segment into batches by overlap 1088 | batches = [] 1089 | log('start arrange_matches') 1090 | while len(matches) > 0: # iterate over batches 1091 | last_added_tier = None 1092 | overlaps = [] 1093 | virt = [ False ] * len(self.copy_data) 1094 | batch = [] 1095 | for m in matches: # iterate over remaining matches 1096 | if not pack_tiers and last_added_tier != None and m[0] != last_added_tier: 1097 | break 1098 | # Check if overlaps 1099 | o = False 1100 | for i in range(m[3][0], m[3][1]): 1101 | if virt[i]: 1102 | o = True 1103 | break 1104 | if o: 1105 | overlaps.append(m) 1106 | else: 1107 | batch.append(m) 1108 | for i in range(m[3][0], m[3][1]): 1109 | virt[i] = True 1110 | last_added_tier = m[0] 1111 | batches.append(batch) 1112 | matches = overlaps 1113 | return batches 1114 | 1115 | def run_batch(self, batch): 1116 | # Returns a match object if one is selected. (actually a list of match objects that will all have same text) 1117 | # Returns None to cycle to next batch 1118 | # Throws ActionCanceled if canceled or invalid selection 1119 | 1120 | # Assign a code to each match in the batch 1121 | labels = [] 1122 | match_text_label_map = {} # use this so matches with same text have same label 1123 | label_it = gen_em_labels(len(batch), self.em_label_chars) 1124 | for match in batch: 1125 | if match[2] in match_text_label_map: 1126 | labels.append(match_text_label_map[match[2]]) 1127 | else: 1128 | l = next(label_it) 1129 | labels.append(l) 1130 | match_text_label_map[match[2]] = l 1131 | 1132 | # Set up match_locations and highlights 1133 | self.match_locations = [ ( match[4][0], match[4][1], labels[i] ) for i, match in enumerate(batch) ] 1134 | line_width = self.orig_pane['pane_size'][0] 1135 | def updatehl(): 1136 | self.highlight_ranges = [ 1137 | ( 1138 | ( min(match[4][0] + len(labels[i]) - self.cur_label_pos, line_width), match[4][1] ), 1139 | ( match[5][0], match[5][1] ) 1140 | ) 1141 | for i, match in enumerate(batch) 1142 | ] 1143 | updatehl() 1144 | self.redraw() 1145 | 1146 | # Input label 1147 | keyed_label = '' 1148 | while True: # loop over each key/char in the label 1149 | k = self.getkey() # checks for cancel key and throws 1150 | if k in self.next_batch_char: 1151 | return None 1152 | keyed_label += k 1153 | self.cur_label_pos += 1 1154 | # Update match locations and highlights 1155 | new_match_locations = [] 1156 | new_labels = [] 1157 | new_batch = [] 1158 | for i, label in enumerate(labels): 1159 | if label.startswith(keyed_label): 1160 | new_labels.append(label) 1161 | new_match_locations.append(self.match_locations[i]) 1162 | new_batch.append(batch[i]) 1163 | batch = new_batch 1164 | labels = new_labels 1165 | self.match_locations = new_match_locations 1166 | updatehl() 1167 | self.match_locations = [ m for m in self.match_locations if m[2].startswith(keyed_label) ] 1168 | # count remaining matches by ones with unique text rather than total count 1169 | num_unique_texts = len(set(( m[2] for m in batch ))) 1170 | if num_unique_texts < 2: 1171 | break 1172 | self.redraw() 1173 | log('keyed label: ' + keyed_label, time=True) 1174 | 1175 | self.reset() 1176 | if len(batch) == 0: 1177 | raise ActionCanceled() # invalid entry 1178 | else: 1179 | return batch 1180 | 1181 | def run_quickselect(self): 1182 | log('quickcopy run') 1183 | # Get a list of all matches 1184 | matches = self.find_matches() 1185 | if len(matches) == 0: raise ActionCanceled() 1186 | log('got matches') 1187 | 1188 | # Group them into display batches 1189 | batches = self.arrange_matches(matches, self.pack_tiers) 1190 | log('arranged matches') 1191 | 1192 | swap_hidden_pane(True) 1193 | log('swapped in hidden pane') 1194 | 1195 | # Display each batch until a valid match has been selected 1196 | selected = None 1197 | for batch in batches: 1198 | selected = self.run_batch(batch) 1199 | if selected: break 1200 | if not selected: raise ActionCanceled() 1201 | 1202 | # Got result. 1203 | selected_data = selected[0][2] 1204 | log('Copied: ' + selected_data) 1205 | return selected_data, selected 1206 | 1207 | def run(self): 1208 | selected_data, selected = self.run_quickselect() 1209 | execute_copy(selected_data) 1210 | 1211 | # Flash highlights 1212 | self.match_locations = None 1213 | hl_ranges = [ (match[4], match[5]) for match in selected ] 1214 | flash_only_one = str2bool(get_tmux_option('@copytk-flash-only-one', 'on')) 1215 | if flash_only_one: hl_ranges = [ hl_ranges[-1] ] 1216 | self.flash_highlight_range(hl_ranges, preflash=True) 1217 | 1218 | 1219 | class QuickOpenAction(QuickCopyAction): 1220 | 1221 | def __init__(self, stdscr): 1222 | super().__init__(stdscr, options_prefix='@copytk-quickopen-') 1223 | self.command_extra_env = self.load_env_file() 1224 | 1225 | def load_env_file(self): 1226 | fn = os.path.expanduser(get_tmux_option('@copytk-quickopen-env-file', '~/.tmux-copytk-env')) 1227 | if not os.path.exists(fn): 1228 | return {} 1229 | ret = {} 1230 | with open(fn, 'r') as f: 1231 | for line in f: 1232 | line = line.strip() 1233 | if not len(line): continue 1234 | if line[0] == '#': continue 1235 | parts = line.split('=') 1236 | if len(parts) < 2: continue 1237 | name = parts[0] 1238 | value = '='.join(parts[1:]) 1239 | if len(value) >= 2 and value[0] in ('"', "'") and value[-1] in ('"', "'"): 1240 | value = value[1:-1] 1241 | ret[name] = value 1242 | log('Loaded env file: ' + str(ret)) 1243 | return ret 1244 | 1245 | def run(self): 1246 | selected_data, selected = self.run_quickselect() 1247 | log('quickopen selected: ' + selected_data) 1248 | 1249 | default_open_cmd = 'xdg-open' 1250 | if platform.system() == 'Darwin': 1251 | default_open_cmd = 'open' 1252 | open_cmd = get_tmux_option('@copytk-quickopen-open-command', default_open_cmd) 1253 | env = dict(os.environ) 1254 | env.update(self.command_extra_env) 1255 | full_cmd = 'nohup ' + open_cmd + " '" + selected_data + "' &>/dev/null & disown" 1256 | log('Command: ' + full_cmd) 1257 | log('Env: ' + str(env)) 1258 | subprocess.Popen( 1259 | full_cmd, 1260 | executable='/bin/bash', 1261 | shell=True, 1262 | env=env, 1263 | close_fds=True 1264 | ) 1265 | 1266 | 1267 | def run_easymotion(stdscr): 1268 | nkeys = 1 1269 | if args.search_nkeys: 1270 | nkeys = int(args.search_nkeys) 1271 | action = args.action[11:] 1272 | EasyMotionAction(stdscr, nkeys).run(action) 1273 | 1274 | def run_easycopy(stdscr): 1275 | nkeys = 1 1276 | if args.search_nkeys: 1277 | nkeys = int(args.search_nkeys) 1278 | EasyCopyAction(stdscr, nkeys).run() 1279 | 1280 | def run_linecopy(stdscr): 1281 | LineCopyAction(stdscr).run() 1282 | 1283 | def run_quickcopy(stdscr): 1284 | QuickCopyAction(stdscr).run() 1285 | 1286 | def run_quickopen(stdscr): 1287 | QuickOpenAction(stdscr).run() 1288 | 1289 | 1290 | 1291 | def run_wrapper(main_action, args): 1292 | log('running wrapper', time=True) 1293 | pane = get_pane_info(args.t) 1294 | # Wrap the inner utility in different ways depending on if the pane is zoomed or not. 1295 | # This is because tmux does funny thingy when swapping zoomed panes. 1296 | # When an ordinary pane, use 'pane-swap' mode. In this case, the internal utility 1297 | # is run as a command in a newly created pane of the same size in a newly created window. 1298 | # The command pane is then swapped with the target pane, and swapped back once complete. 1299 | # In 'window-switch' mode, the internal utility is run as a single pane in a new window, 1300 | # then the active window is switched to that new window. Once complete, the window is 1301 | # switched back. 1302 | if pane['zoomed']: 1303 | z_win_id = runtmux([ 'new-window', '-dP', '-F', '#{session_id}:#{window_id}', '/bin/cat' ], one=True) 1304 | hidden_pane = get_pane_info(z_win_id) 1305 | swap_mode = 'window-switch' 1306 | else: 1307 | hidden_pane = create_window_pane_of_size(pane['pane_size']) 1308 | swap_mode = 'pane-swap' 1309 | thisfile = os.path.abspath(__file__) 1310 | cmd = f'{python_command} "{thisfile}"' 1311 | def addopt(opt, val=None): 1312 | nonlocal cmd 1313 | if val == None: 1314 | cmd += ' \'' + opt + '\'' 1315 | else: 1316 | cmd += ' \'' + opt + '\' \'' + str(val) + '\'' 1317 | addopt('--run-internal') 1318 | addopt('-t', pane['pane_id']) 1319 | addopt('--hidden-t', hidden_pane['pane_id']) 1320 | addopt('--hidden-window', hidden_pane['window_id']) 1321 | addopt('--orig-window', pane['window_id']) 1322 | addopt('--swap-mode', swap_mode) 1323 | 1324 | if args.search_nkeys: 1325 | addopt('--search-nkeys', args.search_nkeys) 1326 | if args.search_direction: 1327 | addopt('--search-direction', args.search_direction) 1328 | 1329 | cmd += f' "{main_action}"' 1330 | #cmd += ' 2>/tmp/tm_wrap_log' 1331 | log('wrapper triggering hidden pane respawn of inner process', time=True) 1332 | runtmux([ 'respawn-pane', '-k', '-t', hidden_pane['pane_id_full'], cmd ]) 1333 | 1334 | 1335 | 1336 | 1337 | 1338 | 1339 | argp = argparse.ArgumentParser(description='tmux pane utils') 1340 | argp.add_argument('-t', help='target pane') 1341 | argp.add_argument('--search-nkeys', help='number of characters to key in to search') 1342 | argp.add_argument('--search-direction', help='direction to search from cursor, both|forward|reverse') 1343 | 1344 | # internal args 1345 | argp.add_argument('--run-internal', action='store_true') 1346 | argp.add_argument('--hidden-t') 1347 | argp.add_argument('--hidden-window') 1348 | argp.add_argument('--orig-window') 1349 | argp.add_argument('--swap-mode') 1350 | 1351 | argp.add_argument('action') 1352 | args = argp.parse_args() 1353 | 1354 | if not args.run_internal: 1355 | log_clear() 1356 | run_wrapper(args.action, args) 1357 | exit(0) 1358 | 1359 | 1360 | assert(args.t) 1361 | assert(args.t.startswith('%')) 1362 | assert(args.hidden_t) 1363 | assert(args.hidden_t.startswith('%')) 1364 | assert(args.hidden_window) 1365 | assert(args.orig_window) 1366 | assert(args.swap_mode) 1367 | 1368 | try: 1369 | 1370 | os.environ.setdefault('ESCDELAY', '10') # lower curses pause on escape 1371 | if args.action.startswith('easymotion-'): 1372 | curses.wrapper(run_easymotion) 1373 | elif args.action == 'easycopy': 1374 | curses.wrapper(run_easycopy) 1375 | elif args.action == 'linecopy': 1376 | curses.wrapper(run_linecopy) 1377 | elif args.action == 'quickcopy': 1378 | curses.wrapper(run_quickcopy) 1379 | elif args.action == 'quickopen': 1380 | curses.wrapper(run_quickopen) 1381 | else: 1382 | print('Invalid action') 1383 | exit(1) 1384 | 1385 | except ActionCanceled: 1386 | pass 1387 | 1388 | except Exception as ex: 1389 | print('Error:') 1390 | print(ex) 1391 | traceback.print_exc() 1392 | print('ENTER to continue ...') 1393 | input() 1394 | 1395 | finally: 1396 | cleanup_internal_process() 1397 | exit(0) 1398 | 1399 | 1400 | 1401 | 1402 | --------------------------------------------------------------------------------