677 lines
19 KiB
Python
677 lines
19 KiB
Python
from __future__ import unicode_literals
|
|
import docopt
|
|
import os
|
|
import re
|
|
import shlex
|
|
import six
|
|
|
|
from prompt_toolkit.application.current import get_app
|
|
from prompt_toolkit.document import Document
|
|
from prompt_toolkit.key_binding.vi_state import InputMode
|
|
|
|
from pymux.arrangement import LayoutTypes
|
|
from pymux.commands.aliases import ALIASES
|
|
from pymux.commands.utils import wrap_argument
|
|
from pymux.format import format_pymux_string
|
|
from pymux.key_mappings import pymux_key_to_prompt_toolkit_key_sequence, prompt_toolkit_key_to_vt100_key
|
|
from pymux.layout import focus_right, focus_left, focus_up, focus_down
|
|
from pymux.log import logger
|
|
from pymux.options import SetOptionError
|
|
|
|
__all__ = (
|
|
'call_command_handler',
|
|
'get_documentation_for_command',
|
|
'get_option_flags_for_command',
|
|
'handle_command',
|
|
'has_command_handler',
|
|
)
|
|
|
|
COMMANDS_TO_HANDLERS = {} # Global mapping of pymux commands to their handlers.
|
|
COMMANDS_TO_HELP = {}
|
|
COMMANDS_TO_OPTION_FLAGS = {}
|
|
|
|
|
|
def has_command_handler(command):
|
|
return command in COMMANDS_TO_HANDLERS
|
|
|
|
|
|
def get_documentation_for_command(command):
|
|
""" Return the help text for this command, or None if the command is not
|
|
known. """
|
|
if command in COMMANDS_TO_HELP:
|
|
return 'Usage: %s %s' % (command, COMMANDS_TO_HELP.get(command, ''))
|
|
|
|
|
|
def get_option_flags_for_command(command):
|
|
" Return a list of options (-x flags) for this command. "
|
|
return COMMANDS_TO_OPTION_FLAGS.get(command, [])
|
|
|
|
|
|
def handle_command(pymux, input_string):
|
|
"""
|
|
Handle command.
|
|
"""
|
|
assert isinstance(input_string, six.text_type)
|
|
|
|
input_string = input_string.strip()
|
|
logger.info('handle command: %s %s.', input_string, type(input_string))
|
|
|
|
if input_string and not input_string.startswith('#'): # Ignore comments.
|
|
try:
|
|
if six.PY2:
|
|
# In Python2.6, shlex doesn't work with unicode input at all.
|
|
# In Python2.7, shlex tries to encode using ASCII.
|
|
parts = shlex.split(input_string.encode('utf-8'))
|
|
parts = [p.decode('utf-8') for p in parts]
|
|
else:
|
|
parts = shlex.split(input_string)
|
|
except ValueError as e:
|
|
# E.g. missing closing quote.
|
|
pymux.show_message('Invalid command %s: %s' % (input_string, e))
|
|
else:
|
|
call_command_handler(parts[0], pymux, parts[1:])
|
|
|
|
|
|
def call_command_handler(command, pymux, arguments):
|
|
"""
|
|
Execute command.
|
|
|
|
:param arguments: List of options.
|
|
"""
|
|
assert isinstance(arguments, list)
|
|
|
|
# Resolve aliases.
|
|
command = ALIASES.get(command, command)
|
|
|
|
try:
|
|
handler = COMMANDS_TO_HANDLERS[command]
|
|
except KeyError:
|
|
pymux.show_message('Invalid command: %s' % (command,))
|
|
else:
|
|
try:
|
|
handler(pymux, arguments)
|
|
except CommandException as e:
|
|
pymux.show_message(e.message)
|
|
|
|
|
|
def cmd(name, options=''):
|
|
"""
|
|
Decorator for all commands.
|
|
|
|
Commands will receive (pymux, variables) as input.
|
|
Commands can raise CommandException.
|
|
"""
|
|
# Validate options.
|
|
if options:
|
|
try:
|
|
docopt.docopt('Usage:\n %s %s' % (name, options, ), [])
|
|
except SystemExit:
|
|
pass
|
|
|
|
def decorator(func):
|
|
def command_wrapper(pymux, arguments):
|
|
# Hack to make the 'bind-key' option work.
|
|
# (bind-key expects a variable number of arguments.)
|
|
if name == 'bind-key' and '--' not in arguments:
|
|
# Insert a double dash after the first non-option.
|
|
for i, p in enumerate(arguments):
|
|
if not p.startswith('-'):
|
|
arguments.insert(i + 1, '--')
|
|
break
|
|
|
|
# Parse options.
|
|
try:
|
|
# Python 2 workaround: pass bytes to docopt.
|
|
# From the following, only the bytes version returns the right
|
|
# output in Python 2:
|
|
# docopt.docopt('Usage:\n app <params>...', [b'a', b'b'])
|
|
# docopt.docopt('Usage:\n app <params>...', [u'a', u'b'])
|
|
# https://github.com/docopt/docopt/issues/30
|
|
# (Not sure how reliable this is...)
|
|
if six.PY2:
|
|
arguments = [a.encode('utf-8') for a in arguments]
|
|
|
|
received_options = docopt.docopt(
|
|
'Usage:\n %s %s' % (name, options),
|
|
arguments,
|
|
help=False) # Don't interpret the '-h' option as help.
|
|
|
|
# Make sure that all the received options from docopt are
|
|
# unicode objects. (Docopt returns 'str' for Python2.)
|
|
for k, v in received_options.items():
|
|
if isinstance(v, six.binary_type):
|
|
received_options[k] = v.decode('utf-8')
|
|
except SystemExit:
|
|
raise CommandException('Usage: %s %s' % (name, options))
|
|
|
|
# Call handler.
|
|
func(pymux, received_options)
|
|
|
|
# Invalidate all clients, not just the current CLI.
|
|
pymux.invalidate()
|
|
|
|
COMMANDS_TO_HANDLERS[name] = command_wrapper
|
|
COMMANDS_TO_HELP[name] = options
|
|
|
|
# Get list of option flags.
|
|
flags = re.findall(r'-[a-zA-Z0-9]\b', options)
|
|
COMMANDS_TO_OPTION_FLAGS[name] = flags
|
|
|
|
return func
|
|
return decorator
|
|
|
|
|
|
class CommandException(Exception):
|
|
" When raised from a command handler, this message will be shown. "
|
|
def __init__(self, message):
|
|
self.message = message
|
|
|
|
#
|
|
# The actual commands.
|
|
#
|
|
|
|
|
|
@cmd('break-pane', options='[-d]')
|
|
def break_pane(pymux, variables):
|
|
dont_focus_window = variables['-d']
|
|
|
|
pymux.arrangement.break_pane(set_active=not dont_focus_window)
|
|
pymux.invalidate()
|
|
|
|
|
|
@cmd('select-pane', options='(-L|-R|-U|-D|-t <pane-id>)')
|
|
def select_pane(pymux, variables):
|
|
|
|
if variables['-t']:
|
|
pane_id = variables['<pane-id>']
|
|
w = pymux.arrangement.get_active_window()
|
|
|
|
if pane_id == ':.+':
|
|
w.focus_next()
|
|
elif pane_id == ':.-':
|
|
w.focus_previous()
|
|
else:
|
|
# Select pane by index.
|
|
try:
|
|
pane_id = int(pane_id[1:])
|
|
w.active_pane = w.panes[pane_id]
|
|
except (IndexError, ValueError):
|
|
raise CommandException('Invalid pane.')
|
|
|
|
else:
|
|
if variables['-L']: h = focus_left
|
|
if variables['-U']: h = focus_up
|
|
if variables['-D']: h = focus_down
|
|
if variables['-R']: h = focus_right
|
|
|
|
h(pymux)
|
|
|
|
|
|
@cmd('select-window', options='(-t <target-window>)')
|
|
def select_window(pymux, variables):
|
|
"""
|
|
Select a window. E.g: select-window -t :3
|
|
"""
|
|
window_id = variables['<target-window>']
|
|
|
|
def invalid_window():
|
|
raise CommandException('Invalid window: %s' % window_id)
|
|
|
|
if window_id.startswith(':'):
|
|
try:
|
|
number = int(window_id[1:])
|
|
except ValueError:
|
|
invalid_window()
|
|
else:
|
|
w = pymux.arrangement.get_window_by_index(number)
|
|
if w:
|
|
pymux.arrangement.set_active_window(w)
|
|
else:
|
|
invalid_window()
|
|
else:
|
|
invalid_window()
|
|
|
|
|
|
@cmd('move-window', options='(-t <dst-window>)')
|
|
def move_window(pymux, variables):
|
|
"""
|
|
Move window to a new index.
|
|
"""
|
|
dst_window = variables['<dst-window>']
|
|
try:
|
|
new_index = int(dst_window)
|
|
except ValueError:
|
|
raise CommandException('Invalid window index: %r' % (dst_window, ))
|
|
|
|
# Check first whether the index was not yet taken.
|
|
if pymux.arrangement.get_window_by_index(new_index):
|
|
raise CommandException("Can't move window: index in use.")
|
|
|
|
# Save index.
|
|
w = pymux.arrangement.get_active_window()
|
|
pymux.arrangement.move_window(w, new_index)
|
|
|
|
|
|
@cmd('rotate-window', options='[-D|-U]')
|
|
def rotate_window(pymux, variables):
|
|
if variables['-D']:
|
|
pymux.arrangement.rotate_window(count=-1)
|
|
else:
|
|
pymux.arrangement.rotate_window()
|
|
|
|
|
|
@cmd('swap-pane', options='(-D|-U)')
|
|
def swap_pane(pymux, variables):
|
|
pymux.arrangement.get_active_window().rotate(with_pane_after_only=variables['-U'])
|
|
|
|
|
|
@cmd('kill-pane')
|
|
def kill_pane(pymux, variables):
|
|
pane = pymux.arrangement.get_active_pane()
|
|
pymux.kill_pane(pane)
|
|
|
|
|
|
@cmd('kill-window')
|
|
def kill_window(pymux, variables):
|
|
" Kill all panes in the current window. "
|
|
for pane in pymux.arrangement.get_active_window().panes:
|
|
pymux.kill_pane(pane)
|
|
|
|
|
|
@cmd('suspend-client')
|
|
def suspend_client(pymux, variables):
|
|
connection = pymux.get_connection()
|
|
|
|
if connection:
|
|
connection.suspend_client_to_background()
|
|
|
|
|
|
@cmd('clock-mode')
|
|
def clock_mode(pymux, variables):
|
|
pane = pymux.arrangement.get_active_pane()
|
|
if pane:
|
|
pane.clock_mode = not pane.clock_mode
|
|
|
|
|
|
@cmd('last-pane')
|
|
def last_pane(pymux, variables):
|
|
w = pymux.arrangement.get_active_window()
|
|
prev_active_pane = w.previous_active_pane
|
|
|
|
if prev_active_pane:
|
|
w.active_pane = prev_active_pane
|
|
|
|
|
|
@cmd('next-layout')
|
|
def next_layout(pymux, variables):
|
|
" Select next layout. "
|
|
pane = pymux.arrangement.get_active_window()
|
|
if pane:
|
|
pane.select_next_layout()
|
|
|
|
|
|
@cmd('previous-layout')
|
|
def previous_layout(pymux, variables):
|
|
" Select previous layout. "
|
|
pane = pymux.arrangement.get_active_window()
|
|
if pane:
|
|
pane.select_previous_layout()
|
|
|
|
|
|
@cmd('new-window', options='[(-n <name>)] [(-c <start-directory>)] [<executable>]')
|
|
def new_window(pymux, variables):
|
|
executable = variables['<executable>']
|
|
start_directory = variables['<start-directory>']
|
|
name = variables['<name>']
|
|
|
|
pymux.create_window(executable, start_directory=start_directory, name=name)
|
|
|
|
|
|
@cmd('next-window')
|
|
def next_window(pymux, variables):
|
|
" Focus the next window. "
|
|
pymux.arrangement.focus_next_window()
|
|
|
|
|
|
@cmd('last-window')
|
|
def _(pymux, variables):
|
|
" Go to previous active window. "
|
|
w = pymux.arrangement.get_previous_active_window()
|
|
|
|
if w:
|
|
pymux.arrangement.set_active_window(w)
|
|
|
|
|
|
@cmd('previous-window')
|
|
def previous_window(pymux, variables):
|
|
" Focus the previous window. "
|
|
pymux.arrangement.focus_previous_window()
|
|
|
|
|
|
@cmd('select-layout', options='<layout-type>')
|
|
def select_layout(pymux, variables):
|
|
layout_type = variables['<layout-type>']
|
|
|
|
if layout_type in LayoutTypes._ALL:
|
|
pymux.arrangement.get_active_window().select_layout(layout_type)
|
|
else:
|
|
raise CommandException('Invalid layout type.')
|
|
|
|
|
|
@cmd('rename-window', options='<name>')
|
|
def rename_window(pymux, variables):
|
|
"""
|
|
Rename the active window.
|
|
"""
|
|
pymux.arrangement.get_active_window().chosen_name = variables['<name>']
|
|
|
|
|
|
@cmd('rename-pane', options='<name>')
|
|
def rename_pane(pymux, variables):
|
|
"""
|
|
Rename the active pane.
|
|
"""
|
|
pymux.arrangement.get_active_pane().chosen_name = variables['<name>']
|
|
|
|
|
|
@cmd('rename-session', options='<name>')
|
|
def rename_session(pymux, variables):
|
|
"""
|
|
Rename this session.
|
|
"""
|
|
pymux.session_name = variables['<name>']
|
|
|
|
|
|
@cmd('split-window', options='[-v|-h] [(-c <start-directory>)] [<executable>]')
|
|
def split_window(pymux, variables):
|
|
"""
|
|
Split horizontally or vertically.
|
|
"""
|
|
executable = variables['<executable>']
|
|
start_directory = variables['<start-directory>']
|
|
|
|
# The tmux definition of horizontal is the opposite of prompt_toolkit.
|
|
pymux.add_process(executable, vsplit=variables['-h'],
|
|
start_directory=start_directory)
|
|
|
|
|
|
@cmd('resize-pane', options="[(-L <left>)] [(-U <up>)] [(-D <down>)] [(-R <right>)] [-Z]")
|
|
def resize_pane(pymux, variables):
|
|
"""
|
|
Resize/zoom the active pane.
|
|
"""
|
|
try:
|
|
left = int(variables['<left>'] or 0)
|
|
right = int(variables['<right>'] or 0)
|
|
up = int(variables['<up>'] or 0)
|
|
down = int(variables['<down>'] or 0)
|
|
except ValueError:
|
|
raise CommandException('Expecting an integer.')
|
|
|
|
w = pymux.arrangement.get_active_window()
|
|
|
|
if w:
|
|
w.change_size_for_active_pane(up=up, right=right, down=down, left=left)
|
|
|
|
# Zoom in/out.
|
|
if variables['-Z']:
|
|
w.zoom = not w.zoom
|
|
|
|
|
|
@cmd('detach-client')
|
|
def detach_client(pymux, variables):
|
|
"""
|
|
Detach client.
|
|
"""
|
|
pymux.detach_client(get_app())
|
|
|
|
|
|
@cmd('confirm-before', options='[(-p <message>)] <command>')
|
|
def confirm_before(pymux, variables):
|
|
client_state = pymux.get_client_state()
|
|
|
|
client_state.confirm_text = variables['<message>'] or ''
|
|
client_state.confirm_command = variables['<command>']
|
|
|
|
|
|
@cmd('command-prompt', options='[(-p <message>)] [(-I <default>)] [<command>]')
|
|
def command_prompt(pymux, variables):
|
|
"""
|
|
Enter command prompt.
|
|
"""
|
|
client_state = pymux.get_client_state()
|
|
|
|
if variables['<command>']:
|
|
# When a 'command' has been given.
|
|
client_state.prompt_text = variables['<message>'] or '(%s)' % variables['<command>'].split()[0]
|
|
client_state.prompt_command = variables['<command>']
|
|
|
|
client_state.prompt_mode = True
|
|
client_state.prompt_buffer.reset(Document(
|
|
format_pymux_string(pymux, variables['<default>'] or '')))
|
|
|
|
get_app().layout.focus(client_state.prompt_buffer)
|
|
else:
|
|
# Show the ':' prompt.
|
|
client_state.prompt_text = ''
|
|
client_state.prompt_command = ''
|
|
|
|
get_app().layout.focus(client_state.command_buffer)
|
|
|
|
# Go to insert mode.
|
|
get_app().vi_state.input_mode = InputMode.INSERT
|
|
|
|
|
|
@cmd('send-prefix')
|
|
def send_prefix(pymux, variables):
|
|
"""
|
|
Send prefix to active pane.
|
|
"""
|
|
process = pymux.arrangement.get_active_pane().process
|
|
|
|
for k in pymux.key_bindings_manager.prefix:
|
|
vt100_data = prompt_toolkit_key_to_vt100_key(k)
|
|
process.write_input(vt100_data)
|
|
|
|
|
|
@cmd('bind-key', options='[-n] <key> [--] <command> [<arguments>...]')
|
|
def bind_key(pymux, variables):
|
|
"""
|
|
Bind a key sequence.
|
|
-n: Not necessary to use the prefix.
|
|
"""
|
|
key = variables['<key>']
|
|
command = variables['<command>']
|
|
arguments = variables['<arguments>']
|
|
needs_prefix = not variables['-n']
|
|
|
|
try:
|
|
pymux.key_bindings_manager.add_custom_binding(
|
|
key, command, arguments, needs_prefix=needs_prefix)
|
|
except ValueError:
|
|
raise CommandException('Invalid key: %r' % (key, ))
|
|
|
|
|
|
@cmd('unbind-key', options='[-n] <key>')
|
|
def unbind_key(pymux, variables):
|
|
"""
|
|
Remove key binding.
|
|
"""
|
|
key = variables['<key>']
|
|
needs_prefix = not variables['-n']
|
|
|
|
pymux.key_bindings_manager.remove_custom_binding(
|
|
key, needs_prefix=needs_prefix)
|
|
|
|
|
|
@cmd('send-keys', options='<keys>...')
|
|
def send_keys(pymux, variables):
|
|
"""
|
|
Send key strokes to the active process.
|
|
"""
|
|
pane = pymux.arrangement.get_active_pane()
|
|
|
|
if pane.display_scroll_buffer:
|
|
raise CommandException('Cannot send keys. Pane is in copy mode.')
|
|
|
|
for key in variables['<keys>']:
|
|
# Translate key from pymux key to prompt_toolkit key.
|
|
try:
|
|
keys_sequence = pymux_key_to_prompt_toolkit_key_sequence(key)
|
|
except ValueError:
|
|
raise CommandException('Invalid key: %r' % (key, ))
|
|
|
|
# Translate prompt_toolkit key to VT100 key.
|
|
for k in keys_sequence:
|
|
pane.process.write_key(k)
|
|
|
|
|
|
@cmd('copy-mode', options='[-u]')
|
|
def copy_mode(pymux, variables):
|
|
"""
|
|
Enter copy mode.
|
|
"""
|
|
go_up = variables['-u'] # Go in copy mode and page-up directly.
|
|
# TODO: handle '-u'
|
|
|
|
pane = pymux.arrangement.get_active_pane()
|
|
pane.enter_copy_mode()
|
|
|
|
|
|
@cmd('paste-buffer')
|
|
def paste_buffer(pymux, variables):
|
|
"""
|
|
Paste clipboard content into buffer.
|
|
"""
|
|
pane = pymux.arrangement.get_active_pane()
|
|
pane.process.write_input(get_app().clipboard.get_data().text, paste=True)
|
|
|
|
|
|
@cmd('source-file', options='<filename>')
|
|
def source_file(pymux, variables):
|
|
"""
|
|
Source configuration file.
|
|
"""
|
|
filename = os.path.expanduser(variables['<filename>'])
|
|
try:
|
|
with open(filename, 'rb') as f:
|
|
for line in f:
|
|
line = line.decode('utf-8')
|
|
handle_command(pymux, line)
|
|
except IOError as e:
|
|
raise CommandException('IOError: %s' % (e, ))
|
|
|
|
|
|
@cmd('set-option', options='<option> <value>')
|
|
def set_option(pymux, variables, window=False):
|
|
name = variables['<option>']
|
|
value = variables['<value>']
|
|
|
|
if window:
|
|
option = pymux.window_options.get(name)
|
|
else:
|
|
option = pymux.options.get(name)
|
|
|
|
if option:
|
|
try:
|
|
option.set_value(pymux, value)
|
|
except SetOptionError as e:
|
|
raise CommandException(e.message)
|
|
else:
|
|
raise CommandException('Invalid option: %s' % (name, ))
|
|
|
|
@cmd('set-window-option', options='<option> <value>')
|
|
def set_window_option(pymux, variables):
|
|
set_option(pymux, variables, window=True)
|
|
|
|
|
|
@cmd('display-panes')
|
|
def display_panes(pymux, variables):
|
|
" Display the pane numbers. "
|
|
pymux.display_pane_numbers = True
|
|
|
|
|
|
@cmd('display-message', options='<message>')
|
|
def display_message(pymux, variables):
|
|
" Display a message. "
|
|
message = variables['<message>']
|
|
client_state = pymux.get_client_state()
|
|
client_state.message = message
|
|
|
|
############################################################################################################ DECODED
|
|
@cmd('display-decoded', options='<decoded>')
|
|
def display_decoded(pymux, variables):
|
|
" Display a decoded. "
|
|
decoded = variables['<decoded>']
|
|
client_state = pymux.get_client_state()
|
|
client_state.message = decoded
|
|
############################################################################################################ DECODED
|
|
|
|
@cmd('clear-history')
|
|
def clear_history(pymux, variables):
|
|
" Clear scrollback buffer. "
|
|
pane = pymux.arrangement.get_active_pane()
|
|
|
|
if pane.display_scroll_buffer:
|
|
raise CommandException('Not available in copy mode')
|
|
else:
|
|
pane.process.screen.clear_history()
|
|
|
|
|
|
@cmd('list-keys')
|
|
def list_keys(pymux, variables):
|
|
"""
|
|
Display all configured key bindings.
|
|
"""
|
|
# Create help string.
|
|
result = []
|
|
|
|
for k, custom_binding in pymux.key_bindings_manager.custom_bindings.items():
|
|
needs_prefix, key = k
|
|
|
|
result.append('bind-key %3s %-10s %s %s' % (
|
|
('-n' if needs_prefix else ''), key, custom_binding.command,
|
|
' '.join(map(wrap_argument, custom_binding.arguments))))
|
|
|
|
# Display help in pane.
|
|
result = '\n'.join(sorted(result))
|
|
pymux.get_client_state().layout_manager.display_popup('list-keys', result)
|
|
|
|
|
|
@cmd('list-panes')
|
|
def list_panes(pymux, variables):
|
|
"""
|
|
Display a list of all the panes.
|
|
"""
|
|
w = pymux.arrangement.get_active_window()
|
|
active_pane = w.active_pane
|
|
|
|
result = []
|
|
|
|
for i, p in enumerate(w.panes):
|
|
process = p.process
|
|
|
|
result.append('%i: [%sx%s] [history %s/%s] %s' % (
|
|
i, process.sx, process.sy,
|
|
min(pymux.history_limit, process.screen.line_offset + process.sy),
|
|
pymux.history_limit,
|
|
('(active)' if p == active_pane else '')))
|
|
|
|
# Display help in pane.
|
|
result = '\n'.join(sorted(result))
|
|
pymux.get_client_state().layout_manager.display_popup('list-keys', result)
|
|
|
|
|
|
@cmd('show-buffer')
|
|
def show_buffer(pymux, variables):
|
|
"""
|
|
Display the clipboard content.
|
|
"""
|
|
text = get_app().clipboard.get_data().text
|
|
pymux.get_client_state().layout_manager.display_popup('show-buffer', text)
|
|
|
|
|
|
# Check whether all aliases point to real commands.
|
|
for k in ALIASES.values():
|
|
assert k in COMMANDS_TO_HANDLERS
|