mirror of
https://github.com/CrispyConductor/tmux-copy-toolkit.git
synced 2026-09-10 04:06:14 -04:00
1252 lines
40 KiB
Python
1252 lines
40 KiB
Python
# Tmux Copy Toolkit
|
|
# (C) Chris Breneman 2021
|
|
|
|
import os
|
|
import os.path
|
|
import sys
|
|
import re
|
|
import argparse
|
|
import traceback
|
|
import curses
|
|
import itertools
|
|
import math
|
|
import subprocess
|
|
import shutil
|
|
from datetime import datetime
|
|
import time
|
|
import platform
|
|
|
|
#logdir = '/tmp/copytklog'
|
|
logdir = None
|
|
|
|
python_command = 'python3'
|
|
# Find full path to tmux command so it can be invoked without a shell
|
|
def find_command_path(c):
|
|
cmd = 'command -V ' + c
|
|
f = os.popen(cmd)
|
|
result = f.read()
|
|
estatus = f.close()
|
|
if estatus:
|
|
raise Exception('Error finding tmux: ' + cmd)
|
|
r = ' '.join(result.split('\n')[0].split(' ')[2:])
|
|
if r[0] != '/':
|
|
raise Exception('Got unexpected result from tmux path finding: ' + cmd)
|
|
return r
|
|
tmux_command = find_command_path('tmux')
|
|
|
|
# strings that can be used to map to regexes in the quickcopy matches
|
|
match_expr_presets = {
|
|
# matches common types of urls and things that look like urls
|
|
'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'])',
|
|
# Unix and window style absolute paths
|
|
'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'])',
|
|
# Absolute or relative paths
|
|
'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'])',
|
|
# Isolated filenames without paths
|
|
'filenames': r'(?:^|[][\s:=,#$"{}<>()`/'+"'"+r'])([a-zA-Z0-9_-]{1,80}\.[a-zA-Z][a-zA-Z0-9]{0,5})(?:$|[][\s:=,#$"{}<>()`'+"'"+r'])',
|
|
}
|
|
|
|
|
|
|
|
def log_clear():
|
|
if not logdir: return
|
|
shutil.rmtree(logdir, ignore_errors=True)
|
|
os.makedirs(logdir)
|
|
|
|
def log(message, fn=None, time=False):
|
|
if not logdir: return
|
|
if fn == None: fn = 'main.log'
|
|
if time:
|
|
message = str(datetime.now()) + ': ' + message
|
|
with open(os.path.join(logdir, fn), 'a') as f:
|
|
f.write(message + '\n')
|
|
|
|
# We need to replace the current pane with a pane running the plugin script.
|
|
# Ideally this would be done without messing with any application currently running within the pane.
|
|
# So, create a new window and pane for that application. If the created window/pane
|
|
# is larger than the current pane, divide it into sections and set the pane to the proper size.
|
|
|
|
|
|
def runcmd(command, one=False, lines=False, noblanklines=False):
|
|
# runs command in shell via popen
|
|
f = os.popen(command)
|
|
data = f.read()
|
|
estatus = f.close()
|
|
if estatus:
|
|
raise Exception(f'Command "{command}" exited with status {estatus}')
|
|
if one or lines: # return list of lines
|
|
dlines = data.split('\n')
|
|
if not one and noblanklines:
|
|
dlines = [ l for l in dlines if len(l) > 0 ]
|
|
if one: # single-line
|
|
return dlines[0] if len(dlines) > 0 else ''
|
|
return dlines if lines else data
|
|
|
|
def runtmux(args, one=False, lines=False, noblanklines=False, sendstdin=None):
|
|
args = [ str(a) for a in args ]
|
|
log('run tmux: ' + ' '.join(args), time=True)
|
|
with subprocess.Popen(
|
|
[ tmux_command ] + args,
|
|
shell=False,
|
|
stdin=subprocess.PIPE if sendstdin != None else subprocess.DEVNULL,
|
|
stdout=subprocess.PIPE
|
|
) as proc:
|
|
if sendstdin != None and isinstance(sendstdin, str):
|
|
sendstdin = bytearray(sendstdin, 'utf8')
|
|
recvstdout, _ = proc.communicate(input=sendstdin)
|
|
if proc.returncode != 0:
|
|
raise Exception(f'tmux {" ".join(args)} exited with status {proc.returncode}')
|
|
log('tmux returned', time=True)
|
|
data = recvstdout.decode('utf8')
|
|
if one or lines: # return list of lines
|
|
dlines = data.split('\n')
|
|
if not one and noblanklines:
|
|
dlines = [ l for l in dlines if len(l) > 0 ]
|
|
if one: # single-line
|
|
return dlines[0] if len(dlines) > 0 else ''
|
|
return dlines if lines else data
|
|
|
|
def runshellcommand(command, sendstdin=None, raisenonzero=True):
|
|
log('run shell command: ' + command, time=True)
|
|
with subprocess.Popen(
|
|
command,
|
|
shell=True,
|
|
executable='/bin/bash',
|
|
stdin=subprocess.PIPE if sendstdin != None else subprocess.DEVNULL,
|
|
stdout=subprocess.DEVNULL
|
|
) as proc:
|
|
if sendstdin != None and isinstance(sendstdin, str):
|
|
sendstdin = bytearray(sendstdin, 'utf8')
|
|
proc.communicate(input=sendstdin)
|
|
if proc.returncode != 0 and raisenonzero:
|
|
raise Exception(f'Command {command} returned exit code {proc.returncode}')
|
|
|
|
|
|
def runtmuxmulti(argsets):
|
|
if len(argsets) < 1: return
|
|
allargs = []
|
|
for argset in argsets:
|
|
if len(allargs) > 0:
|
|
allargs.append(';')
|
|
allargs.extend(argset)
|
|
runtmux(allargs)
|
|
|
|
tmux_options_cache = {}
|
|
def fetch_tmux_options(optmode='g'):
|
|
if optmode in tmux_options_cache:
|
|
return tmux_options_cache[optmode]
|
|
tmuxargs = [ 'show-options' ]
|
|
if optmode:
|
|
tmuxargs += [ '-' + optmode ]
|
|
rows = runtmux(tmuxargs, lines=True, noblanklines=True)
|
|
opts = {}
|
|
for row in rows:
|
|
i = row.find(' ')
|
|
if i == -1:
|
|
opts[row] = 'on'
|
|
continue
|
|
name = row[:i]
|
|
val = row[i+1:]
|
|
# need to process val for quoting and backslash-escapes
|
|
if len(val) > 1 and val[0] == '"':
|
|
assert(val[-1] == '"')
|
|
val = val[1:-1]
|
|
elif len(val) > 1 and val[0] == "'":
|
|
assert(val[-1] == "'")
|
|
val = val[1:-1]
|
|
if val.find('\\') != -1:
|
|
rval = ''
|
|
esc = False
|
|
for c in val:
|
|
if esc:
|
|
rval += c
|
|
esc = False
|
|
elif c == '\\':
|
|
esc = True
|
|
else:
|
|
rval += c
|
|
val = rval
|
|
opts[name] = val
|
|
tmux_options_cache[optmode] = opts
|
|
return opts
|
|
|
|
def get_tmux_option(name, default=None, optmode='g', aslist=False, userlist=False):
|
|
opts = fetch_tmux_options(optmode)
|
|
if aslist:
|
|
ret = []
|
|
if name in opts:
|
|
ret.append(opts[name])
|
|
i = 0
|
|
while True:
|
|
if userlist:
|
|
lname = name + '-' + str(i)
|
|
else:
|
|
lname = name + '[' + str(i) + ']'
|
|
if lname not in opts: break
|
|
ret.append(opts[lname])
|
|
i += 1
|
|
if len(ret) == 0 and default != None:
|
|
if isinstance(default, list):
|
|
return default
|
|
else:
|
|
return [ default ]
|
|
return ret
|
|
else:
|
|
return opts.get(name, default)
|
|
|
|
def str2bool(s):
|
|
return str(s).lower() not in ( '', 'off', 'no', 'false', '0' )
|
|
|
|
def get_tmux_option_key_curses(name, default=None, optmode='g', aslist=False):
|
|
remap = {
|
|
'Escape': '\x1b',
|
|
'Enter': '\n',
|
|
'Space': ' '
|
|
}
|
|
v = get_tmux_option(name, default=default, optmode=optmode, aslist=aslist)
|
|
if aslist:
|
|
# also allow space-separated list
|
|
return [ remap.get(s, s) for k in v for s in k.split(' ') ]
|
|
else:
|
|
return remap.get(v, v)
|
|
|
|
def capture_pane_contents(target=None, opts=None):
|
|
args = [ 'capture-pane', '-p' ]
|
|
if opts:
|
|
args += opts
|
|
if target != None:
|
|
args += [ '-t', target ]
|
|
return runtmux(args)[:-1]
|
|
|
|
def get_pane_info(target=None, capture=False, capturej=False):
|
|
args = [ 'display-message', '-p' ]
|
|
if target != None:
|
|
args += [ '-t', target ]
|
|
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}' ]
|
|
r = runtmux(args, one=True).split(' ')
|
|
try:
|
|
cursorpos = (int(r[6]), int(r[7]))
|
|
except:
|
|
cursorpos = (0, 0)
|
|
try:
|
|
copycursorpos = (int(r[8]), int(r[9]))
|
|
except:
|
|
copycursorpos = (0, 0)
|
|
mode = r[10]
|
|
rdict = {
|
|
'session_id': r[0],
|
|
'window_id': r[1],
|
|
'window_id_full': r[0] + ':' + r[1],
|
|
'pane_id': r[2],
|
|
'pane_id_full': r[0] + ':' + r[1] + '.' + r[2],
|
|
'pane_size': (int(r[3]), int(r[4])),
|
|
'zoomed': bool(int(r[5])),
|
|
'cursor': copycursorpos if mode == 'copy-mode' else cursorpos,
|
|
'scroll_position': int(r[11]) if r[11] != '' else None,
|
|
'mode': mode
|
|
}
|
|
capture_opts = []
|
|
if mode == 'copy-mode' and rdict['scroll_position'] != None and rdict['scroll_position'] > 0:
|
|
capture_opts += [ '-S', str(-rdict['scroll_position']), '-E', str(-rdict['scroll_position'] + rdict['pane_size'][1] - 1) ]
|
|
if capture:
|
|
rdict['contents'] = capture_pane_contents(rdict['pane_id_full'], capture_opts)
|
|
if capturej:
|
|
rdict['contentsj'] = capture_pane_contents(rdict['pane_id_full'], [ '-J' ] + capture_opts)
|
|
return rdict
|
|
|
|
def create_window_pane_of_size(size):
|
|
# Create a new window in the background
|
|
window_id_full = runtmux([ 'new-window', '-dP', '-F', '#{session_id}:#{window_id}', '/bin/cat' ], one=True)
|
|
# Get the information about the new pane just created
|
|
pane = get_pane_info(window_id_full)
|
|
# If the width is greater than the target width, do a vertical split.
|
|
# Note that splitting reduces width by at least 2 due to the separator
|
|
tmuxcmds = []
|
|
resize = False
|
|
if pane['pane_size'][0] > size[0] + 1:
|
|
tmuxcmds.append([ 'split-window', '-t', pane['pane_id_full'], '-hd', '/bin/cat' ])
|
|
resize = True
|
|
# If too tall, do a horizontal split
|
|
if pane['pane_size'][1] > size[1] + 1:
|
|
tmuxcmds.append([ 'split-window', '-t', pane['pane_id_full'], '-vd', '/bin/cat' ])
|
|
resize = True
|
|
# Resize the pane to desired size
|
|
if resize:
|
|
tmuxcmds.append([ 'resize-pane', '-t', pane['pane_id_full'], '-x', size[0], '-y', size[1] ])
|
|
if len(tmuxcmds) > 0:
|
|
runtmuxmulti(tmuxcmds)
|
|
# Return info
|
|
pane['pane_size'] = size
|
|
return pane
|
|
|
|
swap_count = 0
|
|
def swap_hidden_pane(show_hidden=None):
|
|
global swap_count
|
|
if show_hidden == True and swap_count % 2 == 1:
|
|
return
|
|
if show_hidden == False and swap_count % 2 == 0:
|
|
return
|
|
|
|
if args.swap_mode == 'pane-swap':
|
|
# Swap target pane and hidden pane
|
|
t1 = args.t
|
|
t2 = args.hidden_t
|
|
runtmux([ 'swap-pane', '-s', t2, '-t', t1 ])
|
|
else:
|
|
# Switch to either the hidden window or the orig window
|
|
if swap_count % 2 == 0:
|
|
selectwin = args.hidden_window
|
|
else:
|
|
selectwin = args.orig_window
|
|
runtmux([ 'select-window', '-t', selectwin ])
|
|
swap_count += 1
|
|
|
|
def move_tmux_cursor(pos, target, gotocopy=True): # (x, y)
|
|
log('move cursor to: ' + str(pos), time=True)
|
|
tmuxcmds = []
|
|
if gotocopy:
|
|
tmuxcmds.append([ 'copy-mode', '-t', target ])
|
|
tmuxcmds.append([ 'send-keys', '-X', '-t', target, 'top-line' ])
|
|
if pos[1] > 0:
|
|
tmuxcmds.append([ 'send-keys', '-X', '-t', target, '-N', str(pos[1]), 'cursor-down' ])
|
|
#tmuxcmds.append([ 'send-keys', '-X', '-t', target, 'start-of-line' ]) # Was breaking when on a wrapped line
|
|
if pos[0] > 0:
|
|
tmuxcmds.append([ 'send-keys', '-X', '-t', target, '-N', str(pos[0]), 'cursor-right' ])
|
|
runtmuxmulti(tmuxcmds)
|
|
|
|
def cleanup_internal_process():
|
|
if swap_count % 2 == 1:
|
|
swap_hidden_pane()
|
|
runtmux([ 'kill-window', '-t', args.hidden_window ])
|
|
|
|
def gen_em_labels(n, chars=None, min_nchars=1, max_nchars=None):
|
|
# Generates easy-motion letter abbreviation sequences
|
|
all_chars = chars or 'asdghklqwertyuiopzxcvbnmfj;'
|
|
# Determine how many chars per label are needed
|
|
need_label_len = max(math.ceil(math.log(n, len(all_chars))), 1)
|
|
if min_nchars > need_label_len:
|
|
need_label_len = min_nchars
|
|
if max_nchars and need_label_len > max_nchars:
|
|
need_label_len = max_nchars
|
|
# Determine how many letters are actually needed at such a length
|
|
at_len_need_chars = math.ceil(n ** (1 / need_label_len))
|
|
# If there are free letters, then there are some available lower on the stack. Evenly divide the
|
|
# remaininder among the lower tiers.
|
|
n_remaining_chars = len(all_chars) - at_len_need_chars
|
|
nchars_per_tier = [ at_len_need_chars ]
|
|
for i in range(need_label_len - 1):
|
|
nc = n_remaining_chars // (need_label_len - 1 - i)
|
|
if i+1 < min_nchars:
|
|
nc = 0
|
|
nchars_per_tier.append(nc)
|
|
n_remaining_chars -= nc
|
|
nchars_per_tier.reverse()
|
|
|
|
# Construct the labels
|
|
remaining_chars = all_chars
|
|
for tier in range(need_label_len):
|
|
tierchars = remaining_chars[:nchars_per_tier[tier]]
|
|
remaining_chars = remaining_chars[nchars_per_tier[tier]:]
|
|
for label in itertools.product(*[tierchars for i in range(tier + 1)]):
|
|
yield ''.join(label)
|
|
|
|
def process_pane_capture_lines(data, nlines=None):
|
|
# processes pane capture data into an array of lines
|
|
# also handles nonprintables
|
|
lines = [
|
|
''.join([
|
|
' ' if c == '\t' else (
|
|
c if c.isprintable() else ''
|
|
)
|
|
for c in line
|
|
])
|
|
for line in data.split('\n')
|
|
]
|
|
if nlines != None:
|
|
lines = lines[:nlines]
|
|
return lines
|
|
|
|
def process_pane_capture_line(line):
|
|
return ''.join([
|
|
' ' if c == '\t' else (
|
|
c if c.isprintable() else ''
|
|
)
|
|
for c in line
|
|
])
|
|
|
|
|
|
|
|
# Aligns display capture data to actual data that doesn't include wraps.
|
|
# Returns a dict mapping each (x, y) in disp_data to an index in j_data.
|
|
# If alignment fails, returns None.
|
|
# size is x, y (column, lineno)
|
|
def align_capture_data(disp_data, j_data, size):
|
|
# TODO: Add checks for if arguments are 0-length or otherwise invalid
|
|
jidx = 0
|
|
didx = 0
|
|
charmap = [] # map from index in disp_data to index in j_data
|
|
jcharmap = [] # map from index in j_data to index in disp_data
|
|
while didx < len(disp_data):
|
|
if jidx >= len(j_data):
|
|
charmap.append(len(j_data) - 1)
|
|
didx += 1
|
|
continue
|
|
jc = j_data[jidx]
|
|
dc = disp_data[didx]
|
|
if jc == dc: # usual case - characters match
|
|
charmap.append(jidx)
|
|
jcharmap.append(didx)
|
|
didx += 1
|
|
jidx += 1
|
|
elif dc == '\t' and jc == ' ':
|
|
for i in range(8):
|
|
if jidx < len(j_data) and j_data[jidx] == ' ':
|
|
jcharmap.append(didx)
|
|
jidx += 1
|
|
else:
|
|
break
|
|
elif jc == '\t' and dc == ' ':
|
|
for i in range(8):
|
|
if didx < len(disp_data) and disp_data[didx] == ' ':
|
|
charmap.append(jidx)
|
|
didx += 1
|
|
else:
|
|
break
|
|
elif dc == '\n' or dc == ' ' or dc == '\t':
|
|
charmap.append(max(jidx - 1, 0))
|
|
didx += 1
|
|
elif jc == ' ' or jc == '\t':
|
|
jcharmap.append(didx)
|
|
jidx += 1
|
|
else:
|
|
return None
|
|
# Pad maps to full length if necessary
|
|
while len(charmap) < len(disp_data):
|
|
charmap.append(len(j_data) - 1)
|
|
while len(jcharmap) < len(j_data):
|
|
jcharmap.append(len(disp_data) - 1)
|
|
# Convert character mapping to mapping indexed by disp_data (x, y)
|
|
xymap = {
|
|
xy : charmap[didx] if didx < len(charmap) and didx < len(disp_data) else len(j_data) - 1
|
|
for xy, didx in get_data_xy_idx_map(disp_data, size).items()
|
|
}
|
|
# Convert j character mapping to a mapping from j char index to display (x, y)
|
|
didx_rev_coord_map = get_data_xy_idx_rev_map(disp_data, size)
|
|
xymapj = [
|
|
didx_rev_coord_map[min(didx, len(disp_data) - 1)]
|
|
for didx in jcharmap
|
|
]
|
|
# Return values are:
|
|
# 0. Mapping dict from tuple (x, y) display position to index into j_data
|
|
# 1. Mapping list from index into j_data to (x, y) display position
|
|
# 2. Mapping list from index into disp_data to index into j_data
|
|
# 3. Mapping list from index into j_data to index into disp_data
|
|
return xymap, xymapj, charmap, jcharmap
|
|
|
|
# Returns a mapping array from index in data (the pane capture data) to the (x, y) coordinates on screen
|
|
def get_data_xy_idx_rev_map(data, size):
|
|
revmap = []
|
|
lineno = 0
|
|
col = 0
|
|
for dchar in data:
|
|
if dchar == '\n':
|
|
revmap.append((col, lineno))
|
|
lineno += 1
|
|
col = 0
|
|
continue
|
|
if col >= size[0]:
|
|
lineno += 1
|
|
col = 0
|
|
revmap.append((col, lineno))
|
|
if dchar == '\t':
|
|
col = min(col + 8, size[0])
|
|
else:
|
|
col += 1
|
|
return revmap
|
|
|
|
# Return a map from (x,y) to index into data
|
|
def get_data_xy_idx_map(data, size):
|
|
xymap = {}
|
|
didx = 0
|
|
for lineno in range(size[1]):
|
|
lineended = False
|
|
for col in range(size[0]):
|
|
if didx >= len(data):
|
|
xymap[(col, lineno)] = max(len(data) - 1, 0)
|
|
continue
|
|
dc = data[didx]
|
|
if lineended or dc == '\n':
|
|
lineended = True
|
|
xymap[(col, lineno)] = max(didx - 1, 0)
|
|
else:
|
|
xymap[(col, lineno)] = didx if didx < len(data) else len(data) - 1
|
|
didx += 1
|
|
if didx < len(data) and data[didx] == '\n':
|
|
didx += 1
|
|
return xymap
|
|
|
|
|
|
def execute_copy(data):
|
|
command = os.path.expanduser(get_tmux_option('@copytk-copy-command', 'tmux load-buffer -'))
|
|
runshellcommand(command, sendstdin=data)
|
|
log('Copied data.')
|
|
|
|
#n = 10000
|
|
#ls = gen_em_labels(n)
|
|
#for i in range(n):
|
|
# print(next(ls))
|
|
#exit(0)
|
|
|
|
class ActionCanceled(Exception):
|
|
def __init__(self):
|
|
super().__init__('Action Canceled')
|
|
|
|
class PaneJumpAction:
|
|
|
|
def __init__(self, stdscr):
|
|
self.stdscr = stdscr
|
|
log('start run easymotion internal', time=True)
|
|
|
|
# Fetch information about the panes and capture original contents
|
|
self.orig_pane = get_pane_info(args.t, capture=True, capturej=True)
|
|
self.overlay_pane = get_pane_info(args.hidden_t)
|
|
|
|
# Fetch options
|
|
self.em_label_chars = get_tmux_option('@copytk-label-chars', 'asdghklqwertyuiopzxcvbnmfj;')
|
|
|
|
# Sanitize the J capture data by removing trailing spaces on each line
|
|
self.copy_data = '\n'.join(( line.rstrip() for line in self.orig_pane['contentsj'].split('\n') ))
|
|
log(self.copy_data, 'copy_data')
|
|
|
|
# Create a mapping from display coordinates to indexes into the copy data
|
|
aligninfo = align_capture_data(self.orig_pane['contents'], self.copy_data, self.orig_pane['pane_size'])
|
|
if aligninfo == None:
|
|
log('alignment failed')
|
|
# raise Exception('alignment failed')
|
|
# Fall back to just mapping the display data to itself. Will break wrapped lines.
|
|
self.copy_data = self.orig_pane['contents']
|
|
self.disp_copy_map = get_data_xy_idx_map(self.copy_data, self.orig_pane['pane_size'])
|
|
self.copy_disp_map = get_data_xy_idx_rev_map(self.copy_data, self.orig_pane['pane_size'])
|
|
else:
|
|
self.disp_copy_map = aligninfo[0]
|
|
self.copy_disp_map = aligninfo[1]
|
|
|
|
# Fetch options
|
|
self.cancel_keys = get_tmux_option_key_curses('@copytk-cancel-key', default='Escape Enter ^C', aslist=True)
|
|
|
|
# Initialize curses stuff
|
|
curses.curs_set(False)
|
|
curses.start_color()
|
|
curses.use_default_colors()
|
|
curses.init_pair(1, curses.COLOR_RED, -1) # color for label first char
|
|
curses.init_pair(2, curses.COLOR_YELLOW, -1) # color for label second+ char
|
|
curses.init_pair(3, curses.COLOR_GREEN, curses.COLOR_YELLOW) # color for highlight
|
|
curses.init_pair(4, curses.COLOR_RED, -1)
|
|
self.stdscr.clear()
|
|
|
|
# Track the size as known by curses
|
|
self.curses_size = stdscr.getmaxyx() # note: in (y,x) not (x,y)
|
|
|
|
# Set the contents to display
|
|
self.display_content_lines = process_pane_capture_lines(self.orig_pane['contents'], self.orig_pane['pane_size'][1])
|
|
self.reset()
|
|
|
|
def reset(self, keep_highlight=False):
|
|
# Initialize properties for later
|
|
self.cur_label_pos = 0 # how many label chars have been keyed in
|
|
self.match_locations = None # the currently valid search results [ (x, y, label) ]
|
|
self.status_msg = None # Message in bottom-right of screen
|
|
|
|
# Highlighted location
|
|
if not keep_highlight:
|
|
self.highlight_ranges = None # range is inclusive
|
|
|
|
# display current contents
|
|
log('\n'.join(self.display_content_lines), 'display_content_lines')
|
|
self.redraw()
|
|
|
|
def flash_highlight_range(self, hlrange, noredraw=False, preflash=False):
|
|
if not self.highlight_ranges:
|
|
self.highlight_ranges = []
|
|
if preflash:
|
|
delayt = float(get_tmux_option('@copytk-preflash-time', '0.05'))
|
|
self.redraw()
|
|
time.sleep(delayt)
|
|
if isinstance(hlrange, list):
|
|
self.highlight_ranges.extend(hlrange)
|
|
else:
|
|
self.highlight_ranges.append(hlrange)
|
|
self._redraw_highlight_ranges()
|
|
self.stdscr.refresh()
|
|
delayt = float(get_tmux_option('@copytk-flash-time', '0.5'))
|
|
time.sleep(delayt)
|
|
if isinstance(hlrange, list):
|
|
self.highlight_ranges = self.highlight_ranges[:-len(hlrange)]
|
|
else:
|
|
self.highlight_ranges.pop()
|
|
if not noredraw:
|
|
self._redraw_contents()
|
|
self.stdscr.refresh()
|
|
|
|
def addstr(self, y, x, s, a=None):
|
|
if len(s) == 0: return
|
|
try:
|
|
if a == None:
|
|
self.stdscr.addstr(y, x, s)
|
|
else:
|
|
self.stdscr.addstr(y, x, s, a)
|
|
except Exception as err:
|
|
pass
|
|
# note: errors are expected in writes to bottom-right
|
|
#log(f'Error writing str to screen. curses_size={self.curses_size} linelen={len(line)} i={i} err={str(err)}')
|
|
|
|
def _redraw_contents(self):
|
|
line_width = min(self.curses_size[1], self.orig_pane['pane_size'][0])
|
|
max_line = min(self.curses_size[0], len(self.display_content_lines))
|
|
for i in range(max_line):
|
|
line = self.display_content_lines[i][:line_width].ljust(self.curses_size[0])
|
|
self.addstr(i, 0, line)
|
|
|
|
def _redraw_highlight_ranges(self):
|
|
if not self.highlight_ranges: return
|
|
line_width = min(self.curses_size[1], self.orig_pane['pane_size'][0])
|
|
hlattr = curses.color_pair(3)
|
|
for rng in self.highlight_ranges:
|
|
for i in range(rng[0][1], rng[1][1] + 1):
|
|
line = self.display_content_lines[i]
|
|
if i < rng[0][1] or i > rng[1][1]: # whole line not hl
|
|
continue
|
|
elif i > rng[0][1] and i < rng[1][1]: # whole line hl
|
|
self.addstr(i, 0, line.ljust(line_width), hlattr)
|
|
elif i == rng[0][1] and i == rng[1][1]: # range starts and stops on this line
|
|
self.addstr(i, rng[0][0], line[rng[0][0]:rng[1][0]+1], hlattr)
|
|
elif i == rng[0][1]: # range starts on this line
|
|
self.addstr(i, rng[0][0], line.ljust(line_width)[rng[0][0]:], hlattr)
|
|
elif i == rng[1][1]: # range ends on this line
|
|
self.addstr(i, 0, line[0:rng[1][0]+1], hlattr)
|
|
else:
|
|
assert(False)
|
|
|
|
def _redraw_labels(self):
|
|
line_width = min(self.curses_size[1], self.orig_pane['pane_size'][0])
|
|
if self.match_locations:
|
|
for col, row, label in self.match_locations:
|
|
if col + len(label) > line_width:
|
|
label = label[:line_width - col]
|
|
if len(label) > self.cur_label_pos:
|
|
try:
|
|
self.stdscr.addstr(row, col, label[self.cur_label_pos], curses.color_pair(1))
|
|
except Exception as err:
|
|
pass
|
|
#log(f'Error writing str to screen. curses_size={self.curses_size} linelen={len(line)} i={i} err={str(err)}')
|
|
if len(label) > self.cur_label_pos + 1:
|
|
try:
|
|
self.stdscr.addstr(row, col+1, label[self.cur_label_pos+1:], curses.color_pair(2))
|
|
except Exception as err:
|
|
pass
|
|
#log(f'Error writing str to screen. curses_size={self.curses_size} linelen={len(line)} i={i} err={str(err)}')
|
|
|
|
def redraw(self):
|
|
self._redraw_contents()
|
|
self._redraw_labels()
|
|
# highlight ranges
|
|
self._redraw_highlight_ranges()
|
|
# status message
|
|
if self.status_msg:
|
|
try:
|
|
self.stdscr.addstr(self.curses_size[0] - 1, self.curses_size[1] - len(self.status_msg), self.status_msg, curses.color_pair(4))
|
|
except:
|
|
pass
|
|
# refresh
|
|
self.stdscr.refresh()
|
|
|
|
def setstatus(self, msg):
|
|
self.status_msg = msg
|
|
|
|
def cancel(self):
|
|
raise ActionCanceled()
|
|
|
|
def getkey(self, valid=None):
|
|
if valid == None:
|
|
valid = lambda k: len(k) == 1 and k.isprintable()
|
|
while True:
|
|
try:
|
|
key = self.stdscr.getkey()
|
|
except: # fix occasional weird curses bug where this behaves as non-blocking
|
|
key = 'none'
|
|
#if key in ('^[', '^C', '\n', '\x1b'):
|
|
if key in self.cancel_keys:
|
|
self.cancel()
|
|
if key == 'KEY_RESIZE':
|
|
self.curses_size = self.stdscr.getmaxyx()
|
|
self.redraw()
|
|
continue
|
|
if valid(key):
|
|
return key
|
|
#key = ' '.join([str(hex(ord(c))) for c in key])
|
|
#self.stdscr.addstr(0, 0, key)
|
|
|
|
|
|
def run(self):
|
|
pass
|
|
|
|
|
|
class EasyMotionAction(PaneJumpAction):
|
|
|
|
def __init__(self, stdscr, search_len=1):
|
|
super().__init__(stdscr)
|
|
self.search_len = search_len
|
|
self.case_sensitive_search = get_tmux_option('@copytk-case-sensitive-search', 'upper') # value values: on, off, upper
|
|
self.min_match_spacing = int(get_tmux_option('@copytk-min-match-spacing', '2'))
|
|
|
|
def _em_filter_locs(self, locs):
|
|
d = args.search_direction
|
|
cursor = self.orig_pane['cursor']
|
|
if d == 'forward' or d == 'down':
|
|
return [
|
|
loc
|
|
for loc in locs
|
|
if loc[1] > cursor[1] or (loc[1] == cursor[1] and loc[0] >= cursor[0])
|
|
]
|
|
elif d == 'reverse' or d == 'up' or d == 'backward':
|
|
return [
|
|
loc
|
|
for loc in locs
|
|
if loc[1] < cursor[1] or (loc[1] == cursor[1] and loc[0] < cursor[0])
|
|
]
|
|
else:
|
|
return locs
|
|
|
|
def _em_sort_locs_cursor_proximity(self, locs, cursor=None):
|
|
# Sort locations by proximity to cursor
|
|
if cursor == None:
|
|
cursor = self.orig_pane['cursor']
|
|
locs.sort(key=lambda pos: abs(cursor[0] - pos[0]) + abs(cursor[1] - pos[1]) * self.orig_pane['pane_size'][0])
|
|
|
|
def _em_search_lines(self, datalines, srch, min_match_spacing=2, matchcase=False):
|
|
if not matchcase: srch = srch.lower()
|
|
results = [] # (x, y)
|
|
for linenum, line in reversed(list(enumerate(datalines))):
|
|
if not matchcase: line = line.lower()
|
|
pos = 0
|
|
while True:
|
|
r = line.find(srch, pos)
|
|
if r == -1: break
|
|
results.append((r, linenum))
|
|
pos = r + len(srch) + min_match_spacing
|
|
return results
|
|
|
|
def _em_input_search_chars(self):
|
|
search_str = ''
|
|
self.setstatus('INPUT CHAR')
|
|
self.redraw()
|
|
for i in range(self.search_len):
|
|
search_str += self.getkey()
|
|
self.setstatus(None)
|
|
self.redraw()
|
|
return search_str
|
|
|
|
def get_locations(self, action):
|
|
pane_search_lines = self.display_content_lines
|
|
log('\n'.join(pane_search_lines), 'pane_search_lines')
|
|
|
|
if action == 'search':
|
|
search_str = self._em_input_search_chars()
|
|
return self._em_search_lines(
|
|
pane_search_lines,
|
|
search_str,
|
|
self.min_match_spacing,
|
|
self.case_sensitive_search == 'on' or (self.case_sensitive_search == 'upper' and search_str.lower() != search_str)
|
|
)
|
|
elif action == 'lines':
|
|
return [ (0, y) for y in range(self.orig_pane['pane_size'][1]) ]
|
|
else:
|
|
raise Exception('Invalid copytk easymotion action')
|
|
|
|
def do_easymotion(self, action, filter_locs=None, sort_close_to=None):
|
|
# Get possible jump locations sorted by proximity to cursor
|
|
locs = self.get_locations(action)
|
|
locs = self._em_filter_locs(locs)
|
|
if filter_locs:
|
|
locs = [ l for l in locs if filter_locs(l) ]
|
|
if len(locs) == 0:
|
|
raise ActionCanceled()
|
|
self._em_sort_locs_cursor_proximity(locs, sort_close_to)
|
|
|
|
# Assign each match a label
|
|
label_it = gen_em_labels(len(locs), self.em_label_chars)
|
|
self.match_locations = [ (ml[0], ml[1], next(label_it) ) for ml in locs ]
|
|
|
|
# Draw labels
|
|
self.redraw()
|
|
|
|
# Wait for label presses
|
|
keyed_label = ''
|
|
while True: # loop over each key/char in the label
|
|
keyed_label += self.getkey()
|
|
self.cur_label_pos += 1
|
|
self.match_locations = [ m for m in self.match_locations if m[2].startswith(keyed_label) ]
|
|
if len(self.match_locations) < 2:
|
|
break
|
|
self.redraw()
|
|
log('keyed label: ' + keyed_label, time=True)
|
|
|
|
if len(self.match_locations) == 0:
|
|
return None
|
|
else:
|
|
return (self.match_locations[0][0], self.match_locations[0][1])
|
|
|
|
def run(self, action):
|
|
log('easymotion swapping in hidden pane', time=True)
|
|
swap_hidden_pane(True)
|
|
|
|
loc = self.do_easymotion(action)
|
|
|
|
# If a location was found, move cursor there in original pane
|
|
if loc:
|
|
log('match location: ' + str(loc), time=True)
|
|
move_tmux_cursor((loc[0], loc[1]), self.orig_pane['pane_id'])
|
|
|
|
|
|
class EasyCopyAction(EasyMotionAction):
|
|
|
|
def __init__(self, stdscr, search_len=1):
|
|
super().__init__(stdscr, search_len)
|
|
|
|
def run(self):
|
|
log('easycopy swapping in hidden pane', time=True)
|
|
swap_hidden_pane(True)
|
|
|
|
# Input searches to get bounds
|
|
pos1 = self.do_easymotion('search')
|
|
if not pos1: return
|
|
self.highlight_ranges = [ (pos1, pos1) ]
|
|
self.reset(keep_highlight=True)
|
|
# restrict second search to after first position
|
|
pos2 = self.do_easymotion(
|
|
'search',
|
|
filter_locs=lambda loc: loc[1] > pos1[1] or (loc[1] == pos1[1] and loc[0] > pos1[0]),
|
|
sort_close_to=pos1
|
|
)
|
|
if not pos2: return
|
|
|
|
# since typing last n letters of word, advance end position by n-1 (-1 because range is inclusive)
|
|
pos2 = (pos2[0] + self.search_len - 1, pos2[1])
|
|
|
|
# Find the data associated with this range and run the copy command
|
|
selected_data = self.copy_data[self.disp_copy_map[pos1] : self.disp_copy_map[pos2] + 1]
|
|
log('Copied: ' + selected_data)
|
|
execute_copy(selected_data)
|
|
|
|
# Flash selected range as confirmation
|
|
self.flash_highlight_range((pos1, pos2))
|
|
|
|
|
|
class QuickCopyAction(PaneJumpAction):
|
|
|
|
def __init__(self, stdscr, options_prefix='@copytk-quickcopy-'):
|
|
super().__init__(stdscr)
|
|
self.options_prefix = options_prefix
|
|
self._load_options(options_prefix)
|
|
self.em_label_chars = ''.join(( c for c in self.em_label_chars if c not in self.next_batch_char ))
|
|
|
|
def _load_options(self, prefix='@copytk-quickcopy-'):
|
|
# Load in the tiers of match expressions.
|
|
# Options for this are in the form: @copytk-quickcopy-match-<Tier>-<TierIndex>
|
|
# Each tier list is terminated by a missing option at the index.
|
|
# The set of tiers is terminated by a missing 0 index for the tier.
|
|
tier_exprs = [] # list (of tiers) of lists of strings
|
|
tier_ctr = 0
|
|
while True:
|
|
l = get_tmux_option(prefix + 'match-' + str(tier_ctr), aslist=True, userlist=True)
|
|
if l == None or len(l) == 0:
|
|
break
|
|
tier_exprs.append(l)
|
|
tier_ctr += 1
|
|
self.tier_exprs = tier_exprs
|
|
self.next_batch_char = get_tmux_option_key_curses(prefix + 'next-batch-char', ' n', aslist=True)
|
|
self.min_match_len = get_tmux_option(prefix + 'min-match-len', 4)
|
|
self.pack_tiers = str2bool(get_tmux_option(prefix + 'pack-tiers', 'on'))
|
|
|
|
def _matchobj(self, start, end, tier=0):
|
|
return (
|
|
tier,
|
|
end-start,
|
|
self.copy_data[start:end],
|
|
(start, end),
|
|
self.copy_disp_map[start] if start < len(self.copy_data) else len(self.copy_data) - 1,
|
|
self.copy_disp_map[end - 1]
|
|
)
|
|
|
|
def _matchobjs(self, tuplist, tier=0):
|
|
return [ self._matchobj(start, end, tier=tier) for start, end in tuplist ]
|
|
|
|
def _find_lines_matches(self):
|
|
start = 0
|
|
for i, c in enumerate(self.copy_data):
|
|
if c == '\n':
|
|
if i > start:
|
|
yield (start, i)
|
|
start = i + 1
|
|
if len(self.copy_data) > start + 1:
|
|
yield (start, len(self.copy_data))
|
|
|
|
# Returns an iterator over (start, end) tuples
|
|
def find_expr_matches(self, expr):
|
|
if expr in match_expr_presets:
|
|
expr = match_expr_presets[expr]
|
|
if expr == 'lines':
|
|
for m in self._find_lines_matches():
|
|
yield m
|
|
return
|
|
# regex expr
|
|
log('Matching against expr ' + expr)
|
|
flags = 0
|
|
if expr.startswith('(?m)'):
|
|
flags = re.MULTILINE
|
|
expr = expr[4:]
|
|
for match in re.finditer(expr, self.copy_data, flags):
|
|
try:
|
|
d = ( match.start(1), match.end(1) )
|
|
except IndexError:
|
|
d = ( match.start(0), match.end(0) )
|
|
log('Found match: ' + str(d) + ': ' + self.copy_data[d[0]:d[1]])
|
|
if d[0] < 0 or d[1] < 0:
|
|
d = ( 0, 0 )
|
|
yield d
|
|
|
|
def find_matches(self):
|
|
# Produce a list of matches where each entry is in this format:
|
|
# ( tiernum, matchlen, data, ( copy data start, copy data end ), ( disp start x, disp start y ), ( disp end x, disp end y ) )
|
|
allmatches = []
|
|
for tier, exprs in enumerate(self.tier_exprs):
|
|
for expr in exprs:
|
|
allmatches.extend(self._matchobjs(self.find_expr_matches(expr), tier))
|
|
# Filter out matches shorter than the minimum
|
|
return [ m for m in allmatches if m[1] >= self.min_match_len ]
|
|
|
|
def arrange_matches(self, matches, pack_tiers=True):
|
|
# Arrange the set of matches into batches of non-overlapping ones, by tier, and by shortness (shorter preferred)
|
|
# Do this by "writing" each match's range onto a virtual screen, marking each char, and pushing overlapping ones
|
|
# to the next batch.
|
|
# Sort tuples (first by tier then length)
|
|
matches.sort()
|
|
# Dedup matches
|
|
c_match_set = set()
|
|
newmatches = []
|
|
for match in matches:
|
|
if match[3] not in c_match_set:
|
|
c_match_set.add(match[3])
|
|
newmatches.append(match)
|
|
matches = newmatches
|
|
# Segment into batches by overlap
|
|
batches = []
|
|
log('start arrange_matches')
|
|
while len(matches) > 0: # iterate over batches
|
|
last_added_tier = None
|
|
overlaps = []
|
|
virt = [ False ] * len(self.copy_data)
|
|
batch = []
|
|
for m in matches: # iterate over remaining matches
|
|
if not pack_tiers and last_added_tier != None and m[0] != last_added_tier:
|
|
break
|
|
# Check if overlaps
|
|
o = False
|
|
for i in range(m[3][0], m[3][1]):
|
|
if virt[i]:
|
|
o = True
|
|
break
|
|
if o:
|
|
overlaps.append(m)
|
|
else:
|
|
batch.append(m)
|
|
for i in range(m[3][0], m[3][1]):
|
|
virt[i] = True
|
|
last_added_tier = m[0]
|
|
batches.append(batch)
|
|
matches = overlaps
|
|
return batches
|
|
|
|
def run_batch(self, batch):
|
|
# Returns a match object if one is selected. (actually a list of match objects that will all have same text)
|
|
# Returns None to cycle to next batch
|
|
# Throws ActionCanceled if canceled or invalid selection
|
|
|
|
# Assign a code to each match in the batch
|
|
labels = []
|
|
match_text_label_map = {} # use this so matches with same text have same label
|
|
label_it = gen_em_labels(len(batch), self.em_label_chars)
|
|
for match in batch:
|
|
if match[2] in match_text_label_map:
|
|
labels.append(match_text_label_map[match[2]])
|
|
else:
|
|
l = next(label_it)
|
|
labels.append(l)
|
|
match_text_label_map[match[2]] = l
|
|
|
|
# Set up match_locations and highlights
|
|
self.match_locations = [ ( match[4][0], match[4][1], labels[i] ) for i, match in enumerate(batch) ]
|
|
line_width = self.orig_pane['pane_size'][0]
|
|
def updatehl():
|
|
self.highlight_ranges = [
|
|
(
|
|
( min(match[4][0] + len(labels[i]) - self.cur_label_pos, line_width), match[4][1] ),
|
|
( match[5][0], match[5][1] )
|
|
)
|
|
for i, match in enumerate(batch)
|
|
]
|
|
updatehl()
|
|
self.redraw()
|
|
|
|
# Input label
|
|
keyed_label = ''
|
|
while True: # loop over each key/char in the label
|
|
k = self.getkey() # checks for cancel key and throws
|
|
if k in self.next_batch_char:
|
|
return None
|
|
keyed_label += k
|
|
self.cur_label_pos += 1
|
|
# Update match locations and highlights
|
|
new_match_locations = []
|
|
new_labels = []
|
|
new_batch = []
|
|
for i, label in enumerate(labels):
|
|
if label.startswith(keyed_label):
|
|
new_labels.append(label)
|
|
new_match_locations.append(self.match_locations[i])
|
|
new_batch.append(batch[i])
|
|
batch = new_batch
|
|
labels = new_labels
|
|
self.match_locations = new_match_locations
|
|
updatehl()
|
|
self.match_locations = [ m for m in self.match_locations if m[2].startswith(keyed_label) ]
|
|
# count remaining matches by ones with unique text rather than total count
|
|
num_unique_texts = len(set(( m[2] for m in batch )))
|
|
if num_unique_texts < 2:
|
|
break
|
|
self.redraw()
|
|
log('keyed label: ' + keyed_label, time=True)
|
|
|
|
self.reset()
|
|
if len(batch) == 0:
|
|
raise ActionCanceled() # invalid entry
|
|
else:
|
|
return batch
|
|
|
|
def run_quickselect(self):
|
|
log('quickcopy run')
|
|
# Get a list of all matches
|
|
matches = self.find_matches()
|
|
if len(matches) == 0: raise ActionCanceled()
|
|
log('got matches')
|
|
|
|
# Group them into display batches
|
|
batches = self.arrange_matches(matches, self.pack_tiers)
|
|
log('arranged matches')
|
|
|
|
swap_hidden_pane(True)
|
|
log('swapped in hidden pane')
|
|
|
|
# Display each batch until a valid match has been selected
|
|
selected = None
|
|
for batch in batches:
|
|
selected = self.run_batch(batch)
|
|
if selected: break
|
|
if not selected: raise ActionCanceled()
|
|
|
|
# Got result.
|
|
selected_data = selected[0][2]
|
|
log('Copied: ' + selected_data)
|
|
return selected_data, selected
|
|
|
|
def run(self):
|
|
selected_data, selected = self.run_quickselect()
|
|
execute_copy(selected_data)
|
|
|
|
# Flash highlights
|
|
self.match_locations = None
|
|
hl_ranges = [ (match[4], match[5]) for match in selected ]
|
|
flash_only_one = str2bool(get_tmux_option('@copytk-flash-only-one', 'on'))
|
|
if flash_only_one: hl_ranges = [ hl_ranges[-1] ]
|
|
self.flash_highlight_range(hl_ranges, preflash=True)
|
|
|
|
|
|
class QuickOpenAction(QuickCopyAction):
|
|
|
|
def __init__(self, stdscr):
|
|
super().__init__(stdscr, options_prefix='@copytk-quickopen-')
|
|
self.command_extra_env = self.load_env_file()
|
|
|
|
def load_env_file(self):
|
|
fn = os.path.expanduser(get_tmux_option('@copytk-quickopen-env-file', '~/.tmux-copytk-env'))
|
|
if not os.path.exists(fn):
|
|
return {}
|
|
ret = {}
|
|
with open(fn, 'r') as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not len(line): continue
|
|
if line[0] == '#': continue
|
|
parts = line.split('=')
|
|
if len(parts) < 2: continue
|
|
name = parts[0]
|
|
value = '='.join(parts[1:])
|
|
if len(value) >= 2 and value[0] in ('"', "'") and value[-1] in ('"', "'"):
|
|
value = value[1:-1]
|
|
ret[name] = value
|
|
log('Loaded env file: ' + str(ret))
|
|
return ret
|
|
|
|
def run(self):
|
|
selected_data, selected = self.run_quickselect()
|
|
log('quickopen selected: ' + selected_data)
|
|
|
|
default_open_cmd = 'xdg-open'
|
|
if platform.system() == 'Darwin':
|
|
default_open_cmd = 'open'
|
|
open_cmd = get_tmux_option('@copytk-quickopen-open-command', default_open_cmd)
|
|
env = dict(os.environ)
|
|
env.update(self.command_extra_env)
|
|
full_cmd = 'nohup ' + open_cmd + " '" + selected_data + "' &>/dev/null & disown"
|
|
log('Command: ' + full_cmd)
|
|
log('Env: ' + str(env))
|
|
subprocess.Popen(
|
|
full_cmd,
|
|
executable='/bin/bash',
|
|
shell=True,
|
|
env=env,
|
|
close_fds=True
|
|
)
|
|
|
|
|
|
def run_easymotion(stdscr):
|
|
nkeys = 1
|
|
if args.search_nkeys:
|
|
nkeys = int(args.search_nkeys)
|
|
action = args.action[11:]
|
|
EasyMotionAction(stdscr, nkeys).run(action)
|
|
|
|
def run_easycopy(stdscr):
|
|
nkeys = 1
|
|
if args.search_nkeys:
|
|
nkeys = int(args.search_nkeys)
|
|
EasyCopyAction(stdscr, nkeys).run()
|
|
|
|
def run_quickcopy(stdscr):
|
|
QuickCopyAction(stdscr).run()
|
|
|
|
def run_quickopen(stdscr):
|
|
QuickOpenAction(stdscr).run()
|
|
|
|
|
|
|
|
def run_wrapper(main_action, args):
|
|
log('running wrapper', time=True)
|
|
pane = get_pane_info(args.t)
|
|
# Wrap the inner utility in different ways depending on if the pane is zoomed or not.
|
|
# This is because tmux does funny thingy when swapping zoomed panes.
|
|
# When an ordinary pane, use 'pane-swap' mode. In this case, the internal utility
|
|
# is run as a command in a newly created pane of the same size in a newly created window.
|
|
# The command pane is then swapped with the target pane, and swapped back once complete.
|
|
# In 'window-switch' mode, the internal utility is run as a single pane in a new window,
|
|
# then the active window is switched to that new window. Once complete, the window is
|
|
# switched back.
|
|
if pane['zoomed']:
|
|
z_win_id = runtmux([ 'new-window', '-dP', '-F', '#{session_id}:#{window_id}', '/bin/cat' ], one=True)
|
|
hidden_pane = get_pane_info(z_win_id)
|
|
swap_mode = 'window-switch'
|
|
else:
|
|
hidden_pane = create_window_pane_of_size(pane['pane_size'])
|
|
swap_mode = 'pane-swap'
|
|
thisfile = os.path.abspath(__file__)
|
|
cmd = f'{python_command} "{thisfile}"'
|
|
def addopt(opt, val=None):
|
|
nonlocal cmd
|
|
if val == None:
|
|
cmd += ' \'' + opt + '\''
|
|
else:
|
|
cmd += ' \'' + opt + '\' \'' + str(val) + '\''
|
|
addopt('--run-internal')
|
|
addopt('-t', pane['pane_id'])
|
|
addopt('--hidden-t', hidden_pane['pane_id'])
|
|
addopt('--hidden-window', hidden_pane['window_id'])
|
|
addopt('--orig-window', pane['window_id'])
|
|
addopt('--swap-mode', swap_mode)
|
|
|
|
if args.search_nkeys:
|
|
addopt('--search-nkeys', args.search_nkeys)
|
|
if args.search_direction:
|
|
addopt('--search-direction', args.search_direction)
|
|
|
|
cmd += f' "{main_action}"'
|
|
#cmd += ' 2>/tmp/tm_wrap_log'
|
|
log('wrapper triggering hidden pane respawn of inner process', time=True)
|
|
runtmux([ 'respawn-pane', '-k', '-t', hidden_pane['pane_id_full'], cmd ])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
argp = argparse.ArgumentParser(description='tmux pane utils')
|
|
argp.add_argument('-t', help='target pane')
|
|
argp.add_argument('--search-nkeys', help='number of characters to key in to search')
|
|
argp.add_argument('--search-direction', help='direction to search from cursor, both|forward|reverse')
|
|
|
|
# internal args
|
|
argp.add_argument('--run-internal', action='store_true')
|
|
argp.add_argument('--hidden-t')
|
|
argp.add_argument('--hidden-window')
|
|
argp.add_argument('--orig-window')
|
|
argp.add_argument('--swap-mode')
|
|
|
|
argp.add_argument('action')
|
|
args = argp.parse_args()
|
|
|
|
if not args.run_internal:
|
|
log_clear()
|
|
run_wrapper(args.action, args)
|
|
exit(0)
|
|
|
|
|
|
assert(args.t)
|
|
assert(args.t.startswith('%'))
|
|
assert(args.hidden_t)
|
|
assert(args.hidden_t.startswith('%'))
|
|
assert(args.hidden_window)
|
|
assert(args.orig_window)
|
|
assert(args.swap_mode)
|
|
|
|
try:
|
|
|
|
os.environ.setdefault('ESCDELAY', '10') # lower curses pause on escape
|
|
if args.action.startswith('easymotion-'):
|
|
curses.wrapper(run_easymotion)
|
|
elif args.action == 'easycopy':
|
|
curses.wrapper(run_easycopy)
|
|
elif args.action == 'quickcopy':
|
|
curses.wrapper(run_quickcopy)
|
|
elif args.action == 'quickopen':
|
|
curses.wrapper(run_quickopen)
|
|
else:
|
|
print('Invalid action')
|
|
exit(1)
|
|
|
|
except ActionCanceled:
|
|
pass
|
|
|
|
except Exception as ex:
|
|
print('Error:')
|
|
print(ex)
|
|
traceback.print_exc()
|
|
print('ENTER to continue ...')
|
|
input()
|
|
|
|
finally:
|
|
cleanup_internal_process()
|
|
exit(0)
|
|
|
|
|
|
|
|
|