├── .gitignore ├── CHANGELOG ├── LICENSE ├── README.rst ├── examples ├── example-config.conf └── true_color_test.py ├── images ├── copy-mode.png ├── menu-true-color.png ├── multiple-clients.png └── pymux.png ├── pymux ├── __init__.py ├── __main__.py ├── arrangement.py ├── client │ ├── __init__.py │ ├── base.py │ ├── defaults.py │ ├── posix.py │ └── windows.py ├── commands │ ├── __init__.py │ ├── aliases.py │ ├── commands.py │ ├── completer.py │ └── utils.py ├── entry_points │ ├── __init__.py │ └── run_pymux.py ├── enums.py ├── filters.py ├── format.py ├── key_bindings.py ├── key_mappings.py ├── layout.py ├── log.py ├── main.py ├── options.py ├── pipes │ ├── __init__.py │ ├── base.py │ ├── posix.py │ ├── win32.py │ ├── win32_client.py │ └── win32_server.py ├── rc.py ├── server.py ├── style.py └── utils.py └── setup.py /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /images/copy-mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prompt-toolkit/pymux/163eeb31e516b0f6356af46082e8e7aea708386b/images/copy-mode.png -------------------------------------------------------------------------------- /images/menu-true-color.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prompt-toolkit/pymux/163eeb31e516b0f6356af46082e8e7aea708386b/images/menu-true-color.png -------------------------------------------------------------------------------- /images/multiple-clients.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prompt-toolkit/pymux/163eeb31e516b0f6356af46082e8e7aea708386b/images/multiple-clients.png -------------------------------------------------------------------------------- /images/pymux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prompt-toolkit/pymux/163eeb31e516b0f6356af46082e8e7aea708386b/images/pymux.png -------------------------------------------------------------------------------- /pymux/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | -------------------------------------------------------------------------------- /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/arrangement.py: -------------------------------------------------------------------------------- 1 | """ 2 | Arrangement of panes. 3 | 4 | Don't confuse with the prompt_toolkit VSplit/HSplit classes. This is a higher 5 | level abstraction of the Pymux window layout. 6 | 7 | An arrangement consists of a list of windows. And a window has a list of panes, 8 | arranged by ordering them in HSplit/VSplit instances. 9 | """ 10 | from __future__ import unicode_literals 11 | 12 | from ptterm import Terminal 13 | from prompt_toolkit.application.current import get_app, set_app 14 | from prompt_toolkit.buffer import Buffer 15 | 16 | import math 17 | import os 18 | import weakref 19 | import six 20 | 21 | __all__ = ( 22 | 'LayoutTypes', 23 | 'Pane', 24 | 'HSplit', 25 | 'VSplit', 26 | 'Window', 27 | 'Arrangement', 28 | ) 29 | 30 | 31 | class LayoutTypes: 32 | # The values are in lowercase with dashes, because that is what users can 33 | # use at the command line. 34 | EVEN_HORIZONTAL = 'even-horizontal' 35 | EVEN_VERTICAL = 'even-vertical' 36 | MAIN_HORIZONTAL = 'main-horizontal' 37 | MAIN_VERTICAL = 'main-vertical' 38 | TILED = 'tiled' 39 | 40 | _ALL = [EVEN_HORIZONTAL, EVEN_VERTICAL, MAIN_HORIZONTAL, MAIN_VERTICAL, TILED] 41 | 42 | 43 | class Pane(object): 44 | """ 45 | One pane, containing one process and a search buffer for going into copy 46 | mode or displaying the help. 47 | """ 48 | _pane_counter = 1000 # Start at 1000, to be sure to not confuse this with pane indexes. 49 | 50 | def __init__(self, terminal=None): 51 | assert isinstance(terminal, Terminal) 52 | 53 | self.terminal = terminal 54 | self.chosen_name = None 55 | 56 | # Displayed the clock instead of this pane content. 57 | self.clock_mode = False 58 | 59 | # Give unique ID. 60 | Pane._pane_counter += 1 61 | self.pane_id = Pane._pane_counter 62 | 63 | # Prompt_toolkit buffer, for displaying scrollable text. 64 | # (In copy mode, or help mode.) 65 | # Note: Because the scroll_buffer can only contain text, we also use the 66 | # get_tokens_for_line, that returns the token list with color 67 | # information for each line. 68 | self.scroll_buffer = Buffer(read_only=True) 69 | self.copy_get_tokens_for_line = lambda lineno: [] 70 | self.display_scroll_buffer = False 71 | self.scroll_buffer_title = '' 72 | 73 | @property 74 | def process(self): 75 | return self.terminal.process 76 | 77 | @property 78 | def name(self): 79 | """ 80 | The name for the window as displayed in the title bar and status bar. 81 | """ 82 | # Name, explicitely set for the pane. 83 | if self.chosen_name: 84 | return self.chosen_name 85 | else: 86 | # Name from the process running inside the pane. 87 | name = self.process.get_name() 88 | if name: 89 | return os.path.basename(name) 90 | 91 | return '' 92 | 93 | def enter_copy_mode(self): 94 | """ 95 | Suspend the process, and copy the screen content to the `scroll_buffer`. 96 | That way the user can search through the history and copy/paste. 97 | """ 98 | self.terminal.enter_copy_mode() 99 | 100 | def focus(self): 101 | """ 102 | Focus this pane. 103 | """ 104 | get_app().layout.focus(self.terminal) 105 | 106 | 107 | class _WeightsDictionary(weakref.WeakKeyDictionary): 108 | """ 109 | Dictionary for the weights: weak keys, but defaults to 1. 110 | 111 | (Weights are used to represent the proportion of pane sizes in 112 | HSplit/VSplit lists.) 113 | 114 | This dictionary maps the child (another HSplit/VSplit or Pane), to the 115 | size. (Integer.) 116 | """ 117 | def __getitem__(self, key): 118 | try: 119 | # (Don't use 'super' here. This is a classobj in Python2.) 120 | return weakref.WeakKeyDictionary.__getitem__(self, key) 121 | except KeyError: 122 | return 1 123 | 124 | 125 | class _Split(list): 126 | """ 127 | Base class for horizontal and vertical splits. (This is a higher level 128 | split than prompt_toolkit.layout.HSplit.) 129 | """ 130 | def __init__(self, *a, **kw): 131 | list.__init__(self, *a, **kw) 132 | 133 | # Mapping children to its weight. 134 | self.weights = _WeightsDictionary() 135 | 136 | def __hash__(self): 137 | # Required in order to add HSplit/VSplit to the weights dict. " 138 | return id(self) 139 | 140 | def __repr__(self): 141 | return '%s(%s)' % (self.__class__.__name__, list.__repr__(self)) 142 | 143 | 144 | class HSplit(_Split): 145 | """ Horizontal split. """ 146 | 147 | 148 | class VSplit(_Split): 149 | """ Horizontal split. """ 150 | 151 | 152 | class Window(object): 153 | """ 154 | Pymux window. 155 | """ 156 | _window_counter = 1000 # Start here, to avoid confusion with window index. 157 | 158 | def __init__(self, index=0): 159 | self.index = index 160 | self.root = HSplit() 161 | self._active_pane = None 162 | self._prev_active_pane = None 163 | self.chosen_name = None 164 | self.previous_selected_layout = None 165 | 166 | #: When true, the current pane is zoomed in. 167 | self.zoom = False 168 | 169 | #: When True, send input to all panes simultaniously. 170 | self.synchronize_panes = False 171 | 172 | # Give unique ID. 173 | Window._window_counter += 1 174 | self.window_id = Window._window_counter 175 | 176 | def invalidation_hash(self): 177 | """ 178 | Return a hash (string) that can be used to determine when the layout 179 | has to be rebuild. 180 | """ 181 | # if not self.root: 182 | # return '' 183 | 184 | def _hash_for_split(split): 185 | result = [] 186 | for item in split: 187 | if isinstance(item, (VSplit, HSplit)): 188 | result.append(_hash_for_split(item)) 189 | elif isinstance(item, Pane): 190 | result.append('p%s' % item.pane_id) 191 | 192 | if isinstance(split, HSplit): 193 | return 'HSplit(%s)' % (','.join(result)) 194 | else: 195 | return 'VSplit(%s)' % (','.join(result)) 196 | 197 | return '' % ( 198 | self.window_id, self.zoom, _hash_for_split(self.root)) 199 | 200 | @property 201 | def active_pane(self): 202 | """ 203 | The current active :class:`.Pane`. 204 | """ 205 | return self._active_pane 206 | 207 | @active_pane.setter 208 | def active_pane(self, value): 209 | assert isinstance(value, Pane) 210 | 211 | # Remember previous active pane. 212 | if self._active_pane: 213 | self._prev_active_pane = weakref.ref(self._active_pane) 214 | 215 | self.zoom = False 216 | self._active_pane = value 217 | 218 | @property 219 | def previous_active_pane(self): 220 | """ 221 | The previous active :class:`.Pane` or `None` if unknown. 222 | """ 223 | p = self._prev_active_pane and self._prev_active_pane() 224 | 225 | # Only return when this pane actually still exists in the current 226 | # window. 227 | if p and p in self.panes: 228 | return p 229 | 230 | @property 231 | def name(self): 232 | """ 233 | The name for this window as it should be displayed in the status bar. 234 | """ 235 | # Name, explicitely set for the window. 236 | if self.chosen_name: 237 | return self.chosen_name 238 | else: 239 | pane = self.active_pane 240 | if pane: 241 | return pane.name 242 | 243 | return '' 244 | 245 | def add_pane(self, pane, vsplit=False): 246 | """ 247 | Add another pane to this Window. 248 | """ 249 | assert isinstance(pane, Pane) 250 | assert isinstance(vsplit, bool) 251 | 252 | split_cls = VSplit if vsplit else HSplit 253 | 254 | if self.active_pane is None: 255 | self.root.append(pane) 256 | else: 257 | parent = self._get_parent(self.active_pane) 258 | same_direction = isinstance(parent, split_cls) 259 | 260 | index = parent.index(self.active_pane) 261 | 262 | if same_direction: 263 | parent.insert(index + 1, pane) 264 | else: 265 | new_split = split_cls([self.active_pane, pane]) 266 | parent[index] = new_split 267 | 268 | # Give the newly created split the same weight as the original 269 | # pane that was at this position. 270 | parent.weights[new_split] = parent.weights[self.active_pane] 271 | 272 | self.active_pane = pane 273 | self.zoom = False 274 | 275 | def remove_pane(self, pane): 276 | """ 277 | Remove pane from this Window. 278 | """ 279 | assert isinstance(pane, Pane) 280 | 281 | if pane in self.panes: 282 | # When this pane was focused, switch to previous active or next in order. 283 | if pane == self.active_pane: 284 | if self.previous_active_pane: 285 | self.active_pane = self.previous_active_pane 286 | else: 287 | self.focus_next() 288 | 289 | # Remove from the parent. When the parent becomes empty, remove the 290 | # parent itself recursively. 291 | p = self._get_parent(pane) 292 | p.remove(pane) 293 | 294 | while len(p) == 0 and p != self.root: 295 | p2 = self._get_parent(p) 296 | p2.remove(p) 297 | p = p2 298 | 299 | # When the parent has only one item left, collapse into its parent. 300 | while len(p) == 1 and p != self.root: 301 | p2 = self._get_parent(p) 302 | p2.weights[p[0]] = p2.weights[p] # Keep dimensions. 303 | i = p2.index(p) 304 | p2[i] = p[0] 305 | p = p2 306 | 307 | @property 308 | def panes(self): 309 | " List with all panes from this Window. " 310 | result = [] 311 | 312 | for s in self.splits: 313 | for item in s: 314 | if isinstance(item, Pane): 315 | result.append(item) 316 | 317 | return result 318 | 319 | @property 320 | def splits(self): 321 | " Return a list with all HSplit/VSplit instances. " 322 | result = [] 323 | 324 | def collect(split): 325 | result.append(split) 326 | 327 | for item in split: 328 | if isinstance(item, (HSplit, VSplit)): 329 | collect(item) 330 | 331 | collect(self.root) 332 | return result 333 | 334 | def _get_parent(self, item): 335 | " The HSplit/VSplit that contains the active pane. " 336 | for s in self.splits: 337 | if item in s: 338 | return s 339 | 340 | @property 341 | def has_panes(self): 342 | " True when this window contains at least one pane. " 343 | return len(self.panes) > 0 344 | 345 | @property 346 | def active_process(self): 347 | " Return `Process` that should receive user input. " 348 | p = self.active_pane 349 | 350 | if p is not None: 351 | return p.process 352 | 353 | def focus_next(self, count=1): 354 | " Focus the next pane. " 355 | panes = self.panes 356 | if panes: 357 | self.active_pane = panes[(panes.index(self.active_pane) + count) % len(panes)] 358 | else: 359 | self.active_pane = None # No panes left. 360 | 361 | def focus_previous(self): 362 | " Focus the previous pane. " 363 | self.focus_next(count=-1) 364 | 365 | def rotate(self, count=1, with_pane_before_only=False, with_pane_after_only=False): 366 | """ 367 | Rotate panes. 368 | When `with_pane_before_only` or `with_pane_after_only` is True, only rotate 369 | with the pane before/after the active pane. 370 | """ 371 | # Create (split, index, pane, weight) tuples. 372 | items = [] 373 | current_pane_index = None 374 | 375 | for s in self.splits: 376 | for index, item in enumerate(s): 377 | if isinstance(item, Pane): 378 | items.append((s, index, item, s.weights[item])) 379 | if item == self.active_pane: 380 | current_pane_index = len(items) - 1 381 | 382 | # Only before after? Reduce list of panes. 383 | if with_pane_before_only: 384 | items = items[current_pane_index - 1:current_pane_index + 1] 385 | 386 | elif with_pane_after_only: 387 | items = items[current_pane_index:current_pane_index + 2] 388 | 389 | # Rotate positions. 390 | for i, triple in enumerate(items): 391 | split, index, pane, weight = triple 392 | 393 | new_item = items[(i + count) % len(items)][2] 394 | 395 | split[index] = new_item 396 | split.weights[new_item] = weight 397 | 398 | def select_layout(self, layout_type): 399 | """ 400 | Select one of the predefined layouts. 401 | """ 402 | assert layout_type in LayoutTypes._ALL 403 | 404 | # When there is only one pane, always choose EVEN_HORIZONTAL, 405 | # Otherwise, we create VSplit/HSplit instances with an empty list of 406 | # children. 407 | if len(self.panes) == 1: 408 | layout_type = LayoutTypes.EVEN_HORIZONTAL 409 | 410 | # even-horizontal. 411 | if layout_type == LayoutTypes.EVEN_HORIZONTAL: 412 | self.root = HSplit(self.panes) 413 | 414 | # even-vertical. 415 | elif layout_type == LayoutTypes.EVEN_VERTICAL: 416 | self.root = VSplit(self.panes) 417 | 418 | # main-horizontal. 419 | elif layout_type == LayoutTypes.MAIN_HORIZONTAL: 420 | self.root = HSplit([ 421 | self.active_pane, 422 | VSplit([p for p in self.panes if p != self.active_pane]) 423 | ]) 424 | 425 | # main-vertical. 426 | elif layout_type == LayoutTypes.MAIN_VERTICAL: 427 | self.root = VSplit([ 428 | self.active_pane, 429 | HSplit([p for p in self.panes if p != self.active_pane]) 430 | ]) 431 | 432 | # tiled. 433 | elif layout_type == LayoutTypes.TILED: 434 | panes = self.panes 435 | column_count = math.ceil(len(panes) ** .5) 436 | 437 | rows = HSplit() 438 | current_row = VSplit() 439 | 440 | for p in panes: 441 | current_row.append(p) 442 | 443 | if len(current_row) >= column_count: 444 | rows.append(current_row) 445 | current_row = VSplit() 446 | if current_row: 447 | rows.append(current_row) 448 | 449 | self.root = rows 450 | 451 | self.previous_selected_layout = layout_type 452 | 453 | def select_next_layout(self, count=1): 454 | """ 455 | Select next layout. (Cycle through predefined layouts.) 456 | """ 457 | # List of all layouts. (When we have just two panes, only toggle 458 | # between horizontal/vertical.) 459 | if len(self.panes) == 2: 460 | all_layouts = [LayoutTypes.EVEN_HORIZONTAL, LayoutTypes.EVEN_VERTICAL] 461 | else: 462 | all_layouts = LayoutTypes._ALL 463 | 464 | # Get index of current layout. 465 | layout = self.previous_selected_layout or LayoutTypes._ALL[-1] 466 | try: 467 | index = all_layouts.index(layout) 468 | except ValueError: 469 | index = 0 470 | 471 | # Switch to new layout. 472 | new_layout = all_layouts[(index + count) % len(all_layouts)] 473 | self.select_layout(new_layout) 474 | 475 | def select_previous_layout(self): 476 | self.select_next_layout(count=-1) 477 | 478 | def change_size_for_active_pane(self, up=0, right=0, down=0, left=0): 479 | """ 480 | Increase the size of the current pane in any of the four directions. 481 | """ 482 | child = self.active_pane 483 | self.change_size_for_pane(child, up=up, right=right, down=down, left=left) 484 | 485 | def change_size_for_pane(self, pane, up=0, right=0, down=0, left=0): 486 | """ 487 | Increase the size of the current pane in any of the four directions. 488 | Positive values indicate an increase, negative values a decrease. 489 | """ 490 | assert isinstance(pane, Pane) 491 | 492 | def find_split_and_child(split_cls, is_before): 493 | " Find the split for which we will have to update the weights. " 494 | child = pane 495 | split = self._get_parent(child) 496 | 497 | def found(): 498 | return isinstance(split, split_cls) and ( 499 | not is_before or split.index(child) > 0) and ( 500 | is_before or split.index(child) < len(split) - 1) 501 | 502 | while split and not found(): 503 | child = split 504 | split = self._get_parent(child) 505 | 506 | return split, child # split can be None! 507 | 508 | def handle_side(split_cls, is_before, amount, trying_other_side=False): 509 | " Increase weights on one side. (top/left/right/bottom). " 510 | if amount: 511 | split, child = find_split_and_child(split_cls, is_before) 512 | 513 | if split: 514 | # Find neighbour. 515 | neighbour_index = split.index(child) + (-1 if is_before else 1) 516 | neighbour_child = split[neighbour_index] 517 | 518 | # Increase/decrease weights. 519 | split.weights[child] += amount 520 | split.weights[neighbour_child] -= amount 521 | 522 | # Ensure that all weights are at least one. 523 | for k, value in split.weights.items(): 524 | if value < 1: 525 | split.weights[k] = 1 526 | 527 | else: 528 | # When no split has been found where we can move in this 529 | # direction, try to move the other side instead using a 530 | # negative amount. This happens when we run "resize-pane -R 4" 531 | # inside the pane that is completely on the right. In that 532 | # case it's logical to move the left border to the right 533 | # instead. 534 | if not trying_other_side: 535 | handle_side(split_cls, not is_before, -amount, 536 | trying_other_side=True) 537 | 538 | handle_side(VSplit, True, left) 539 | handle_side(VSplit, False, right) 540 | handle_side(HSplit, True, up) 541 | handle_side(HSplit, False, down) 542 | 543 | def get_pane_index(self, pane): 544 | " Return the index of the given pane. ValueError if not found. " 545 | assert isinstance(pane, Pane) 546 | return self.panes.index(pane) 547 | 548 | 549 | class Arrangement(object): 550 | """ 551 | Arrangement class for one Pymux session. 552 | This contains the list of windows and the layout of the panes for each 553 | window. All the clients share the same Arrangement instance, but they can 554 | have different windows active. 555 | """ 556 | def __init__(self): 557 | self.windows = [] 558 | self.base_index = 0 559 | 560 | self._active_window_for_cli = weakref.WeakKeyDictionary() 561 | self._prev_active_window_for_cli = weakref.WeakKeyDictionary() 562 | 563 | # The active window of the last CLI. Used as default when a new session 564 | # is attached. 565 | self._last_active_window = None 566 | 567 | def invalidation_hash(self): 568 | """ 569 | When this changes, the layout needs to be rebuild. 570 | """ 571 | if not self.windows: 572 | return '' 573 | 574 | w = self.get_active_window() 575 | return w.invalidation_hash() 576 | 577 | def get_active_window(self): 578 | """ 579 | The current active :class:`.Window`. 580 | """ 581 | app = get_app() 582 | 583 | try: 584 | return self._active_window_for_cli[app] 585 | except KeyError: 586 | self._active_window_for_cli[app] = self._last_active_window or self.windows[0] 587 | return self.windows[0] 588 | 589 | def set_active_window(self, window): 590 | assert isinstance(window, Window) 591 | app = get_app() 592 | 593 | previous = self.get_active_window() 594 | self._prev_active_window_for_cli[app] = previous 595 | self._active_window_for_cli[app] = window 596 | self._last_active_window = window 597 | 598 | def set_active_window_from_pane_id(self, pane_id): 599 | """ 600 | Make the window with this pane ID the active Window. 601 | """ 602 | assert isinstance(pane_id, int) 603 | 604 | for w in self.windows: 605 | for p in w.panes: 606 | if p.pane_id == pane_id: 607 | self.set_active_window(w) 608 | 609 | def get_previous_active_window(self): 610 | " The previous active Window or None if unknown. " 611 | app = get_app() 612 | 613 | try: 614 | return self._prev_active_window_for_cli[app] 615 | except KeyError: 616 | return None 617 | 618 | def get_window_by_index(self, index): 619 | " Return the Window with this index or None if not found. " 620 | for w in self.windows: 621 | if w.index == index: 622 | return w 623 | 624 | def create_window(self, pane, name=None, set_active=True): 625 | """ 626 | Create a new window that contains just this pane. 627 | 628 | :param pane: The :class:`.Pane` instance to put in the new window. 629 | :param name: If given, name for the new window. 630 | :param set_active: When True, focus the new window. 631 | """ 632 | assert isinstance(pane, Pane) 633 | assert name is None or isinstance(name, six.text_type) 634 | 635 | # Take the first available index. 636 | taken_indexes = [w.index for w in self.windows] 637 | 638 | index = self.base_index 639 | while index in taken_indexes: 640 | index += 1 641 | 642 | # Create new window and add it. 643 | w = Window(index) 644 | w.add_pane(pane) 645 | self.windows.append(w) 646 | 647 | # Sort windows by index. 648 | self.windows = sorted(self.windows, key=lambda w: w.index) 649 | 650 | app = get_app(return_none=True) 651 | 652 | if app is not None and set_active: 653 | self.set_active_window(w) 654 | 655 | if name is not None: 656 | w.chosen_name = name 657 | 658 | assert w.active_pane == pane 659 | assert w._get_parent(pane) 660 | 661 | def move_window(self, window, new_index): 662 | """ 663 | Move window to a new index. 664 | """ 665 | assert isinstance(window, Window) 666 | assert isinstance(new_index, int) 667 | 668 | window.index = new_index 669 | 670 | # Sort windows by index. 671 | self.windows = sorted(self.windows, key=lambda w: w.index) 672 | 673 | def get_active_pane(self): 674 | """ 675 | The current :class:`.Pane` from the current window. 676 | """ 677 | w = self.get_active_window() 678 | if w is not None: 679 | return w.active_pane 680 | 681 | def remove_pane(self, pane): 682 | """ 683 | Remove a :class:`.Pane`. (Look in all windows.) 684 | """ 685 | assert isinstance(pane, Pane) 686 | 687 | for w in self.windows: 688 | w.remove_pane(pane) 689 | 690 | # No panes left in this window? 691 | if not w.has_panes: 692 | # Focus next. 693 | for app, active_w in self._active_window_for_cli.items(): 694 | if w == active_w: 695 | with set_app(app): 696 | self.focus_next_window() 697 | 698 | self.windows.remove(w) 699 | 700 | def focus_previous_window(self): 701 | w = self.get_active_window() 702 | 703 | self.set_active_window(self.windows[ 704 | (self.windows.index(w) - 1) % len(self.windows)]) 705 | 706 | def focus_next_window(self): 707 | w = self.get_active_window() 708 | 709 | self.set_active_window(self.windows[ 710 | (self.windows.index(w) + 1) % len(self.windows)]) 711 | 712 | def break_pane(self, set_active=True): 713 | """ 714 | When the current window has multiple panes, remove the pane from this 715 | window and put it in a new window. 716 | 717 | :param set_active: When True, focus the new window. 718 | """ 719 | w = self.get_active_window() 720 | 721 | if len(w.panes) > 1: 722 | pane = w.active_pane 723 | self.get_active_window().remove_pane(pane) 724 | self.create_window(pane, set_active=set_active) 725 | 726 | def rotate_window(self, count=1): 727 | " Rotate the panes in the active window. " 728 | w = self.get_active_window() 729 | w.rotate(count=count) 730 | 731 | @property 732 | def has_panes(self): 733 | " True when any of the windows has a :class:`.Pane`. " 734 | for w in self.windows: 735 | if w.has_panes: 736 | return True 737 | return False 738 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prompt-toolkit/pymux/163eeb31e516b0f6356af46082e8e7aea708386b/pymux/commands/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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='