├── pymux ├── commands │ ├── __init__.py │ ├── utils.py │ ├── aliases.py │ ├── completer.py │ └── commands.py ├── entry_points │ ├── __init__.py │ └── run_pymux.py ├── __init__.py ├── client │ ├── __init__.py │ ├── base.py │ ├── defaults.py │ ├── windows.py │ └── posix.py ├── log.py ├── __main__.py ├── enums.py ├── pipes │ ├── __init__.py │ ├── base.py │ ├── win32_client.py │ ├── posix.py │ ├── win32_server.py │ └── win32.py ├── rc.py ├── format.py ├── filters.py ├── utils.py ├── style.py ├── options.py ├── key_mappings.py ├── server.py ├── key_bindings.py ├── main.py ├── arrangement.py └── layout.py ├── images ├── pymux.png ├── copy-mode.png ├── menu-true-color.png └── multiple-clients.png ├── examples ├── true_color_test.py └── example-config.conf ├── setup.py ├── .gitignore ├── LICENSE ├── CHANGELOG └── README.rst /pymux/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pymux/entry_points/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pymux/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | -------------------------------------------------------------------------------- /images/pymux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prompt-toolkit/pymux/HEAD/images/pymux.png -------------------------------------------------------------------------------- /images/copy-mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prompt-toolkit/pymux/HEAD/images/copy-mode.png -------------------------------------------------------------------------------- /images/menu-true-color.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prompt-toolkit/pymux/HEAD/images/menu-true-color.png -------------------------------------------------------------------------------- /images/multiple-clients.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prompt-toolkit/pymux/HEAD/images/multiple-clients.png -------------------------------------------------------------------------------- /pymux/client/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from .base import Client 3 | from .defaults import create_client, list_clients 4 | -------------------------------------------------------------------------------- /pymux/log.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | import logging 3 | 4 | __all__ = ( 5 | 'logger', 6 | ) 7 | 8 | 9 | logger = logging.getLogger(__package__) 10 | -------------------------------------------------------------------------------- /pymux/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Make sure `python -m pymux` works. 3 | """ 4 | from __future__ import unicode_literals 5 | from .entry_points.run_pymux import run 6 | 7 | if __name__ == '__main__': 8 | run() 9 | -------------------------------------------------------------------------------- /pymux/enums.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | __all__ = ( 4 | 'COMMAND', 5 | 'PROMPT', 6 | ) 7 | 8 | 9 | #: Name of the command buffer. 10 | COMMAND = 'COMMAND' 11 | 12 | #: Name of the input for a "command-prompt" command. 13 | PROMPT = 'PROMPT' 14 | -------------------------------------------------------------------------------- /pymux/commands/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | __all__ = ( 4 | 'wrap_argument', 5 | ) 6 | 7 | 8 | def wrap_argument(text): 9 | """ 10 | Wrap command argument in quotes and escape when this contains special characters. 11 | """ 12 | if not any(x in text for x in [' ', '"', "'", '\\']): 13 | return text 14 | else: 15 | return '"%s"' % (text.replace('\\', r'\\').replace('"', r'\"'), ) 16 | -------------------------------------------------------------------------------- /examples/true_color_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Run this script inside 'pymux' in order to discover whether or not in supports 4 | true 24bit color. It should display a rectangle with both red and green values 5 | changing between 0 and 80. 6 | """ 7 | from __future__ import unicode_literals, print_function 8 | 9 | i = 0 10 | for r in range(0, 80): 11 | for g in range(0, 80): 12 | b = 1 13 | print('\x1b[0;48;2;%s;%s;%sm ' % (r, g, b), end='') 14 | if i == 1000: 15 | break 16 | 17 | print('\x1b[0m \n', end='') 18 | print('\x1b[0m\r\n') 19 | -------------------------------------------------------------------------------- /pymux/client/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from prompt_toolkit.output import ColorDepth 4 | from abc import ABCMeta 5 | from six import with_metaclass 6 | 7 | 8 | __all__ = [ 9 | 'Client', 10 | ] 11 | 12 | 13 | class Client(with_metaclass(ABCMeta, object)): 14 | def run_command(self, command, pane_id=None): 15 | """ 16 | Ask the server to run this command. 17 | """ 18 | 19 | def attach(self, detach_other_clients=False, color_depth=ColorDepth.DEPTH_8_BIT): 20 | """ 21 | Attach client user interface. 22 | """ 23 | -------------------------------------------------------------------------------- /pymux/client/defaults.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from prompt_toolkit.utils import is_windows 3 | __all__ = [ 4 | 'create_client', 5 | 'list_clients', 6 | ] 7 | 8 | 9 | def create_client(socket_name): 10 | if is_windows(): 11 | from .windows import WindowsClient 12 | return WindowsClient(socket_name) 13 | else: 14 | from .posix import PosixClient 15 | return PosixClient(socket_name) 16 | 17 | 18 | def list_clients(): 19 | if is_windows(): 20 | from .windows import list_clients 21 | return list_clients() 22 | else: 23 | from .posix import list_clients 24 | return list_clients() 25 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | from setuptools import setup, find_packages 4 | 5 | 6 | with open(os.path.join(os.path.dirname(__file__), 'README.rst')) as f: 7 | long_description = f.read() 8 | 9 | 10 | setup( 11 | name='pymux', 12 | author='Jonathan Slenders', 13 | version='0.14', 14 | license='LICENSE', 15 | url='https://github.com/jonathanslenders/', 16 | description='Pure Python terminal multiplexer.', 17 | long_description=long_description, 18 | packages=find_packages('.'), 19 | install_requires = [ 20 | 'prompt_toolkit>=2.0.0,<2.1.0', 21 | 'ptterm', 22 | 'six>=1.9.0', 23 | 'docopt>=0.6.2', 24 | ], 25 | entry_points={ 26 | 'console_scripts': [ 27 | 'pymux = pymux.entry_points.run_pymux:run', 28 | ] 29 | }, 30 | ) 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # PyInstaller 26 | # Usually these files are written by a python script from a template 27 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 28 | *.manifest 29 | *.spec 30 | 31 | # Installer logs 32 | pip-log.txt 33 | pip-delete-this-directory.txt 34 | 35 | # Unit test / coverage reports 36 | htmlcov/ 37 | .tox/ 38 | .coverage 39 | .cache 40 | nosetests.xml 41 | coverage.xml 42 | 43 | # Translations 44 | *.mo 45 | *.pot 46 | 47 | # Django stuff: 48 | *.log 49 | 50 | # Sphinx documentation 51 | docs/_build/ 52 | 53 | # PyBuilder 54 | target/ 55 | -------------------------------------------------------------------------------- /examples/example-config.conf: -------------------------------------------------------------------------------- 1 | # Example pymux configuration. 2 | # Copy to ~/.pymux.conf and modify. 3 | 4 | 5 | # Use Control-A as a prefix. 6 | set-option prefix C-a 7 | unbind C-b 8 | bind C-a send-prefix 9 | 10 | 11 | # Rename panes with ': 12 | bind-key "'" command-prompt -p '(rename-pane)' 'rename-pane "%%"' 13 | 14 | 15 | # Open 'htop' with t 16 | bind-key t split-window -h htop 17 | 18 | 19 | # Use '|' and '-' for splitting panes. 20 | # (The double dash after '-' is required due to a bug in docopt.) 21 | bind-key "|" split-window -h 22 | bind-key "-" -- split-window -v 23 | 24 | 25 | # Use Vi key bindings instead of emacs. (For both the status bar and copy 26 | # mode.) 27 | set-option mode-keys vi 28 | set-option status-keys vi 29 | 30 | 31 | # Display the hostname on the left side of the status bar. 32 | set-option status-left '[#h:#S] ' 33 | 34 | 35 | # Start numbering windows from 1. 36 | set-option base-index 1 37 | -------------------------------------------------------------------------------- /pymux/pipes/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Platform specific (Windows+posix) implementations for inter process 3 | communication through pipes between the Pymux server and clients. 4 | """ 5 | from __future__ import unicode_literals 6 | from prompt_toolkit.utils import is_windows 7 | from .base import PipeConnection, BrokenPipeError 8 | 9 | __all__ = [ 10 | 'bind_and_listen_on_socket', 11 | 12 | # Base. 13 | 'PipeConnection', 14 | 'BrokenPipeError', 15 | ] 16 | 17 | 18 | def bind_and_listen_on_socket(socket_name, accept_callback): 19 | """ 20 | Return socket name. 21 | 22 | :param accept_callback: Callback is called with a `PipeConnection` as 23 | argument. 24 | """ 25 | if is_windows(): 26 | from .win32_server import bind_and_listen_on_win32_socket 27 | return bind_and_listen_on_win32_socket(socket_name, accept_callback) 28 | else: 29 | from .posix import bind_and_listen_on_posix_socket 30 | return bind_and_listen_on_posix_socket(socket_name, accept_callback) 31 | -------------------------------------------------------------------------------- /pymux/pipes/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from abc import ABCMeta, abstractmethod 3 | from six import with_metaclass 4 | 5 | __all__ = [ 6 | 'PipeConnection', 7 | 'BrokenPipeError', 8 | ] 9 | 10 | 11 | class PipeConnection(with_metaclass(ABCMeta, object)): 12 | """ 13 | A single active Win32 pipe connection on the server side. 14 | 15 | - Win32PipeConnection 16 | """ 17 | @abstractmethod 18 | def read(self): 19 | """ 20 | (coroutine) 21 | Read a single message from the pipe. (Return as text.) 22 | 23 | This can can BrokenPipeError. 24 | """ 25 | 26 | @abstractmethod 27 | def write(self, message): 28 | """ 29 | (coroutine) 30 | Write a single message into the pipe. 31 | 32 | This can can BrokenPipeError. 33 | """ 34 | 35 | @abstractmethod 36 | def close(self): 37 | """ 38 | Close connection. 39 | """ 40 | 41 | 42 | class BrokenPipeError(Exception): 43 | " Raised when trying to write to or read from a broken pipe. " 44 | -------------------------------------------------------------------------------- /pymux/pipes/win32_client.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from .win32 import read_message_from_pipe, write_message_to_pipe, connect_to_pipe 3 | from ctypes import windll 4 | from prompt_toolkit.eventloop import From, Return 5 | import six 6 | 7 | __all__ = [ 8 | 'PipeClient', 9 | ] 10 | 11 | 12 | class PipeClient(object): 13 | r""" 14 | Windows pipe client. 15 | 16 | :param pipe_name: Name of the pipe. E.g. \\.\pipe\pipe_name 17 | """ 18 | def __init__(self, pipe_name): 19 | assert isinstance(pipe_name, six.text_type) 20 | self.pipe_handle = connect_to_pipe(pipe_name) 21 | 22 | def write_message(self, text): 23 | """ 24 | (coroutine) 25 | Write message into the pipe. 26 | """ 27 | yield From(write_message_to_pipe(self.pipe_handle, text)) 28 | 29 | def read_message(self): 30 | """ 31 | (coroutine) 32 | Read one single message from the pipe and return as text. 33 | """ 34 | message = yield From(read_message_from_pipe(self.pipe_handle)) 35 | raise Return(message) 36 | 37 | def close(self): 38 | """ 39 | Close the connection. 40 | """ 41 | windll.kernel32.CloseHandle(self.pipe_handle) 42 | -------------------------------------------------------------------------------- /pymux/commands/aliases.py: -------------------------------------------------------------------------------- 1 | """ 2 | Aliases for all commands. 3 | (On purpose kept compatible with tmux.) 4 | """ 5 | from __future__ import unicode_literals 6 | 7 | __all__ = ( 8 | 'ALIASES', 9 | ) 10 | 11 | 12 | ALIASES = { 13 | 'bind': 'bind-key', 14 | 'breakp': 'break-pane', 15 | 'clearhist': 'clear-history', 16 | 'confirm': 'confirm-before', 17 | 'detach': 'detach-client', 18 | 'display': 'display-message', 19 | 'displayp': 'display-panes', 20 | 'killp': 'kill-pane', 21 | 'killw': 'kill-window', 22 | 'last': 'last-window', 23 | 'lastp': 'last-pane', 24 | 'lextl': 'next-layout', 25 | 'lsk': 'list-keys', 26 | 'lsp': 'list-panes', 27 | 'movew': 'move-window', 28 | 'neww': 'new-window', 29 | 'next': 'next-window', 30 | 'pasteb': 'paste-buffer', 31 | 'prev': 'previous-window', 32 | 'prevl': 'previous-layout', 33 | 'rename': 'rename-session', 34 | 'renamew': 'rename-window', 35 | 'resizep': 'resize-pane', 36 | 'rotatew': 'rotate-window', 37 | 'selectl': 'select-layout', 38 | 'selectp': 'select-pane', 39 | 'selectw': 'select-window', 40 | 'send': 'send-keys', 41 | 'set': 'set-option', 42 | 'setw': 'set-window-option', 43 | 'source': 'source-file', 44 | 'splitw': 'split-window', 45 | 'suspendc': 'suspend-client', 46 | 'swapp': 'swap-pane', 47 | 'unbind': 'unbind-key', 48 | } 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, Jonathan Slenders 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, this 11 | list of conditions and the following disclaimer in the documentation and/or 12 | other materials provided with the distribution. 13 | 14 | * Neither the name of the {organization} nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /pymux/rc.py: -------------------------------------------------------------------------------- 1 | """ 2 | Initial configuration. 3 | """ 4 | from __future__ import unicode_literals 5 | 6 | __all__ = ( 7 | 'STARTUP_COMMANDS' 8 | ) 9 | 10 | STARTUP_COMMANDS = """ 11 | bind-key '"' split-window -v 12 | bind-key % split-window -h 13 | bind-key c new-window 14 | bind-key Right select-pane -R 15 | bind-key Left select-pane -L 16 | bind-key Up select-pane -U 17 | bind-key Down select-pane -D 18 | bind-key C-l select-pane -R 19 | bind-key C-h select-pane -L 20 | bind-key C-j select-pane -D 21 | bind-key C-k select-pane -U 22 | bind-key ; last-pane 23 | bind-key ! break-pane 24 | bind-key d detach-client 25 | bind-key t clock-mode 26 | bind-key Space next-layout 27 | bind-key C-z suspend-client 28 | 29 | bind-key z resize-pane -Z 30 | bind-key k resize-pane -U 2 31 | bind-key j resize-pane -D 2 32 | bind-key h resize-pane -L 2 33 | bind-key l resize-pane -R 2 34 | bind-key q display-panes 35 | bind-key C-Up resize-pane -U 2 36 | bind-key C-Down resize-pane -D 2 37 | bind-key C-Left resize-pane -L 2 38 | bind-key C-Right resize-pane -R 2 39 | bind-key M-Up resize-pane -U 5 40 | bind-key M-Down resize-pane -D 5 41 | bind-key M-Left resize-pane -L 5 42 | bind-key M-Right resize-pane -R 5 43 | 44 | bind-key : command-prompt 45 | bind-key 0 select-window -t :0 46 | bind-key 1 select-window -t :1 47 | bind-key 2 select-window -t :2 48 | bind-key 3 select-window -t :3 49 | bind-key 4 select-window -t :4 50 | bind-key 5 select-window -t :5 51 | bind-key 6 select-window -t :6 52 | bind-key 7 select-window -t :7 53 | bind-key 8 select-window -t :8 54 | bind-key 9 select-window -t :9 55 | bind-key n next-window 56 | bind-key p previous-window 57 | bind-key o select-pane -t :.+ 58 | bind-key { swap-pane -U 59 | bind-key } swap-pane -D 60 | bind-key x confirm-before -p "kill-pane #P?" kill-pane 61 | bind-key & confirm-before -p "kill-window #W?" kill-window 62 | bind-key C-o rotate-window 63 | bind-key M-o rotate-window -D 64 | bind-key C-b send-prefix 65 | bind-key . command-prompt "move-window -t '%%'" 66 | bind-key [ copy-mode 67 | bind-key ] paste-buffer 68 | bind-key ? list-keys 69 | bind-key PPage copy-mode -u 70 | 71 | # Layouts. 72 | bind-key M-1 select-layout even-horizontal 73 | bind-key M-2 select-layout even-vertical 74 | bind-key M-3 select-layout main-horizontal 75 | bind-key M-4 select-layout main-vertical 76 | bind-key M-5 select-layout tiled 77 | 78 | # Renaming stuff. 79 | bind-key , command-prompt -I #W "rename-window '%%'" 80 | #bind-key "'" command-prompt -I #W "rename-pane '%%'" 81 | bind-key "'" command-prompt -p index "select-window -t ':%%'" 82 | bind-key . command-prompt "move-window -t '%%'" 83 | """ 84 | -------------------------------------------------------------------------------- /pymux/format.py: -------------------------------------------------------------------------------- 1 | """ 2 | Pymux string formatting. 3 | """ 4 | from __future__ import unicode_literals 5 | import datetime 6 | import socket 7 | import six 8 | 9 | __all__ = ( 10 | 'format_pymux_string', 11 | ) 12 | 13 | 14 | def format_pymux_string(pymux, string, window=None, pane=None): 15 | """ 16 | Apply pymux sting formatting. (Similar to tmux.) 17 | E.g. #P is replaced by the index of the active pane. 18 | 19 | We try to stay compatible with tmux, if possible. 20 | One thing that we won't support (for now) is colors, because our styling 21 | works different. (With a Style class.) On the other hand, in the future, we 22 | could allow things like `#[token=Token.Title.PID]`. This gives a clean 23 | separation of semantics and colors, making it easy to write different color 24 | schemes. 25 | """ 26 | arrangement = pymux.arrangement 27 | 28 | if window is None: 29 | window = arrangement.get_active_window() 30 | 31 | if pane is None: 32 | pane = window.active_pane 33 | 34 | def id_of_pane(): 35 | return '%s' % (pane.pane_id, ) 36 | 37 | def index_of_pane(): 38 | try: 39 | return '%s' % (window.get_pane_index(pane), ) 40 | except ValueError: 41 | return '/' 42 | 43 | def index_of_window(): 44 | return '%s' % (window.index, ) 45 | 46 | def name_of_window(): 47 | return window.name or '(noname)' 48 | 49 | def window_flags(): 50 | z = 'Z' if window.zoom else '' 51 | 52 | if window == arrangement.get_active_window(): 53 | return '*' + z 54 | elif window == arrangement.get_previous_active_window(): 55 | return '-' + z 56 | else: 57 | return z + ' ' 58 | 59 | def name_of_session(): 60 | return pymux.session_name 61 | 62 | def title_of_pane(): 63 | return pane.process.screen.title 64 | 65 | def hostname(): 66 | return socket.gethostname() 67 | 68 | def literal(): 69 | return '#' 70 | 71 | format_table = { 72 | '#D': id_of_pane, 73 | '#F': window_flags, 74 | '#I': index_of_window, 75 | '#P': index_of_pane, 76 | '#S': name_of_session, 77 | '#T': title_of_pane, 78 | '#W': name_of_window, 79 | '#h': hostname, 80 | '##': literal, 81 | } 82 | 83 | # Date/time formatting. 84 | if '%' in string: 85 | try: 86 | if six.PY2: 87 | string = datetime.datetime.now().strftime( 88 | string.encode('utf-8')).decode('utf-8') 89 | else: 90 | string = datetime.datetime.now().strftime(string) 91 | except ValueError: # strftime format ends with raw % 92 | string = '' 93 | 94 | # Apply '#' formatting. 95 | for symbol, f in format_table.items(): 96 | if symbol in string: 97 | string = string.replace(symbol, f()) 98 | 99 | return string 100 | -------------------------------------------------------------------------------- /pymux/filters.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from prompt_toolkit.filters import Filter 3 | 4 | __all__ = ( 5 | 'HasPrefix', 6 | 'WaitsForConfirmation', 7 | 'InCommandMode', 8 | 'WaitsForPrompt', 9 | 'InScrollBuffer', 10 | 'InScrollBufferNotSearching', 11 | 'InScrollBufferSearching', 12 | ) 13 | 14 | 15 | class HasPrefix(Filter): 16 | """ 17 | When the prefix key (Usual C-b) has been pressed. 18 | """ 19 | def __init__(self, pymux): 20 | self.pymux = pymux 21 | 22 | def __call__(self): 23 | return self.pymux.get_client_state().has_prefix 24 | 25 | 26 | class WaitsForConfirmation(Filter): 27 | """ 28 | Waiting for a yes/no key press. 29 | """ 30 | def __init__(self, pymux): 31 | self.pymux = pymux 32 | 33 | def __call__(self): 34 | return bool(self.pymux.get_client_state().confirm_command) 35 | 36 | 37 | class InCommandMode(Filter): 38 | """ 39 | When ':' has been pressed.' 40 | """ 41 | def __init__(self, pymux): 42 | self.pymux = pymux 43 | 44 | def __call__(self): 45 | client_state = self.pymux.get_client_state() 46 | return client_state.command_mode and not client_state.confirm_command 47 | 48 | 49 | class WaitsForPrompt(Filter): 50 | """ 51 | Waiting for input for a "command-prompt" command. 52 | """ 53 | def __init__(self, pymux): 54 | self.pymux = pymux 55 | 56 | def __call__(self): 57 | client_state = self.pymux.get_client_state() 58 | return bool(client_state.prompt_command) and not client_state.confirm_command 59 | 60 | 61 | def _confirm_or_prompt_or_command(pymux): 62 | " True when we are waiting for a command, prompt or confirmation. " 63 | client_state = pymux.get_client_state() 64 | if client_state.confirm_text or client_state.prompt_command or client_state.command_mode: 65 | return True 66 | 67 | 68 | class InScrollBuffer(Filter): 69 | def __init__(self, pymux): 70 | self.pymux = pymux 71 | 72 | def __call__(self): 73 | if _confirm_or_prompt_or_command(self.pymux): 74 | return False 75 | 76 | pane = self.pymux.arrangement.get_active_pane() 77 | return pane.display_scroll_buffer 78 | 79 | 80 | class InScrollBufferNotSearching(Filter): 81 | def __init__(self, pymux): 82 | self.pymux = pymux 83 | 84 | def __call__(self): 85 | if _confirm_or_prompt_or_command(self.pymux): 86 | return False 87 | 88 | pane = self.pymux.arrangement.get_active_pane() 89 | return pane.display_scroll_buffer and not pane.is_searching 90 | 91 | 92 | class InScrollBufferSearching(Filter): 93 | def __init__(self, pymux): 94 | self.pymux = pymux 95 | 96 | def __call__(self): 97 | if _confirm_or_prompt_or_command(self.pymux): 98 | return False 99 | 100 | pane = self.pymux.arrangement.get_active_pane() 101 | return pane.display_scroll_buffer and pane.is_searching 102 | -------------------------------------------------------------------------------- /pymux/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Some utilities. 3 | """ 4 | from __future__ import unicode_literals 5 | from prompt_toolkit.utils import is_windows 6 | 7 | import os 8 | import sys 9 | 10 | __all__ = ( 11 | 'daemonize', 12 | 'nonblocking', 13 | 'get_default_shell', 14 | ) 15 | 16 | 17 | def daemonize(stdin='/dev/null', stdout='/dev/null', stderr='/dev/null'): 18 | """ 19 | Double fork-trick. For starting a posix daemon. 20 | 21 | This forks the current process into a daemon. The stdin, stdout, and stderr 22 | arguments are file names that will be opened and be used to replace the 23 | standard file descriptors in sys.stdin, sys.stdout, and sys.stderr. These 24 | arguments are optional and default to /dev/null. Note that stderr is opened 25 | unbuffered, so if it shares a file with stdout then interleaved output may 26 | not appear in the order that you expect. 27 | 28 | Thanks to: 29 | http://code.activestate.com/recipes/66012-fork-a-daemon-process-on-unix/ 30 | """ 31 | # Do first fork. 32 | try: 33 | pid = os.fork() 34 | if pid > 0: 35 | os.waitpid(pid, 0) 36 | return 0 # Return 0 from first parent. 37 | except OSError as e: 38 | sys.stderr.write("fork #1 failed: (%d) %s\n" % (e.errno, e.strerror)) 39 | sys.exit(1) 40 | 41 | # Decouple from parent environment. 42 | os.chdir("/") 43 | os.umask(0) 44 | os.setsid() 45 | 46 | # Do second fork. 47 | try: 48 | pid = os.fork() 49 | if pid > 0: 50 | sys.exit(0) # Exit second parent. 51 | except OSError as e: 52 | sys.stderr.write("fork #2 failed: (%d) %s\n" % (e.errno, e.strerror)) 53 | sys.exit(1) 54 | 55 | # Now I am a daemon! 56 | 57 | # Redirect standard file descriptors. 58 | 59 | # NOTE: For debugging, you meight want to take these instead of /dev/null. 60 | # so = open('/tmp/log2', 'ab+') 61 | # se = open('/tmp/log2', 'ab+', 0) 62 | 63 | si = open(stdin, 'rb') 64 | so = open(stdout, 'ab+') 65 | se = open(stderr, 'ab+', 0) 66 | os.dup2(si.fileno(), sys.stdin.fileno()) 67 | os.dup2(so.fileno(), sys.stdout.fileno()) 68 | os.dup2(se.fileno(), sys.stderr.fileno()) 69 | 70 | # Return 1 from daemon. 71 | return 1 72 | 73 | 74 | class nonblocking(object): 75 | """ 76 | Make fd non blocking. 77 | """ 78 | def __init__(self, fd): 79 | self.fd = fd 80 | 81 | def __enter__(self): 82 | import fcntl 83 | self.orig_fl = fcntl.fcntl(self.fd, fcntl.F_GETFL) 84 | fcntl.fcntl(self.fd, fcntl.F_SETFL, self.orig_fl | os.O_NONBLOCK) 85 | 86 | def __exit__(self, *args): 87 | import fcntl 88 | fcntl.fcntl(self.fd, fcntl.F_SETFL, self.orig_fl) 89 | 90 | 91 | def get_default_shell(): 92 | """ 93 | return the path to the default shell for the current user. 94 | """ 95 | if is_windows(): 96 | return 'cmd.exe' 97 | else: 98 | import pwd 99 | import getpass 100 | 101 | if 'SHELL' in os.environ: 102 | return os.environ['SHELL'] 103 | else: 104 | username = getpass.getuser() 105 | shell = pwd.getpwnam(username).pw_shell 106 | return shell 107 | -------------------------------------------------------------------------------- /pymux/style.py: -------------------------------------------------------------------------------- 1 | """ 2 | The color scheme. 3 | """ 4 | from __future__ import unicode_literals 5 | from prompt_toolkit.styles import Style, Priority 6 | 7 | __all__ = ( 8 | 'ui_style', 9 | ) 10 | 11 | 12 | ui_style = Style.from_dict({ 13 | 'border': '#888888', 14 | 'terminal.focused border': 'ansigreen bold', 15 | 16 | #'terminal titleba': 'bg:#aaaaaa #dddddd ', 17 | 'terminal titlebar': 'bg:#888888 #ffffff', 18 | # 'terminal titlebar paneindex': 'bg:#888888 #000000', 19 | 20 | 'terminal.focused titlebar': 'bg:#448844 #ffffff', 21 | 'terminal.focused titlebar name': 'bg:#88aa44 #ffffff', 22 | 'terminal.focused titlebar paneindex': 'bg:#ff0000', 23 | 24 | # 'titlebar title': '', 25 | # 'titlebar name': '#ffffff noitalic', 26 | # 'focused-terminal titlebar name': 'bg:#88aa44', 27 | # 'titlebar.line': '#444444', 28 | # 'titlebar.line focused': '#448844 noinherit', 29 | # 'titlebar focused': 'bg:#5f875f #ffffff bold', 30 | # 'titlebar.title focused': '', 31 | # 'titlebar.zoom': 'bg:#884400 #ffffff', 32 | # 'titlebar paneindex': '', 33 | # 'titlebar.copymode': 'bg:#88aa88 #444444', 34 | # 'titlebar.copymode.position': '', 35 | 36 | # 'focused-terminal titlebar.copymode': 'bg:#aaff44 #000000', 37 | # 'titlebar.copymode.position': '#888888', 38 | 39 | 'commandline': 'bg:#4e4e4e #ffffff', 40 | 'commandline.command': 'bold', 41 | 'commandline.prompt': 'bold', 42 | #'statusbar': 'noreverse bg:#448844 #000000', 43 | 'statusbar': 'noreverse bg:ansigreen #000000', 44 | 'statusbar window': '#ffffff', 45 | 'statusbar window.current': 'bg:#44ff44 #000000', 46 | 'auto-suggestion': 'bg:#4e5e4e #88aa88', 47 | 'message': 'bg:#bbee88 #222222', 48 | 'background': '#888888', 49 | 'clock': 'bg:#88aa00', 50 | 'panenumber': 'bg:#888888', 51 | 'panenumber focused': 'bg:#aa8800', 52 | 'terminated': 'bg:#aa0000 #ffffff', 53 | 54 | 'confirmationtoolbar': 'bg:#880000 #ffffff', 55 | 'confirmationtoolbar question': '', 56 | 'confirmationtoolbar yesno': 'bg:#440000', 57 | 58 | 'copy-mode-cursor-position': 'bg:ansiyellow ansiblack', 59 | 60 | # 'search-toolbar': 'bg:#88ff44 #444444', 61 | 'search-toolbar.prompt': 'bg:#88ff44 #444444', 62 | 'search-toolbar.text': 'bg:#88ff44 #000000', 63 | # 'search-toolbar focused': 'bg:#aaff44 #444444', 64 | # 'search-toolbar.text focused': 'bold #000000', 65 | 66 | 'search-match': '#000000 bg:#88aa88', 67 | 'search-match.current': '#000000 bg:#aaffaa underline', 68 | 69 | # Pop-up dialog. Ignore built-in style. 70 | 'dialog': 'noinherit', 71 | 'dialog.body': 'noinherit', 72 | 'dialog frame': 'noinherit', 73 | 'dialog.body text-area': 'noinherit', 74 | 'dialog.body text-area last-line': 'noinherit', 75 | 76 | }, priority=Priority.MOST_PRECISE) 77 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | 0.14: 2017-07-27 5 | ---------------- 6 | 7 | Fixes: 8 | - Fixed bug in remove_reader (ValueError). 9 | - Pin Pyte requirements belowe 0.6.0. 10 | 11 | 12 | 0.13: 2016-10-16 13 | ---------------- 14 | 15 | New features: 16 | - Added status-interval option. 17 | - Support for ANSI colors only. 18 | * Added --ansicolor option. 19 | * Check PROMPT_TOOLKIT_ANSI_COLORS_ONLY environment variable. 20 | - Added pane-status option for hiding the pane status bar. 21 | (Disabled by default.) 22 | - Expose shift+arrow keys for key bindings. 23 | 24 | Performance improvements: 25 | - Only do datetime formatting if the string actually contains a '%' 26 | character. 27 | 28 | Fixes: 29 | - Catch OSError in os.tcgetpgrp. 30 | - Clean up sockets, also in case of a crash. 31 | - Fix in process.py: don't call remove_reader when master is None. 32 | 33 | 34 | 0.12: 2016-08-03 35 | ---------------- 36 | 37 | Fixes: 38 | - Prompt_toolkit 1.0.4 compatibilty. 39 | - Python 2.6 compatibility. 40 | 41 | 42 | 0.11: 2016-06-27 43 | ---------------- 44 | 45 | Fixes: 46 | - Fix for OS X El Capitan: LoadLibrary('libc.dylib') failed. 47 | - Compatibility with the latest prompt_toolkit. 48 | 49 | 50 | 0.10: 2016-05-05 51 | ---------------- 52 | 53 | Upgrade to prompt_toolkit 1.0.0 54 | 55 | New features: 56 | - Added 'C-b PPage' key binding (like tmux). 57 | - Many performance improvements in the vt100 parser. 58 | 59 | Improvements/fixes: 60 | - Don't crash when decoding utf-8 input fails. (Sometimes it happens when using 61 | the mouse in lxterminal.) 62 | - Cleanup CLI object when the client was detached. (The server would become 63 | very slow if the CLI was not removed for a couple of times.) 64 | - Replace errors when decoding utf-8 input. 65 | - Fixes regarding multiwidth characters. 66 | - Bugfix: Don't use 'del' on a defaultdict, but use pop(..., None) instead, in 67 | order to avoid key errors. 68 | - Handle decomposed unicode characters correctly. 69 | - Bugfix regarding the handling of 'clear'. 70 | - Fixes a bug where the cursor stays at the top. 71 | - Fix: The socket in the pymux client should be blocking. 72 | 73 | 74 | 0.9: 2016-03-14 75 | --------------- 76 | 77 | Upgrade to prompt_toolkit 0.60 78 | 79 | 80 | 0.8: 2016-03-06 81 | --------------- 82 | 83 | Upgrade to prompt_toolkit 0.59 84 | 85 | 86 | 0.7: 2016-01-16 87 | --------------- 88 | 89 | Fixes: 90 | - Fixed FreeBSD support. 91 | - Compatibility with the latest Pyte version. 92 | - Handle 'No such process' in os.kill. 93 | 94 | 95 | 0.6: 2016-01-11 96 | --------------- 97 | 98 | Fixes: 99 | - Fix module import of pyte==0.5.1 100 | - Use gettempdir() for sockets. 101 | - Disable bracketed paste when leaving client. 102 | - Keep dimensions when closing a pane. 103 | 104 | New features: 105 | - Display the process name on Mac OS X. 106 | - Exit scroll buffer when pressing enter. 107 | - Added synchronize-panes window option. 108 | 109 | 110 | 0.5: 2016-01-05 111 | ---------------- 112 | 113 | Fixes: 114 | - Handle KeyError in screen.insert_lines. 115 | 116 | 117 | 0.4: 2016-01-04 118 | ---------------- 119 | 120 | Fixes: 121 | - After closing a pane, go to the previous pane. 122 | - Write crash reports to a secure temp file. 123 | - Added 'ls' as alias for list-sessions. 124 | 125 | Better performance: 126 | - Using a coroutine for the vt100 parser. (Much faster.) 127 | - Give priority to panes that have the focus. 128 | - Never postpone the rendering in case of high CPU. (Fix in prompt_toolkit.) 129 | 130 | 131 | 0.3: 2016-01-03 132 | ---------------- 133 | 134 | New features: 135 | - Take $SHELL into account. 136 | 137 | Fixes: 138 | - Python 2 encoding bug fixed. 139 | 140 | 141 | 0.2: 2016-01-03 142 | ---------------- 143 | 144 | First published version of Pymux, using prompt_toolkit. 145 | 146 | 147 | 0.1: 2014-02-19 148 | --------------- 149 | 150 | Initial experimental version of Pymux, written using asyncio. (This one is 151 | discontinued in favour of the new version, that uses prompt_toolkit.) 152 | still available here: https://github.com/jonathanslenders/old-pymux 153 | -------------------------------------------------------------------------------- /pymux/client/windows.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from ctypes import byref, windll 4 | from ctypes.wintypes import DWORD 5 | from prompt_toolkit.eventloop import ensure_future, From 6 | from prompt_toolkit.eventloop import get_event_loop 7 | from prompt_toolkit.input.win32 import Win32Input 8 | from prompt_toolkit.output import ColorDepth 9 | from prompt_toolkit.output.win32 import Win32Output 10 | from prompt_toolkit.win32_types import STD_OUTPUT_HANDLE 11 | import json 12 | import os 13 | import sys 14 | 15 | from ..pipes.win32_client import PipeClient 16 | from .base import Client 17 | 18 | __all__ = [ 19 | 'WindowsClient', 20 | 'list_clients', 21 | ] 22 | 23 | # See: https://msdn.microsoft.com/pl-pl/library/windows/desktop/ms686033(v=vs.85).aspx 24 | ENABLE_PROCESSED_INPUT = 0x0001 25 | ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004 26 | 27 | 28 | class WindowsClient(Client): 29 | def __init__(self, pipe_name): 30 | self._input = Win32Input() 31 | self._hconsole = windll.kernel32.GetStdHandle(STD_OUTPUT_HANDLE) 32 | self._data_buffer = b'' 33 | 34 | self.pipe = PipeClient(pipe_name) 35 | 36 | def attach(self, detach_other_clients=False, color_depth=ColorDepth.DEPTH_8_BIT): 37 | assert isinstance(detach_other_clients, bool) 38 | self._send_size() 39 | self._send_packet({ 40 | 'cmd': 'start-gui', 41 | 'detach-others': detach_other_clients, 42 | 'color-depth': color_depth, 43 | 'term': os.environ.get('TERM', ''), 44 | 'data': '' 45 | }) 46 | 47 | f = ensure_future(self._start_reader()) 48 | with self._input.attach(self._input_ready): 49 | # Run as long as we have a connection with the server. 50 | get_event_loop().run_until_complete(f) # Run forever. 51 | 52 | def _start_reader(self): 53 | """ 54 | Read messages from the Win32 pipe server and handle them. 55 | """ 56 | while True: 57 | message = yield From(self.pipe.read_message()) 58 | self._process(message) 59 | 60 | def _process(self, data_buffer): 61 | """ 62 | Handle incoming packet from server. 63 | """ 64 | packet = json.loads(data_buffer) 65 | 66 | if packet['cmd'] == 'out': 67 | # Call os.write manually. In Python2.6, sys.stdout.write doesn't use UTF-8. 68 | original_mode = DWORD(0) 69 | windll.kernel32.GetConsoleMode(self._hconsole, byref(original_mode)) 70 | 71 | windll.kernel32.SetConsoleMode(self._hconsole, DWORD( 72 | ENABLE_PROCESSED_INPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING)) 73 | 74 | try: 75 | os.write(sys.stdout.fileno(), packet['data'].encode('utf-8')) 76 | finally: 77 | windll.kernel32.SetConsoleMode(self._hconsole, original_mode) 78 | 79 | elif packet['cmd'] == 'suspend': 80 | # Suspend client process to background. 81 | pass 82 | 83 | elif packet['cmd'] == 'mode': 84 | pass 85 | 86 | # # Set terminal to raw/cooked. 87 | # action = packet['data'] 88 | 89 | # if action == 'raw': 90 | # cm = raw_mode(sys.stdin.fileno()) 91 | # cm.__enter__() 92 | # self._mode_context_managers.append(cm) 93 | 94 | # elif action == 'cooked': 95 | # cm = cooked_mode(sys.stdin.fileno()) 96 | # cm.__enter__() 97 | # self._mode_context_managers.append(cm) 98 | 99 | # elif action == 'restore' and self._mode_context_managers: 100 | # cm = self._mode_context_managers.pop() 101 | # cm.__exit__() 102 | 103 | def _input_ready(self): 104 | keys = self._input.read_keys() 105 | if keys: 106 | self._send_packet({ 107 | 'cmd': 'in', 108 | 'data': ''.join(key_press.data for key_press in keys), 109 | }) 110 | 111 | def _send_packet(self, data): 112 | " Send to server. " 113 | data = json.dumps(data) 114 | ensure_future(self.pipe.write_message(data)) 115 | 116 | def _send_size(self): 117 | " Report terminal size to server. " 118 | output = Win32Output(sys.stdout) 119 | rows, cols = output.get_size() 120 | 121 | self._send_packet({ 122 | 'cmd': 'size', 123 | 'data': [rows, cols] 124 | }) 125 | 126 | 127 | def list_clients(): 128 | return [] 129 | -------------------------------------------------------------------------------- /pymux/pipes/posix.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | import getpass 3 | import os 4 | import six 5 | import socket 6 | import tempfile 7 | 8 | from prompt_toolkit.eventloop import From, Return, Future, get_event_loop 9 | 10 | from ..log import logger 11 | from .base import PipeConnection, BrokenPipeError 12 | 13 | __all__ = [ 14 | 'bind_and_listen_on_posix_socket', 15 | 'PosixSocketConnection', 16 | ] 17 | 18 | 19 | def bind_and_listen_on_posix_socket(socket_name, accept_callback): 20 | """ 21 | :param accept_callback: Called with `PosixSocketConnection` when a new 22 | connection is established. 23 | """ 24 | assert socket_name is None or isinstance(socket_name, six.text_type) 25 | assert callable(accept_callback) 26 | 27 | # Py2 uses 0027 and Py3 uses 0o027, but both know 28 | # how to create the right value from the string '0027'. 29 | old_umask = os.umask(int('0027', 8)) 30 | 31 | # Bind socket. 32 | socket_name, socket = _bind_posix_socket(socket_name) 33 | 34 | _ = os.umask(old_umask) 35 | 36 | # Listen on socket. 37 | socket.listen(0) 38 | 39 | def _accept_cb(): 40 | connection, client_address = socket.accept() 41 | # Note: We don't have to put this socket in non blocking mode. 42 | # This can cause crashes when sending big packets on OS X. 43 | 44 | posix_connection = PosixSocketConnection(connection) 45 | 46 | accept_callback(posix_connection) 47 | 48 | get_event_loop().add_reader(socket.fileno(), _accept_cb) 49 | 50 | logger.info('Listening on %r.' % socket_name) 51 | return socket_name 52 | 53 | 54 | def _bind_posix_socket(socket_name=None): 55 | """ 56 | Find a socket to listen on and return it. 57 | 58 | Returns (socket_name, sock_obj) 59 | """ 60 | assert socket_name is None or isinstance(socket_name, six.text_type) 61 | 62 | s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 63 | 64 | if socket_name: 65 | s.bind(socket_name) 66 | return socket_name, s 67 | else: 68 | i = 0 69 | while True: 70 | try: 71 | socket_name = '%s/pymux.sock.%s.%i' % ( 72 | tempfile.gettempdir(), getpass.getuser(), i) 73 | s.bind(socket_name) 74 | return socket_name, s 75 | except (OSError, socket.error): 76 | i += 1 77 | 78 | # When 100 times failed, cancel server 79 | if i == 100: 80 | logger.warning('100 times failed to listen on posix socket. ' 81 | 'Please clean up old sockets.') 82 | raise 83 | 84 | 85 | class PosixSocketConnection(PipeConnection): 86 | """ 87 | A single active posix pipe connection on the server side. 88 | """ 89 | def __init__(self, socket): 90 | self.socket = socket 91 | self._fd = socket.fileno() 92 | self._recv_buffer = b'' 93 | 94 | def read(self): 95 | r""" 96 | Coroutine that reads the next packet. 97 | (Packets are \0 separated.) 98 | """ 99 | # Read until we have a \0 in our buffer. 100 | while b'\0' not in self._recv_buffer: 101 | self._recv_buffer += yield From(_read_chunk_from_socket(self.socket)) 102 | 103 | # Split on the first separator. 104 | pos = self._recv_buffer.index(b'\0') 105 | 106 | packet = self._recv_buffer[:pos] 107 | self._recv_buffer = self._recv_buffer[pos + 1:] 108 | 109 | raise Return(packet) 110 | 111 | 112 | def write(self, message): 113 | """ 114 | Coroutine that writes the next packet. 115 | """ 116 | try: 117 | self.socket.send(message.encode('utf-8') + b'\0') 118 | except socket.error: 119 | if not self._closed: 120 | raise BrokenPipeError 121 | 122 | return Future.succeed(None) 123 | 124 | def close(self): 125 | """ 126 | Close connection. 127 | """ 128 | self.socket.close() 129 | 130 | # Make sure to remove the reader from the event loop. 131 | get_event_loop().remove_reader(self._fd) 132 | 133 | 134 | def _read_chunk_from_socket(socket): 135 | """ 136 | (coroutine) 137 | Turn socket reading into coroutine. 138 | """ 139 | fd = socket.fileno() 140 | f = Future() 141 | 142 | def read_callback(): 143 | get_event_loop().remove_reader(fd) 144 | 145 | # Read next chunk. 146 | try: 147 | data = socket.recv(1024) 148 | except OSError as e: 149 | # On OSX, when we try to create a new window by typing "pymux 150 | # new-window" in a centain pane, very often we get the following 151 | # error: "OSError: [Errno 9] Bad file descriptor." 152 | # This doesn't seem very harmful, and we can just try again. 153 | logger.warning('Got OSError while reading data from client: %s. ' 154 | 'Trying again.', e) 155 | f.set_result('') 156 | return 157 | 158 | if data: 159 | f.set_result(data) 160 | else: 161 | f.set_exception(BrokenPipeError) 162 | 163 | get_event_loop().add_reader(fd, read_callback) 164 | 165 | return f 166 | -------------------------------------------------------------------------------- /pymux/entry_points/run_pymux.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | pymux: Pure Python terminal multiplexer. 4 | Usage: 5 | pymux [(standalone|start-server|attach)] [-d] 6 | [--truecolor] [--ansicolor] [(-S )] [(-f )] 7 | [(--log )] 8 | [--] [] 9 | pymux list-sessions 10 | pymux -h | --help 11 | pymux 12 | 13 | Options: 14 | standalone : Run as a standalone process. (for debugging, detaching is 15 | not possible. 16 | start-server : Run a server daemon that can be attached later on. 17 | attach : Attach to a running session. 18 | 19 | -f : Path to configuration file. By default: '~/.pymux.conf'. 20 | -S : Unix socket path. 21 | -d : Detach all other clients, when attaching. 22 | --log : Logfile. 23 | --truecolor : Render true color (24 bit) instead of 256 colors. 24 | (Each client can set this separately.) 25 | """ 26 | from __future__ import unicode_literals, absolute_import 27 | 28 | from prompt_toolkit.output import ColorDepth 29 | from pymux.main import Pymux 30 | from pymux.client import create_client, list_clients 31 | from pymux.utils import daemonize 32 | 33 | import docopt 34 | import getpass 35 | import logging 36 | import os 37 | import sys 38 | import tempfile 39 | 40 | __all__ = ( 41 | 'run', 42 | ) 43 | 44 | 45 | def run(): 46 | a = docopt.docopt(__doc__) 47 | socket_name = a[''] or os.environ.get('PYMUX') 48 | socket_name_from_env = not a[''] and os.environ.get('PYMUX') 49 | filename = a[''] 50 | command = a[''] 51 | true_color = a['--truecolor'] 52 | ansi_colors_only = a['--ansicolor'] or \ 53 | bool(os.environ.get('PROMPT_TOOLKIT_ANSI_COLORS_ONLY', False)) 54 | 55 | # Parse pane_id from socket_name. It looks like "socket_name,pane_id". 56 | if socket_name and ',' in socket_name: 57 | socket_name, pane_id = socket_name.rsplit(',', 1) 58 | else: 59 | pane_id = None 60 | 61 | # Color depth. 62 | if ansi_colors_only: 63 | color_depth = ColorDepth.DEPTH_4_BIT 64 | elif true_color: 65 | color_depth = ColorDepth.DEPTH_24_BIT 66 | else: 67 | color_depth = ColorDepth.DEPTH_8_BIT 68 | 69 | # Expand socket name. (Make it possible to just accept numbers.) 70 | if socket_name and socket_name.isdigit(): 71 | socket_name = '%s/pymux.sock.%s.%s' % ( 72 | tempfile.gettempdir(), getpass.getuser(), socket_name) 73 | 74 | # Configuration filename. 75 | default_config = os.path.abspath(os.path.expanduser('~/.pymux.conf')) 76 | if not filename and os.path.exists(default_config): 77 | filename = default_config 78 | 79 | if filename: 80 | filename = os.path.abspath(os.path.expanduser(filename)) 81 | 82 | # Create 'Pymux'. 83 | mux = Pymux(source_file=filename, startup_command=command) 84 | 85 | # Setup logging. 86 | if a['']: 87 | logging.basicConfig(filename=a[''], level=logging.DEBUG) 88 | 89 | if a['standalone']: 90 | mux.run_standalone(color_depth=color_depth) 91 | 92 | elif a['list-sessions'] or a[''] in ('ls', 'list-sessions'): 93 | for c in list_clients(): 94 | print(c.socket_name) 95 | 96 | elif a['start-server']: 97 | if socket_name_from_env: 98 | _socket_from_env_warning() 99 | sys.exit(1) 100 | 101 | # Log to stdout. 102 | logging.basicConfig(stream=sys.stdout, level=logging.DEBUG) 103 | 104 | # Run server. 105 | socket_name = mux.listen_on_socket() 106 | try: 107 | mux.run_server() 108 | except KeyboardInterrupt: 109 | sys.exit(1) 110 | 111 | elif a['attach']: 112 | if socket_name_from_env: 113 | _socket_from_env_warning() 114 | sys.exit(1) 115 | 116 | detach_other_clients = a['-d'] 117 | 118 | if socket_name: 119 | create_client(socket_name).attach( 120 | detach_other_clients=detach_other_clients, 121 | color_depth=color_depth) 122 | else: 123 | # Connect to the first server. 124 | for c in list_clients(): 125 | c.attach(detach_other_clients=detach_other_clients, 126 | color_depth=color_depth) 127 | break 128 | else: # Nobreak. 129 | print('No pymux instance found.') 130 | sys.exit(1) 131 | 132 | elif a[''] and socket_name: 133 | create_client(socket_name).run_command(a[''], pane_id) 134 | 135 | elif not socket_name: 136 | # Run client/server combination. 137 | socket_name = mux.listen_on_socket(socket_name) 138 | pid = daemonize() 139 | 140 | if pid > 0: 141 | # Create window. It is important that this happens in the daemon, 142 | # because the parent of the process running inside should be this 143 | # daemon. (Otherwise the `waitpid` call won't work.) 144 | mux.run_server() 145 | else: 146 | create_client(socket_name).attach(color_depth=color_depth) 147 | 148 | else: 149 | if socket_name_from_env: 150 | _socket_from_env_warning() 151 | sys.exit(1) 152 | else: 153 | print('Invalid command.') 154 | sys.exit(1) 155 | 156 | 157 | def _socket_from_env_warning(): 158 | print('Please be careful nesting pymux sessions.') 159 | print('Unset PYMUX environment variable first.') 160 | 161 | 162 | if __name__ == '__main__': 163 | run() 164 | -------------------------------------------------------------------------------- /pymux/pipes/win32_server.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from ctypes import windll, byref 3 | from ctypes.wintypes import DWORD 4 | from prompt_toolkit.eventloop import From, Future, Return, ensure_future 5 | from ptterm.backends.win32_pipes import OVERLAPPED 6 | 7 | from .win32 import wait_for_event, create_event, read_message_from_pipe, write_message_to_pipe 8 | from .base import PipeConnection, BrokenPipeError 9 | from ..log import logger 10 | 11 | __all__ = [ 12 | 'bind_and_listen_on_win32_socket', 13 | 'Win32PipeConnection', 14 | 'PipeInstance', 15 | ] 16 | 17 | 18 | INSTANCES = 10 19 | BUFSIZE = 4096 20 | 21 | # CreateNamedPipeW flags. 22 | # See: https://docs.microsoft.com/en-us/windows/desktop/api/winbase/nf-winbase-createnamedpipea 23 | PIPE_ACCESS_DUPLEX = 0x00000003 24 | FILE_FLAG_OVERLAPPED = 0x40000000 25 | PIPE_TYPE_MESSAGE = 0x00000004 26 | PIPE_READMODE_MESSAGE = 0x00000002 27 | PIPE_WAIT = 0x00000000 28 | PIPE_NOWAIT = 0x00000001 29 | 30 | ERROR_IO_PENDING = 997 31 | ERROR_BROKEN_PIPE= 109 32 | ERROR_NO_DATA = 232 33 | 34 | CONNECTING_STATE = 0 35 | READING_STATE = 1 36 | WRITING_STATE = 2 37 | 38 | 39 | def bind_and_listen_on_win32_socket(socket_name, accept_callback): 40 | """ 41 | :param accept_callback: Called with `Win32PipeConnection` when a new 42 | connection is established. 43 | """ 44 | assert callable(accept_callback) 45 | socket_name = r'\\.\pipe\pymux.sock.jonathan.42' 46 | 47 | pipes = [PipeInstance(socket_name, pipe_connection_cb=accept_callback) 48 | for i in range(INSTANCES)] 49 | 50 | for p in pipes: 51 | # Start pipe. 52 | ensure_future(p.handle_pipe()) 53 | 54 | return socket_name 55 | 56 | 57 | class Win32PipeConnection(PipeConnection): 58 | """ 59 | A single active Win32 pipe connection on the server side. 60 | """ 61 | def __init__(self, pipe_instance): 62 | assert isinstance(pipe_instance, PipeInstance) 63 | self.pipe_instance = pipe_instance 64 | self.done_f = Future() 65 | 66 | def read(self): 67 | """ 68 | (coroutine) 69 | Read a single message from the pipe. (Return as text.) 70 | """ 71 | if self.done_f.done(): 72 | raise BrokenPipeError 73 | 74 | try: 75 | result = yield From(read_message_from_pipe(self.pipe_instance.pipe_handle)) 76 | raise Return(result) 77 | except BrokenPipeError: 78 | self.done_f.set_result(None) 79 | raise 80 | 81 | def write(self, message): 82 | """ 83 | (coroutine) 84 | Write a single message into the pipe. 85 | """ 86 | if self.done_f.done(): 87 | raise BrokenPipeError 88 | 89 | try: 90 | yield From(write_message_to_pipe(self.pipe_instance.pipe_handle, message)) 91 | except BrokenPipeError: 92 | self.done_f.set_result(None) 93 | raise 94 | 95 | def close(self): 96 | pass 97 | 98 | 99 | class PipeInstance(object): 100 | def __init__(self, pipe_name, instances=INSTANCES, buffsize=BUFSIZE, 101 | timeout=5000, pipe_connection_cb=None): 102 | 103 | self.pipe_handle = windll.kernel32.CreateNamedPipeW( 104 | pipe_name, # Pipe name. 105 | PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED, 106 | PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT, 107 | DWORD(instances), # Max instances. (TODO: increase). 108 | DWORD(buffsize), # Output buffer size. 109 | DWORD(buffsize), # Input buffer size. 110 | DWORD(timeout), # Client time-out. 111 | None, # Default security attributes. 112 | ) 113 | self.pipe_connection_cb = pipe_connection_cb 114 | 115 | if not self.pipe_handle: 116 | raise Exception('invalid pipe') 117 | 118 | def handle_pipe(self): 119 | """ 120 | Coroutine that handles this pipe. 121 | """ 122 | while True: 123 | yield From(self._handle_client()) 124 | 125 | def _handle_client(self): 126 | """ 127 | Coroutine that connects to a single client and handles that. 128 | """ 129 | while True: 130 | try: 131 | # Wait for connection. 132 | logger.info('Waiting for connection in pipe instance.') 133 | yield From(self._connect_client()) 134 | logger.info('Connected in pipe instance') 135 | 136 | conn = Win32PipeConnection(self) 137 | self.pipe_connection_cb(conn) 138 | 139 | yield From(conn.done_f) 140 | logger.info('Pipe instance done.') 141 | 142 | finally: 143 | # Disconnect and reconnect. 144 | logger.info('Disconnecting pipe instance.') 145 | windll.kernel32.DisconnectNamedPipe(self.pipe_handle) 146 | 147 | def _connect_client(self): 148 | """ 149 | Wait for a client to connect to this pipe. 150 | """ 151 | overlapped = OVERLAPPED() 152 | overlapped.hEvent = create_event() 153 | 154 | while True: 155 | success = windll.kernel32.ConnectNamedPipe( 156 | self.pipe_handle, 157 | byref(overlapped)) 158 | 159 | if success: 160 | return 161 | 162 | last_error = windll.kernel32.GetLastError() 163 | if last_error == ERROR_IO_PENDING: 164 | yield From(wait_for_event(overlapped.hEvent)) 165 | 166 | # XXX: Call GetOverlappedResult. 167 | return # Connection succeeded. 168 | 169 | else: 170 | raise Exception('connect failed with error code' + str(last_error)) 171 | -------------------------------------------------------------------------------- /pymux/pipes/win32.py: -------------------------------------------------------------------------------- 1 | """ 2 | Common Win32 pipe operations. 3 | """ 4 | from __future__ import unicode_literals 5 | from ctypes import windll, byref, create_string_buffer 6 | from ctypes.wintypes import DWORD, BOOL 7 | from prompt_toolkit.eventloop import get_event_loop, From, Return, Future 8 | from ptterm.backends.win32_pipes import OVERLAPPED 9 | from .base import BrokenPipeError 10 | 11 | __all__ = [ 12 | 'read_message_from_pipe', 13 | 'read_message_bytes_from_pipe', 14 | 'write_message_to_pipe', 15 | 'write_message_bytes_to_pipe', 16 | 'wait_for_event', 17 | ] 18 | 19 | BUFSIZE = 4096 20 | 21 | GENERIC_READ = 0x80000000 22 | GENERIC_WRITE = 0x40000000 23 | OPEN_EXISTING = 0x3 24 | 25 | ERROR_BROKEN_PIPE = 109 26 | ERROR_IO_PENDING = 997 27 | ERROR_MORE_DATA = 234 28 | ERROR_NO_DATA = 232 29 | FILE_FLAG_OVERLAPPED = 0x40000000 30 | 31 | PIPE_READMODE_MESSAGE = 0x2 32 | FILE_WRITE_ATTRIBUTES = 0x100 # 256 33 | INVALID_HANDLE_VALUE = -1 34 | 35 | 36 | def connect_to_pipe(pipe_name): 37 | """ 38 | Connect to a new pipe in message mode. 39 | """ 40 | pipe_handle = windll.kernel32.CreateFileW( 41 | pipe_name, 42 | DWORD(GENERIC_READ | GENERIC_WRITE | FILE_WRITE_ATTRIBUTES), 43 | DWORD(0), # No sharing. 44 | None, # Default security attributes. 45 | DWORD(OPEN_EXISTING), # dwCreationDisposition. 46 | FILE_FLAG_OVERLAPPED, # dwFlagsAndAttributes. 47 | None # hTemplateFile, 48 | ) 49 | if pipe_handle == INVALID_HANDLE_VALUE: 50 | raise Exception('Invalid handle. Connecting to pipe %r failed.' % pipe_name) 51 | 52 | # Turn pipe into message mode. 53 | dwMode = DWORD(PIPE_READMODE_MESSAGE) 54 | windll.kernel32.SetNamedPipeHandleState( 55 | pipe_handle, 56 | byref(dwMode), 57 | None, 58 | None) 59 | 60 | return pipe_handle 61 | 62 | 63 | def create_event(): 64 | """ 65 | Create Win32 event. 66 | """ 67 | event = windll.kernel32.CreateEventA( 68 | None, # Default security attributes. 69 | BOOL(True), # Manual reset event. 70 | BOOL(True), # Initial state = signaled. 71 | None # Unnamed event object. 72 | ) 73 | if not event: 74 | raise Exception('event creation failed.') 75 | return event 76 | 77 | 78 | def read_message_from_pipe(pipe_handle): 79 | """ 80 | (coroutine) 81 | Read message from this pipe. Return text. 82 | """ 83 | data = yield From(read_message_bytes_from_pipe(pipe_handle)) 84 | assert isinstance(data, bytes) 85 | raise Return(data.decode('utf-8', 'ignore')) 86 | 87 | 88 | def read_message_bytes_from_pipe(pipe_handle): 89 | """ 90 | (coroutine) 91 | Read message from this pipe. Return bytes. 92 | """ 93 | overlapped = OVERLAPPED() 94 | overlapped.hEvent = create_event() 95 | 96 | try: 97 | buff = create_string_buffer(BUFSIZE + 1) 98 | c_read = DWORD() 99 | 100 | success = windll.kernel32.ReadFile( 101 | pipe_handle, 102 | buff, 103 | DWORD(BUFSIZE), 104 | byref(c_read), 105 | byref(overlapped)) 106 | 107 | if success: 108 | buff[c_read.value] = b'\0' 109 | raise Return(buff.value) 110 | 111 | error_code = windll.kernel32.GetLastError() 112 | 113 | if error_code == ERROR_IO_PENDING: 114 | yield From(wait_for_event(overlapped.hEvent)) 115 | 116 | success = windll.kernel32.GetOverlappedResult( 117 | pipe_handle, 118 | byref(overlapped), 119 | byref(c_read), 120 | BOOL(False)) 121 | 122 | if success: 123 | buff[c_read.value] = b'\0' 124 | raise Return(buff.value) 125 | 126 | else: 127 | error_code = windll.kernel32.GetLastError() 128 | if error_code == ERROR_BROKEN_PIPE: 129 | raise BrokenPipeError 130 | 131 | elif error_code == ERROR_MORE_DATA: 132 | more_data = yield From(read_message_bytes_from_pipe(pipe_handle)) 133 | raise Return(buff.value + more_data) 134 | else: 135 | raise Exception( 136 | 'reading overlapped IO failed. error_code=%r' % error_code) 137 | 138 | elif error_code == ERROR_BROKEN_PIPE: 139 | raise BrokenPipeError 140 | 141 | elif error_code == ERROR_MORE_DATA: 142 | more_data = yield From(read_message_bytes_from_pipe(pipe_handle)) 143 | raise Return(buff.value + more_data) 144 | 145 | else: 146 | raise Exception('Reading pipe failed, error_code=%s' % error_code) 147 | finally: 148 | windll.kernel32.CloseHandle(overlapped.hEvent) 149 | 150 | 151 | def write_message_to_pipe(pipe_handle, text): 152 | data = text.encode('utf-8') 153 | yield From(write_message_bytes_to_pipe(pipe_handle, data)) 154 | 155 | 156 | def write_message_bytes_to_pipe(pipe_handle, data): 157 | overlapped = OVERLAPPED() 158 | overlapped.hEvent = create_event() 159 | 160 | try: 161 | c_written = DWORD() 162 | 163 | success = windll.kernel32.WriteFile( 164 | pipe_handle, 165 | create_string_buffer(data), 166 | len(data), 167 | byref(c_written), 168 | byref(overlapped)) 169 | 170 | if success: 171 | return 172 | 173 | error_code = windll.kernel32.GetLastError() 174 | if error_code == ERROR_IO_PENDING: 175 | yield From(wait_for_event(overlapped.hEvent)) 176 | 177 | success = windll.kernel32.GetOverlappedResult( 178 | pipe_handle, 179 | byref(overlapped), 180 | byref(c_written), 181 | BOOL(False)) 182 | 183 | if not success: 184 | error_code = windll.kernel32.GetLastError() 185 | if error_code == ERROR_BROKEN_PIPE: 186 | raise BrokenPipeError 187 | else: 188 | raise Exception('Writing overlapped IO failed. error_code=%r' % error_code) 189 | 190 | elif error_code == ERROR_BROKEN_PIPE: 191 | raise BrokenPipeError 192 | finally: 193 | windll.kernel32.CloseHandle(overlapped.hEvent) 194 | 195 | 196 | def wait_for_event(event): 197 | """ 198 | Wraps a win32 event into a `Future` and wait for it. 199 | """ 200 | f = Future() 201 | def ready(): 202 | get_event_loop().remove_win32_handle(event) 203 | f.set_result(None) 204 | get_event_loop().add_win32_handle(event, ready) 205 | return f 206 | -------------------------------------------------------------------------------- /pymux/commands/completer.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from prompt_toolkit.completion import Completer, Completion, WordCompleter 4 | from prompt_toolkit.document import Document 5 | 6 | from .aliases import ALIASES 7 | from .commands import COMMANDS_TO_HANDLERS, get_option_flags_for_command 8 | from .utils import wrap_argument 9 | 10 | from pymux.arrangement import LayoutTypes 11 | from pymux.key_mappings import PYMUX_TO_PROMPT_TOOLKIT_KEYS 12 | 13 | from functools import partial 14 | 15 | 16 | __all__ = ( 17 | 'create_command_completer', 18 | ) 19 | 20 | 21 | def create_command_completer(pymux): 22 | return ShlexCompleter(partial(get_completions_for_parts, pymux=pymux)) 23 | 24 | 25 | class CommandCompleter(Completer): 26 | """ 27 | Completer for command names. 28 | """ 29 | def __init__(self): 30 | # Completer for full command names. 31 | self._command_completer = WordCompleter( 32 | sorted(COMMANDS_TO_HANDLERS.keys()), 33 | ignore_case=True, WORD=True, match_middle=True) 34 | 35 | # Completer for aliases. 36 | self._aliases_completer = WordCompleter( 37 | sorted(ALIASES.keys()), 38 | ignore_case=True, WORD=True, match_middle=True) 39 | 40 | def get_completions(self, document, complete_event): 41 | # First, complete on full command names. 42 | found = False 43 | 44 | for c in self._command_completer.get_completions(document, complete_event): 45 | found = True 46 | yield c 47 | 48 | # When no matches are found, complete aliases instead. 49 | # The completion however, inserts the full name. 50 | if not found: 51 | for c in self._aliases_completer.get_completions(document, complete_event): 52 | full_name = ALIASES.get(c.display) 53 | 54 | yield Completion(full_name, 55 | start_position=c.start_position, 56 | display='%s (%s)' % (c.display, full_name)) 57 | 58 | 59 | _command_completer = CommandCompleter() 60 | _layout_type_completer = WordCompleter(sorted(LayoutTypes._ALL), WORD=True) 61 | _keys_completer = WordCompleter(sorted(PYMUX_TO_PROMPT_TOOLKIT_KEYS.keys()), 62 | ignore_case=True, WORD=True) 63 | 64 | 65 | def get_completions_for_parts(parts, last_part, complete_event, pymux): 66 | completer = None 67 | 68 | # Resolve aliases. 69 | if len(parts) > 0: 70 | parts = [ALIASES.get(parts[0], parts[0])] + parts[1:] 71 | 72 | if len(parts) == 0: 73 | # New command. 74 | completer = _command_completer 75 | 76 | elif len(parts) >= 1 and last_part.startswith('-'): 77 | flags = get_option_flags_for_command(parts[0]) 78 | completer = WordCompleter(sorted(flags), WORD=True) 79 | 80 | elif len(parts) == 1 and parts[0] in ('set-option', 'set-window-option'): 81 | options = pymux.options if parts[0] == 'set-option' else pymux.window_options 82 | 83 | completer = WordCompleter(sorted(options.keys()), sentence=True) 84 | 85 | elif len(parts) == 2 and parts[0] in ('set-option', 'set-window-option'): 86 | options = pymux.options if parts[0] == 'set-option' else pymux.window_options 87 | 88 | option = options.get(parts[1]) 89 | if option: 90 | completer = WordCompleter(sorted(option.get_all_values(pymux)), sentence=True) 91 | 92 | elif len(parts) == 1 and parts[0] == 'select-layout': 93 | completer = _layout_type_completer 94 | 95 | elif len(parts) == 1 and parts[0] == 'send-keys': 96 | completer = _keys_completer 97 | 98 | elif parts[0] == 'bind-key': 99 | if len(parts) == 1: 100 | completer = _keys_completer 101 | 102 | elif len(parts) == 2: 103 | completer = _command_completer 104 | 105 | # Recursive, for bind-key options. 106 | if parts and parts[0] == 'bind-key' and len(parts) > 2: 107 | for c in get_completions_for_parts(parts[2:], last_part, complete_event, pymux): 108 | yield c 109 | 110 | if completer: 111 | for c in completer.get_completions(Document(last_part), complete_event): 112 | yield c 113 | 114 | 115 | class ShlexCompleter(Completer): 116 | """ 117 | Completer that can be used when the input is parsed with shlex. 118 | """ 119 | def __init__(self, get_completions_for_parts): 120 | assert callable(get_completions_for_parts) 121 | self.get_completions_for_parts = get_completions_for_parts 122 | 123 | def get_completions(self, document, complete_event): 124 | text = document.text_before_cursor 125 | 126 | parts, part_start_pos = self.parse(text) 127 | 128 | for c in self.get_completions_for_parts(parts[:-1], parts[-1], complete_event): 129 | yield Completion(wrap_argument(parts[-1][:c.start_position] + c.text), 130 | start_position=part_start_pos - len(document.text), 131 | display=c.display, 132 | display_meta=c.display_meta) 133 | 134 | @classmethod 135 | def parse(cls, text): 136 | """ 137 | Parse the given text. Returns a tuple: 138 | (list_of_parts, start_pos_of_the_last_part). 139 | """ 140 | OUTSIDE, IN_DOUBLE, IN_SINGLE = 0, 1, 2 141 | 142 | iterator = enumerate(text) 143 | state = OUTSIDE 144 | parts = [] 145 | current_part = '' 146 | part_start_pos = 0 147 | 148 | for i, c in iterator: # XXX: correctly handle empty strings. 149 | if state == OUTSIDE: 150 | if c.isspace(): 151 | # New part. 152 | if current_part: 153 | parts.append(current_part) 154 | part_start_pos = i + 1 155 | current_part = '' 156 | elif c == '"': 157 | state = IN_DOUBLE 158 | elif c == "'": 159 | state = IN_SINGLE 160 | else: 161 | current_part += c 162 | 163 | elif state == IN_SINGLE: 164 | if c == "'": 165 | state = OUTSIDE 166 | elif c == "\\": 167 | next(iterator) 168 | current_part += c 169 | else: 170 | current_part += c 171 | 172 | elif state == IN_DOUBLE: 173 | if c == '"': 174 | state = OUTSIDE 175 | elif c == "\\": 176 | next(iterator) 177 | current_part += c 178 | else: 179 | current_part += c 180 | 181 | parts.append(current_part) 182 | return parts, part_start_pos 183 | 184 | 185 | # assert ShlexCompleter.parse('"hello" world') == (['hello', 'world'], 8) 186 | -------------------------------------------------------------------------------- /pymux/options.py: -------------------------------------------------------------------------------- 1 | """ 2 | All configurable options which can be changed through "set-option" commands. 3 | """ 4 | from __future__ import unicode_literals 5 | from abc import ABCMeta, abstractmethod 6 | import six 7 | 8 | from .key_mappings import PYMUX_TO_PROMPT_TOOLKIT_KEYS, pymux_key_to_prompt_toolkit_key_sequence 9 | from .utils import get_default_shell 10 | from .layout import Justify 11 | 12 | __all__ = ( 13 | 'Option', 14 | 'SetOptionError', 15 | 'OnOffOption', 16 | 'ALL_OPTIONS', 17 | 'ALL_WINDOW_OPTIONS', 18 | ) 19 | 20 | 21 | class Option(six.with_metaclass(ABCMeta, object)): 22 | """ 23 | Base class for all options. 24 | """ 25 | @abstractmethod 26 | def get_all_values(self): 27 | """ 28 | Return a list of strings, with all possible values. (For 29 | autocompletion.) 30 | """ 31 | 32 | @abstractmethod 33 | def set_value(self, pymux, value): 34 | " Set option. This can raise SetOptionError. " 35 | 36 | 37 | class SetOptionError(Exception): 38 | """ 39 | Raised when setting an option fails. 40 | """ 41 | def __init__(self, message): 42 | self.message = message 43 | 44 | 45 | class OnOffOption(Option): 46 | """ 47 | Boolean on/off option. 48 | """ 49 | def __init__(self, attribute_name, window_option=False): 50 | self.attribute_name = attribute_name 51 | self.window_option = window_option 52 | 53 | def get_all_values(self, pymux): 54 | return ['on', 'off'] 55 | 56 | def set_value(self, pymux, value): 57 | value = value.lower() 58 | 59 | if value in ('on', 'off'): 60 | if self.window_option: 61 | w = pymux.arrangement.get_active_window() 62 | setattr(w, self.attribute_name, (value == 'on')) 63 | else: 64 | setattr(pymux, self.attribute_name, (value == 'on')) 65 | else: 66 | raise SetOptionError('Expecting "yes" or "no".') 67 | 68 | 69 | class StringOption(Option): 70 | """ 71 | String option, the attribute is set as a Pymux attribute. 72 | """ 73 | def __init__(self, attribute_name, possible_values=None): 74 | self.attribute_name = attribute_name 75 | self.possible_values = possible_values or [] 76 | 77 | def get_all_values(self, pymux): 78 | return sorted(set( 79 | self.possible_values + [getattr(pymux, self.attribute_name)] 80 | )) 81 | 82 | def set_value(self, pymux, value): 83 | setattr(pymux, self.attribute_name, value) 84 | 85 | 86 | class PositiveIntOption(Option): 87 | """ 88 | Positive integer option, the attribute is set as a Pymux attribute. 89 | """ 90 | def __init__(self, attribute_name, possible_values=None): 91 | self.attribute_name = attribute_name 92 | self.possible_values = ['%s' % i for i in (possible_values or [])] 93 | 94 | def get_all_values(self, pymux): 95 | return sorted(set( 96 | self.possible_values + 97 | ['%s' % getattr(pymux, self.attribute_name)] 98 | )) 99 | 100 | def set_value(self, pymux, value): 101 | """ 102 | Take a string, and return an integer. Raise SetOptionError when the 103 | given text does not parse to a positive integer. 104 | """ 105 | try: 106 | value = int(value) 107 | if value < 0: 108 | raise ValueError 109 | except ValueError: 110 | raise SetOptionError('Expecting an integer.') 111 | else: 112 | setattr(pymux, self.attribute_name, value) 113 | 114 | 115 | class KeyPrefixOption(Option): 116 | def get_all_values(self, pymux): 117 | return PYMUX_TO_PROMPT_TOOLKIT_KEYS.keys() 118 | 119 | def set_value(self, pymux, value): 120 | # Translate prefix to prompt_toolkit 121 | try: 122 | keys = pymux_key_to_prompt_toolkit_key_sequence(value) 123 | except ValueError: 124 | raise SetOptionError('Invalid key: %r' % (value, )) 125 | else: 126 | pymux.key_bindings_manager.prefix = keys 127 | 128 | 129 | class BaseIndexOption(Option): 130 | " Base index for window numbering. " 131 | def get_all_values(self, pymux): 132 | return ['0', '1'] 133 | 134 | def set_value(self, pymux, value): 135 | try: 136 | value = int(value) 137 | except ValueError: 138 | raise SetOptionError('Expecting an integer.') 139 | else: 140 | pymux.arrangement.base_index = value 141 | 142 | 143 | class KeysOption(Option): 144 | " Emacs or Vi mode. " 145 | def __init__(self, attribute_name): 146 | self.attribute_name = attribute_name 147 | 148 | def get_all_values(self, pymux): 149 | return ['emacs', 'vi'] 150 | 151 | def set_value(self, pymux, value): 152 | if value in ('emacs', 'vi'): 153 | setattr(pymux, self.attribute_name, value == 'vi') 154 | else: 155 | raise SetOptionError('Expecting "vi" or "emacs".') 156 | 157 | class JustifyOption(Option): 158 | def __init__(self, attribute_name): 159 | self.attribute_name = attribute_name 160 | 161 | def get_all_values(self, pymux): 162 | return Justify._ALL 163 | 164 | def set_value(self, pymux, value): 165 | if value in Justify._ALL: 166 | setattr(pymux, self.attribute_name, value) 167 | else: 168 | raise SetOptionError('Invalid justify option.') 169 | 170 | 171 | ALL_OPTIONS = { 172 | 'base-index': BaseIndexOption(), 173 | 'bell': OnOffOption('enable_bell'), 174 | 'history-limit': PositiveIntOption( 175 | 'history_limit', [200, 500, 1000, 2000, 5000, 10000]), 176 | 'mouse': OnOffOption('enable_mouse_support'), 177 | 'prefix': KeyPrefixOption(), 178 | 'remain-on-exit': OnOffOption('remain_on_exit'), 179 | 'status': OnOffOption('enable_status'), 180 | 'pane-border-status': OnOffOption('enable_pane_status'), 181 | 'status-keys': KeysOption('status_keys_vi_mode'), 182 | 'mode-keys': KeysOption('mode_keys_vi_mode'), 183 | 'default-terminal': StringOption( 184 | 'default_terminal', ['xterm', 'xterm-256color', 'screen']), 185 | 'status-right': StringOption('status_right'), 186 | 'status-left': StringOption('status_left'), 187 | 'status-right-length': PositiveIntOption('status_right_length', [20]), 188 | 'status-left-length': PositiveIntOption('status_left_length', [20]), 189 | 'window-status-format': StringOption('window_status_format'), 190 | 'window-status-current-format': StringOption('window_status_current_format'), 191 | 'default-shell': StringOption( 192 | 'default_shell', [get_default_shell()]), 193 | 'status-justify': JustifyOption('status_justify'), 194 | 'status-interval': PositiveIntOption( 195 | 'status_interval', [1, 2, 4, 8, 16, 30, 60]), 196 | 197 | # Prompt-toolkit/pymux specific. 198 | 'swap-light-and-dark-colors': OnOffOption('swap_dark_and_light'), 199 | } 200 | 201 | 202 | ALL_WINDOW_OPTIONS = { 203 | 'synchronize-panes': OnOffOption('synchronize_panes', window_option=True), 204 | } 205 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Pymux 2 | ===== 3 | 4 | WARNING: This project requires maintenance. The current master branch requires 5 | an old version of both prompt_toolkit and ptterm. There is a prompt-toolkit-3.0 6 | branch here that is compatible with the latest prompt_toolkit and the latest 7 | commit of the master branch of ptterm, but for that branch, only `pymux 8 | standalone` is working at the moment. 9 | 10 | 11 | *A terminal multiplexer (like tmux) in Python* 12 | 13 | :: 14 | 15 | pip install pymux 16 | 17 | .. image :: https://raw.githubusercontent.com/jonathanslenders/pymux/master/images/pymux.png 18 | 19 | 20 | Issues, questions, wishes, comments, feedback, remarks? Please create a GitHub 21 | issue, I appreciate it. 22 | 23 | 24 | Installation 25 | ------------ 26 | 27 | Simply install ``pymux`` using pip: 28 | 29 | :: 30 | 31 | pip install pymux 32 | 33 | Start it by typing ``pymux``. 34 | 35 | 36 | What does it do? 37 | ---------------- 38 | 39 | A terminal multiplexer makes it possible to run multiple applications in the 40 | same terminal. It does this by emulating a vt100 terminal for each application. 41 | There are serveral programs doing this. The most famous are `GNU Screen 42 | `_ and `tmux `_. 43 | 44 | Pymux is written entirely in Python. It doesn't need any C extension. It runs 45 | on all Python versions from 2.6 until 3.5. It should work on OS X and Linux. 46 | 47 | 48 | Compared to tmux 49 | ---------------- 50 | 51 | To some extent, pymux is a clone of tmux. This means that all the default 52 | shortcuts are the same; the commands are the same or very similar, and even a 53 | simple configuration file could be the same. (There are some small 54 | incompatibilities.) However, we definitely don't intend to create a fully 55 | compatible clone. Right now, only a subset of the command options that tmux 56 | provides are supported. 57 | 58 | Pymux implements a few improvements over tmux: 59 | 60 | - There is a completion menu for the command line. (At the bottom of the screen.) 61 | - The command line has `fish-style `_ suggestions. 62 | - Both Emacs and Vi key bindings for the command line and copy buffer are well 63 | developed, thanks to all the effort we have put earlier in `prompt_toolkit 64 | `_. 65 | - Search in the copy buffer is highlighted while searching. 66 | - Every pane has its own titlebar. 67 | - When several clients are attached to the same session, each client can watch 68 | a different window. When clients are watching different windows, every client 69 | uses the full terminal size. 70 | - Support for 24bit true color. (Disabled by default: not all terminals support 71 | it. Use the ``--truecolor`` option at startup or during attach in order to 72 | enable it.) 73 | - Support for unicode input and output. Pymux correctly understands utf-8 74 | encoded double width characters. (Also for the titlebars.) 75 | 76 | About the performance: 77 | 78 | - Tmux is written in C, which is obviously faster than Python. This is 79 | noticeable when applications generate a lot of output. Where tmux is able to 80 | give fast real-time output for, for instance ``find /`` or ``yes``, pymux 81 | will process the output slightly slower, and in this case render the output 82 | only a few times per second to the terminal. Usually, this should not be an 83 | issue. If it is, `Pypy `_ should provide a significant 84 | speedup. 85 | 86 | The big advantage of using Python and `prompt_toolkit 87 | `_ is that the 88 | implementation of new features becomes very easy. 89 | 90 | 91 | More screenshots 92 | ---------------- 93 | 94 | 24 bit color support and the autocompletion menu: 95 | 96 | .. image :: https://raw.githubusercontent.com/jonathanslenders/pymux/master/images/menu-true-color.png 97 | 98 | What happens if another client with a smaller screen size attaches: 99 | 100 | .. image :: https://raw.githubusercontent.com/jonathanslenders/pymux/master/images/multiple-clients.png 101 | 102 | When a pane enters copy mode, search results are highlighted: 103 | 104 | .. image :: https://raw.githubusercontent.com/jonathanslenders/pymux/master/images/copy-mode.png 105 | 106 | 107 | Why create a tmux clone? 108 | ------------------------ 109 | 110 | For several reasons. Having a terminal multiplexer in Python makes it easy to 111 | experiment and implement new features. While C is a good language, it's not as 112 | easy to develop as Python. 113 | 114 | Just like `pyvim `_ (A ``Vi`` clone 115 | in Python.), it started as another experiment. A project to challenge the 116 | design of prompt_toolkit. At this point, however, pymux should be stable and 117 | usable for daily work. 118 | 119 | The development resulted in many improvements in prompt_toolkit, especially 120 | performance improvements, but also some functionality improvements. 121 | 122 | Further, the development is especially interesting, because it touches so many 123 | different areas that are unknown to most Python developers. It also proves that 124 | Python is a good tool to create terminal applications. 125 | 126 | 127 | The roadmap 128 | ----------- 129 | 130 | There is no official roadmap, the code is mostly written for the fun and of 131 | course, time is limited, but I use pymux professionally and I'm eager to 132 | implement new ideas. 133 | 134 | Some ideas: 135 | 136 | - Support for color schemes. 137 | - Support for extensions written in Python. 138 | - Better support for scripting. (Right now, it's already possible to run pymux 139 | commands from inside the shell of a pane. E.g. ``pymux split-window``. 140 | However, status codes and feedback aren't transferred yet.) 141 | - Improved mouse support. (Reporting of mouse movement.) 142 | - Parts of pymux could become a library, so that any prompt_toolkit application 143 | can embed a vt100 terminal. (Imagine a terminal emulator embedded in `pyvim 144 | `_.) 145 | - Maybe some cool widgets to traverse the windows and panes. 146 | - Better autocompletion. 147 | 148 | 149 | Configuring 150 | ----------- 151 | 152 | Create a file ``~/.pymux.conf``, and populate it with commands, like you can 153 | enter at the command line. There is an `example config 154 | `_ 155 | in the examples directory. 156 | 157 | 158 | What if it crashes? 159 | ------------------- 160 | 161 | If for some reason pymux crashes, it will attempt to write a stack trace to a 162 | file with a name like ``/tmp/pymux.crash-*``. It is possible that the user 163 | interface freezes. Please create a GitHub issue with this stack trace. 164 | 165 | 166 | Special thanks 167 | -------------- 168 | 169 | - `Pyte `_, for providing a working vt100 170 | parser. (This one is extended in order to support some xterm extensions.) 171 | - `docopt `_, for parsing the command line arguments. 172 | - `prompt_toolkit 173 | `_, for the UI 174 | toolkit. 175 | - `wcwidth `_: for better unicode support 176 | (support of double width characters). 177 | - `tmux `_, for the inspiration. 178 | -------------------------------------------------------------------------------- /pymux/client/posix.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from prompt_toolkit.eventloop.select import select_fds 4 | from prompt_toolkit.input.posix_utils import PosixStdinReader 5 | from prompt_toolkit.input.vt100 import raw_mode, cooked_mode 6 | from prompt_toolkit.output.vt100 import _get_size, Vt100_Output 7 | from prompt_toolkit.output import ColorDepth 8 | 9 | from pymux.utils import nonblocking 10 | 11 | import getpass 12 | import glob 13 | import json 14 | import os 15 | import signal 16 | import socket 17 | import sys 18 | import tempfile 19 | from .base import Client 20 | 21 | INPUT_TIMEOUT = .5 22 | 23 | __all__ = ( 24 | 'PosixClient', 25 | 'list_clients', 26 | ) 27 | 28 | 29 | class PosixClient(Client): 30 | def __init__(self, socket_name): 31 | self.socket_name = socket_name 32 | self._mode_context_managers = [] 33 | 34 | # Connect to socket. 35 | self.socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 36 | self.socket.connect(socket_name) 37 | self.socket.setblocking(1) 38 | 39 | # Input reader. 40 | # Some terminals, like lxterminal send non UTF-8 input sequences, 41 | # even when the input encoding is supposed to be UTF-8. This 42 | # happens in the case of mouse clicks in the right area of a wide 43 | # terminal. Apparently, these are some binary blobs in between the 44 | # UTF-8 input.) 45 | # We should not replace these, because this would break the 46 | # decoding otherwise. (Also don't pass errors='ignore', because 47 | # that doesn't work for parsing mouse input escape sequences, which 48 | # consist of a fixed number of bytes.) 49 | self._stdin_reader = PosixStdinReader(sys.stdin.fileno(), errors='replace') 50 | 51 | def run_command(self, command, pane_id=None): 52 | """ 53 | Ask the server to run this command. 54 | 55 | :param pane_id: Optional identifier of the current pane. 56 | """ 57 | self._send_packet({ 58 | 'cmd': 'run-command', 59 | 'data': command, 60 | 'pane_id': pane_id 61 | }) 62 | 63 | def attach(self, detach_other_clients=False, color_depth=ColorDepth.DEPTH_8_BIT): 64 | """ 65 | Attach client user interface. 66 | """ 67 | assert isinstance(detach_other_clients, bool) 68 | 69 | self._send_size() 70 | self._send_packet({ 71 | 'cmd': 'start-gui', 72 | 'detach-others': detach_other_clients, 73 | 'color-depth': color_depth, 74 | 'term': os.environ.get('TERM', ''), 75 | 'data': '' 76 | }) 77 | 78 | with raw_mode(sys.stdin.fileno()): 79 | data_buffer = b'' 80 | 81 | stdin_fd = sys.stdin.fileno() 82 | socket_fd = self.socket.fileno() 83 | current_timeout = INPUT_TIMEOUT # Timeout, used to flush escape sequences. 84 | 85 | try: 86 | def winch_handler(signum, frame): 87 | self._send_size() 88 | 89 | signal.signal(signal.SIGWINCH, winch_handler) 90 | while True: 91 | r = select_fds([stdin_fd, socket_fd], current_timeout) 92 | 93 | if socket_fd in r: 94 | # Received packet from server. 95 | data = self.socket.recv(1024) 96 | 97 | if data == b'': 98 | # End of file. Connection closed. 99 | # Reset terminal 100 | o = Vt100_Output.from_pty(sys.stdout) 101 | o.quit_alternate_screen() 102 | o.disable_mouse_support() 103 | o.disable_bracketed_paste() 104 | o.reset_attributes() 105 | o.flush() 106 | return 107 | else: 108 | data_buffer += data 109 | 110 | while b'\0' in data_buffer: 111 | pos = data_buffer.index(b'\0') 112 | self._process(data_buffer[:pos]) 113 | data_buffer = data_buffer[pos + 1:] 114 | 115 | elif stdin_fd in r: 116 | # Got user input. 117 | self._process_stdin() 118 | current_timeout = INPUT_TIMEOUT 119 | 120 | else: 121 | # Timeout. (Tell the server to flush the vt100 Escape.) 122 | self._send_packet({'cmd': 'flush-input'}) 123 | current_timeout = None 124 | finally: 125 | signal.signal(signal.SIGWINCH, signal.SIG_IGN) 126 | 127 | def _process(self, data_buffer): 128 | """ 129 | Handle incoming packet from server. 130 | """ 131 | packet = json.loads(data_buffer.decode('utf-8')) 132 | 133 | if packet['cmd'] == 'out': 134 | # Call os.write manually. In Python2.6, sys.stdout.write doesn't use UTF-8. 135 | os.write(sys.stdout.fileno(), packet['data'].encode('utf-8')) 136 | 137 | elif packet['cmd'] == 'suspend': 138 | # Suspend client process to background. 139 | if hasattr(signal, 'SIGTSTP'): 140 | os.kill(os.getpid(), signal.SIGTSTP) 141 | 142 | elif packet['cmd'] == 'mode': 143 | # Set terminal to raw/cooked. 144 | action = packet['data'] 145 | 146 | if action == 'raw': 147 | cm = raw_mode(sys.stdin.fileno()) 148 | cm.__enter__() 149 | self._mode_context_managers.append(cm) 150 | 151 | elif action == 'cooked': 152 | cm = cooked_mode(sys.stdin.fileno()) 153 | cm.__enter__() 154 | self._mode_context_managers.append(cm) 155 | 156 | elif action == 'restore' and self._mode_context_managers: 157 | cm = self._mode_context_managers.pop() 158 | cm.__exit__() 159 | 160 | def _process_stdin(self): 161 | """ 162 | Received data on stdin. Read and send to server. 163 | """ 164 | with nonblocking(sys.stdin.fileno()): 165 | data = self._stdin_reader.read() 166 | 167 | # Send input in chunks of 4k. 168 | step = 4056 169 | for i in range(0, len(data), step): 170 | self._send_packet({ 171 | 'cmd': 'in', 172 | 'data': data[i:i + step], 173 | }) 174 | 175 | def _send_packet(self, data): 176 | " Send to server. " 177 | data = json.dumps(data).encode('utf-8') 178 | 179 | # Be sure that our socket is blocking, otherwise, the send() call could 180 | # raise `BlockingIOError` if the buffer is full. 181 | self.socket.setblocking(1) 182 | 183 | self.socket.send(data + b'\0') 184 | 185 | def _send_size(self): 186 | " Report terminal size to server. " 187 | rows, cols = _get_size(sys.stdout.fileno()) 188 | self._send_packet({ 189 | 'cmd': 'size', 190 | 'data': [rows, cols] 191 | }) 192 | 193 | 194 | def list_clients(): 195 | """ 196 | List all the servers that are running. 197 | """ 198 | p = '%s/pymux.sock.%s.*' % (tempfile.gettempdir(), getpass.getuser()) 199 | for path in glob.glob(p): 200 | try: 201 | yield PosixClient(path) 202 | except socket.error: 203 | pass 204 | -------------------------------------------------------------------------------- /pymux/key_mappings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Mapping between vt100 key sequences, the prompt_toolkit key constants and the 3 | Pymux namings. (Those namings are kept compatible with tmux.) 4 | """ 5 | from __future__ import unicode_literals 6 | from prompt_toolkit.keys import Keys 7 | from prompt_toolkit.input.vt100_parser import ANSI_SEQUENCES 8 | 9 | __all__ = ( 10 | 'pymux_key_to_prompt_toolkit_key_sequence', 11 | 'prompt_toolkit_key_to_vt100_key', 12 | 'PYMUX_TO_PROMPT_TOOLKIT_KEYS', 13 | ) 14 | 15 | 16 | def pymux_key_to_prompt_toolkit_key_sequence(key): 17 | """ 18 | Turn a pymux description of a key. E.g. "C-a" or "M-x" into a 19 | prompt-toolkit key sequence. 20 | 21 | Raises `ValueError` if the key is not known. 22 | """ 23 | # Make the c- and m- prefixes case insensitive. 24 | if key.lower().startswith('m-c-'): 25 | key = 'M-C-' + key[4:] 26 | elif key.lower().startswith('c-'): 27 | key = 'C-' + key[2:] 28 | elif key.lower().startswith('m-'): 29 | key = 'M-' + key[2:] 30 | 31 | # Lookup key. 32 | try: 33 | return PYMUX_TO_PROMPT_TOOLKIT_KEYS[key] 34 | except KeyError: 35 | if len(key) == 1: 36 | return (key, ) 37 | else: 38 | raise ValueError('Unknown key: %r' % (key, )) 39 | 40 | 41 | # Create a mapping from prompt_toolkit keys to their ANSI sequences. 42 | # TODO: This is not completely correct yet. It doesn't take 43 | # cursor/application mode into account. Create new tables for this. 44 | _PROMPT_TOOLKIT_KEY_TO_VT100 = dict( 45 | (key, vt100_data) for vt100_data, key in ANSI_SEQUENCES.items()) 46 | 47 | 48 | def prompt_toolkit_key_to_vt100_key(key, application_mode=False): 49 | """ 50 | Turn a prompt toolkit key. (E.g Keys.ControlB) into a Vt100 key sequence. 51 | (E.g. \x1b[A.) 52 | """ 53 | application_mode_keys = { 54 | Keys.Up: '\x1bOA', 55 | Keys.Left: '\x1bOD', 56 | Keys.Right: '\x1bOC', 57 | Keys.Down: '\x1bOB', 58 | } 59 | 60 | if key == Keys.ControlJ: 61 | # Required for redis-cli. This can be removed when prompt_toolkit stops 62 | # replacing \r by \n. 63 | return '\r' 64 | 65 | if key == '\n': 66 | return '\r' 67 | 68 | elif application_mode and key in application_mode_keys: 69 | return application_mode_keys.get(key) 70 | else: 71 | return _PROMPT_TOOLKIT_KEY_TO_VT100.get(key, key) 72 | 73 | 74 | PYMUX_TO_PROMPT_TOOLKIT_KEYS = { 75 | 'Space': (' '), 76 | 77 | 'C-a': (Keys.ControlA, ), 78 | 'C-b': (Keys.ControlB, ), 79 | 'C-c': (Keys.ControlC, ), 80 | 'C-d': (Keys.ControlD, ), 81 | 'C-e': (Keys.ControlE, ), 82 | 'C-f': (Keys.ControlF, ), 83 | 'C-g': (Keys.ControlG, ), 84 | 'C-h': (Keys.ControlH, ), 85 | 'C-i': (Keys.ControlI, ), 86 | 'C-j': (Keys.ControlJ, ), 87 | 'C-k': (Keys.ControlK, ), 88 | 'C-l': (Keys.ControlL, ), 89 | 'C-m': (Keys.ControlM, ), 90 | 'C-n': (Keys.ControlN, ), 91 | 'C-o': (Keys.ControlO, ), 92 | 'C-p': (Keys.ControlP, ), 93 | 'C-q': (Keys.ControlQ, ), 94 | 'C-r': (Keys.ControlR, ), 95 | 'C-s': (Keys.ControlS, ), 96 | 'C-t': (Keys.ControlT, ), 97 | 'C-u': (Keys.ControlU, ), 98 | 'C-v': (Keys.ControlV, ), 99 | 'C-w': (Keys.ControlW, ), 100 | 'C-x': (Keys.ControlX, ), 101 | 'C-y': (Keys.ControlY, ), 102 | 'C-z': (Keys.ControlZ, ), 103 | 104 | 'C-Left': (Keys.ControlLeft, ), 105 | 'C-Right': (Keys.ControlRight, ), 106 | 'C-Up': (Keys.ControlUp, ), 107 | 'C-Down': (Keys.ControlDown, ), 108 | 'C-\\': (Keys.ControlBackslash, ), 109 | 110 | 'S-Left': (Keys.ShiftLeft, ), 111 | 'S-Right': (Keys.ShiftRight, ), 112 | 'S-Up': (Keys.ShiftUp, ), 113 | 'S-Down': (Keys.ShiftDown, ), 114 | 115 | 'M-C-a': (Keys.Escape, Keys.ControlA, ), 116 | 'M-C-b': (Keys.Escape, Keys.ControlB, ), 117 | 'M-C-c': (Keys.Escape, Keys.ControlC, ), 118 | 'M-C-d': (Keys.Escape, Keys.ControlD, ), 119 | 'M-C-e': (Keys.Escape, Keys.ControlE, ), 120 | 'M-C-f': (Keys.Escape, Keys.ControlF, ), 121 | 'M-C-g': (Keys.Escape, Keys.ControlG, ), 122 | 'M-C-h': (Keys.Escape, Keys.ControlH, ), 123 | 'M-C-i': (Keys.Escape, Keys.ControlI, ), 124 | 'M-C-j': (Keys.Escape, Keys.ControlJ, ), 125 | 'M-C-k': (Keys.Escape, Keys.ControlK, ), 126 | 'M-C-l': (Keys.Escape, Keys.ControlL, ), 127 | 'M-C-m': (Keys.Escape, Keys.ControlM, ), 128 | 'M-C-n': (Keys.Escape, Keys.ControlN, ), 129 | 'M-C-o': (Keys.Escape, Keys.ControlO, ), 130 | 'M-C-p': (Keys.Escape, Keys.ControlP, ), 131 | 'M-C-q': (Keys.Escape, Keys.ControlQ, ), 132 | 'M-C-r': (Keys.Escape, Keys.ControlR, ), 133 | 'M-C-s': (Keys.Escape, Keys.ControlS, ), 134 | 'M-C-t': (Keys.Escape, Keys.ControlT, ), 135 | 'M-C-u': (Keys.Escape, Keys.ControlU, ), 136 | 'M-C-v': (Keys.Escape, Keys.ControlV, ), 137 | 'M-C-w': (Keys.Escape, Keys.ControlW, ), 138 | 'M-C-x': (Keys.Escape, Keys.ControlX, ), 139 | 'M-C-y': (Keys.Escape, Keys.ControlY, ), 140 | 'M-C-z': (Keys.Escape, Keys.ControlZ, ), 141 | 142 | 'M-C-Left': (Keys.Escape, Keys.ControlLeft, ), 143 | 'M-C-Right': (Keys.Escape, Keys.ControlRight, ), 144 | 'M-C-Up': (Keys.Escape, Keys.ControlUp, ), 145 | 'M-C-Down': (Keys.Escape, Keys.ControlDown, ), 146 | 'M-C-\\': (Keys.Escape, Keys.ControlBackslash, ), 147 | 148 | 'M-a': (Keys.Escape, 'a'), 149 | 'M-b': (Keys.Escape, 'b'), 150 | 'M-c': (Keys.Escape, 'c'), 151 | 'M-d': (Keys.Escape, 'd'), 152 | 'M-e': (Keys.Escape, 'e'), 153 | 'M-f': (Keys.Escape, 'f'), 154 | 'M-g': (Keys.Escape, 'g'), 155 | 'M-h': (Keys.Escape, 'h'), 156 | 'M-i': (Keys.Escape, 'i'), 157 | 'M-j': (Keys.Escape, 'j'), 158 | 'M-k': (Keys.Escape, 'k'), 159 | 'M-l': (Keys.Escape, 'l'), 160 | 'M-m': (Keys.Escape, 'm'), 161 | 'M-n': (Keys.Escape, 'n'), 162 | 'M-o': (Keys.Escape, 'o'), 163 | 'M-p': (Keys.Escape, 'p'), 164 | 'M-q': (Keys.Escape, 'q'), 165 | 'M-r': (Keys.Escape, 'r'), 166 | 'M-s': (Keys.Escape, 's'), 167 | 'M-t': (Keys.Escape, 't'), 168 | 'M-u': (Keys.Escape, 'u'), 169 | 'M-v': (Keys.Escape, 'v'), 170 | 'M-w': (Keys.Escape, 'w'), 171 | 'M-x': (Keys.Escape, 'x'), 172 | 'M-y': (Keys.Escape, 'y'), 173 | 'M-z': (Keys.Escape, 'z'), 174 | 175 | 'M-0': (Keys.Escape, '0'), 176 | 'M-1': (Keys.Escape, '1'), 177 | 'M-2': (Keys.Escape, '2'), 178 | 'M-3': (Keys.Escape, '3'), 179 | 'M-4': (Keys.Escape, '4'), 180 | 'M-5': (Keys.Escape, '5'), 181 | 'M-6': (Keys.Escape, '6'), 182 | 'M-7': (Keys.Escape, '7'), 183 | 'M-8': (Keys.Escape, '8'), 184 | 'M-9': (Keys.Escape, '9'), 185 | 186 | 'M-Up': (Keys.Escape, Keys.Up), 187 | 'M-Down': (Keys.Escape, Keys.Down, ), 188 | 'M-Left': (Keys.Escape, Keys.Left, ), 189 | 'M-Right': (Keys.Escape, Keys.Right, ), 190 | 'Left': (Keys.Left, ), 191 | 'Right': (Keys.Right, ), 192 | 'Up': (Keys.Up, ), 193 | 'Down': (Keys.Down, ), 194 | 'BSpace': (Keys.Backspace, ), 195 | 'BTab': (Keys.BackTab, ), 196 | 'DC': (Keys.Delete, ), 197 | 'IC': (Keys.Insert, ), 198 | 'End': (Keys.End, ), 199 | 'Enter': (Keys.ControlJ, ), 200 | 'Home': (Keys.Home, ), 201 | 'Escape': (Keys.Escape, ), 202 | 'Tab': (Keys.Tab, ), 203 | 204 | 'F1': (Keys.F1, ), 205 | 'F2': (Keys.F2, ), 206 | 'F3': (Keys.F3, ), 207 | 'F4': (Keys.F4, ), 208 | 'F5': (Keys.F5, ), 209 | 'F6': (Keys.F6, ), 210 | 'F7': (Keys.F7, ), 211 | 'F8': (Keys.F8, ), 212 | 'F9': (Keys.F9, ), 213 | 'F10': (Keys.F10, ), 214 | 'F11': (Keys.F11, ), 215 | 'F12': (Keys.F12, ), 216 | 'F13': (Keys.F13, ), 217 | 'F14': (Keys.F14, ), 218 | 'F15': (Keys.F15, ), 219 | 'F16': (Keys.F16, ), 220 | 'F17': (Keys.F17, ), 221 | 'F18': (Keys.F18, ), 222 | 'F19': (Keys.F19, ), 223 | 'F20': (Keys.F20, ), 224 | 225 | 'NPage': (Keys.PageDown, ), 226 | 'PageDown': (Keys.PageDown, ), 227 | 'PgDn': (Keys.PageDown, ), 228 | 'PPage': (Keys.PageUp, ), 229 | 'PageUp': (Keys.PageUp, ), 230 | 'PgUp': (Keys.PageUp, ), 231 | } 232 | -------------------------------------------------------------------------------- /pymux/server.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | import json 3 | 4 | from prompt_toolkit.application.current import set_app 5 | from prompt_toolkit.eventloop import ensure_future, From 6 | from prompt_toolkit.input.vt100_parser import Vt100Parser 7 | from prompt_toolkit.layout.screen import Size 8 | from prompt_toolkit.output.vt100 import Vt100_Output 9 | from prompt_toolkit.utils import is_windows 10 | 11 | from .log import logger 12 | from .pipes import BrokenPipeError 13 | 14 | __all__ = ( 15 | 'ServerConnection', 16 | ) 17 | 18 | 19 | class ServerConnection(object): 20 | """ 21 | For each client that connects, we have one instance of this class. 22 | """ 23 | def __init__(self, pymux, pipe_connection): 24 | self.pymux = pymux 25 | 26 | self.pipe_connection = pipe_connection 27 | 28 | self.size = Size(rows=20, columns=80) 29 | self._closed = False 30 | 31 | self._recv_buffer = b'' 32 | self.client_state = None 33 | 34 | def feed_key(key): 35 | self.client_state.app.key_processor.feed(key) 36 | self.client_state.app.key_processor.process_keys() 37 | 38 | self._inputstream = Vt100Parser(feed_key) 39 | self._pipeinput = _ClientInput(self._send_packet) 40 | 41 | ensure_future(self._start_reading()) 42 | 43 | def _start_reading(self): 44 | while True: 45 | try: 46 | data = yield From(self.pipe_connection.read()) 47 | self._process(data) 48 | except BrokenPipeError: 49 | self.detach_and_close() 50 | break 51 | 52 | except Exception as e: 53 | import traceback; traceback.print_stack() 54 | print('got exception ', repr(e)) 55 | break 56 | 57 | def _process(self, data): 58 | """ 59 | Process packet received from client. 60 | """ 61 | try: 62 | packet = json.loads(data) 63 | except ValueError: 64 | # So far, this never happened. But it would be good to have some 65 | # protection. 66 | logger.warning('Received invalid JSON from client. Ignoring.') 67 | return 68 | 69 | # Handle commands. 70 | if packet['cmd'] == 'run-command': 71 | self._run_command(packet) 72 | 73 | # Handle stdin. 74 | elif packet['cmd'] == 'in': 75 | self._pipeinput.send_text(packet['data']) 76 | 77 | # elif packet['cmd'] == 'flush-input': 78 | # self._inputstream.flush() # Flush escape key. # XXX: I think we no longer need this. 79 | 80 | # Set size. (The client reports the size.) 81 | elif packet['cmd'] == 'size': 82 | data = packet['data'] 83 | self.size = Size(rows=data[0], columns=data[1]) 84 | self.pymux.invalidate() 85 | 86 | # Start GUI. (Create CommandLineInterface front-end for pymux.) 87 | elif packet['cmd'] == 'start-gui': 88 | detach_other_clients = bool(packet['detach-others']) 89 | color_depth = packet['color-depth'] 90 | term = packet['term'] 91 | 92 | if detach_other_clients: 93 | for c in self.pymux.connections: 94 | c.detach_and_close() 95 | 96 | print('Create app...') 97 | self._create_app(color_depth=color_depth, term=term) 98 | 99 | def _send_packet(self, data): 100 | """ 101 | Send packet to client. 102 | """ 103 | if self._closed: 104 | return 105 | 106 | data = json.dumps(data) 107 | 108 | def send(): 109 | try: 110 | yield From(self.pipe_connection.write(data)) 111 | except BrokenPipeError: 112 | self.detach_and_close() 113 | ensure_future(send()) 114 | 115 | def _run_command(self, packet): 116 | """ 117 | Execute a run command from the client. 118 | """ 119 | create_temp_cli = self.client_states is None 120 | 121 | if create_temp_cli: 122 | # If this client doesn't have a CLI. Create a Fake CLI where the 123 | # window containing this pane, is the active one. (The CLI instance 124 | # will be removed before the render function is called, so it doesn't 125 | # hurt too much and makes the code easier.) 126 | pane_id = int(packet['pane_id']) 127 | self._create_app() 128 | with set_app(self.client_state.app): 129 | self.pymux.arrangement.set_active_window_from_pane_id(pane_id) 130 | 131 | with set_app(self.client_state.app): 132 | try: 133 | self.pymux.handle_command(packet['data']) 134 | finally: 135 | self._close_connection() 136 | 137 | def _create_app(self, color_depth, term='xterm'): 138 | """ 139 | Create CommandLineInterface for this client. 140 | Called when the client wants to attach the UI to the server. 141 | """ 142 | output = Vt100_Output(_SocketStdout(self._send_packet), 143 | lambda: self.size, 144 | term=term, 145 | write_binary=False) 146 | 147 | self.client_state = self.pymux.add_client( 148 | input=self._pipeinput, output=output, connection=self, color_depth=color_depth) 149 | 150 | print('Start running app...') 151 | future = self.client_state.app.run_async() 152 | print('Start running app got future...', future) 153 | 154 | @future.add_done_callback 155 | def done(_): 156 | print('APP DONE.........') 157 | print(future.result()) 158 | self._close_connection() 159 | 160 | def _close_connection(self): 161 | # This is important. If we would forget this, the server will 162 | # render CLI output for clients that aren't connected anymore. 163 | self.pymux.remove_client(self) 164 | self.client_state = None 165 | self._closed = True 166 | 167 | # Remove from eventloop. 168 | self.pipe_connection.close() 169 | 170 | def suspend_client_to_background(self): 171 | """ 172 | Ask the client to suspend itself. (Like, when Ctrl-Z is pressed.) 173 | """ 174 | self._send_packet({'cmd': 'suspend'}) 175 | 176 | def detach_and_close(self): 177 | # Remove from Pymux. 178 | self._close_connection() 179 | 180 | 181 | 182 | class _SocketStdout(object): 183 | """ 184 | Stdout-like object that writes everything through the unix socket to the 185 | client. 186 | """ 187 | def __init__(self, send_packet): 188 | assert callable(send_packet) 189 | self.send_packet = send_packet 190 | self._buffer = [] 191 | 192 | def write(self, data): 193 | self._buffer.append(data) 194 | 195 | def flush(self): 196 | data = {'cmd': 'out', 'data': ''.join(self._buffer)} 197 | self.send_packet(data) 198 | self._buffer = [] 199 | 200 | 201 | if is_windows(): 202 | from prompt_toolkit.input.win32_pipe import Win32PipeInput as PipeInput 203 | else: 204 | from prompt_toolkit.input.posix_pipe import PosixPipeInput as PipeInput 205 | 206 | class _ClientInput(PipeInput): 207 | """ 208 | Input class that can be given to the CommandLineInterface. 209 | We only need this for turning the client into raw_mode/cooked_mode. 210 | """ 211 | def __init__(self, send_packet): 212 | super(_ClientInput, self).__init__() 213 | assert callable(send_packet) 214 | self.send_packet = send_packet 215 | 216 | # Implement raw/cooked mode by sending this to the attached client. 217 | 218 | def raw_mode(self): 219 | return self._create_context_manager('raw') 220 | 221 | def cooked_mode(self): 222 | return self._create_context_manager('cooked') 223 | 224 | def _create_context_manager(self, mode): 225 | " Create a context manager that sends 'mode' commands to the client. " 226 | class mode_context_manager(object): 227 | def __enter__(*a): 228 | self.send_packet({'cmd': 'mode', 'data': mode}) 229 | 230 | def __exit__(*a): 231 | self.send_packet({'cmd': 'mode', 'data': 'restore'}) 232 | 233 | return mode_context_manager() 234 | -------------------------------------------------------------------------------- /pymux/key_bindings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Key bindings. 3 | """ 4 | from __future__ import unicode_literals 5 | from prompt_toolkit.filters import has_focus, Condition, has_selection 6 | from prompt_toolkit.keys import Keys 7 | from prompt_toolkit.selection import SelectionType 8 | from prompt_toolkit.key_binding import KeyBindings, merge_key_bindings 9 | 10 | from .enums import COMMAND, PROMPT 11 | from .filters import WaitsForConfirmation, HasPrefix, InScrollBufferNotSearching 12 | from .key_mappings import pymux_key_to_prompt_toolkit_key_sequence 13 | from .commands.commands import call_command_handler 14 | 15 | import six 16 | 17 | __all__ = ( 18 | 'PymuxKeyBindings', 19 | ) 20 | 21 | 22 | class PymuxKeyBindings(object): 23 | """ 24 | Pymux key binding manager. 25 | """ 26 | def __init__(self, pymux): 27 | self.pymux = pymux 28 | 29 | def get_search_state(): 30 | " Return the currently active SearchState. (The one for the focused pane.) " 31 | return pymux.arrangement.get_active_pane().search_state 32 | 33 | self.custom_key_bindings = KeyBindings() 34 | 35 | self.key_bindings = merge_key_bindings([ 36 | self._load_builtins(), 37 | self.custom_key_bindings, 38 | ]) 39 | 40 | self._prefix = ('c-b', ) 41 | self._prefix_binding = None 42 | 43 | # Load initial bindings. 44 | self._load_prefix_binding() 45 | 46 | # Custom user configured key bindings. 47 | # { (needs_prefix, key) -> (command, handler) } 48 | self.custom_bindings = {} 49 | 50 | def _load_prefix_binding(self): 51 | """ 52 | Load the prefix key binding. 53 | """ 54 | pymux = self.pymux 55 | 56 | # Remove previous binding. 57 | if self._prefix_binding: 58 | self.custom_key_bindings.remove_binding(self._prefix_binding) 59 | 60 | # Create new Python binding. 61 | @self.custom_key_bindings.add(*self._prefix, filter= 62 | ~(HasPrefix(pymux) | has_focus(COMMAND) | has_focus(PROMPT) | 63 | WaitsForConfirmation(pymux))) 64 | def enter_prefix_handler(event): 65 | " Enter prefix mode. " 66 | pymux.get_client_state().has_prefix = True 67 | 68 | self._prefix_binding = enter_prefix_handler 69 | 70 | @property 71 | def prefix(self): 72 | " Get the prefix key. " 73 | return self._prefix 74 | 75 | @prefix.setter 76 | def prefix(self, keys): 77 | """ 78 | Set a new prefix key. 79 | """ 80 | assert isinstance(keys, tuple) 81 | 82 | self._prefix = keys 83 | self._load_prefix_binding() 84 | 85 | def _load_builtins(self): 86 | """ 87 | Fill the Registry with the hard coded key bindings. 88 | """ 89 | pymux = self.pymux 90 | kb = KeyBindings() 91 | 92 | # Create filters. 93 | has_prefix = HasPrefix(pymux) 94 | waits_for_confirmation = WaitsForConfirmation(pymux) 95 | prompt_or_command_focus = has_focus(COMMAND) | has_focus(PROMPT) 96 | display_pane_numbers = Condition(lambda: pymux.display_pane_numbers) 97 | in_scroll_buffer_not_searching = InScrollBufferNotSearching(pymux) 98 | 99 | @kb.add(Keys.Any, filter=has_prefix) 100 | def _(event): 101 | " Ignore unknown Ctrl-B prefixed key sequences. " 102 | pymux.get_client_state().has_prefix = False 103 | 104 | @kb.add('c-c', filter=prompt_or_command_focus & ~has_prefix) 105 | @kb.add('c-g', filter=prompt_or_command_focus & ~has_prefix) 106 | # @kb.add('backspace', filter=has_focus(COMMAND) & ~has_prefix & 107 | # Condition(lambda: cli.buffers[COMMAND].text == '')) 108 | def _(event): 109 | " Leave command mode. " 110 | pymux.leave_command_mode(append_to_history=False) 111 | 112 | @kb.add('y', filter=waits_for_confirmation) 113 | @kb.add('Y', filter=waits_for_confirmation) 114 | def _(event): 115 | """ 116 | Confirm command. 117 | """ 118 | client_state = pymux.get_client_state() 119 | 120 | command = client_state.confirm_command 121 | client_state.confirm_command = None 122 | client_state.confirm_text = None 123 | 124 | pymux.handle_command(command) 125 | 126 | @kb.add('n', filter=waits_for_confirmation) 127 | @kb.add('N', filter=waits_for_confirmation) 128 | @kb.add('c-c' , filter=waits_for_confirmation) 129 | def _(event): 130 | """ 131 | Cancel command. 132 | """ 133 | client_state = pymux.get_client_state() 134 | client_state.confirm_command = None 135 | client_state.confirm_text = None 136 | 137 | @kb.add('c-c', filter=in_scroll_buffer_not_searching) 138 | @kb.add('enter', filter=in_scroll_buffer_not_searching) 139 | @kb.add('q', filter=in_scroll_buffer_not_searching) 140 | def _(event): 141 | " Exit scroll buffer. " 142 | pane = pymux.arrangement.get_active_pane() 143 | pane.exit_scroll_buffer() 144 | 145 | @kb.add(' ', filter=in_scroll_buffer_not_searching) 146 | def _(event): 147 | " Enter selection mode when pressing space in copy mode. " 148 | event.current_buffer.start_selection(selection_type=SelectionType.CHARACTERS) 149 | 150 | @kb.add('enter', filter=in_scroll_buffer_not_searching & has_selection) 151 | def _(event): 152 | " Copy selection when pressing Enter. " 153 | clipboard_data = event.current_buffer.copy_selection() 154 | event.app.clipboard.set_data(clipboard_data) 155 | 156 | @kb.add('v', filter=in_scroll_buffer_not_searching & has_selection) 157 | def _(event): 158 | " Toggle between selection types. " 159 | types = [SelectionType.LINES, SelectionType.BLOCK, SelectionType.CHARACTERS] 160 | selection_state = event.current_buffer.selection_state 161 | 162 | try: 163 | index = types.index(selection_state.type) 164 | except ValueError: # Not in list. 165 | index = 0 166 | 167 | selection_state.type = types[(index + 1) % len(types)] 168 | 169 | @Condition 170 | def popup_displayed(): 171 | return self.pymux.get_client_state().display_popup 172 | 173 | @kb.add('q', filter=popup_displayed, eager=True) 174 | def _(event): 175 | " Quit pop-up dialog. " 176 | self.pymux.get_client_state().display_popup = False 177 | 178 | @kb.add(Keys.Any, eager=True, filter=display_pane_numbers) 179 | def _(event): 180 | " When the pane numbers are shown. Any key press should hide them. " 181 | pymux.display_pane_numbers = False 182 | 183 | @Condition 184 | def clock_displayed(): 185 | " " 186 | pane = pymux.arrangement.get_active_pane() 187 | return pane.clock_mode 188 | 189 | @kb.add(Keys.Any, eager=True, filter=clock_displayed) 190 | def _(event): 191 | " When the clock is displayed. Any key press should hide it. " 192 | pane = pymux.arrangement.get_active_pane() 193 | pane.clock_mode = False 194 | 195 | return kb 196 | 197 | def add_custom_binding(self, key_name, command, arguments, needs_prefix=False): 198 | """ 199 | Add custom binding (for the "bind-key" command.) 200 | Raises ValueError if the give `key_name` is an invalid name. 201 | 202 | :param key_name: Pymux key name, for instance "C-a" or "M-x". 203 | """ 204 | assert isinstance(key_name, six.text_type) 205 | assert isinstance(command, six.text_type) 206 | assert isinstance(arguments, list) 207 | 208 | # Unbind previous key. 209 | self.remove_custom_binding(key_name, needs_prefix=needs_prefix) 210 | 211 | # Translate the pymux key name into a prompt_toolkit key sequence. 212 | # (Can raise ValueError.) 213 | keys_sequence = pymux_key_to_prompt_toolkit_key_sequence(key_name) 214 | 215 | # Create handler and add to Registry. 216 | if needs_prefix: 217 | filter = HasPrefix(self.pymux) 218 | else: 219 | filter = ~HasPrefix(self.pymux) 220 | 221 | filter = filter & ~(WaitsForConfirmation(self.pymux) | 222 | has_focus(COMMAND) | has_focus(PROMPT)) 223 | 224 | def key_handler(event): 225 | " The actual key handler. " 226 | call_command_handler(command, self.pymux, arguments) 227 | self.pymux.get_client_state().has_prefix = False 228 | 229 | self.custom_key_bindings.add(*keys_sequence, filter=filter)(key_handler) 230 | 231 | # Store key in `custom_bindings` in order to be able to call 232 | # "unbind-key" later on. 233 | k = (needs_prefix, key_name) 234 | self.custom_bindings[k] = CustomBinding(key_handler, command, arguments) 235 | 236 | def remove_custom_binding(self, key_name, needs_prefix=False): 237 | """ 238 | Remove custom key binding for a key. 239 | 240 | :param key_name: Pymux key name, for instance "C-A". 241 | """ 242 | k = (needs_prefix, key_name) 243 | 244 | if k in self.custom_bindings: 245 | self.custom_key_bindings.remove(self.custom_bindings[k].handler) 246 | del self.custom_bindings[k] 247 | 248 | 249 | class CustomBinding(object): 250 | """ 251 | Record for storing a single custom key binding. 252 | """ 253 | def __init__(self, handler, command, arguments): 254 | assert callable(handler) 255 | assert isinstance(command, six.text_type) 256 | assert isinstance(arguments, list) 257 | 258 | self.handler = handler 259 | self.command = command 260 | self.arguments = arguments 261 | -------------------------------------------------------------------------------- /pymux/commands/commands.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | import docopt 3 | import os 4 | import re 5 | import shlex 6 | import six 7 | 8 | from prompt_toolkit.application.current import get_app 9 | from prompt_toolkit.document import Document 10 | from prompt_toolkit.key_binding.vi_state import InputMode 11 | 12 | from pymux.arrangement import LayoutTypes 13 | from pymux.commands.aliases import ALIASES 14 | from pymux.commands.utils import wrap_argument 15 | from pymux.format import format_pymux_string 16 | from pymux.key_mappings import pymux_key_to_prompt_toolkit_key_sequence, prompt_toolkit_key_to_vt100_key 17 | from pymux.layout import focus_right, focus_left, focus_up, focus_down 18 | from pymux.log import logger 19 | from pymux.options import SetOptionError 20 | 21 | __all__ = ( 22 | 'call_command_handler', 23 | 'get_documentation_for_command', 24 | 'get_option_flags_for_command', 25 | 'handle_command', 26 | 'has_command_handler', 27 | ) 28 | 29 | COMMANDS_TO_HANDLERS = {} # Global mapping of pymux commands to their handlers. 30 | COMMANDS_TO_HELP = {} 31 | COMMANDS_TO_OPTION_FLAGS = {} 32 | 33 | 34 | def has_command_handler(command): 35 | return command in COMMANDS_TO_HANDLERS 36 | 37 | 38 | def get_documentation_for_command(command): 39 | """ Return the help text for this command, or None if the command is not 40 | known. """ 41 | if command in COMMANDS_TO_HELP: 42 | return 'Usage: %s %s' % (command, COMMANDS_TO_HELP.get(command, '')) 43 | 44 | 45 | def get_option_flags_for_command(command): 46 | " Return a list of options (-x flags) for this command. " 47 | return COMMANDS_TO_OPTION_FLAGS.get(command, []) 48 | 49 | 50 | def handle_command(pymux, input_string): 51 | """ 52 | Handle command. 53 | """ 54 | assert isinstance(input_string, six.text_type) 55 | 56 | input_string = input_string.strip() 57 | logger.info('handle command: %s %s.', input_string, type(input_string)) 58 | 59 | if input_string and not input_string.startswith('#'): # Ignore comments. 60 | try: 61 | if six.PY2: 62 | # In Python2.6, shlex doesn't work with unicode input at all. 63 | # In Python2.7, shlex tries to encode using ASCII. 64 | parts = shlex.split(input_string.encode('utf-8')) 65 | parts = [p.decode('utf-8') for p in parts] 66 | else: 67 | parts = shlex.split(input_string) 68 | except ValueError as e: 69 | # E.g. missing closing quote. 70 | pymux.show_message('Invalid command %s: %s' % (input_string, e)) 71 | else: 72 | call_command_handler(parts[0], pymux, parts[1:]) 73 | 74 | 75 | def call_command_handler(command, pymux, arguments): 76 | """ 77 | Execute command. 78 | 79 | :param arguments: List of options. 80 | """ 81 | assert isinstance(arguments, list) 82 | 83 | # Resolve aliases. 84 | command = ALIASES.get(command, command) 85 | 86 | try: 87 | handler = COMMANDS_TO_HANDLERS[command] 88 | except KeyError: 89 | pymux.show_message('Invalid command: %s' % (command,)) 90 | else: 91 | try: 92 | handler(pymux, arguments) 93 | except CommandException as e: 94 | pymux.show_message(e.message) 95 | 96 | 97 | def cmd(name, options=''): 98 | """ 99 | Decorator for all commands. 100 | 101 | Commands will receive (pymux, variables) as input. 102 | Commands can raise CommandException. 103 | """ 104 | # Validate options. 105 | if options: 106 | try: 107 | docopt.docopt('Usage:\n %s %s' % (name, options, ), []) 108 | except SystemExit: 109 | pass 110 | 111 | def decorator(func): 112 | def command_wrapper(pymux, arguments): 113 | # Hack to make the 'bind-key' option work. 114 | # (bind-key expects a variable number of arguments.) 115 | if name == 'bind-key' and '--' not in arguments: 116 | # Insert a double dash after the first non-option. 117 | for i, p in enumerate(arguments): 118 | if not p.startswith('-'): 119 | arguments.insert(i + 1, '--') 120 | break 121 | 122 | # Parse options. 123 | try: 124 | # Python 2 workaround: pass bytes to docopt. 125 | # From the following, only the bytes version returns the right 126 | # output in Python 2: 127 | # docopt.docopt('Usage:\n app ...', [b'a', b'b']) 128 | # docopt.docopt('Usage:\n app ...', [u'a', u'b']) 129 | # https://github.com/docopt/docopt/issues/30 130 | # (Not sure how reliable this is...) 131 | if six.PY2: 132 | arguments = [a.encode('utf-8') for a in arguments] 133 | 134 | received_options = docopt.docopt( 135 | 'Usage:\n %s %s' % (name, options), 136 | arguments, 137 | help=False) # Don't interpret the '-h' option as help. 138 | 139 | # Make sure that all the received options from docopt are 140 | # unicode objects. (Docopt returns 'str' for Python2.) 141 | for k, v in received_options.items(): 142 | if isinstance(v, six.binary_type): 143 | received_options[k] = v.decode('utf-8') 144 | except SystemExit: 145 | raise CommandException('Usage: %s %s' % (name, options)) 146 | 147 | # Call handler. 148 | func(pymux, received_options) 149 | 150 | # Invalidate all clients, not just the current CLI. 151 | pymux.invalidate() 152 | 153 | COMMANDS_TO_HANDLERS[name] = command_wrapper 154 | COMMANDS_TO_HELP[name] = options 155 | 156 | # Get list of option flags. 157 | flags = re.findall(r'-[a-zA-Z0-9]\b', options) 158 | COMMANDS_TO_OPTION_FLAGS[name] = flags 159 | 160 | return func 161 | return decorator 162 | 163 | 164 | class CommandException(Exception): 165 | " When raised from a command handler, this message will be shown. " 166 | def __init__(self, message): 167 | self.message = message 168 | 169 | # 170 | # The actual commands. 171 | # 172 | 173 | 174 | @cmd('break-pane', options='[-d]') 175 | def break_pane(pymux, variables): 176 | dont_focus_window = variables['-d'] 177 | 178 | pymux.arrangement.break_pane(set_active=not dont_focus_window) 179 | pymux.invalidate() 180 | 181 | 182 | @cmd('select-pane', options='(-L|-R|-U|-D|-t )') 183 | def select_pane(pymux, variables): 184 | 185 | if variables['-t']: 186 | pane_id = variables[''] 187 | w = pymux.arrangement.get_active_window() 188 | 189 | if pane_id == ':.+': 190 | w.focus_next() 191 | elif pane_id == ':.-': 192 | w.focus_previous() 193 | else: 194 | # Select pane by index. 195 | try: 196 | pane_id = int(pane_id[1:]) 197 | w.active_pane = w.panes[pane_id] 198 | except (IndexError, ValueError): 199 | raise CommandException('Invalid pane.') 200 | 201 | else: 202 | if variables['-L']: h = focus_left 203 | if variables['-U']: h = focus_up 204 | if variables['-D']: h = focus_down 205 | if variables['-R']: h = focus_right 206 | 207 | h(pymux) 208 | 209 | 210 | @cmd('select-window', options='(-t )') 211 | def select_window(pymux, variables): 212 | """ 213 | Select a window. E.g: select-window -t :3 214 | """ 215 | window_id = variables[''] 216 | 217 | def invalid_window(): 218 | raise CommandException('Invalid window: %s' % window_id) 219 | 220 | if window_id.startswith(':'): 221 | try: 222 | number = int(window_id[1:]) 223 | except ValueError: 224 | invalid_window() 225 | else: 226 | w = pymux.arrangement.get_window_by_index(number) 227 | if w: 228 | pymux.arrangement.set_active_window(w) 229 | else: 230 | invalid_window() 231 | else: 232 | invalid_window() 233 | 234 | 235 | @cmd('move-window', options='(-t )') 236 | def move_window(pymux, variables): 237 | """ 238 | Move window to a new index. 239 | """ 240 | dst_window = variables[''] 241 | try: 242 | new_index = int(dst_window) 243 | except ValueError: 244 | raise CommandException('Invalid window index: %r' % (dst_window, )) 245 | 246 | # Check first whether the index was not yet taken. 247 | if pymux.arrangement.get_window_by_index(new_index): 248 | raise CommandException("Can't move window: index in use.") 249 | 250 | # Save index. 251 | w = pymux.arrangement.get_active_window() 252 | pymux.arrangement.move_window(w, new_index) 253 | 254 | 255 | @cmd('rotate-window', options='[-D|-U]') 256 | def rotate_window(pymux, variables): 257 | if variables['-D']: 258 | pymux.arrangement.rotate_window(count=-1) 259 | else: 260 | pymux.arrangement.rotate_window() 261 | 262 | 263 | @cmd('swap-pane', options='(-D|-U)') 264 | def swap_pane(pymux, variables): 265 | pymux.arrangement.get_active_window().rotate(with_pane_after_only=variables['-U']) 266 | 267 | 268 | @cmd('kill-pane') 269 | def kill_pane(pymux, variables): 270 | pane = pymux.arrangement.get_active_pane() 271 | pymux.kill_pane(pane) 272 | 273 | 274 | @cmd('kill-window') 275 | def kill_window(pymux, variables): 276 | " Kill all panes in the current window. " 277 | for pane in pymux.arrangement.get_active_window().panes: 278 | pymux.kill_pane(pane) 279 | 280 | 281 | @cmd('suspend-client') 282 | def suspend_client(pymux, variables): 283 | connection = pymux.get_connection() 284 | 285 | if connection: 286 | connection.suspend_client_to_background() 287 | 288 | 289 | @cmd('clock-mode') 290 | def clock_mode(pymux, variables): 291 | pane = pymux.arrangement.get_active_pane() 292 | if pane: 293 | pane.clock_mode = not pane.clock_mode 294 | 295 | 296 | @cmd('last-pane') 297 | def last_pane(pymux, variables): 298 | w = pymux.arrangement.get_active_window() 299 | prev_active_pane = w.previous_active_pane 300 | 301 | if prev_active_pane: 302 | w.active_pane = prev_active_pane 303 | 304 | 305 | @cmd('next-layout') 306 | def next_layout(pymux, variables): 307 | " Select next layout. " 308 | pane = pymux.arrangement.get_active_window() 309 | if pane: 310 | pane.select_next_layout() 311 | 312 | 313 | @cmd('previous-layout') 314 | def previous_layout(pymux, variables): 315 | " Select previous layout. " 316 | pane = pymux.arrangement.get_active_window() 317 | if pane: 318 | pane.select_previous_layout() 319 | 320 | 321 | @cmd('new-window', options='[(-n )] [(-c )] []') 322 | def new_window(pymux, variables): 323 | executable = variables[''] 324 | start_directory = variables[''] 325 | name = variables[''] 326 | 327 | pymux.create_window(executable, start_directory=start_directory, name=name) 328 | 329 | 330 | @cmd('next-window') 331 | def next_window(pymux, variables): 332 | " Focus the next window. " 333 | pymux.arrangement.focus_next_window() 334 | 335 | 336 | @cmd('last-window') 337 | def _(pymux, variables): 338 | " Go to previous active window. " 339 | w = pymux.arrangement.get_previous_active_window() 340 | 341 | if w: 342 | pymux.arrangement.set_active_window(w) 343 | 344 | 345 | @cmd('previous-window') 346 | def previous_window(pymux, variables): 347 | " Focus the previous window. " 348 | pymux.arrangement.focus_previous_window() 349 | 350 | 351 | @cmd('select-layout', options='') 352 | def select_layout(pymux, variables): 353 | layout_type = variables[''] 354 | 355 | if layout_type in LayoutTypes._ALL: 356 | pymux.arrangement.get_active_window().select_layout(layout_type) 357 | else: 358 | raise CommandException('Invalid layout type.') 359 | 360 | 361 | @cmd('rename-window', options='') 362 | def rename_window(pymux, variables): 363 | """ 364 | Rename the active window. 365 | """ 366 | pymux.arrangement.get_active_window().chosen_name = variables[''] 367 | 368 | 369 | @cmd('rename-pane', options='') 370 | def rename_pane(pymux, variables): 371 | """ 372 | Rename the active pane. 373 | """ 374 | pymux.arrangement.get_active_pane().chosen_name = variables[''] 375 | 376 | 377 | @cmd('rename-session', options='') 378 | def rename_session(pymux, variables): 379 | """ 380 | Rename this session. 381 | """ 382 | pymux.session_name = variables[''] 383 | 384 | 385 | @cmd('split-window', options='[-v|-h] [(-c )] []') 386 | def split_window(pymux, variables): 387 | """ 388 | Split horizontally or vertically. 389 | """ 390 | executable = variables[''] 391 | start_directory = variables[''] 392 | 393 | # The tmux definition of horizontal is the opposite of prompt_toolkit. 394 | pymux.add_process(executable, vsplit=variables['-h'], 395 | start_directory=start_directory) 396 | 397 | 398 | @cmd('resize-pane', options="[(-L )] [(-U )] [(-D )] [(-R )] [-Z]") 399 | def resize_pane(pymux, variables): 400 | """ 401 | Resize/zoom the active pane. 402 | """ 403 | try: 404 | left = int(variables[''] or 0) 405 | right = int(variables[''] or 0) 406 | up = int(variables[''] or 0) 407 | down = int(variables[''] or 0) 408 | except ValueError: 409 | raise CommandException('Expecting an integer.') 410 | 411 | w = pymux.arrangement.get_active_window() 412 | 413 | if w: 414 | w.change_size_for_active_pane(up=up, right=right, down=down, left=left) 415 | 416 | # Zoom in/out. 417 | if variables['-Z']: 418 | w.zoom = not w.zoom 419 | 420 | 421 | @cmd('detach-client') 422 | def detach_client(pymux, variables): 423 | """ 424 | Detach client. 425 | """ 426 | pymux.detach_client(get_app()) 427 | 428 | 429 | @cmd('confirm-before', options='[(-p )] ') 430 | def confirm_before(pymux, variables): 431 | client_state = pymux.get_client_state() 432 | 433 | client_state.confirm_text = variables[''] or '' 434 | client_state.confirm_command = variables[''] 435 | 436 | 437 | @cmd('command-prompt', options='[(-p )] [(-I )] []') 438 | def command_prompt(pymux, variables): 439 | """ 440 | Enter command prompt. 441 | """ 442 | client_state = pymux.get_client_state() 443 | 444 | if variables['']: 445 | # When a 'command' has been given. 446 | client_state.prompt_text = variables[''] or '(%s)' % variables[''].split()[0] 447 | client_state.prompt_command = variables[''] 448 | 449 | client_state.prompt_mode = True 450 | client_state.prompt_buffer.reset(Document( 451 | format_pymux_string(pymux, variables[''] or ''))) 452 | 453 | get_app().layout.focus(client_state.prompt_buffer) 454 | else: 455 | # Show the ':' prompt. 456 | client_state.prompt_text = '' 457 | client_state.prompt_command = '' 458 | 459 | get_app().layout.focus(client_state.command_buffer) 460 | 461 | # Go to insert mode. 462 | get_app().vi_state.input_mode = InputMode.INSERT 463 | 464 | 465 | @cmd('send-prefix') 466 | def send_prefix(pymux, variables): 467 | """ 468 | Send prefix to active pane. 469 | """ 470 | process = pymux.arrangement.get_active_pane().process 471 | 472 | for k in pymux.key_bindings_manager.prefix: 473 | vt100_data = prompt_toolkit_key_to_vt100_key(k) 474 | process.write_input(vt100_data) 475 | 476 | 477 | @cmd('bind-key', options='[-n] [--] [...]') 478 | def bind_key(pymux, variables): 479 | """ 480 | Bind a key sequence. 481 | -n: Not necessary to use the prefix. 482 | """ 483 | key = variables[''] 484 | command = variables[''] 485 | arguments = variables[''] 486 | needs_prefix = not variables['-n'] 487 | 488 | try: 489 | pymux.key_bindings_manager.add_custom_binding( 490 | key, command, arguments, needs_prefix=needs_prefix) 491 | except ValueError: 492 | raise CommandException('Invalid key: %r' % (key, )) 493 | 494 | 495 | @cmd('unbind-key', options='[-n] ') 496 | def unbind_key(pymux, variables): 497 | """ 498 | Remove key binding. 499 | """ 500 | key = variables[''] 501 | needs_prefix = not variables['-n'] 502 | 503 | pymux.key_bindings_manager.remove_custom_binding( 504 | key, needs_prefix=needs_prefix) 505 | 506 | 507 | @cmd('send-keys', options='...') 508 | def send_keys(pymux, variables): 509 | """ 510 | Send key strokes to the active process. 511 | """ 512 | pane = pymux.arrangement.get_active_pane() 513 | 514 | if pane.display_scroll_buffer: 515 | raise CommandException('Cannot send keys. Pane is in copy mode.') 516 | 517 | for key in variables['']: 518 | # Translate key from pymux key to prompt_toolkit key. 519 | try: 520 | keys_sequence = pymux_key_to_prompt_toolkit_key_sequence(key) 521 | except ValueError: 522 | raise CommandException('Invalid key: %r' % (key, )) 523 | 524 | # Translate prompt_toolkit key to VT100 key. 525 | for k in keys_sequence: 526 | pane.process.write_key(k) 527 | 528 | 529 | @cmd('copy-mode', options='[-u]') 530 | def copy_mode(pymux, variables): 531 | """ 532 | Enter copy mode. 533 | """ 534 | go_up = variables['-u'] # Go in copy mode and page-up directly. 535 | # TODO: handle '-u' 536 | 537 | pane = pymux.arrangement.get_active_pane() 538 | pane.enter_copy_mode() 539 | 540 | 541 | @cmd('paste-buffer') 542 | def paste_buffer(pymux, variables): 543 | """ 544 | Paste clipboard content into buffer. 545 | """ 546 | pane = pymux.arrangement.get_active_pane() 547 | pane.process.write_input(get_app().clipboard.get_data().text, paste=True) 548 | 549 | 550 | @cmd('source-file', options='') 551 | def source_file(pymux, variables): 552 | """ 553 | Source configuration file. 554 | """ 555 | filename = os.path.expanduser(variables['']) 556 | try: 557 | with open(filename, 'rb') as f: 558 | for line in f: 559 | line = line.decode('utf-8') 560 | handle_command(pymux, line) 561 | except IOError as e: 562 | raise CommandException('IOError: %s' % (e, )) 563 | 564 | 565 | @cmd('set-option', options='