added chat overlay

This commit is contained in:
.[d]. 2022-08-15 19:23:49 -05:00
commit 3275444755
37 changed files with 6060 additions and 0 deletions

67
README.md Normal file
View File

@ -0,0 +1,67 @@
## [MAPLE|G1MP]+[ML^N]+[NETSPANNING[i]]
---
```
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMh+MMMMMMMMMMMMMMhsMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMm/ oMMMMMMMMMMMMMMm +NMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMy` yMMMMMMMMMMMMMMM- -mMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM
MMMMMMMMMMMMMMMMMMMMMMMMs+dMMMMMMMMMM+ sMMMMMMMMMMMMMMM- `dMMMMMMMMMMms/NMMMMMMMMMMMMMMMMMMMMMMM
MMMMMMMMMMMMMMMMMMMMMMMMM+ .omMMMMMM: -MMMMMMMMMMMMMMo `yMMMMMMMy: `dMMMMMMMMMMMMMMMMMMMMMMMM
MMMMMMMMMMMMMMMMMMMMMMMMMM- /dMMM+ sMMMMMMMMMMMMh `hMMMNo` sMMMMMMMMMMMMMMMMMMMMMMMMM
MMMMMMMMMMMMMMMMMMMMMMMMMMd :dm `mMMMMMMMMMMN. .NNo` .MMMMMMMMMMMMMMMMMMMMMMMMMM
MMMMMMMMMMMMMMMMMMMMMMMMMMM: - :MMMMMMMMMMs :` sMMMMMMMMMMMMMMMMMMMMMMMMMM
MMMMMMMMMMMMMMMMMMMMMMMMMMMs ymNMMMMMNm. NMMMMMMMMMMMMMMMMMMMMMMMMMM
MMMMMMMMMMMMMMMMMMMMMMMMMMMy `-/-` .MMMMMMMMMMMMMMMMMMMMMMMMMMM
MMMMMMMMMMMMMMMMMMMMMMMMMMMo .NMMMMMMMMMMMMMMMMMMMMMMMMMM
MMMMMMMMMMMMMMMMMMMMMMMNh+. :sdMMMMMMMMMMMMMMMMMMMMMMM
MMMMMMMMMMMMMMMMMhso+:. `-/+syMMMMMMMMMMMMMMMMM
MMMMMMMMMMMMMMMMM- dMMMMMMMMMMMMMMMM
MMMMMMMMMMMMMMMMM` `.:+/. `/s+:. sMMMMMMMMMMMMMMMM
MMMMMMMMMMMMMMMNo -oms. .//-` `:/:` `+md+` .hMMMMMMMMMMMMMMM
MMMMMMMMMMMMMNs` .odNdo. .ohmd+` :dMMMMMMMMMMMMM
MMMMMMMMMMMNo` .. .- :hMMMMMMMMMMM
MMMMMMMMMd+` -sNMMMMMMMM
MMMMMMNs- `.. `/-. `+dMMMMMM
MMMNy: ./sdNMMMh: `sNMMMNds/. .odMMM
MM+ :ymMMMMMMMMMMh. +NMMMMMMMMMMmo- /NM
MMMh: .sNMMMMMMMMMMMMMMN- `hMMMMMMMMMMMMMMMm+` :hMMM
MMMMMd:` ``-:+shmMMMMMMMMMMMMMMMMMMN. hMMMMMMMMMMMMMMMMMMMmhs+/-..``````./dMMMMM
MMMMMMMMMNNNNNNMMMMMMMMMMMMMMMMMMMMMMMMMMMo .MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMy .MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMN. /MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMN+` `+NMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMNs. -hMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMdyymMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM
```
---
## Summary
base: `https://github.com/prompt-toolkit/pymux`
```
tailoring a multiplexor for personal usage. using pymux for the base code, it's a tmux python port.
```
---
## Changelog - v0.1
- added own chat window, and wrote in an an arbitrary data poplulator until fd socks are networked.
---
## Prerequisites
###### [ substitute apt for the package manager of your choice ]
- `apt install python3` - `*note: python3.9 is ideal`
- `apt install python3-pip`
- `python3 -m pip install virtualenv`
---
## Instructions
###### - [ virtualenv could be substituted for pyenv/pipenv or no virtualenv et al ]
- `git clone --recursive https://git.tcp.direct/decoded/maple_netspan.git`
- `cd dr1p_pymux`
- `virtualenv -p python3.9 env`
- `source env/bin/activate`
- `pip install -r requirements.txt`
---
# Usage
## 1 - `cd dr1p_pymux`
## 2 - `source env/bin/activate`
## 3 - `python3.9 dr1p_pymux.py`
---

18
dr1p_pymux.py Normal file
View File

@ -0,0 +1,18 @@
###################################################################################### SOF
class DR1PP1NG():
color_depth="DEPTH_24_BIT"
def __init__(self,mode):
self.__mode__(mode)
def __mode__(self,mode):
if mode==1:
from pymux.main import Pymux
self.dr1pp1ng=Pymux()
self.dr1pp1ng.run_standalone(self.color_depth)
elif mode==2:
from pymux.entry_points.run_pymux import run
run()
##########################################################################################
if __name__=="__main__":
dr1pp1ng=DR1PP1NG(2)
#dr1pp1ng=DR1PP1NG(1)
###################################################################################### EOF

1
pymux/__init__.py Normal file
View File

@ -0,0 +1 @@
from __future__ import unicode_literals

8
pymux/__main__.py Normal file
View File

@ -0,0 +1,8 @@
"""
Make sure `python -m pymux` works.
"""
from __future__ import unicode_literals
from .entry_points.run_pymux import run
if __name__ == '__main__':
run()

737
pymux/arrangement.py Normal file
View File

@ -0,0 +1,737 @@
"""
Arrangement of panes.
Don't confuse with the prompt_toolkit VSplit/HSplit classes. This is a higher
level abstraction of the Pymux window layout.
An arrangement consists of a list of windows. And a window has a list of panes,
arranged by ordering them in HSplit/VSplit instances.
"""
from __future__ import unicode_literals
from ptterm import Terminal
from prompt_toolkit.application.current import get_app, set_app
from prompt_toolkit.buffer import Buffer
import math
import os
import weakref
import six
__all__ = (
'LayoutTypes',
'Pane',
'HSplit',
'VSplit',
'Window',
'Arrangement',
)
class LayoutTypes:
# The values are in lowercase with dashes, because that is what users can
# use at the command line.
EVEN_HORIZONTAL = 'even-horizontal'
EVEN_VERTICAL = 'even-vertical'
MAIN_HORIZONTAL = 'main-horizontal'
MAIN_VERTICAL = 'main-vertical'
TILED = 'tiled'
_ALL = [EVEN_HORIZONTAL, EVEN_VERTICAL, MAIN_HORIZONTAL, MAIN_VERTICAL, TILED]
class Pane(object):
"""
One pane, containing one process and a search buffer for going into copy
mode or displaying the help.
"""
_pane_counter = 1000 # Start at 1000, to be sure to not confuse this with pane indexes.
def __init__(self, terminal=None):
assert isinstance(terminal, Terminal)
self.terminal = terminal
self.chosen_name = None
# Displayed the clock instead of this pane content.
self.clock_mode = False
# Give unique ID.
Pane._pane_counter += 1
self.pane_id = Pane._pane_counter
# Prompt_toolkit buffer, for displaying scrollable text.
# (In copy mode, or help mode.)
# Note: Because the scroll_buffer can only contain text, we also use the
# get_tokens_for_line, that returns the token list with color
# information for each line.
self.scroll_buffer = Buffer(read_only=True)
self.copy_get_tokens_for_line = lambda lineno: []
self.display_scroll_buffer = False
self.scroll_buffer_title = ''
@property
def process(self):
return self.terminal.process
@property
def name(self):
"""
The name for the window as displayed in the title bar and status bar.
"""
# Name, explicitely set for the pane.
if self.chosen_name:
return self.chosen_name
else:
# Name from the process running inside the pane.
name = self.process.get_name()
if name:
return os.path.basename(name)
return ''
def enter_copy_mode(self):
"""
Suspend the process, and copy the screen content to the `scroll_buffer`.
That way the user can search through the history and copy/paste.
"""
self.terminal.enter_copy_mode()
def focus(self):
"""
Focus this pane.
"""
get_app().layout.focus(self.terminal)
class _WeightsDictionary(weakref.WeakKeyDictionary):
"""
Dictionary for the weights: weak keys, but defaults to 1.
(Weights are used to represent the proportion of pane sizes in
HSplit/VSplit lists.)
This dictionary maps the child (another HSplit/VSplit or Pane), to the
size. (Integer.)
"""
def __getitem__(self, key):
try:
# (Don't use 'super' here. This is a classobj in Python2.)
return weakref.WeakKeyDictionary.__getitem__(self, key)
except KeyError:
return 1
class _Split(list):
"""
Base class for horizontal and vertical splits. (This is a higher level
split than prompt_toolkit.layout.HSplit.)
"""
def __init__(self, *a, **kw):
list.__init__(self, *a, **kw)
# Mapping children to its weight.
self.weights = _WeightsDictionary()
def __hash__(self):
# Required in order to add HSplit/VSplit to the weights dict. "
return id(self)
def __repr__(self):
return '%s(%s)' % (self.__class__.__name__, list.__repr__(self))
class HSplit(_Split):
""" Horizontal split. """
class VSplit(_Split):
""" Horizontal split. """
class Window(object):
"""
Pymux window.
"""
_window_counter = 1000 # Start here, to avoid confusion with window index.
def __init__(self, index=0):
self.index = index
self.root = HSplit()
self._active_pane = None
self._prev_active_pane = None
self.chosen_name = None
self.previous_selected_layout = None
#: When true, the current pane is zoomed in.
self.zoom = False
#: When True, send input to all panes simultaniously.
self.synchronize_panes = False
# Give unique ID.
Window._window_counter += 1
self.window_id = Window._window_counter
def invalidation_hash(self):
"""
Return a hash (string) that can be used to determine when the layout
has to be rebuild.
"""
# if not self.root:
# return '<empty-window>'
def _hash_for_split(split):
result = []
for item in split:
if isinstance(item, (VSplit, HSplit)):
result.append(_hash_for_split(item))
elif isinstance(item, Pane):
result.append('p%s' % item.pane_id)
if isinstance(split, HSplit):
return 'HSplit(%s)' % (','.join(result))
else:
return 'VSplit(%s)' % (','.join(result))
return '<window_id=%s,zoom=%s,children=%s>' % (
self.window_id, self.zoom, _hash_for_split(self.root))
@property
def active_pane(self):
"""
The current active :class:`.Pane`.
"""
return self._active_pane
@active_pane.setter
def active_pane(self, value):
assert isinstance(value, Pane)
# Remember previous active pane.
if self._active_pane:
self._prev_active_pane = weakref.ref(self._active_pane)
self.zoom = False
self._active_pane = value
@property
def previous_active_pane(self):
"""
The previous active :class:`.Pane` or `None` if unknown.
"""
p = self._prev_active_pane and self._prev_active_pane()
# Only return when this pane actually still exists in the current
# window.
if p and p in self.panes:
return p
@property
def name(self):
"""
The name for this window as it should be displayed in the status bar.
"""
# Name, explicitely set for the window.
if self.chosen_name:
return self.chosen_name
else:
pane = self.active_pane
if pane:
return pane.name
return ''
def add_pane(self, pane, vsplit=False):
"""
Add another pane to this Window.
"""
assert isinstance(pane, Pane)
assert isinstance(vsplit, bool)
split_cls = VSplit if vsplit else HSplit
if self.active_pane is None:
self.root.append(pane)
else:
parent = self._get_parent(self.active_pane)
same_direction = isinstance(parent, split_cls)
index = parent.index(self.active_pane)
if same_direction:
parent.insert(index + 1, pane)
else:
new_split = split_cls([self.active_pane, pane])
parent[index] = new_split
# Give the newly created split the same weight as the original
# pane that was at this position.
parent.weights[new_split] = parent.weights[self.active_pane]
self.active_pane = pane
self.zoom = False
def remove_pane(self, pane):
"""
Remove pane from this Window.
"""
assert isinstance(pane, Pane)
if pane in self.panes:
# When this pane was focused, switch to previous active or next in order.
if pane == self.active_pane:
if self.previous_active_pane:
self.active_pane = self.previous_active_pane
else:
self.focus_next()
# Remove from the parent. When the parent becomes empty, remove the
# parent itself recursively.
p = self._get_parent(pane)
p.remove(pane)
while len(p) == 0 and p != self.root:
p2 = self._get_parent(p)
p2.remove(p)
p = p2
# When the parent has only one item left, collapse into its parent.
while len(p) == 1 and p != self.root:
p2 = self._get_parent(p)
p2.weights[p[0]] = p2.weights[p] # Keep dimensions.
i = p2.index(p)
p2[i] = p[0]
p = p2
@property
def panes(self):
" List with all panes from this Window. "
result = []
for s in self.splits:
for item in s:
if isinstance(item, Pane):
result.append(item)
return result
@property
def splits(self):
" Return a list with all HSplit/VSplit instances. "
result = []
def collect(split):
result.append(split)
for item in split:
if isinstance(item, (HSplit, VSplit)):
collect(item)
collect(self.root)
return result
def _get_parent(self, item):
" The HSplit/VSplit that contains the active pane. "
for s in self.splits:
if item in s:
return s
@property
def has_panes(self):
" True when this window contains at least one pane. "
return len(self.panes) > 0
@property
def active_process(self):
" Return `Process` that should receive user input. "
p = self.active_pane
if p is not None:
return p.process
def focus_next(self, count=1):
" Focus the next pane. "
panes = self.panes
if panes:
self.active_pane = panes[(panes.index(self.active_pane) + count) % len(panes)]
else:
self.active_pane = None # No panes left.
def focus_previous(self):
" Focus the previous pane. "
self.focus_next(count=-1)
def rotate(self, count=1, with_pane_before_only=False, with_pane_after_only=False):
"""
Rotate panes.
When `with_pane_before_only` or `with_pane_after_only` is True, only rotate
with the pane before/after the active pane.
"""
# Create (split, index, pane, weight) tuples.
items = []
current_pane_index = None
for s in self.splits:
for index, item in enumerate(s):
if isinstance(item, Pane):
items.append((s, index, item, s.weights[item]))
if item == self.active_pane:
current_pane_index = len(items) - 1
# Only before after? Reduce list of panes.
if with_pane_before_only:
items = items[current_pane_index - 1:current_pane_index + 1]
elif with_pane_after_only:
items = items[current_pane_index:current_pane_index + 2]
# Rotate positions.
for i, triple in enumerate(items):
split, index, pane, weight = triple
new_item = items[(i + count) % len(items)][2]
split[index] = new_item
split.weights[new_item] = weight
def select_layout(self, layout_type):
"""
Select one of the predefined layouts.
"""
assert layout_type in LayoutTypes._ALL
# When there is only one pane, always choose EVEN_HORIZONTAL,
# Otherwise, we create VSplit/HSplit instances with an empty list of
# children.
if len(self.panes) == 1:
layout_type = LayoutTypes.EVEN_HORIZONTAL
# even-horizontal.
if layout_type == LayoutTypes.EVEN_HORIZONTAL:
self.root = HSplit(self.panes)
# even-vertical.
elif layout_type == LayoutTypes.EVEN_VERTICAL:
self.root = VSplit(self.panes)
# main-horizontal.
elif layout_type == LayoutTypes.MAIN_HORIZONTAL:
self.root = HSplit([
self.active_pane,
VSplit([p for p in self.panes if p != self.active_pane])
])
# main-vertical.
elif layout_type == LayoutTypes.MAIN_VERTICAL:
self.root = VSplit([
self.active_pane,
HSplit([p for p in self.panes if p != self.active_pane])
])
# tiled.
elif layout_type == LayoutTypes.TILED:
panes = self.panes
column_count = math.ceil(len(panes) ** .5)
rows = HSplit()
current_row = VSplit()
for p in panes:
current_row.append(p)
if len(current_row) >= column_count:
rows.append(current_row)
current_row = VSplit()
if current_row:
rows.append(current_row)
self.root = rows
self.previous_selected_layout = layout_type
def select_next_layout(self, count=1):
"""
Select next layout. (Cycle through predefined layouts.)
"""
# List of all layouts. (When we have just two panes, only toggle
# between horizontal/vertical.)
if len(self.panes) == 2:
all_layouts = [LayoutTypes.EVEN_HORIZONTAL, LayoutTypes.EVEN_VERTICAL]
else:
all_layouts = LayoutTypes._ALL
# Get index of current layout.
layout = self.previous_selected_layout or LayoutTypes._ALL[-1]
try:
index = all_layouts.index(layout)
except ValueError:
index = 0
# Switch to new layout.
new_layout = all_layouts[(index + count) % len(all_layouts)]
self.select_layout(new_layout)
def select_previous_layout(self):
self.select_next_layout(count=-1)
def change_size_for_active_pane(self, up=0, right=0, down=0, left=0):
"""
Increase the size of the current pane in any of the four directions.
"""
child = self.active_pane
self.change_size_for_pane(child, up=up, right=right, down=down, left=left)
def change_size_for_pane(self, pane, up=0, right=0, down=0, left=0):
"""
Increase the size of the current pane in any of the four directions.
Positive values indicate an increase, negative values a decrease.
"""
assert isinstance(pane, Pane)
def find_split_and_child(split_cls, is_before):
" Find the split for which we will have to update the weights. "
child = pane
split = self._get_parent(child)
def found():
return isinstance(split, split_cls) and (
not is_before or split.index(child) > 0) and (
is_before or split.index(child) < len(split) - 1)
while split and not found():
child = split
split = self._get_parent(child)
return split, child # split can be None!
def handle_side(split_cls, is_before, amount, trying_other_side=False):
" Increase weights on one side. (top/left/right/bottom). "
if amount:
split, child = find_split_and_child(split_cls, is_before)
if split:
# Find neighbour.
neighbour_index = split.index(child) + (-1 if is_before else 1)
neighbour_child = split[neighbour_index]
# Increase/decrease weights.
split.weights[child] += amount
split.weights[neighbour_child] -= amount
# Ensure that all weights are at least one.
for k, value in split.weights.items():
if value < 1:
split.weights[k] = 1
else:
# When no split has been found where we can move in this
# direction, try to move the other side instead using a
# negative amount. This happens when we run "resize-pane -R 4"
# inside the pane that is completely on the right. In that
# case it's logical to move the left border to the right
# instead.
if not trying_other_side:
handle_side(split_cls, not is_before, -amount,
trying_other_side=True)
handle_side(VSplit, True, left)
handle_side(VSplit, False, right)
handle_side(HSplit, True, up)
handle_side(HSplit, False, down)
def get_pane_index(self, pane):
" Return the index of the given pane. ValueError if not found. "
assert isinstance(pane, Pane)
return self.panes.index(pane)
class Arrangement(object):
"""
Arrangement class for one Pymux session.
This contains the list of windows and the layout of the panes for each
window. All the clients share the same Arrangement instance, but they can
have different windows active.
"""
def __init__(self):
self.windows = []
self.base_index = 0
self._active_window_for_cli = weakref.WeakKeyDictionary()
self._prev_active_window_for_cli = weakref.WeakKeyDictionary()
# The active window of the last CLI. Used as default when a new session
# is attached.
self._last_active_window = None
def invalidation_hash(self):
"""
When this changes, the layout needs to be rebuild.
"""
if not self.windows:
return '<no-windows>'
w = self.get_active_window()
return w.invalidation_hash()
def get_active_window(self):
"""
The current active :class:`.Window`.
"""
app = get_app()
try:
return self._active_window_for_cli[app]
except KeyError:
self._active_window_for_cli[app] = self._last_active_window or self.windows[0]
return self.windows[0]
def set_active_window(self, window):
assert isinstance(window, Window)
app = get_app()
previous = self.get_active_window()
self._prev_active_window_for_cli[app] = previous
self._active_window_for_cli[app] = window
self._last_active_window = window
def set_active_window_from_pane_id(self, pane_id):
"""
Make the window with this pane ID the active Window.
"""
assert isinstance(pane_id, int)
for w in self.windows:
for p in w.panes:
if p.pane_id == pane_id:
self.set_active_window(w)
def get_previous_active_window(self):
" The previous active Window or None if unknown. "
app = get_app()
try:
return self._prev_active_window_for_cli[app]
except KeyError:
return None
def get_window_by_index(self, index):
" Return the Window with this index or None if not found. "
for w in self.windows:
if w.index == index:
return w
def create_window(self, pane, name=None, set_active=True):
"""
Create a new window that contains just this pane.
:param pane: The :class:`.Pane` instance to put in the new window.
:param name: If given, name for the new window.
:param set_active: When True, focus the new window.
"""
assert isinstance(pane, Pane)
assert name is None or isinstance(name, six.text_type)
# Take the first available index.
taken_indexes = [w.index for w in self.windows]
index = self.base_index
while index in taken_indexes:
index += 1
# Create new window and add it.
w = Window(index)
w.add_pane(pane)
self.windows.append(w)
# Sort windows by index.
self.windows = sorted(self.windows, key=lambda w: w.index)
app = get_app(return_none=True)
if app is not None and set_active:
self.set_active_window(w)
if name is not None:
w.chosen_name = name
assert w.active_pane == pane
assert w._get_parent(pane)
def move_window(self, window, new_index):
"""
Move window to a new index.
"""
assert isinstance(window, Window)
assert isinstance(new_index, int)
window.index = new_index
# Sort windows by index.
self.windows = sorted(self.windows, key=lambda w: w.index)
def get_active_pane(self):
"""
The current :class:`.Pane` from the current window.
"""
w = self.get_active_window()
if w is not None:
return w.active_pane
def remove_pane(self, pane):
"""
Remove a :class:`.Pane`. (Look in all windows.)
"""
assert isinstance(pane, Pane)
for w in self.windows:
w.remove_pane(pane)
# No panes left in this window?
if not w.has_panes:
# Focus next.
for app, active_w in self._active_window_for_cli.items():
if w == active_w:
with set_app(app):
self.focus_next_window()
self.windows.remove(w)
def focus_previous_window(self):
w = self.get_active_window()
self.set_active_window(self.windows[
(self.windows.index(w) - 1) % len(self.windows)])
def focus_next_window(self):
w = self.get_active_window()
self.set_active_window(self.windows[
(self.windows.index(w) + 1) % len(self.windows)])
def break_pane(self, set_active=True):
"""
When the current window has multiple panes, remove the pane from this
window and put it in a new window.
:param set_active: When True, focus the new window.
"""
w = self.get_active_window()
if len(w.panes) > 1:
pane = w.active_pane
self.get_active_window().remove_pane(pane)
self.create_window(pane, set_active=set_active)
def rotate_window(self, count=1):
" Rotate the panes in the active window. "
w = self.get_active_window()
w.rotate(count=count)
@property
def has_panes(self):
" True when any of the windows has a :class:`.Pane`. "
for w in self.windows:
if w.has_panes:
return True
return False

3
pymux/client/__init__.py Normal file
View File

@ -0,0 +1,3 @@
from __future__ import unicode_literals
from .base import Client
from .defaults import create_client, list_clients

22
pymux/client/base.py Normal file
View File

@ -0,0 +1,22 @@
from __future__ import unicode_literals
from prompt_toolkit.output import ColorDepth
from abc import ABCMeta
from six import with_metaclass
__all__ = [
'Client',
]
class Client(with_metaclass(ABCMeta, object)):
def run_command(self, command, pane_id=None):
"""
Ask the server to run this command.
"""
def attach(self, detach_other_clients=False, color_depth=ColorDepth.DEPTH_8_BIT):
"""
Attach client user interface.
"""

24
pymux/client/defaults.py Normal file
View File

@ -0,0 +1,24 @@
from __future__ import unicode_literals
from prompt_toolkit.utils import is_windows
__all__ = [
'create_client',
'list_clients',
]
def create_client(socket_name):
if is_windows():
from .windows import WindowsClient
return WindowsClient(socket_name)
else:
from .posix import PosixClient
return PosixClient(socket_name)
def list_clients():
if is_windows():
from .windows import list_clients
return list_clients()
else:
from .posix import list_clients
return list_clients()

207
pymux/client/posix.py Normal file
View File

@ -0,0 +1,207 @@
from __future__ import unicode_literals
from prompt_toolkit.eventloop.select import select_fds
from prompt_toolkit.input.posix_utils import PosixStdinReader
from prompt_toolkit.input.vt100 import raw_mode, cooked_mode
from prompt_toolkit.output.vt100 import _get_size, Vt100_Output
from prompt_toolkit.output import ColorDepth
from pymux.utils import nonblocking
import getpass
import glob
import json
import os
import signal
import socket
import sys
import tempfile
from .base import Client
INPUT_TIMEOUT = .5
__all__ = (
'PosixClient',
'list_clients',
)
class PosixClient(Client):
def __init__(self, socket_name):
self.socket_name = socket_name
self._mode_context_managers = []
# Connect to socket.
self.socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.socket.connect(socket_name)
self.socket.setblocking(1)
# Input reader.
# Some terminals, like lxterminal send non UTF-8 input sequences,
# even when the input encoding is supposed to be UTF-8. This
# happens in the case of mouse clicks in the right area of a wide
# terminal. Apparently, these are some binary blobs in between the
# UTF-8 input.)
# We should not replace these, because this would break the
# decoding otherwise. (Also don't pass errors='ignore', because
# that doesn't work for parsing mouse input escape sequences, which
# consist of a fixed number of bytes.)
self._stdin_reader = PosixStdinReader(sys.stdin.fileno(), errors='replace')
def run_command(self, command, pane_id=None):
"""
Ask the server to run this command.
:param pane_id: Optional identifier of the current pane.
"""
self._send_packet({
'cmd': 'run-command',
'data': command,
'pane_id': pane_id
})
def attach(self, detach_other_clients=False, color_depth=ColorDepth.DEPTH_8_BIT):
"""
Attach client user interface.
"""
assert isinstance(detach_other_clients, bool)
self._send_size()
self._send_packet({
'cmd': 'start-gui',
'detach-others': detach_other_clients,
'color-depth': color_depth,
'term': os.environ.get('TERM', ''),
'data': ''
})
with raw_mode(sys.stdin.fileno()):
data_buffer = b''
stdin_fd = sys.stdin.fileno()
socket_fd = self.socket.fileno()
current_timeout = INPUT_TIMEOUT # Timeout, used to flush escape sequences.
try:
def winch_handler(signum, frame):
self._send_size()
signal.signal(signal.SIGWINCH, winch_handler)
while True:
r = select_fds([stdin_fd, socket_fd], current_timeout)
if socket_fd in r:
# Received packet from server.
#################################################################################### DECODED
try:
data = self.socket.recv(1024)
except ConnectionResetError:
pass
#################################################################################### DECODED
if data == b'':
# End of file. Connection closed.
# Reset terminal
o = Vt100_Output.from_pty(sys.stdout)
o.quit_alternate_screen()
o.disable_mouse_support()
o.disable_bracketed_paste()
o.reset_attributes()
o.flush()
return
else:
data_buffer += data
while b'\0' in data_buffer:
pos = data_buffer.index(b'\0')
self._process(data_buffer[:pos])
data_buffer = data_buffer[pos + 1:]
elif stdin_fd in r:
# Got user input.
self._process_stdin()
current_timeout = INPUT_TIMEOUT
else:
# Timeout. (Tell the server to flush the vt100 Escape.)
self._send_packet({'cmd': 'flush-input'})
current_timeout = None
finally:
signal.signal(signal.SIGWINCH, signal.SIG_IGN)
def _process(self, data_buffer):
"""
Handle incoming packet from server.
"""
packet = json.loads(data_buffer.decode('utf-8'))
if packet['cmd'] == 'out':
# Call os.write manually. In Python2.6, sys.stdout.write doesn't use UTF-8.
os.write(sys.stdout.fileno(), packet['data'].encode('utf-8'))
elif packet['cmd'] == 'suspend':
# Suspend client process to background.
if hasattr(signal, 'SIGTSTP'):
os.kill(os.getpid(), signal.SIGTSTP)
elif packet['cmd'] == 'mode':
# Set terminal to raw/cooked.
action = packet['data']
if action == 'raw':
cm = raw_mode(sys.stdin.fileno())
cm.__enter__()
self._mode_context_managers.append(cm)
elif action == 'cooked':
cm = cooked_mode(sys.stdin.fileno())
cm.__enter__()
self._mode_context_managers.append(cm)
elif action == 'restore' and self._mode_context_managers:
cm = self._mode_context_managers.pop()
cm.__exit__()
def _process_stdin(self):
"""
Received data on stdin. Read and send to server.
"""
with nonblocking(sys.stdin.fileno()):
data = self._stdin_reader.read()
# Send input in chunks of 4k.
step = 4056
for i in range(0, len(data), step):
self._send_packet({
'cmd': 'in',
'data': data[i:i + step],
})
def _send_packet(self, data):
" Send to server. "
data = json.dumps(data).encode('utf-8')
# Be sure that our socket is blocking, otherwise, the send() call could
# raise `BlockingIOError` if the buffer is full.
self.socket.setblocking(1)
self.socket.send(data + b'\0')
def _send_size(self):
" Report terminal size to server. "
rows, cols = _get_size(sys.stdout.fileno())
self._send_packet({
'cmd': 'size',
'data': [rows, cols]
})
def list_clients():
"""
List all the servers that are running.
"""
p = '%s/pymux.sock.%s.*' % (tempfile.gettempdir(), getpass.getuser())
for path in glob.glob(p):
try:
yield PosixClient(path)
except socket.error:
pass

128
pymux/client/windows.py Normal file
View File

@ -0,0 +1,128 @@
from __future__ import unicode_literals
from ctypes import byref, windll
from ctypes.wintypes import DWORD
from prompt_toolkit.eventloop import ensure_future, From
from prompt_toolkit.eventloop import get_event_loop
from prompt_toolkit.input.win32 import Win32Input
from prompt_toolkit.output import ColorDepth
from prompt_toolkit.output.win32 import Win32Output
from prompt_toolkit.win32_types import STD_OUTPUT_HANDLE
import json
import os
import sys
from ..pipes.win32_client import PipeClient
from .base import Client
__all__ = [
'WindowsClient',
'list_clients',
]
# See: https://msdn.microsoft.com/pl-pl/library/windows/desktop/ms686033(v=vs.85).aspx
ENABLE_PROCESSED_INPUT = 0x0001
ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
class WindowsClient(Client):
def __init__(self, pipe_name):
self._input = Win32Input()
self._hconsole = windll.kernel32.GetStdHandle(STD_OUTPUT_HANDLE)
self._data_buffer = b''
self.pipe = PipeClient(pipe_name)
def attach(self, detach_other_clients=False, color_depth=ColorDepth.DEPTH_8_BIT):
assert isinstance(detach_other_clients, bool)
self._send_size()
self._send_packet({
'cmd': 'start-gui',
'detach-others': detach_other_clients,
'color-depth': color_depth,
'term': os.environ.get('TERM', ''),
'data': ''
})
f = ensure_future(self._start_reader())
with self._input.attach(self._input_ready):
# Run as long as we have a connection with the server.
get_event_loop().run_until_complete(f) # Run forever.
def _start_reader(self):
"""
Read messages from the Win32 pipe server and handle them.
"""
while True:
message = yield From(self.pipe.read_message())
self._process(message)
def _process(self, data_buffer):
"""
Handle incoming packet from server.
"""
packet = json.loads(data_buffer)
if packet['cmd'] == 'out':
# Call os.write manually. In Python2.6, sys.stdout.write doesn't use UTF-8.
original_mode = DWORD(0)
windll.kernel32.GetConsoleMode(self._hconsole, byref(original_mode))
windll.kernel32.SetConsoleMode(self._hconsole, DWORD(
ENABLE_PROCESSED_INPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING))
try:
os.write(sys.stdout.fileno(), packet['data'].encode('utf-8'))
finally:
windll.kernel32.SetConsoleMode(self._hconsole, original_mode)
elif packet['cmd'] == 'suspend':
# Suspend client process to background.
pass
elif packet['cmd'] == 'mode':
pass
# # Set terminal to raw/cooked.
# action = packet['data']
# if action == 'raw':
# cm = raw_mode(sys.stdin.fileno())
# cm.__enter__()
# self._mode_context_managers.append(cm)
# elif action == 'cooked':
# cm = cooked_mode(sys.stdin.fileno())
# cm.__enter__()
# self._mode_context_managers.append(cm)
# elif action == 'restore' and self._mode_context_managers:
# cm = self._mode_context_managers.pop()
# cm.__exit__()
def _input_ready(self):
keys = self._input.read_keys()
if keys:
self._send_packet({
'cmd': 'in',
'data': ''.join(key_press.data for key_press in keys),
})
def _send_packet(self, data):
" Send to server. "
data = json.dumps(data)
ensure_future(self.pipe.write_message(data))
def _send_size(self):
" Report terminal size to server. "
output = Win32Output(sys.stdout)
rows, cols = output.get_size()
self._send_packet({
'cmd': 'size',
'data': [rows, cols]
})
def list_clients():
return []

View File

51
pymux/commands/aliases.py Normal file
View File

@ -0,0 +1,51 @@
"""
Aliases for all commands.
(On purpose kept compatible with tmux.)
"""
from __future__ import unicode_literals
__all__ = (
'ALIASES',
)
ALIASES = {
'bind': 'bind-key',
'breakp': 'break-pane',
'clearhist': 'clear-history',
'confirm': 'confirm-before',
'detach': 'detach-client',
'display': 'display-message',
######################################################################################################## DECODED
'decoded': 'display-decoded',
######################################################################################################## DECODED
'displayp': 'display-panes',
'killp': 'kill-pane',
'killw': 'kill-window',
'last': 'last-window',
'lastp': 'last-pane',
'lextl': 'next-layout',
'lsk': 'list-keys',
'lsp': 'list-panes',
'movew': 'move-window',
'neww': 'new-window',
'next': 'next-window',
'pasteb': 'paste-buffer',
'prev': 'previous-window',
'prevl': 'previous-layout',
'rename': 'rename-session',
'renamew': 'rename-window',
'resizep': 'resize-pane',
'rotatew': 'rotate-window',
'selectl': 'select-layout',
'selectp': 'select-pane',
'selectw': 'select-window',
'send': 'send-keys',
'set': 'set-option',
'setw': 'set-window-option',
'source': 'source-file',
'splitw': 'split-window',
'suspendc': 'suspend-client',
'swapp': 'swap-pane',
'unbind': 'unbind-key',
}

676
pymux/commands/commands.py Normal file
View File

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

185
pymux/commands/completer.py Normal file
View File

@ -0,0 +1,185 @@
from __future__ import unicode_literals
from prompt_toolkit.completion import Completer, Completion, WordCompleter
from prompt_toolkit.document import Document
from .aliases import ALIASES
from .commands import COMMANDS_TO_HANDLERS, get_option_flags_for_command
from .utils import wrap_argument
from pymux.arrangement import LayoutTypes
from pymux.key_mappings import PYMUX_TO_PROMPT_TOOLKIT_KEYS
from functools import partial
__all__ = (
'create_command_completer',
)
def create_command_completer(pymux):
return ShlexCompleter(partial(get_completions_for_parts, pymux=pymux))
class CommandCompleter(Completer):
"""
Completer for command names.
"""
def __init__(self):
# Completer for full command names.
self._command_completer = WordCompleter(
sorted(COMMANDS_TO_HANDLERS.keys()),
ignore_case=True, WORD=True, match_middle=True)
# Completer for aliases.
self._aliases_completer = WordCompleter(
sorted(ALIASES.keys()),
ignore_case=True, WORD=True, match_middle=True)
def get_completions(self, document, complete_event):
# First, complete on full command names.
found = False
for c in self._command_completer.get_completions(document, complete_event):
found = True
yield c
# When no matches are found, complete aliases instead.
# The completion however, inserts the full name.
if not found:
for c in self._aliases_completer.get_completions(document, complete_event):
full_name = ALIASES.get(c.display)
yield Completion(full_name,
start_position=c.start_position,
display='%s (%s)' % (c.display, full_name))
_command_completer = CommandCompleter()
_layout_type_completer = WordCompleter(sorted(LayoutTypes._ALL), WORD=True)
_keys_completer = WordCompleter(sorted(PYMUX_TO_PROMPT_TOOLKIT_KEYS.keys()),
ignore_case=True, WORD=True)
def get_completions_for_parts(parts, last_part, complete_event, pymux):
completer = None
# Resolve aliases.
if len(parts) > 0:
parts = [ALIASES.get(parts[0], parts[0])] + parts[1:]
if len(parts) == 0:
# New command.
completer = _command_completer
elif len(parts) >= 1 and last_part.startswith('-'):
flags = get_option_flags_for_command(parts[0])
completer = WordCompleter(sorted(flags), WORD=True)
elif len(parts) == 1 and parts[0] in ('set-option', 'set-window-option'):
options = pymux.options if parts[0] == 'set-option' else pymux.window_options
completer = WordCompleter(sorted(options.keys()), sentence=True)
elif len(parts) == 2 and parts[0] in ('set-option', 'set-window-option'):
options = pymux.options if parts[0] == 'set-option' else pymux.window_options
option = options.get(parts[1])
if option:
completer = WordCompleter(sorted(option.get_all_values(pymux)), sentence=True)
elif len(parts) == 1 and parts[0] == 'select-layout':
completer = _layout_type_completer
elif len(parts) == 1 and parts[0] == 'send-keys':
completer = _keys_completer
elif parts[0] == 'bind-key':
if len(parts) == 1:
completer = _keys_completer
elif len(parts) == 2:
completer = _command_completer
# Recursive, for bind-key options.
if parts and parts[0] == 'bind-key' and len(parts) > 2:
for c in get_completions_for_parts(parts[2:], last_part, complete_event, pymux):
yield c
if completer:
for c in completer.get_completions(Document(last_part), complete_event):
yield c
class ShlexCompleter(Completer):
"""
Completer that can be used when the input is parsed with shlex.
"""
def __init__(self, get_completions_for_parts):
assert callable(get_completions_for_parts)
self.get_completions_for_parts = get_completions_for_parts
def get_completions(self, document, complete_event):
text = document.text_before_cursor
parts, part_start_pos = self.parse(text)
for c in self.get_completions_for_parts(parts[:-1], parts[-1], complete_event):
yield Completion(wrap_argument(parts[-1][:c.start_position] + c.text),
start_position=part_start_pos - len(document.text),
display=c.display,
display_meta=c.display_meta)
@classmethod
def parse(cls, text):
"""
Parse the given text. Returns a tuple:
(list_of_parts, start_pos_of_the_last_part).
"""
OUTSIDE, IN_DOUBLE, IN_SINGLE = 0, 1, 2
iterator = enumerate(text)
state = OUTSIDE
parts = []
current_part = ''
part_start_pos = 0
for i, c in iterator: # XXX: correctly handle empty strings.
if state == OUTSIDE:
if c.isspace():
# New part.
if current_part:
parts.append(current_part)
part_start_pos = i + 1
current_part = ''
elif c == '"':
state = IN_DOUBLE
elif c == "'":
state = IN_SINGLE
else:
current_part += c
elif state == IN_SINGLE:
if c == "'":
state = OUTSIDE
elif c == "\\":
next(iterator)
current_part += c
else:
current_part += c
elif state == IN_DOUBLE:
if c == '"':
state = OUTSIDE
elif c == "\\":
next(iterator)
current_part += c
else:
current_part += c
parts.append(current_part)
return parts, part_start_pos
# assert ShlexCompleter.parse('"hello" world') == (['hello', 'world'], 8)

15
pymux/commands/utils.py Normal file
View File

@ -0,0 +1,15 @@
from __future__ import unicode_literals
__all__ = (
'wrap_argument',
)
def wrap_argument(text):
"""
Wrap command argument in quotes and escape when this contains special characters.
"""
if not any(x in text for x in [' ', '"', "'", '\\']):
return text
else:
return '"%s"' % (text.replace('\\', r'\\').replace('"', r'\"'), )

View File

View File

@ -0,0 +1,163 @@
#!/usr/bin/env python
"""
pymux: Pure Python terminal multiplexer.
Usage:
pymux [(standalone|start-server|attach)] [-d]
[--truecolor] [--ansicolor] [(-S <socket>)] [(-f <file>)]
[(--log <logfile>)]
[--] [<command>]
pymux list-sessions
pymux -h | --help
pymux <command>
Options:
standalone : Run as a standalone process. (for debugging, detaching is
not possible.
start-server : Run a server daemon that can be attached later on.
attach : Attach to a running session.
-f : Path to configuration file. By default: '~/.pymux.conf'.
-S : Unix socket path.
-d : Detach all other clients, when attaching.
--log : Logfile.
--truecolor : Render true color (24 bit) instead of 256 colors.
(Each client can set this separately.)
"""
from __future__ import unicode_literals, absolute_import
from prompt_toolkit.output import ColorDepth
from pymux.main import Pymux
from pymux.client import create_client, list_clients
from pymux.utils import daemonize
import docopt
import getpass
import logging
import os
import sys
import tempfile
__all__ = (
'run',
)
def run():
a = docopt.docopt(__doc__)
socket_name = a['<socket>'] or os.environ.get('PYMUX')
socket_name_from_env = not a['<socket>'] and os.environ.get('PYMUX')
filename = a['<file>']
command = a['<command>']
true_color = a['--truecolor']
ansi_colors_only = a['--ansicolor'] or \
bool(os.environ.get('PROMPT_TOOLKIT_ANSI_COLORS_ONLY', False))
# Parse pane_id from socket_name. It looks like "socket_name,pane_id".
if socket_name and ',' in socket_name:
socket_name, pane_id = socket_name.rsplit(',', 1)
else:
pane_id = None
# Color depth.
if ansi_colors_only:
color_depth = ColorDepth.DEPTH_4_BIT
elif true_color:
color_depth = ColorDepth.DEPTH_24_BIT
else:
color_depth = ColorDepth.DEPTH_8_BIT
# Expand socket name. (Make it possible to just accept numbers.)
if socket_name and socket_name.isdigit():
socket_name = '%s/pymux.sock.%s.%s' % (
tempfile.gettempdir(), getpass.getuser(), socket_name)
# Configuration filename.
default_config = os.path.abspath(os.path.expanduser('~/.pymux.conf'))
if not filename and os.path.exists(default_config):
filename = default_config
if filename:
filename = os.path.abspath(os.path.expanduser(filename))
# Create 'Pymux'.
mux = Pymux(source_file=filename, startup_command=command)
# Setup logging.
if a['<logfile>']:
logging.basicConfig(filename=a['<logfile>'], level=logging.DEBUG)
if a['standalone']:
mux.run_standalone(color_depth=color_depth)
elif a['list-sessions'] or a['<command>'] in ('ls', 'list-sessions'):
for c in list_clients():
print(c.socket_name)
elif a['start-server']:
if socket_name_from_env:
_socket_from_env_warning()
sys.exit(1)
# Log to stdout.
logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)
# Run server.
socket_name = mux.listen_on_socket()
try:
mux.run_server()
except KeyboardInterrupt:
sys.exit(1)
elif a['attach']:
if socket_name_from_env:
_socket_from_env_warning()
sys.exit(1)
detach_other_clients = a['-d']
if socket_name:
create_client(socket_name).attach(
detach_other_clients=detach_other_clients,
color_depth=color_depth)
else:
# Connect to the first server.
for c in list_clients():
c.attach(detach_other_clients=detach_other_clients,
color_depth=color_depth)
break
else: # Nobreak.
print('No pymux instance found.')
sys.exit(1)
elif a['<command>'] and socket_name:
create_client(socket_name).run_command(a['<command>'], pane_id)
elif not socket_name:
# Run client/server combination.
socket_name = mux.listen_on_socket(socket_name)
pid = daemonize()
if pid > 0:
# Create window. It is important that this happens in the daemon,
# because the parent of the process running inside should be this
# daemon. (Otherwise the `waitpid` call won't work.)
mux.run_server()
else:
create_client(socket_name).attach(color_depth=color_depth)
else:
if socket_name_from_env:
_socket_from_env_warning()
sys.exit(1)
else:
print('Invalid command.')
sys.exit(1)
def _socket_from_env_warning():
print('Please be careful nesting pymux sessions.')
print('Unset PYMUX environment variable first.')
if __name__ == '__main__':
run()

13
pymux/enums.py Normal file
View File

@ -0,0 +1,13 @@
from __future__ import unicode_literals
__all__ = (
'COMMAND',
'PROMPT',
)
#: Name of the command buffer.
COMMAND = 'COMMAND'
#: Name of the input for a "command-prompt" command.
PROMPT = 'PROMPT'

101
pymux/filters.py Normal file
View File

@ -0,0 +1,101 @@
from __future__ import unicode_literals
from prompt_toolkit.filters import Filter
__all__ = (
'HasPrefix',
'WaitsForConfirmation',
'InCommandMode',
'WaitsForPrompt',
'InScrollBuffer',
'InScrollBufferNotSearching',
'InScrollBufferSearching',
)
class HasPrefix(Filter):
"""
When the prefix key (Usual C-b) has been pressed.
"""
def __init__(self, pymux):
self.pymux = pymux
def __call__(self):
return self.pymux.get_client_state().has_prefix
class WaitsForConfirmation(Filter):
"""
Waiting for a yes/no key press.
"""
def __init__(self, pymux):
self.pymux = pymux
def __call__(self):
return bool(self.pymux.get_client_state().confirm_command)
class InCommandMode(Filter):
"""
When ':' has been pressed.'
"""
def __init__(self, pymux):
self.pymux = pymux
def __call__(self):
client_state = self.pymux.get_client_state()
return client_state.command_mode and not client_state.confirm_command
class WaitsForPrompt(Filter):
"""
Waiting for input for a "command-prompt" command.
"""
def __init__(self, pymux):
self.pymux = pymux
def __call__(self):
client_state = self.pymux.get_client_state()
return bool(client_state.prompt_command) and not client_state.confirm_command
def _confirm_or_prompt_or_command(pymux):
" True when we are waiting for a command, prompt or confirmation. "
client_state = pymux.get_client_state()
if client_state.confirm_text or client_state.prompt_command or client_state.command_mode:
return True
class InScrollBuffer(Filter):
def __init__(self, pymux):
self.pymux = pymux
def __call__(self):
if _confirm_or_prompt_or_command(self.pymux):
return False
pane = self.pymux.arrangement.get_active_pane()
return pane.display_scroll_buffer
class InScrollBufferNotSearching(Filter):
def __init__(self, pymux):
self.pymux = pymux
def __call__(self):
if _confirm_or_prompt_or_command(self.pymux):
return False
pane = self.pymux.arrangement.get_active_pane()
return pane.display_scroll_buffer and not pane.is_searching
class InScrollBufferSearching(Filter):
def __init__(self, pymux):
self.pymux = pymux
def __call__(self):
if _confirm_or_prompt_or_command(self.pymux):
return False
pane = self.pymux.arrangement.get_active_pane()
return pane.display_scroll_buffer and pane.is_searching

99
pymux/format.py Normal file
View File

@ -0,0 +1,99 @@
"""
Pymux string formatting.
"""
from __future__ import unicode_literals
import datetime
import socket
import six
__all__ = (
'format_pymux_string',
)
def format_pymux_string(pymux, string, window=None, pane=None):
"""
Apply pymux sting formatting. (Similar to tmux.)
E.g. #P is replaced by the index of the active pane.
We try to stay compatible with tmux, if possible.
One thing that we won't support (for now) is colors, because our styling
works different. (With a Style class.) On the other hand, in the future, we
could allow things like `#[token=Token.Title.PID]`. This gives a clean
separation of semantics and colors, making it easy to write different color
schemes.
"""
arrangement = pymux.arrangement
if window is None:
window = arrangement.get_active_window()
if pane is None:
pane = window.active_pane
def id_of_pane():
return '%s' % (pane.pane_id, )
def index_of_pane():
try:
return '%s' % (window.get_pane_index(pane), )
except ValueError:
return '/'
def index_of_window():
return '%s' % (window.index, )
def name_of_window():
return window.name or '(noname)'
def window_flags():
z = 'Z' if window.zoom else ''
if window == arrangement.get_active_window():
return '*' + z
elif window == arrangement.get_previous_active_window():
return '-' + z
else:
return z + ' '
def name_of_session():
return pymux.session_name
def title_of_pane():
return pane.process.screen.title
def hostname():
return socket.gethostname()
def literal():
return '#'
format_table = {
'#D': id_of_pane,
'#F': window_flags,
'#I': index_of_window,
'#P': index_of_pane,
'#S': name_of_session,
'#T': title_of_pane,
'#W': name_of_window,
'#h': hostname,
'##': literal,
}
# Date/time formatting.
if '%' in string:
try:
if six.PY2:
string = datetime.datetime.now().strftime(
string.encode('utf-8')).decode('utf-8')
else:
string = datetime.datetime.now().strftime(string)
except ValueError: # strftime format ends with raw %
string = '<ValueError>'
# Apply '#' formatting.
for symbol, f in format_table.items():
if symbol in string:
string = string.replace(symbol, f())
return string

260
pymux/key_bindings.py Normal file
View File

@ -0,0 +1,260 @@
"""
Key bindings.
"""
from __future__ import unicode_literals
from prompt_toolkit.filters import has_focus, Condition, has_selection
from prompt_toolkit.keys import Keys
from prompt_toolkit.selection import SelectionType
from prompt_toolkit.key_binding import KeyBindings, merge_key_bindings
from .enums import COMMAND, PROMPT
from .filters import WaitsForConfirmation, HasPrefix, InScrollBufferNotSearching
from .key_mappings import pymux_key_to_prompt_toolkit_key_sequence
from .commands.commands import call_command_handler
import six
__all__ = (
'PymuxKeyBindings',
)
class PymuxKeyBindings(object):
"""
Pymux key binding manager.
"""
def __init__(self, pymux):
self.pymux = pymux
def get_search_state():
" Return the currently active SearchState. (The one for the focused pane.) "
return pymux.arrangement.get_active_pane().search_state
self.custom_key_bindings = KeyBindings()
self.key_bindings = merge_key_bindings([
self._load_builtins(),
self.custom_key_bindings,
])
self._prefix = ('c-b', )
self._prefix_binding = None
# Load initial bindings.
self._load_prefix_binding()
# Custom user configured key bindings.
# { (needs_prefix, key) -> (command, handler) }
self.custom_bindings = {}
def _load_prefix_binding(self):
"""
Load the prefix key binding.
"""
pymux = self.pymux
# Remove previous binding.
if self._prefix_binding:
self.custom_key_bindings.remove_binding(self._prefix_binding)
# Create new Python binding.
@self.custom_key_bindings.add(*self._prefix, filter=
~(HasPrefix(pymux) | has_focus(COMMAND) | has_focus(PROMPT) |
WaitsForConfirmation(pymux)))
def enter_prefix_handler(event):
" Enter prefix mode. "
pymux.get_client_state().has_prefix = True
self._prefix_binding = enter_prefix_handler
@property
def prefix(self):
" Get the prefix key. "
return self._prefix
@prefix.setter
def prefix(self, keys):
"""
Set a new prefix key.
"""
assert isinstance(keys, tuple)
self._prefix = keys
self._load_prefix_binding()
def _load_builtins(self):
"""
Fill the Registry with the hard coded key bindings.
"""
pymux = self.pymux
kb = KeyBindings()
# Create filters.
has_prefix = HasPrefix(pymux)
waits_for_confirmation = WaitsForConfirmation(pymux)
prompt_or_command_focus = has_focus(COMMAND) | has_focus(PROMPT)
display_pane_numbers = Condition(lambda: pymux.display_pane_numbers)
in_scroll_buffer_not_searching = InScrollBufferNotSearching(pymux)
@kb.add(Keys.Any, filter=has_prefix)
def _(event):
" Ignore unknown Ctrl-B prefixed key sequences. "
pymux.get_client_state().has_prefix = False
@kb.add('c-c', filter=prompt_or_command_focus & ~has_prefix)
@kb.add('c-g', filter=prompt_or_command_focus & ~has_prefix)
# @kb.add('backspace', filter=has_focus(COMMAND) & ~has_prefix &
# Condition(lambda: cli.buffers[COMMAND].text == ''))
def _(event):
" Leave command mode. "
pymux.leave_command_mode(append_to_history=False)
@kb.add('y', filter=waits_for_confirmation)
@kb.add('Y', filter=waits_for_confirmation)
def _(event):
"""
Confirm command.
"""
client_state = pymux.get_client_state()
command = client_state.confirm_command
client_state.confirm_command = None
client_state.confirm_text = None
pymux.handle_command(command)
@kb.add('n', filter=waits_for_confirmation)
@kb.add('N', filter=waits_for_confirmation)
@kb.add('c-c' , filter=waits_for_confirmation)
def _(event):
"""
Cancel command.
"""
client_state = pymux.get_client_state()
client_state.confirm_command = None
client_state.confirm_text = None
@kb.add('c-c', filter=in_scroll_buffer_not_searching)
@kb.add('enter', filter=in_scroll_buffer_not_searching)
@kb.add('q', filter=in_scroll_buffer_not_searching)
def _(event):
" Exit scroll buffer. "
pane = pymux.arrangement.get_active_pane()
pane.exit_scroll_buffer()
@kb.add(' ', filter=in_scroll_buffer_not_searching)
def _(event):
" Enter selection mode when pressing space in copy mode. "
event.current_buffer.start_selection(selection_type=SelectionType.CHARACTERS)
@kb.add('enter', filter=in_scroll_buffer_not_searching & has_selection)
def _(event):
" Copy selection when pressing Enter. "
clipboard_data = event.current_buffer.copy_selection()
event.app.clipboard.set_data(clipboard_data)
@kb.add('v', filter=in_scroll_buffer_not_searching & has_selection)
def _(event):
" Toggle between selection types. "
types = [SelectionType.LINES, SelectionType.BLOCK, SelectionType.CHARACTERS]
selection_state = event.current_buffer.selection_state
try:
index = types.index(selection_state.type)
except ValueError: # Not in list.
index = 0
selection_state.type = types[(index + 1) % len(types)]
@Condition
def popup_displayed():
return self.pymux.get_client_state().display_popup
@kb.add('q', filter=popup_displayed, eager=True)
def _(event):
" Quit pop-up dialog. "
self.pymux.get_client_state().display_popup = False
@kb.add(Keys.Any, eager=True, filter=display_pane_numbers)
def _(event):
" When the pane numbers are shown. Any key press should hide them. "
pymux.display_pane_numbers = False
@Condition
def clock_displayed():
" "
pane = pymux.arrangement.get_active_pane()
return pane.clock_mode
@kb.add(Keys.Any, eager=True, filter=clock_displayed)
def _(event):
" When the clock is displayed. Any key press should hide it. "
pane = pymux.arrangement.get_active_pane()
pane.clock_mode = False
return kb
def add_custom_binding(self, key_name, command, arguments, needs_prefix=False):
"""
Add custom binding (for the "bind-key" command.)
Raises ValueError if the give `key_name` is an invalid name.
:param key_name: Pymux key name, for instance "C-a" or "M-x".
"""
assert isinstance(key_name, six.text_type)
assert isinstance(command, six.text_type)
assert isinstance(arguments, list)
# Unbind previous key.
self.remove_custom_binding(key_name, needs_prefix=needs_prefix)
# Translate the pymux key name into a prompt_toolkit key sequence.
# (Can raise ValueError.)
keys_sequence = pymux_key_to_prompt_toolkit_key_sequence(key_name)
# Create handler and add to Registry.
if needs_prefix:
filter = HasPrefix(self.pymux)
else:
filter = ~HasPrefix(self.pymux)
filter = filter & ~(WaitsForConfirmation(self.pymux) |
has_focus(COMMAND) | has_focus(PROMPT))
def key_handler(event):
" The actual key handler. "
call_command_handler(command, self.pymux, arguments)
self.pymux.get_client_state().has_prefix = False
self.custom_key_bindings.add(*keys_sequence, filter=filter)(key_handler)
# Store key in `custom_bindings` in order to be able to call
# "unbind-key" later on.
k = (needs_prefix, key_name)
self.custom_bindings[k] = CustomBinding(key_handler, command, arguments)
def remove_custom_binding(self, key_name, needs_prefix=False):
"""
Remove custom key binding for a key.
:param key_name: Pymux key name, for instance "C-A".
"""
k = (needs_prefix, key_name)
if k in self.custom_bindings:
self.custom_key_bindings.remove(self.custom_bindings[k].handler)
del self.custom_bindings[k]
class CustomBinding(object):
"""
Record for storing a single custom key binding.
"""
def __init__(self, handler, command, arguments):
assert callable(handler)
assert isinstance(command, six.text_type)
assert isinstance(arguments, list)
self.handler = handler
self.command = command
self.arguments = arguments

231
pymux/key_mappings.py Normal file
View File

@ -0,0 +1,231 @@
"""
Mapping between vt100 key sequences, the prompt_toolkit key constants and the
Pymux namings. (Those namings are kept compatible with tmux.)
"""
from __future__ import unicode_literals
from prompt_toolkit.keys import Keys
from prompt_toolkit.input.vt100_parser import ANSI_SEQUENCES
__all__ = (
'pymux_key_to_prompt_toolkit_key_sequence',
'prompt_toolkit_key_to_vt100_key',
'PYMUX_TO_PROMPT_TOOLKIT_KEYS',
)
def pymux_key_to_prompt_toolkit_key_sequence(key):
"""
Turn a pymux description of a key. E.g. "C-a" or "M-x" into a
prompt-toolkit key sequence.
Raises `ValueError` if the key is not known.
"""
# Make the c- and m- prefixes case insensitive.
if key.lower().startswith('m-c-'):
key = 'M-C-' + key[4:]
elif key.lower().startswith('c-'):
key = 'C-' + key[2:]
elif key.lower().startswith('m-'):
key = 'M-' + key[2:]
# Lookup key.
try:
return PYMUX_TO_PROMPT_TOOLKIT_KEYS[key]
except KeyError:
if len(key) == 1:
return (key, )
else:
raise ValueError('Unknown key: %r' % (key, ))
# Create a mapping from prompt_toolkit keys to their ANSI sequences.
# TODO: This is not completely correct yet. It doesn't take
# cursor/application mode into account. Create new tables for this.
_PROMPT_TOOLKIT_KEY_TO_VT100 = dict(
(key, vt100_data) for vt100_data, key in ANSI_SEQUENCES.items())
def prompt_toolkit_key_to_vt100_key(key, application_mode=False):
"""
Turn a prompt toolkit key. (E.g Keys.ControlB) into a Vt100 key sequence.
(E.g. \x1b[A.)
"""
application_mode_keys = {
Keys.Up: '\x1bOA',
Keys.Left: '\x1bOD',
Keys.Right: '\x1bOC',
Keys.Down: '\x1bOB',
}
if key == Keys.ControlJ:
# Required for redis-cli. This can be removed when prompt_toolkit stops
# replacing \r by \n.
return '\r'
if key == '\n':
return '\r'
elif application_mode and key in application_mode_keys:
return application_mode_keys.get(key)
else:
return _PROMPT_TOOLKIT_KEY_TO_VT100.get(key, key)
PYMUX_TO_PROMPT_TOOLKIT_KEYS = {
'Space': (' '),
'C-a': (Keys.ControlA, ),
'C-b': (Keys.ControlB, ),
'C-c': (Keys.ControlC, ),
'C-d': (Keys.ControlD, ),
'C-e': (Keys.ControlE, ),
'C-f': (Keys.ControlF, ),
'C-g': (Keys.ControlG, ),
'C-h': (Keys.ControlH, ),
'C-i': (Keys.ControlI, ),
'C-j': (Keys.ControlJ, ),
'C-k': (Keys.ControlK, ),
'C-l': (Keys.ControlL, ),
'C-m': (Keys.ControlM, ),
'C-n': (Keys.ControlN, ),
'C-o': (Keys.ControlO, ),
'C-p': (Keys.ControlP, ),
'C-q': (Keys.ControlQ, ),
'C-r': (Keys.ControlR, ),
'C-s': (Keys.ControlS, ),
'C-t': (Keys.ControlT, ),
'C-u': (Keys.ControlU, ),
'C-v': (Keys.ControlV, ),
'C-w': (Keys.ControlW, ),
'C-x': (Keys.ControlX, ),
'C-y': (Keys.ControlY, ),
'C-z': (Keys.ControlZ, ),
'C-Left': (Keys.ControlLeft, ),
'C-Right': (Keys.ControlRight, ),
'C-Up': (Keys.ControlUp, ),
'C-Down': (Keys.ControlDown, ),
'C-\\': (Keys.ControlBackslash, ),
'S-Left': (Keys.ShiftLeft, ),
'S-Right': (Keys.ShiftRight, ),
'S-Up': (Keys.ShiftUp, ),
'S-Down': (Keys.ShiftDown, ),
'M-C-a': (Keys.Escape, Keys.ControlA, ),
'M-C-b': (Keys.Escape, Keys.ControlB, ),
'M-C-c': (Keys.Escape, Keys.ControlC, ),
'M-C-d': (Keys.Escape, Keys.ControlD, ),
'M-C-e': (Keys.Escape, Keys.ControlE, ),
'M-C-f': (Keys.Escape, Keys.ControlF, ),
'M-C-g': (Keys.Escape, Keys.ControlG, ),
'M-C-h': (Keys.Escape, Keys.ControlH, ),
'M-C-i': (Keys.Escape, Keys.ControlI, ),
'M-C-j': (Keys.Escape, Keys.ControlJ, ),
'M-C-k': (Keys.Escape, Keys.ControlK, ),
'M-C-l': (Keys.Escape, Keys.ControlL, ),
'M-C-m': (Keys.Escape, Keys.ControlM, ),
'M-C-n': (Keys.Escape, Keys.ControlN, ),
'M-C-o': (Keys.Escape, Keys.ControlO, ),
'M-C-p': (Keys.Escape, Keys.ControlP, ),
'M-C-q': (Keys.Escape, Keys.ControlQ, ),
'M-C-r': (Keys.Escape, Keys.ControlR, ),
'M-C-s': (Keys.Escape, Keys.ControlS, ),
'M-C-t': (Keys.Escape, Keys.ControlT, ),
'M-C-u': (Keys.Escape, Keys.ControlU, ),
'M-C-v': (Keys.Escape, Keys.ControlV, ),
'M-C-w': (Keys.Escape, Keys.ControlW, ),
'M-C-x': (Keys.Escape, Keys.ControlX, ),
'M-C-y': (Keys.Escape, Keys.ControlY, ),
'M-C-z': (Keys.Escape, Keys.ControlZ, ),
'M-C-Left': (Keys.Escape, Keys.ControlLeft, ),
'M-C-Right': (Keys.Escape, Keys.ControlRight, ),
'M-C-Up': (Keys.Escape, Keys.ControlUp, ),
'M-C-Down': (Keys.Escape, Keys.ControlDown, ),
'M-C-\\': (Keys.Escape, Keys.ControlBackslash, ),
'M-a': (Keys.Escape, 'a'),
'M-b': (Keys.Escape, 'b'),
'M-c': (Keys.Escape, 'c'),
'M-d': (Keys.Escape, 'd'),
'M-e': (Keys.Escape, 'e'),
'M-f': (Keys.Escape, 'f'),
'M-g': (Keys.Escape, 'g'),
'M-h': (Keys.Escape, 'h'),
'M-i': (Keys.Escape, 'i'),
'M-j': (Keys.Escape, 'j'),
'M-k': (Keys.Escape, 'k'),
'M-l': (Keys.Escape, 'l'),
'M-m': (Keys.Escape, 'm'),
'M-n': (Keys.Escape, 'n'),
'M-o': (Keys.Escape, 'o'),
'M-p': (Keys.Escape, 'p'),
'M-q': (Keys.Escape, 'q'),
'M-r': (Keys.Escape, 'r'),
'M-s': (Keys.Escape, 's'),
'M-t': (Keys.Escape, 't'),
'M-u': (Keys.Escape, 'u'),
'M-v': (Keys.Escape, 'v'),
'M-w': (Keys.Escape, 'w'),
'M-x': (Keys.Escape, 'x'),
'M-y': (Keys.Escape, 'y'),
'M-z': (Keys.Escape, 'z'),
'M-0': (Keys.Escape, '0'),
'M-1': (Keys.Escape, '1'),
'M-2': (Keys.Escape, '2'),
'M-3': (Keys.Escape, '3'),
'M-4': (Keys.Escape, '4'),
'M-5': (Keys.Escape, '5'),
'M-6': (Keys.Escape, '6'),
'M-7': (Keys.Escape, '7'),
'M-8': (Keys.Escape, '8'),
'M-9': (Keys.Escape, '9'),
'M-Up': (Keys.Escape, Keys.Up),
'M-Down': (Keys.Escape, Keys.Down, ),
'M-Left': (Keys.Escape, Keys.Left, ),
'M-Right': (Keys.Escape, Keys.Right, ),
'Left': (Keys.Left, ),
'Right': (Keys.Right, ),
'Up': (Keys.Up, ),
'Down': (Keys.Down, ),
'BSpace': (Keys.Backspace, ),
'BTab': (Keys.BackTab, ),
'DC': (Keys.Delete, ),
'IC': (Keys.Insert, ),
'End': (Keys.End, ),
'Enter': (Keys.ControlJ, ),
'Home': (Keys.Home, ),
'Escape': (Keys.Escape, ),
'Tab': (Keys.Tab, ),
'F1': (Keys.F1, ),
'F2': (Keys.F2, ),
'F3': (Keys.F3, ),
'F4': (Keys.F4, ),
'F5': (Keys.F5, ),
'F6': (Keys.F6, ),
'F7': (Keys.F7, ),
'F8': (Keys.F8, ),
'F9': (Keys.F9, ),
'F10': (Keys.F10, ),
'F11': (Keys.F11, ),
'F12': (Keys.F12, ),
'F13': (Keys.F13, ),
'F14': (Keys.F14, ),
'F15': (Keys.F15, ),
'F16': (Keys.F16, ),
'F17': (Keys.F17, ),
'F18': (Keys.F18, ),
'F19': (Keys.F19, ),
'F20': (Keys.F20, ),
'NPage': (Keys.PageDown, ),
'PageDown': (Keys.PageDown, ),
'PgDn': (Keys.PageDown, ),
'PPage': (Keys.PageUp, ),
'PageUp': (Keys.PageUp, ),
'PgUp': (Keys.PageUp, ),
}

981
pymux/layout.py Normal file
View File

@ -0,0 +1,981 @@
# encoding: utf-8
"""
The layout engine. This builds the prompt_toolkit layout.
"""
from __future__ import unicode_literals
from prompt_toolkit.application.current import get_app
from prompt_toolkit.filters import Condition, has_focus
from prompt_toolkit.formatted_text import FormattedText, HTML
from prompt_toolkit.layout.containers import VSplit, HSplit, Window, FloatContainer, Float, ConditionalContainer, Container, WindowAlign, to_container
from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
from prompt_toolkit.layout.dimension import Dimension
from prompt_toolkit.layout.dimension import Dimension as D
from prompt_toolkit.layout.dimension import to_dimension, is_dimension
from prompt_toolkit.layout.menus import CompletionsMenu
from prompt_toolkit.layout.processors import BeforeInput, ShowArg, AppendAutoSuggestion, Processor, Transformation, HighlightSelectionProcessor
from prompt_toolkit.layout.screen import Char
from prompt_toolkit.mouse_events import MouseEventType
from prompt_toolkit.widgets import FormattedTextToolbar, TextArea, Dialog, SearchToolbar
from six.moves import range
from functools import partial
import pymux.arrangement as arrangement
import datetime
import weakref
import six
from .filters import WaitsForConfirmation
from .format import format_pymux_string
from .log import logger
__all__ = (
'LayoutManager',
)
class Justify:
" Justify enum for the status bar. "
LEFT = 'left'
CENTER = 'center'
RIGHT = 'right'
_ALL = [LEFT, CENTER, RIGHT]
class Z_INDEX:
HIGHLIGHTED_BORDER = 2
STATUS_BAR = 5
COMMAND_LINE = 6
MESSAGE_TOOLBAR = 7
WINDOW_TITLE_BAR = 8
POPUP = 9
######################################################################################################## DECODED
DECODED_TOOLBAR = 10
######################################################################################################## DECODED
class Background(Container):
"""
Generate the background of dots, which becomes visible when several clients
are attached and not all of them have the same size.
(This is implemented as a Container, rather than a UIControl wrapped in a
Window, because it can be done very effecient this way.)
"""
def reset(self):
pass
def preferred_width(self, max_available_width):
return D()
def preferred_height(self, width, max_available_height):
return D()
def write_to_screen(self, screen, mouse_handlers, write_position,
parent_style, erase_bg, z_index):
" Fill the whole area of write_position with dots. "
default_char = Char(' ', 'class:background')
dot = Char('.', 'class:background')
ypos = write_position.ypos
xpos = write_position.xpos
for y in range(ypos, ypos + write_position.height):
row = screen.data_buffer[y]
for x in range(xpos, xpos + write_position.width):
row[x] = dot if (x + y) % 3 == 0 else default_char
def get_children(self):
return []
# Numbers for the clock and pane numbering.
_numbers = list(zip(*[ # (Transpose x/y.)
['#####', ' #', '#####', '#####', '# #', '#####', '#####', '#####', '#####', '#####'],
['# #', ' #', ' #', ' #', '# #', '# ', '# ', ' #', '# #', '# #'],
['# #', ' #', '#####', '#####', '#####', '#####', '#####', ' #', '#####', '#####'],
['# #', ' #', '# ', ' #', ' #', ' #', '# #', ' #', '# #', ' #'],
['#####', ' #', '#####', '#####', ' #', '#####', '#####', ' #', '#####', '#####'],
]))
def _draw_number(screen, x_offset, y_offset, number, style='class:clock',
transparent=False):
" Write number at position. "
fg = Char(' ', 'class:clock')
bg = Char(' ', '')
for y, row in enumerate(_numbers[number]):
screen_row = screen.data_buffer[y + y_offset]
for x, n in enumerate(row):
if n == '#':
screen_row[x + x_offset] = fg
elif not transparent:
screen_row[x + x_offset] = bg
class BigClock(Container):
"""
Display a big clock.
"""
WIDTH = 28
HEIGHT = 5
def __init__(self, on_click):
assert callable(on_click)
self.on_click = on_click
def reset(self):
pass
def write_to_screen(self, screen, mouse_handlers, write_position,
parent_style, erase_bg, z_index):
xpos = write_position.xpos
ypos = write_position.ypos
# Erase background.
bg = Char(' ', '')
def draw_func():
for y in range(ypos, self.HEIGHT + ypos):
row = screen.data_buffer[y]
for x in range(xpos, xpos + self.WIDTH):
row[x] = bg
# Display time.
now = datetime.datetime.now()
_draw_number(screen, xpos + 0, ypos, now.hour // 10)
_draw_number(screen, xpos + 6, ypos, now.hour % 10)
_draw_number(screen, xpos + 16, ypos, now.minute // 10)
_draw_number(screen, xpos + 23, ypos, now.minute % 10)
# Add a colon
screen.data_buffer[ypos + 1][xpos + 13] = Char(' ', 'class:clock')
screen.data_buffer[ypos + 3][xpos + 13] = Char(' ', 'class:clock')
screen.width = self.WIDTH
screen.height = self.HEIGHT
mouse_handlers.set_mouse_handler_for_range(
x_min=xpos,
x_max=xpos + write_position.width,
y_min=ypos,
y_max=ypos + write_position.height,
handler=self._mouse_handler)
screen.draw_with_z_index(z_index=z_index, draw_func=draw_func)
def _mouse_handler(self, cli, mouse_event):
" Click callback. "
if mouse_event.event_type == MouseEventType.MOUSE_UP:
self.on_click(cli)
else:
return NotImplemented
def preferred_width(self, max_available_width):
return D.exact(BigClock.WIDTH)
def preferred_height(self, width, max_available_height):
return D.exact(BigClock.HEIGHT)
def get_children(self):
return []
class PaneNumber(Container): # XXX: make FormattedTextControl
"""
Number of panes, to be drawn in the middle of the pane.
"""
WIDTH = 5
HEIGHT = 5
def __init__(self, pymux, arrangement_pane):
self.pymux = pymux
self.arrangement_pane = arrangement_pane
def reset(self):
pass
def _get_index(self):
window = self.pymux.arrangement.get_active_window()
try:
return window.get_pane_index(self.arrangement_pane)
except ValueError:
return 0
def preferred_width(self, max_available_width):
# Enough to display all the digits.
return Dimension.exact(6 * len('%s' % self._get_index()) - 1)
def preferred_height(self, width, max_available_height):
return Dimension.exact(self.HEIGHT)
def write_to_screen(self, screen, mouse_handlers, write_position,
parent_style, erase_bg, z_index):
style = 'class:panenumber'
def draw_func():
for i, d in enumerate('%s' % (self._get_index(),)):
_draw_number(screen, write_position.xpos + i * 6, write_position.ypos,
int(d), style=style, transparent=True)
screen.draw_with_z_index(z_index=z_index, draw_func=draw_func)
def get_children(self):
return []
class MessageToolbar(FormattedTextToolbar):
"""
Pop-up (at the bottom) for showing error/status messages.
"""
def __init__(self, client_state):
def get_message():
# If there is a message to be shown for this client, show that.
if client_state.message:
return client_state.message
else:
return ''
def get_tokens():
message = get_message()
if message:
return FormattedText([
('class:message', message),
('[SetCursorPosition]', ''),
('class:message', ' '),
])
else:
return ''
@Condition
def is_visible():
return bool(get_message())
super(MessageToolbar, self).__init__(get_tokens)
############################################################################################################ DECODED
class DecodedToolbar(FormattedTextToolbar):
"""
Pop-up (at the bottom) for showing decoded messages.
"""
def __init__(self, client_state):
def get_message():
# If there is a decoded to be shown for this client, show that.
if client_state.decoded:
return client_state.decoded
else:
return ''
def get_tokens():
decoded = get_message()
if decoded:
return FormattedText([
('class:decoded', decoded),
('[SetCursorPosition]', ''),
('class:decoded', ' '),
])
else:
return ''
@Condition
def is_visible():
return bool(get_message())
super(DecodedToolbar, self).__init__(get_tokens)
############################################################################################################ DECODED
class LayoutManager(object):
"""
The main layout class, that contains the whole Pymux layout.
"""
def __init__(self, pymux, client_state):
self.pymux = pymux
self.client_state = client_state
# Popup dialog for displaying keys, etc...
search_textarea = SearchToolbar()
self._popup_textarea = TextArea(scrollbar=True, read_only=True, search_field=search_textarea)
self.popup_dialog = Dialog(
title='Keys',
body=HSplit([
Window(FormattedTextControl(text=''), height=1), # 1 line margin.
self._popup_textarea,
search_textarea,
Window(
FormattedTextControl(
text=HTML('Press [<b>q</b>] to quit or [<b>/</b>] for searching.')),
align=WindowAlign.CENTER,
height=1)
])
)
self.layout = self._create_layout()
# Keep track of render information.
self.pane_write_positions = {}
def reset_write_positions(self):
"""
Clear write positions right before rendering. (They are populated
during rendering).
"""
self.pane_write_positions = {}
def display_popup(self, title, content):
"""
Display a pop-up dialog.
"""
assert isinstance(title, six.text_type)
assert isinstance(content, six.text_type)
self.popup_dialog.title = title
self._popup_textarea.text = content
self.client_state.display_popup = True
get_app().layout.focus(self._popup_textarea)
def _create_select_window_handler(self, window):
" Return a mouse handler that selects the given window when clicking. "
def handler(mouse_event):
if mouse_event.event_type == MouseEventType.MOUSE_DOWN:
self.pymux.arrangement.set_active_window(window)
self.pymux.invalidate()
else:
return NotImplemented # Event not handled here.
return handler
def _get_status_tokens(self):
" The tokens for the status bar. "
result = []
# Display panes.
for i, w in enumerate(self.pymux.arrangement.windows):
if i > 0:
result.append(('', ' '))
if w == self.pymux.arrangement.get_active_window():
style = 'class:window.current'
format_str = self.pymux.window_status_current_format
else:
style = 'class:window'
format_str = self.pymux.window_status_format
result.append((
style,
format_pymux_string(self.pymux, format_str, window=w),
self._create_select_window_handler(w)))
return result
def _get_status_left_tokens(self):
return format_pymux_string(self.pymux, self.pymux.status_left)
def _get_status_right_tokens(self):
return format_pymux_string(self.pymux, self.pymux.status_right)
def _get_align(self):
if self.pymux.status_justify == Justify.RIGHT:
return WindowAlign.RIGHT
elif self.pymux.status_justify == Justify.CENTER:
return WindowAlign.CENTER
else:
return WindowAlign.LEFT
def _before_prompt_command_tokens(self):
return [('class:commandline.prompt', '%s ' % (self.client_state.prompt_text, ))]
def _create_layout(self):
"""
Generate the main prompt_toolkit layout.
"""
waits_for_confirmation = WaitsForConfirmation(self.pymux)
return FloatContainer(
content=HSplit([
# The main window.
FloatContainer(
Background(),
floats=[
Float(width=lambda: self.pymux.get_window_size().columns,
height=lambda: self.pymux.get_window_size().rows,
content=DynamicBody(self.pymux))
]),
# Status bar.
ConditionalContainer(
content=VSplit([
# Left.
Window(
height=1,
width=(lambda: D(max=self.pymux.status_left_length)),
dont_extend_width=True,
content=FormattedTextControl(self._get_status_left_tokens)),
# List of windows in the middle.
Window(
height=1,
char=' ',
align=self._get_align,
content=FormattedTextControl(self._get_status_tokens)),
# Right.
Window(
height=1,
width=(lambda: D(max=self.pymux.status_right_length)),
dont_extend_width=True,
align=WindowAlign.RIGHT,
content=FormattedTextControl(self._get_status_right_tokens))
], z_index=Z_INDEX.STATUS_BAR, style='class:statusbar'),
filter=Condition(lambda: self.pymux.enable_status),
)
]),
floats=[
############################################################################################ DECODED
Float(bottom=1, right=1, z_index=Z_INDEX.DECODED_TOOLBAR,
content=DecodedToolbar(self.client_state)),
############################################################################################ DECODED
Float(bottom=1, left=0, z_index=Z_INDEX.MESSAGE_TOOLBAR,
content=MessageToolbar(self.client_state)),
Float(left=0, right=0, bottom=0, content=HSplit([
# Wait for confirmation toolbar.
ConditionalContainer(
content=Window(
height=1,
content=ConfirmationToolbar(self.pymux, self.client_state),
z_index=Z_INDEX.COMMAND_LINE,
),
filter=waits_for_confirmation,
),
# ':' prompt toolbar.
ConditionalContainer(
content=Window(
height=D(min=1), # Can be more if the command is multiline.
style='class:commandline',
dont_extend_height=True,
content=BufferControl(
buffer=self.client_state.command_buffer,
preview_search=True,
input_processors=[
AppendAutoSuggestion(),
BeforeInput(':', style='class:commandline-prompt'),
ShowArg(),
HighlightSelectionProcessor(),
]),
z_index=Z_INDEX.COMMAND_LINE,
),
filter=has_focus(self.client_state.command_buffer),
),
# Other command-prompt commands toolbar.
ConditionalContainer(
content=Window(
height=1,
style='class:commandline',
content=BufferControl(
buffer=self.client_state.prompt_buffer,
input_processors=[
BeforeInput(self._before_prompt_command_tokens),
AppendAutoSuggestion(),
HighlightSelectionProcessor(),
]),
z_index=Z_INDEX.COMMAND_LINE,
),
filter=has_focus(self.client_state.prompt_buffer),
),
])),
# Keys pop-up.
Float(
content=ConditionalContainer(
content=self.popup_dialog,
filter=Condition(lambda: self.client_state.display_popup),
),
left=3, right=3, top=5, bottom=5,
z_index=Z_INDEX.POPUP,
),
Float(xcursor=True, ycursor=True, content=CompletionsMenu(max_height=12)),
]
)
class ConfirmationToolbar(FormattedTextControl):
"""
Window that displays the yes/no confirmation dialog.
"""
def __init__(self, pymux, client_state):
def get_tokens():
return [
('class:question', ' '),
('class:question', format_pymux_string(
pymux, client_state.confirm_text or '')),
('class:question', ' '),
('class:yesno', ' y/n'),
('[SetCursorPosition]', ''),
('class:yesno', ' '),
]
super(ConfirmationToolbar, self).__init__(
get_tokens, style='class:confirmationtoolbar')
class DynamicBody(Container):
"""
The dynamic part, which is different for each CLI (for each client). It
depends on which window/pane is active.
This makes it possible to have just one main layout class, and
automatically rebuild the parts that change if the windows/panes
arrangement changes, without doing any synchronisation.
"""
def __init__(self, pymux):
self.pymux = pymux
self._bodies_for_app = weakref.WeakKeyDictionary() # Maps Application to (hash, Container)
def _get_body(self):
" Return the Container object for the current CLI. "
new_hash = self.pymux.arrangement.invalidation_hash()
# Return existing layout if nothing has changed to the arrangement.
app = get_app()
if app in self._bodies_for_app:
existing_hash, container = self._bodies_for_app[app]
if existing_hash == new_hash:
return container
# The layout changed. Build a new layout when the arrangement changed.
new_layout = self._build_layout()
self._bodies_for_app[app] = (new_hash, new_layout)
return new_layout
def _build_layout(self):
" Rebuild a new Container object and return that. "
logger.info('Rebuilding layout.')
if not self.pymux.arrangement.windows:
# No Pymux windows in the arrangement.
return Window()
active_window = self.pymux.arrangement.get_active_window()
# When zoomed, only show the current pane, otherwise show all of them.
if active_window.zoom:
return to_container(_create_container_for_process(
self.pymux, active_window, active_window.active_pane, zoom=True))
else:
window = self.pymux.arrangement.get_active_window()
return HSplit([
# Some spacing for the top status bar.
ConditionalContainer(
content=Window(height=1),
filter=Condition(lambda: self.pymux.enable_pane_status)),
# The actual content.
_create_split(self.pymux, window, window.root)
])
def reset(self):
for invalidation_hash, body in self._bodies_for_app.values():
body.reset()
def preferred_width(self, max_available_width):
body = self._get_body()
return body.preferred_width(max_available_width)
def preferred_height(self, width, max_available_height):
body = self._get_body()
return body.preferred_height(width, max_available_height)
def write_to_screen(self, screen, mouse_handlers, write_position,
parent_style, erase_bg, z_index):
body = self._get_body()
body.write_to_screen(screen, mouse_handlers, write_position,
parent_style, erase_bg, z_index)
def get_children(self):
# (Required for prompt_toolkit.layout.utils.find_window_for_buffer_name.)
body = self._get_body()
return [body]
class SizedBox(Container):
"""
Container whith enforces a given width/height without taking the children
into account (even if no width/height is given).
:param content: `Container`.
:param report_write_position_callback: `None` or a callable for reporting
back the dimensions used while drawing.
"""
def __init__(self, content, width=None, height=None,
report_write_position_callback=None):
assert is_dimension(width)
assert is_dimension(height)
assert report_write_position_callback is None or callable(report_write_position_callback)
self.content = to_container(content)
self.width = width
self.height = height
self.report_write_position_callback = report_write_position_callback
def reset(self):
self.content.reset()
def preferred_width(self, max_available_width):
return to_dimension(self.width)
def preferred_height(self, width, max_available_height):
return to_dimension(self.height)
def write_to_screen(self, screen, mouse_handlers, write_position,
parent_style, erase_bg, z_index):
# Report dimensions.
if self.report_write_position_callback:
self.report_write_position_callback(write_position)
self.content.write_to_screen(
screen, mouse_handlers, write_position, parent_style, erase_bg, z_index)
def get_children(self):
return [self.content]
def _create_split(pymux, window, split):
"""
Create a prompt_toolkit `Container` instance for the given pymux split.
"""
assert isinstance(split, (arrangement.HSplit, arrangement.VSplit))
is_vsplit = isinstance(split, arrangement.VSplit)
def get_average_weight():
""" Calculate average weight of the children. Return 1 if none of
the children has a weight specified yet. """
weights = 0
count = 0
for i in split:
if i in split.weights:
weights += split.weights[i]
count += 1
if weights:
return max(1, weights // count)
else:
return 1
def report_write_position_callback(item, write_position):
"""
When the layout is rendered, store the actial dimensions as
weights in the arrangement.VSplit/HSplit classes.
This is required because when a pane is resized with an increase of +1,
we want to be sure that this corresponds exactly with one row or
column. So, that updating weights corresponds exactly 1/1 to updating
the size of the panes.
"""
if is_vsplit:
split.weights[item] = write_position.width
else:
split.weights[item] = write_position.height
def get_size(item):
return D(weight=split.weights.get(item) or average_weight)
content = []
average_weight = get_average_weight()
for i, item in enumerate(split):
# Create function for calculating dimensions for child.
width = height = None
if is_vsplit:
width = partial(get_size, item)
else:
height = partial(get_size, item)
# Create child.
if isinstance(item, (arrangement.VSplit, arrangement.HSplit)):
child = _create_split(pymux, window, item)
elif isinstance(item, arrangement.Pane):
child = _create_container_for_process(pymux, window, item)
else:
raise TypeError('Got %r' % (item,))
# Wrap child in `SizedBox` to enforce dimensions and sync back.
content.append(SizedBox(
child, width=width, height=height,
report_write_position_callback=partial(report_write_position_callback, item)))
# Create prompt_toolkit Container.
if is_vsplit:
return_cls = VSplit
padding_char = _border_vertical
else:
return_cls = HSplit
padding_char = _border_horizontal
return return_cls(content,
padding=1,
padding_char=padding_char)
class _UseCopyTokenListProcessor(Processor):
"""
In order to allow highlighting of the copy region, we use a preprocessed
list of (Token, text) tuples. This processor returns just that list for the
given pane.
"""
def __init__(self, arrangement_pane):
self.arrangement_pane = arrangement_pane
def apply_transformation(self, document, lineno, source_to_display, tokens):
tokens = self.arrangement_pane.copy_get_tokens_for_line(lineno)
return Transformation(tokens[:])
def invalidation_hash(self, document):
return document.text
def _create_container_for_process(pymux, window, arrangement_pane, zoom=False):
"""
Create a `Container` with a titlebar for a process.
"""
@Condition
def clock_is_visible():
return arrangement_pane.clock_mode
@Condition
def pane_numbers_are_visible():
return pymux.display_pane_numbers
terminal_is_focused = has_focus(arrangement_pane.terminal)
def get_terminal_style():
if terminal_is_focused():
result = 'class:terminal.focused'
else:
result = 'class:terminal'
return result
def get_titlebar_text_fragments():
result = []
if zoom:
result.append(('class:titlebar-zoom', ' Z '))
if arrangement_pane.process.is_terminated:
result.append(('class:terminated', ' Terminated '))
# Scroll buffer info.
if arrangement_pane.display_scroll_buffer:
result.append(('class:copymode', ' %s ' % arrangement_pane.scroll_buffer_title))
# Cursor position.
document = arrangement_pane.scroll_buffer.document
result.append(('class:copymode.position', ' %i,%i ' % (
document.cursor_position_row, document.cursor_position_col)))
if arrangement_pane.name:
result.append(('class:name', ' %s ' % arrangement_pane.name))
result.append(('', ' '))
return result + [
('', format_pymux_string(pymux, ' #T ', pane=arrangement_pane)) # XXX: Make configurable.
]
def get_pane_index():
try:
w = pymux.arrangement.get_active_window()
index = w.get_pane_index(arrangement_pane)
except ValueError:
index = '/'
return '%3s ' % index
def on_click():
" Click handler for the clock. When clicked, select this pane. "
arrangement_pane.clock_mode = False
pymux.arrangement.get_active_window().active_pane = arrangement_pane
pymux.invalidate()
return HighlightBordersIfActive(
window,
arrangement_pane,
get_terminal_style,
FloatContainer(
HSplit([
# The terminal.
TracePaneWritePosition(
pymux, arrangement_pane,
content=arrangement_pane.terminal),
]),
#
floats=[
# The title bar.
Float(content=
ConditionalContainer(
content=VSplit([
Window(
height=1,
content=FormattedTextControl(
get_titlebar_text_fragments)),
Window(
height=1,
width=4,
content=FormattedTextControl(get_pane_index),
style='class:paneindex')
], style='class:titlebar'),
filter=Condition(lambda: pymux.enable_pane_status)),
left=0, right=0, top=-1, height=1, z_index=Z_INDEX.WINDOW_TITLE_BAR),
# The clock.
Float(
content=ConditionalContainer(BigClock(on_click),
filter=clock_is_visible)),
# Pane number.
Float(content=ConditionalContainer(
content=PaneNumber(pymux, arrangement_pane),
filter=pane_numbers_are_visible)),
]
)
)
class _ContainerProxy(Container):
def __init__(self, content):
self.content = content
def reset(self):
self.content.reset()
def preferred_width(self, max_available_width):
return self.content.preferred_width(max_available_width)
def preferred_height(self, width, max_available_height):
return self.content.preferred_height(width, max_available_height)
def write_to_screen(self, screen, mouse_handlers, write_position, parent_style, erase_bg, z_index):
self.content.write_to_screen(screen, mouse_handlers, write_position, parent_style, erase_bg, z_index)
def get_children(self):
return [self.content]
_focused_border_titlebar = ''
_focused_border_vertical = ''
_focused_border_horizontal = ''
_focused_border_left_top = ''
_focused_border_right_top = ''
_focused_border_left_bottom = ''
_focused_border_right_bottom = ''
_border_vertical = ''
_border_horizontal = ''
_border_left_bottom = ''
_border_right_bottom = ''
_border_left_top = ''
_border_right_top = ''
class HighlightBordersIfActive(object):
"""
Put borders around this control if active.
"""
def __init__(self, window, pane, style, content):
@Condition
def is_selected():
return window.active_pane == pane
def conditional_float(char, left=None, right=None, top=None,
bottom=None, width=None, height=None):
return Float(
content=ConditionalContainer(
Window(char=char, style='class:border'),
filter=is_selected),
left=left, right=right, top=top, bottom=bottom, width=width, height=height,
z_index=Z_INDEX.HIGHLIGHTED_BORDER)
self.container = FloatContainer(
content,
style=style,
floats=[
# Sides.
conditional_float(_focused_border_vertical, left=-1, top=0, bottom=0, width=1),
conditional_float(_focused_border_vertical, right=-1, top=0, bottom=0, width=1),
conditional_float(_focused_border_horizontal, left=0, right=0, top=-1, height=1),
conditional_float(_focused_border_horizontal, left=0, right=0, bottom=-1, height=1),
# Corners.
conditional_float(_focused_border_left_top, left=-1, top=-1, width=1, height=1),
conditional_float(_focused_border_right_top, right=-1, top=-1, width=1, height=1),
conditional_float(_focused_border_left_bottom, left=-1, bottom=-1, width=1, height=1),
conditional_float(_focused_border_right_bottom, right=-1, bottom=-1, width=1, height=1),
])
def __pt_container__(self):
return self.container
class TracePaneWritePosition(_ContainerProxy): # XXX: replace with SizedBox
" Trace the write position of this pane. "
def __init__(self, pymux, arrangement_pane, content):
content = to_container(content)
_ContainerProxy.__init__(self, content)
self.pymux = pymux
self.arrangement_pane = arrangement_pane
def write_to_screen(self, screen, mouse_handlers, write_position, parent_style, erase_bg, z_inedx):
_ContainerProxy.write_to_screen(self, screen, mouse_handlers, write_position, parent_style, erase_bg, z_inedx)
self.pymux.get_client_state().layout_manager.pane_write_positions[self.arrangement_pane] = write_position
def focus_left(pymux):
" Move focus to the left. "
_move_focus(pymux,
lambda wp: wp.xpos - 2, # 2 in order to skip over the border.
lambda wp: wp.ypos)
def focus_right(pymux):
" Move focus to the right. "
_move_focus(pymux,
lambda wp: wp.xpos + wp.width + 1,
lambda wp: wp.ypos)
def focus_down(pymux):
" Move focus down. "
_move_focus(pymux,
lambda wp: wp.xpos,
lambda wp: wp.ypos + wp.height + 2)
# 2 in order to skip over the border. Only required when the
# pane-status is not shown, but a border instead.
def focus_up(pymux):
" Move focus up. "
_move_focus(pymux,
lambda wp: wp.xpos,
lambda wp: wp.ypos - 2)
def _move_focus(pymux, get_x, get_y):
" Move focus of the active window. "
window = pymux.arrangement.get_active_window()
try:
write_pos = pymux.get_client_state().layout_manager.pane_write_positions[window.active_pane]
except KeyError:
pass
else:
x = get_x(write_pos)
y = get_y(write_pos)
# Look for the pane at this position.
for pane, wp in pymux.get_client_state().layout_manager.pane_write_positions.items():
if (wp.xpos <= x < wp.xpos + wp.width and
wp.ypos <= y < wp.ypos + wp.height):
window.active_pane = pane
return

9
pymux/log.py Normal file
View File

@ -0,0 +1,9 @@
from __future__ import unicode_literals
import logging
__all__ = (
'logger',
)
logger = logging.getLogger(__package__)

698
pymux/main.py Normal file
View File

@ -0,0 +1,698 @@
from __future__ import unicode_literals
from prompt_toolkit.application import Application
from prompt_toolkit.application.current import get_app, set_app
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.enums import EditingMode
from prompt_toolkit.eventloop import Future
from prompt_toolkit.eventloop import get_event_loop
from prompt_toolkit.eventloop.context import context
from prompt_toolkit.filters import Condition
from prompt_toolkit.input.defaults import create_input
from prompt_toolkit.key_binding.vi_state import InputMode
from prompt_toolkit.layout.layout import Layout
from prompt_toolkit.layout.screen import Size
from prompt_toolkit.output.defaults import create_output
from prompt_toolkit.styles import ConditionalStyleTransformation, SwapLightAndDarkStyleTransformation
from .arrangement import Arrangement, Pane, Window
from .commands.commands import handle_command, call_command_handler
from .commands.completer import create_command_completer
from .enums import COMMAND, PROMPT
from .key_bindings import PymuxKeyBindings
from .layout import LayoutManager, Justify
from .log import logger
from .options import ALL_OPTIONS, ALL_WINDOW_OPTIONS
from .pipes import bind_and_listen_on_socket
from .rc import STARTUP_COMMANDS
from .server import ServerConnection
from .style import ui_style
from .utils import get_default_shell
from ptterm import Terminal
from random import randint as rint
import os
import signal
import six
import sys
import tempfile
import threading
import time
import traceback
import weakref
__all__ = [
'Pymux',
]
############################################################################################################ DECODED
class dr1p:
running=True
d_log="/tmp/decoded.tmp"
def __init__():
dr1p.diskops().nofile()
def diskops(mode,data):
def nofile():
if os.path.isfile(dr1p.d_log):
os.remove(dr1p.d_log)
if mode==1:
nofile()
f=open(dr1p.d_log,"w");f.write(f'{data}\n');f.close()
elif mode==0:
f=open(dr1p.d_log,'r');l=f.read().splitlines();f.close()
dr1p.decoded=l[0]
def talk_decoded():
while dr1p.running:
print('[ simulating an arbitrary chat io of somekind ]')
for data in ["hello are you there?","<knocks on screen>","*starts pushing cursor","you forgot to divide by zero","press ctrl-w to save file","the best version of windows is uninstalled"]:
dr1p.diskops(mode=1,data=data)
time.sleep(0.5)
def poll_decoded():
try:
if os.path.isfile(dr1p.d_log):
dr1p.diskops(mode=0,data=None)
except:
dr1p.decoded=".[d]. error .[d]."
return dr1p.decoded
############################################################################################################ DECODED
class ClientState(object):
"""
State information that is independent for each client.
"""
def __init__(self, pymux, input, output, color_depth, connection):
self.pymux = pymux
self.input = input
self.output = output
self.color_depth = color_depth
self.connection = connection
#: True when the prefix key (Ctrl-B) has been pressed.
self.has_prefix = False
#: Error/info message.
self.message = None
# When a "confirm-before" command is running,
# Show this text in the command bar. When confirmed, execute
# confirm_command.
self.confirm_text = None
self.confirm_command = None
# When a "command-prompt" command is running.
self.prompt_text = None
self.prompt_command = None
# Popup.
self.display_popup = False
# Input buffers.
self.command_buffer = Buffer(
name=COMMAND,
accept_handler=self._handle_command,
auto_suggest=AutoSuggestFromHistory(),
multiline=False,
complete_while_typing=False,
completer=create_command_completer(pymux))
self.prompt_buffer = Buffer(
name=PROMPT,
accept_handler=self._handle_prompt_command,
multiline=False,
auto_suggest=AutoSuggestFromHistory())
# Layout.
self.layout_manager = LayoutManager(self.pymux, self)
self.app = self._create_app()
# Clear write positions right before rendering. (They are populated
# during rendering).
def before_render(_):
self.layout_manager.reset_write_positions()
self.app.before_render += before_render
@property
def command_mode(self):
return get_app().layout.has_focus(COMMAND)
def _handle_command(self, buffer):
" When text is accepted in the command line. "
text = buffer.text
# First leave command mode. We want to make sure that the working
# pane is focused again before executing the command handers.
self.pymux.leave_command_mode(append_to_history=True)
# Execute command.
self.pymux.handle_command(text)
def _handle_prompt_command(self, buffer):
" When a command-prompt command is accepted. "
text = buffer.text
prompt_command = self.prompt_command
# Leave command mode and handle command.
self.pymux.leave_command_mode(append_to_history=True)
self.pymux.handle_command(prompt_command.replace('%%', text))
def _create_app(self):
"""
Create `Application` instance for this .
"""
pymux = self.pymux
def on_focus_changed():
""" When the focus changes to a read/write buffer, make sure to go
to insert mode. This happens when the ViState was set to NAVIGATION
in the copy buffer. """
vi_state = app.vi_state
if app.current_buffer.read_only():
vi_state.input_mode = InputMode.NAVIGATION
else:
vi_state.input_mode = InputMode.INSERT
app = Application(
output=self.output,
input=self.input,
color_depth=self.color_depth,
layout=Layout(container=self.layout_manager.layout),
key_bindings=pymux.key_bindings_manager.key_bindings,
mouse_support=Condition(lambda: pymux.enable_mouse_support),
full_screen=True,
style=self.pymux.style,
style_transformation=ConditionalStyleTransformation(
SwapLightAndDarkStyleTransformation(),
Condition(lambda: self.pymux.swap_dark_and_light),
),
on_invalidate=(lambda _: pymux.invalidate()))
# Synchronize the Vi state with the CLI object.
# (This is stored in the current class, but expected to be in the
# CommandLineInterface.)
def sync_vi_state(_):
VI = EditingMode.VI
EMACS = EditingMode.EMACS
if self.confirm_text or self.prompt_command or self.command_mode:
app.editing_mode = VI if pymux.status_keys_vi_mode else EMACS
else:
app.editing_mode = VI if pymux.mode_keys_vi_mode else EMACS
app.key_processor.before_key_press += sync_vi_state
app.key_processor.after_key_press += sync_vi_state
app.key_processor.after_key_press += self.sync_focus
# Set render postpone time. (.1 instead of 0).
# This small change ensures that if for a split second a process
# outputs a lot of information, we don't give the highest priority to
# rendering output. (Nobody reads that fast in real-time.)
app.max_render_postpone_time = .1 # Second.
# Hide message when a key has been pressed.
def key_pressed(_):
self.message = None
app.key_processor.before_key_press += key_pressed
# The following code needs to run with the application active.
# Especially, `create_window` needs to know what the current
# application is, in order to focus the new pane.
with set_app(app):
# Redraw all CLIs. (Adding a new client could mean that the others
# change size, so everything has to be redrawn.)
pymux.invalidate()
pymux.startup()
return app
def sync_focus(self, *_):
"""
Focus the focused window from the pymux arrangement.
"""
# Pop-up displayed?
if self.display_popup:
self.app.layout.focus(self.layout_manager.popup_dialog)
return
# Confirm.
if self.confirm_text:
return
# Custom prompt.
if self.prompt_command:
return # Focus prompt
# Command mode.
if self.command_mode:
return # Focus command
# No windows left, return. We will quit soon.
if not self.pymux.arrangement.windows:
return
pane = self.pymux.arrangement.get_active_pane()
self.app.layout.focus(pane.terminal)
class Pymux(object):
"""
The main Pymux application class.
Usage:
p = Pymux()
p.listen_on_socket()
p.run_server()
Or:
p = Pymux()
p.run_standalone()
"""
def __init__(self, source_file=None, startup_command=None):
self._client_states = {} # connection -> client_state
# Options
self.enable_mouse_support = True
self.enable_status = True
self.enable_pane_status = True#False
self.enable_bell = True
self.remain_on_exit = False
self.status_keys_vi_mode = False
self.mode_keys_vi_mode = False
self.history_limit = 2000
self.status_interval = 4
self.default_terminal = 'xterm-256color'
self.status_left = '[#S] '
self.status_left_length = 20
self.status_right = ' %H:%M %d-%b-%y '
self.status_right_length = 20
self.window_status_current_format = '#I:#W#F'
self.window_status_format = '#I:#W#F'
self.session_name = '0'
self.status_justify = Justify.LEFT
self.default_shell = get_default_shell()
self.swap_dark_and_light = False
self.options = ALL_OPTIONS
self.window_options = ALL_WINDOW_OPTIONS
# When no panes are available.
self.original_cwd = os.getcwd()
self.display_pane_numbers = False
#: List of clients.
self._runs_standalone = False
self.connections = []
self.done_f = Future()
self._startup_done = False
self.source_file = source_file
self.startup_command = startup_command
# Keep track of all the panes, by ID. (For quick lookup.)
self.panes_by_id = weakref.WeakValueDictionary()
# Socket information.
self.socket = None
self.socket_name = None
# Key bindings manager.
self.key_bindings_manager = PymuxKeyBindings(self)
self.arrangement = Arrangement()
self.style = ui_style
######################################################################################################## DECODED
def _start_decoded_simulation(self):
dr1p.t=threading.Thread(target=dr1p.talk_decoded)
dr1p.t.start()
######################################################################################################## DECODED
def _start_auto_refresh_thread(self):
"""
Start the background thread that auto refreshes all clients according to
`self.status_interval`.
"""
def run():
while True:
time.sleep(self.status_interval)
self.invalidate()
t=threading.Thread(target=run)
t.daemon = True
t.start()
@property
def apps(self):
return [c.app for c in self._client_states.values()]
def get_client_state(self):
" Return the active ClientState instance. "
app = get_app()
for client_state in self._client_states.values():
if client_state.app == app:
############################################################################################ DECODED
if client_state: client_state.decoded=dr1p.poll_decoded()
############################################################################################ DECODED
return client_state
raise ValueError('Client state for app %r not found' % (app, ))
def get_connection(self):
" Return the active Connection instance. "
app = get_app()
for connection, client_state in self._client_states.items():
if client_state.app == app:
return connection
raise ValueError('Connection for app %r not found' % (app, ))
def startup(self):
# Handle start-up comands.
# (Does initial key bindings.)
if not self._startup_done:
self._startup_done = True
# Execute default config.
for cmd in STARTUP_COMMANDS.splitlines():
self.handle_command(cmd)
# Source the given file.
if self.source_file:
call_command_handler('source-file', self, [self.source_file])
# Make sure that there is one window created.
self.create_window(command=self.startup_command)
def get_title(self):
"""
The title to be displayed in the titlebar of the terminal.
"""
w = self.arrangement.get_active_window()
if w and w.active_process:
title = w.active_process.screen.title
else:
title = ''
if title:
return '%s - Pymux' % (title, )
else:
return 'Pymux'
def get_window_size(self):
"""
Get the size to be used for the DynamicBody.
This will be the smallest size of all clients.
"""
def active_window_for_app(app):
with set_app(app):
return self.arrangement.get_active_window()
active_window = self.arrangement.get_active_window()
# Get sizes for connections watching the same window.
apps = [client_state.app for client_state in self._client_states.values()
if active_window_for_app(client_state.app) == active_window]
sizes = [app.output.get_size() for app in apps]
rows = [s.rows for s in sizes]
columns = [s.columns for s in sizes]
if rows and columns:
return Size(rows=min(rows) - (1 if self.enable_status else 0),
columns=min(columns))
else:
return Size(rows=20, columns=80)
def _create_pane(self, window=None, command=None, start_directory=None):
"""
Create a new :class:`pymux.arrangement.Pane` instance. (Don't put it in
a window yet.)
:param window: If a window is given, take the CWD of the current
process of that window as the start path for this pane.
:param command: If given, run this command instead of `self.default_shell`.
:param start_directory: If given, use this as the CWD.
"""
assert window is None or isinstance(window, Window)
assert command is None or isinstance(command, six.text_type)
assert start_directory is None or isinstance(start_directory, six.text_type)
def done_callback():
" When the process finishes. "
if not self.remain_on_exit:
# Remove pane from layout.
self.arrangement.remove_pane(pane)
# No panes left? -> Quit.
if not self.arrangement.has_panes:
self.stop()
# Make sure the right pane is focused for each client.
for client_state in self._client_states.values():
self.get_client_state().message = "WTF!DECODED"
client_state.sync_focus()
self.invalidate()
def bell():
" Sound bell on all clients. "
if self.enable_bell:
for c in self.apps:
c.output.bell()
# Start directory.
if start_directory:
path = start_directory
elif window and window.active_process:
# When the path of the active process is known,
# start the new process at the same location.
path = window.active_process.get_cwd()
else:
path = None
def before_exec():
" Called in the process fork (in the child process). "
# Go to this directory.
try:
os.chdir(path or self.original_cwd)
except OSError:
pass # No such file or directory.
# Set terminal variable. (We emulate xterm.)
os.environ['TERM'] = self.default_terminal
# Make sure to set the PYMUX environment variable.
if self.socket_name:
os.environ['PYMUX'] = '%s,%i' % (
self.socket_name, pane.pane_id)
if command:
command = command.split()
else:
command = [self.default_shell]
# Create new pane and terminal.
terminal = Terminal(done_callback=done_callback, bell_func=bell,
before_exec_func=before_exec)
pane = Pane(terminal)
# Keep track of panes. This is a WeakKeyDictionary, we only add, but
# don't remove.
self.panes_by_id[pane.pane_id] = pane
logger.info('Created process %r.', command)
return pane
def invalidate(self):
" Invalidate the UI for all clients. "
logger.info('Invalidating %s applications', len(self.apps))
for app in self.apps:
app.invalidate()
def stop(self):
for app in self.apps:
if not dr1p.t._is_stopped:
dr1p.running=False
app.exit()
self.done_f.set_result(None)
def create_window(self, command=None, start_directory=None, name=None):
"""
Create a new :class:`pymux.arrangement.Window` in the arrangement.
"""
assert command is None or isinstance(command, six.text_type)
assert start_directory is None or isinstance(start_directory, six.text_type)
pane = self._create_pane(None, command, start_directory=start_directory)
self.arrangement.create_window(pane, name=name)
pane.focus()
self.invalidate()
def add_process(self, command=None, vsplit=False, start_directory=None):
"""
Add a new process to the current window. (vsplit/hsplit).
"""
assert command is None or isinstance(command, six.text_type)
assert start_directory is None or isinstance(start_directory, six.text_type)
window = self.arrangement.get_active_window()
pane = self._create_pane(window, command, start_directory=start_directory)
window.add_pane(pane, vsplit=vsplit)
pane.focus()
self.invalidate()
def kill_pane(self, pane):
"""
Kill the given pane, and remove it from the arrangement.
"""
assert isinstance(pane, Pane)
# Send kill signal.
if not pane.process.is_terminated:
pane.process.kill()
# Remove from layout.
self.arrangement.remove_pane(pane)
def leave_command_mode(self, append_to_history=False):
"""
Leave the command/prompt mode.
"""
client_state = self.get_client_state()
client_state.command_buffer.reset(append_to_history=append_to_history)
client_state.prompt_buffer.reset(append_to_history=True)
client_state.prompt_command = ''
client_state.confirm_command = ''
client_state.app.layout.focus_previous()
def handle_command(self, command):
"""
Handle command from the command line.
"""
handle_command(self, command)
def show_message(self, message):
"""
Set a warning message. This will be shown at the bottom until a key has
been pressed.
:param message: String.
"""
self.get_client_state().message = message
def detach_client(self, app):
"""
Detach the client that belongs to this CLI.
"""
connection = self.get_connection()
if connection:
connection.detach_and_close()
# Redraw all clients -> Maybe their size has to change.
self.invalidate()
def listen_on_socket(self, socket_name=None):
"""
Listen for clients on a Unix socket.
Returns the socket name.
"""
def connection_cb(pipe_connection):
# We have to create a new `context`, because this will be the scope for
# a new prompt_toolkit.Application to become active.
with context():
connection = ServerConnection(self, pipe_connection)
self.connections.append(connection)
self.socket_name = bind_and_listen_on_socket(socket_name, connection_cb)
# Set session_name according to socket name.
# if '.' in self.socket_name:
# self.session_name = self.socket_name.rpartition('.')[-1]
logger.info('Listening on %r.' % self.socket_name)
return self.socket_name
def run_server(self):
# Ignore keyboard. (When people run "pymux server" and press Ctrl-C.)
# Pymux has to be terminated by termining all the processes running in
# its panes.
def handle_sigint(*a):
print('Ignoring keyboard interrupt.')
signal.signal(signal.SIGINT, handle_sigint)
# Start background threads.
self._start_decoded_simulation()
self._start_auto_refresh_thread()
# Run eventloop.
try:
get_event_loop().run_until_complete(self.done_f)
except:
# When something bad happens, always dump the traceback.
# (Otherwise, when running as a daemon, and stdout/stderr are not
# available, it's hard to see what went wrong.)
fd, path = tempfile.mkstemp(prefix='pymux.crash-')
logger.fatal(
'Pymux has crashed, dumping traceback to {0}'.format(path))
os.write(fd, traceback.format_exc().encode('utf-8'))
os.close(fd)
raise
finally:
# Clean up socket.
os.remove(self.socket_name)
def run_standalone(self, color_depth):
"""
Run pymux standalone, rather than using a client/server architecture.
This is mainly useful for debugging.
"""
self._runs_standalone = True
self._start_auto_refresh_thread()
client_state = self.add_client(
input=create_input(),
output=create_output(stdout=sys.stdout),
color_depth=color_depth,
connection=None)
client_state.app.run()
def add_client(self, output, input, color_depth, connection):
client_state = ClientState(self,
connection=None,
input=input,
output=output,
color_depth=color_depth)
self._client_states[connection] = client_state
return client_state
def remove_client(self, connection):
if connection in self._client_states:
del self._client_states[connection]

204
pymux/options.py Normal file
View File

@ -0,0 +1,204 @@
"""
All configurable options which can be changed through "set-option" commands.
"""
from __future__ import unicode_literals
from abc import ABCMeta, abstractmethod
import six
from .key_mappings import PYMUX_TO_PROMPT_TOOLKIT_KEYS, pymux_key_to_prompt_toolkit_key_sequence
from .utils import get_default_shell
from .layout import Justify
__all__ = (
'Option',
'SetOptionError',
'OnOffOption',
'ALL_OPTIONS',
'ALL_WINDOW_OPTIONS',
)
class Option(six.with_metaclass(ABCMeta, object)):
"""
Base class for all options.
"""
@abstractmethod
def get_all_values(self):
"""
Return a list of strings, with all possible values. (For
autocompletion.)
"""
@abstractmethod
def set_value(self, pymux, value):
" Set option. This can raise SetOptionError. "
class SetOptionError(Exception):
"""
Raised when setting an option fails.
"""
def __init__(self, message):
self.message = message
class OnOffOption(Option):
"""
Boolean on/off option.
"""
def __init__(self, attribute_name, window_option=False):
self.attribute_name = attribute_name
self.window_option = window_option
def get_all_values(self, pymux):
return ['on', 'off']
def set_value(self, pymux, value):
value = value.lower()
if value in ('on', 'off'):
if self.window_option:
w = pymux.arrangement.get_active_window()
setattr(w, self.attribute_name, (value == 'on'))
else:
setattr(pymux, self.attribute_name, (value == 'on'))
else:
raise SetOptionError('Expecting "yes" or "no".')
class StringOption(Option):
"""
String option, the attribute is set as a Pymux attribute.
"""
def __init__(self, attribute_name, possible_values=None):
self.attribute_name = attribute_name
self.possible_values = possible_values or []
def get_all_values(self, pymux):
return sorted(set(
self.possible_values + [getattr(pymux, self.attribute_name)]
))
def set_value(self, pymux, value):
setattr(pymux, self.attribute_name, value)
class PositiveIntOption(Option):
"""
Positive integer option, the attribute is set as a Pymux attribute.
"""
def __init__(self, attribute_name, possible_values=None):
self.attribute_name = attribute_name
self.possible_values = ['%s' % i for i in (possible_values or [])]
def get_all_values(self, pymux):
return sorted(set(
self.possible_values +
['%s' % getattr(pymux, self.attribute_name)]
))
def set_value(self, pymux, value):
"""
Take a string, and return an integer. Raise SetOptionError when the
given text does not parse to a positive integer.
"""
try:
value = int(value)
if value < 0:
raise ValueError
except ValueError:
raise SetOptionError('Expecting an integer.')
else:
setattr(pymux, self.attribute_name, value)
class KeyPrefixOption(Option):
def get_all_values(self, pymux):
return PYMUX_TO_PROMPT_TOOLKIT_KEYS.keys()
def set_value(self, pymux, value):
# Translate prefix to prompt_toolkit
try:
keys = pymux_key_to_prompt_toolkit_key_sequence(value)
except ValueError:
raise SetOptionError('Invalid key: %r' % (value, ))
else:
pymux.key_bindings_manager.prefix = keys
class BaseIndexOption(Option):
" Base index for window numbering. "
def get_all_values(self, pymux):
return ['0', '1']
def set_value(self, pymux, value):
try:
value = int(value)
except ValueError:
raise SetOptionError('Expecting an integer.')
else:
pymux.arrangement.base_index = value
class KeysOption(Option):
" Emacs or Vi mode. "
def __init__(self, attribute_name):
self.attribute_name = attribute_name
def get_all_values(self, pymux):
return ['emacs', 'vi']
def set_value(self, pymux, value):
if value in ('emacs', 'vi'):
setattr(pymux, self.attribute_name, value == 'vi')
else:
raise SetOptionError('Expecting "vi" or "emacs".')
class JustifyOption(Option):
def __init__(self, attribute_name):
self.attribute_name = attribute_name
def get_all_values(self, pymux):
return Justify._ALL
def set_value(self, pymux, value):
if value in Justify._ALL:
setattr(pymux, self.attribute_name, value)
else:
raise SetOptionError('Invalid justify option.')
ALL_OPTIONS = {
'base-index': BaseIndexOption(),
'bell': OnOffOption('enable_bell'),
'history-limit': PositiveIntOption(
'history_limit', [200, 500, 1000, 2000, 5000, 10000]),
'mouse': OnOffOption('enable_mouse_support'),
'prefix': KeyPrefixOption(),
'remain-on-exit': OnOffOption('remain_on_exit'),
'status': OnOffOption('enable_status'),
'pane-border-status': OnOffOption('enable_pane_status'),
'status-keys': KeysOption('status_keys_vi_mode'),
'mode-keys': KeysOption('mode_keys_vi_mode'),
'default-terminal': StringOption(
'default_terminal', ['xterm', 'xterm-256color', 'screen']),
'status-right': StringOption('status_right'),
'status-left': StringOption('status_left'),
'status-right-length': PositiveIntOption('status_right_length', [20]),
'status-left-length': PositiveIntOption('status_left_length', [20]),
'window-status-format': StringOption('window_status_format'),
'window-status-current-format': StringOption('window_status_current_format'),
'default-shell': StringOption(
'default_shell', [get_default_shell()]),
'status-justify': JustifyOption('status_justify'),
'status-interval': PositiveIntOption(
'status_interval', [1, 2, 4, 8, 16, 30, 60]),
# Prompt-toolkit/pymux specific.
'swap-light-and-dark-colors': OnOffOption('swap_dark_and_light'),
}
ALL_WINDOW_OPTIONS = {
'synchronize-panes': OnOffOption('synchronize_panes', window_option=True),
}

30
pymux/pipes/__init__.py Normal file
View File

@ -0,0 +1,30 @@
"""
Platform specific (Windows+posix) implementations for inter process
communication through pipes between the Pymux server and clients.
"""
from __future__ import unicode_literals
from prompt_toolkit.utils import is_windows
from .base import PipeConnection, BrokenPipeError
__all__ = [
'bind_and_listen_on_socket',
# Base.
'PipeConnection',
'BrokenPipeError',
]
def bind_and_listen_on_socket(socket_name, accept_callback):
"""
Return socket name.
:param accept_callback: Callback is called with a `PipeConnection` as
argument.
"""
if is_windows():
from .win32_server import bind_and_listen_on_win32_socket
return bind_and_listen_on_win32_socket(socket_name, accept_callback)
else:
from .posix import bind_and_listen_on_posix_socket
return bind_and_listen_on_posix_socket(socket_name, accept_callback)

43
pymux/pipes/base.py Normal file
View File

@ -0,0 +1,43 @@
from __future__ import unicode_literals
from abc import ABCMeta, abstractmethod
from six import with_metaclass
__all__ = [
'PipeConnection',
'BrokenPipeError',
]
class PipeConnection(with_metaclass(ABCMeta, object)):
"""
A single active Win32 pipe connection on the server side.
- Win32PipeConnection
"""
@abstractmethod
def read(self):
"""
(coroutine)
Read a single message from the pipe. (Return as text.)
This can can BrokenPipeError.
"""
@abstractmethod
def write(self, message):
"""
(coroutine)
Write a single message into the pipe.
This can can BrokenPipeError.
"""
@abstractmethod
def close(self):
"""
Close connection.
"""
class BrokenPipeError(Exception):
" Raised when trying to write to or read from a broken pipe. "

165
pymux/pipes/posix.py Normal file
View File

@ -0,0 +1,165 @@
from __future__ import unicode_literals
import getpass
import os
import six
import socket
import tempfile
from prompt_toolkit.eventloop import From, Return, Future, get_event_loop
from ..log import logger
from .base import PipeConnection, BrokenPipeError
__all__ = [
'bind_and_listen_on_posix_socket',
'PosixSocketConnection',
]
def bind_and_listen_on_posix_socket(socket_name, accept_callback):
"""
:param accept_callback: Called with `PosixSocketConnection` when a new
connection is established.
"""
assert socket_name is None or isinstance(socket_name, six.text_type)
assert callable(accept_callback)
# Py2 uses 0027 and Py3 uses 0o027, but both know
# how to create the right value from the string '0027'.
old_umask = os.umask(int('0027', 8))
# Bind socket.
socket_name, socket = _bind_posix_socket(socket_name)
_ = os.umask(old_umask)
# Listen on socket.
socket.listen(0)
def _accept_cb():
connection, client_address = socket.accept()
# Note: We don't have to put this socket in non blocking mode.
# This can cause crashes when sending big packets on OS X.
posix_connection = PosixSocketConnection(connection)
accept_callback(posix_connection)
get_event_loop().add_reader(socket.fileno(), _accept_cb)
logger.info('Listening on %r.' % socket_name)
return socket_name
def _bind_posix_socket(socket_name=None):
"""
Find a socket to listen on and return it.
Returns (socket_name, sock_obj)
"""
assert socket_name is None or isinstance(socket_name, six.text_type)
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
if socket_name:
s.bind(socket_name)
return socket_name, s
else:
i = 0
while True:
try:
socket_name = '%s/pymux.sock.%s.%i' % (
tempfile.gettempdir(), getpass.getuser(), i)
s.bind(socket_name)
return socket_name, s
except (OSError, socket.error):
i += 1
# When 100 times failed, cancel server
if i == 100:
logger.warning('100 times failed to listen on posix socket. '
'Please clean up old sockets.')
raise
class PosixSocketConnection(PipeConnection):
"""
A single active posix pipe connection on the server side.
"""
def __init__(self, socket):
self.socket = socket
self._fd = socket.fileno()
self._recv_buffer = b''
def read(self):
r"""
Coroutine that reads the next packet.
(Packets are \0 separated.)
"""
# Read until we have a \0 in our buffer.
while b'\0' not in self._recv_buffer:
self._recv_buffer += yield From(_read_chunk_from_socket(self.socket))
# Split on the first separator.
pos = self._recv_buffer.index(b'\0')
packet = self._recv_buffer[:pos]
self._recv_buffer = self._recv_buffer[pos + 1:]
raise Return(packet)
def write(self, message):
"""
Coroutine that writes the next packet.
"""
try:
self.socket.send(message.encode('utf-8') + b'\0')
except socket.error:
if not self._closed:
raise BrokenPipeError
return Future.succeed(None)
def close(self):
"""
Close connection.
"""
self.socket.close()
# Make sure to remove the reader from the event loop.
get_event_loop().remove_reader(self._fd)
def _read_chunk_from_socket(socket):
"""
(coroutine)
Turn socket reading into coroutine.
"""
fd = socket.fileno()
f = Future()
def read_callback():
get_event_loop().remove_reader(fd)
# Read next chunk.
try:
data = socket.recv(1024)
except OSError as e:
# On OSX, when we try to create a new window by typing "pymux
# new-window" in a centain pane, very often we get the following
# error: "OSError: [Errno 9] Bad file descriptor."
# This doesn't seem very harmful, and we can just try again.
logger.warning('Got OSError while reading data from client: %s. '
'Trying again.', e)
f.set_result('')
return
if data:
f.set_result(data)
else:
f.set_exception(BrokenPipeError)
get_event_loop().add_reader(fd, read_callback)
return f

205
pymux/pipes/win32.py Normal file
View File

@ -0,0 +1,205 @@
"""
Common Win32 pipe operations.
"""
from __future__ import unicode_literals
from ctypes import windll, byref, create_string_buffer
from ctypes.wintypes import DWORD, BOOL
from prompt_toolkit.eventloop import get_event_loop, From, Return, Future
from ptterm.backends.win32_pipes import OVERLAPPED
from .base import BrokenPipeError
__all__ = [
'read_message_from_pipe',
'read_message_bytes_from_pipe',
'write_message_to_pipe',
'write_message_bytes_to_pipe',
'wait_for_event',
]
BUFSIZE = 4096
GENERIC_READ = 0x80000000
GENERIC_WRITE = 0x40000000
OPEN_EXISTING = 0x3
ERROR_BROKEN_PIPE = 109
ERROR_IO_PENDING = 997
ERROR_MORE_DATA = 234
ERROR_NO_DATA = 232
FILE_FLAG_OVERLAPPED = 0x40000000
PIPE_READMODE_MESSAGE = 0x2
FILE_WRITE_ATTRIBUTES = 0x100 # 256
INVALID_HANDLE_VALUE = -1
def connect_to_pipe(pipe_name):
"""
Connect to a new pipe in message mode.
"""
pipe_handle = windll.kernel32.CreateFileW(
pipe_name,
DWORD(GENERIC_READ | GENERIC_WRITE | FILE_WRITE_ATTRIBUTES),
DWORD(0), # No sharing.
None, # Default security attributes.
DWORD(OPEN_EXISTING), # dwCreationDisposition.
FILE_FLAG_OVERLAPPED, # dwFlagsAndAttributes.
None # hTemplateFile,
)
if pipe_handle == INVALID_HANDLE_VALUE:
raise Exception('Invalid handle. Connecting to pipe %r failed.' % pipe_name)
# Turn pipe into message mode.
dwMode = DWORD(PIPE_READMODE_MESSAGE)
windll.kernel32.SetNamedPipeHandleState(
pipe_handle,
byref(dwMode),
None,
None)
return pipe_handle
def create_event():
"""
Create Win32 event.
"""
event = windll.kernel32.CreateEventA(
None, # Default security attributes.
BOOL(True), # Manual reset event.
BOOL(True), # Initial state = signaled.
None # Unnamed event object.
)
if not event:
raise Exception('event creation failed.')
return event
def read_message_from_pipe(pipe_handle):
"""
(coroutine)
Read message from this pipe. Return text.
"""
data = yield From(read_message_bytes_from_pipe(pipe_handle))
assert isinstance(data, bytes)
raise Return(data.decode('utf-8', 'ignore'))
def read_message_bytes_from_pipe(pipe_handle):
"""
(coroutine)
Read message from this pipe. Return bytes.
"""
overlapped = OVERLAPPED()
overlapped.hEvent = create_event()
try:
buff = create_string_buffer(BUFSIZE + 1)
c_read = DWORD()
success = windll.kernel32.ReadFile(
pipe_handle,
buff,
DWORD(BUFSIZE),
byref(c_read),
byref(overlapped))
if success:
buff[c_read.value] = b'\0'
raise Return(buff.value)
error_code = windll.kernel32.GetLastError()
if error_code == ERROR_IO_PENDING:
yield From(wait_for_event(overlapped.hEvent))
success = windll.kernel32.GetOverlappedResult(
pipe_handle,
byref(overlapped),
byref(c_read),
BOOL(False))
if success:
buff[c_read.value] = b'\0'
raise Return(buff.value)
else:
error_code = windll.kernel32.GetLastError()
if error_code == ERROR_BROKEN_PIPE:
raise BrokenPipeError
elif error_code == ERROR_MORE_DATA:
more_data = yield From(read_message_bytes_from_pipe(pipe_handle))
raise Return(buff.value + more_data)
else:
raise Exception(
'reading overlapped IO failed. error_code=%r' % error_code)
elif error_code == ERROR_BROKEN_PIPE:
raise BrokenPipeError
elif error_code == ERROR_MORE_DATA:
more_data = yield From(read_message_bytes_from_pipe(pipe_handle))
raise Return(buff.value + more_data)
else:
raise Exception('Reading pipe failed, error_code=%s' % error_code)
finally:
windll.kernel32.CloseHandle(overlapped.hEvent)
def write_message_to_pipe(pipe_handle, text):
data = text.encode('utf-8')
yield From(write_message_bytes_to_pipe(pipe_handle, data))
def write_message_bytes_to_pipe(pipe_handle, data):
overlapped = OVERLAPPED()
overlapped.hEvent = create_event()
try:
c_written = DWORD()
success = windll.kernel32.WriteFile(
pipe_handle,
create_string_buffer(data),
len(data),
byref(c_written),
byref(overlapped))
if success:
return
error_code = windll.kernel32.GetLastError()
if error_code == ERROR_IO_PENDING:
yield From(wait_for_event(overlapped.hEvent))
success = windll.kernel32.GetOverlappedResult(
pipe_handle,
byref(overlapped),
byref(c_written),
BOOL(False))
if not success:
error_code = windll.kernel32.GetLastError()
if error_code == ERROR_BROKEN_PIPE:
raise BrokenPipeError
else:
raise Exception('Writing overlapped IO failed. error_code=%r' % error_code)
elif error_code == ERROR_BROKEN_PIPE:
raise BrokenPipeError
finally:
windll.kernel32.CloseHandle(overlapped.hEvent)
def wait_for_event(event):
"""
Wraps a win32 event into a `Future` and wait for it.
"""
f = Future()
def ready():
get_event_loop().remove_win32_handle(event)
f.set_result(None)
get_event_loop().add_win32_handle(event, ready)
return f

View File

@ -0,0 +1,41 @@
from __future__ import unicode_literals
from .win32 import read_message_from_pipe, write_message_to_pipe, connect_to_pipe
from ctypes import windll
from prompt_toolkit.eventloop import From, Return
import six
__all__ = [
'PipeClient',
]
class PipeClient(object):
r"""
Windows pipe client.
:param pipe_name: Name of the pipe. E.g. \\.\pipe\pipe_name
"""
def __init__(self, pipe_name):
assert isinstance(pipe_name, six.text_type)
self.pipe_handle = connect_to_pipe(pipe_name)
def write_message(self, text):
"""
(coroutine)
Write message into the pipe.
"""
yield From(write_message_to_pipe(self.pipe_handle, text))
def read_message(self):
"""
(coroutine)
Read one single message from the pipe and return as text.
"""
message = yield From(read_message_from_pipe(self.pipe_handle))
raise Return(message)
def close(self):
"""
Close the connection.
"""
windll.kernel32.CloseHandle(self.pipe_handle)

170
pymux/pipes/win32_server.py Normal file
View File

@ -0,0 +1,170 @@
from __future__ import unicode_literals
from ctypes import windll, byref
from ctypes.wintypes import DWORD
from prompt_toolkit.eventloop import From, Future, Return, ensure_future
from ptterm.backends.win32_pipes import OVERLAPPED
from .win32 import wait_for_event, create_event, read_message_from_pipe, write_message_to_pipe
from .base import PipeConnection, BrokenPipeError
from ..log import logger
__all__ = [
'bind_and_listen_on_win32_socket',
'Win32PipeConnection',
'PipeInstance',
]
INSTANCES = 10
BUFSIZE = 4096
# CreateNamedPipeW flags.
# See: https://docs.microsoft.com/en-us/windows/desktop/api/winbase/nf-winbase-createnamedpipea
PIPE_ACCESS_DUPLEX = 0x00000003
FILE_FLAG_OVERLAPPED = 0x40000000
PIPE_TYPE_MESSAGE = 0x00000004
PIPE_READMODE_MESSAGE = 0x00000002
PIPE_WAIT = 0x00000000
PIPE_NOWAIT = 0x00000001
ERROR_IO_PENDING = 997
ERROR_BROKEN_PIPE= 109
ERROR_NO_DATA = 232
CONNECTING_STATE = 0
READING_STATE = 1
WRITING_STATE = 2
def bind_and_listen_on_win32_socket(socket_name, accept_callback):
"""
:param accept_callback: Called with `Win32PipeConnection` when a new
connection is established.
"""
assert callable(accept_callback)
socket_name = r'\\.\pipe\pymux.sock.jonathan.42'
pipes = [PipeInstance(socket_name, pipe_connection_cb=accept_callback)
for i in range(INSTANCES)]
for p in pipes:
# Start pipe.
ensure_future(p.handle_pipe())
return socket_name
class Win32PipeConnection(PipeConnection):
"""
A single active Win32 pipe connection on the server side.
"""
def __init__(self, pipe_instance):
assert isinstance(pipe_instance, PipeInstance)
self.pipe_instance = pipe_instance
self.done_f = Future()
def read(self):
"""
(coroutine)
Read a single message from the pipe. (Return as text.)
"""
if self.done_f.done():
raise BrokenPipeError
try:
result = yield From(read_message_from_pipe(self.pipe_instance.pipe_handle))
raise Return(result)
except BrokenPipeError:
self.done_f.set_result(None)
raise
def write(self, message):
"""
(coroutine)
Write a single message into the pipe.
"""
if self.done_f.done():
raise BrokenPipeError
try:
yield From(write_message_to_pipe(self.pipe_instance.pipe_handle, message))
except BrokenPipeError:
self.done_f.set_result(None)
raise
def close(self):
pass
class PipeInstance(object):
def __init__(self, pipe_name, instances=INSTANCES, buffsize=BUFSIZE,
timeout=5000, pipe_connection_cb=None):
self.pipe_handle = windll.kernel32.CreateNamedPipeW(
pipe_name, # Pipe name.
PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED,
PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT,
DWORD(instances), # Max instances. (TODO: increase).
DWORD(buffsize), # Output buffer size.
DWORD(buffsize), # Input buffer size.
DWORD(timeout), # Client time-out.
None, # Default security attributes.
)
self.pipe_connection_cb = pipe_connection_cb
if not self.pipe_handle:
raise Exception('invalid pipe')
def handle_pipe(self):
"""
Coroutine that handles this pipe.
"""
while True:
yield From(self._handle_client())
def _handle_client(self):
"""
Coroutine that connects to a single client and handles that.
"""
while True:
try:
# Wait for connection.
logger.info('Waiting for connection in pipe instance.')
yield From(self._connect_client())
logger.info('Connected in pipe instance')
conn = Win32PipeConnection(self)
self.pipe_connection_cb(conn)
yield From(conn.done_f)
logger.info('Pipe instance done.')
finally:
# Disconnect and reconnect.
logger.info('Disconnecting pipe instance.')
windll.kernel32.DisconnectNamedPipe(self.pipe_handle)
def _connect_client(self):
"""
Wait for a client to connect to this pipe.
"""
overlapped = OVERLAPPED()
overlapped.hEvent = create_event()
while True:
success = windll.kernel32.ConnectNamedPipe(
self.pipe_handle,
byref(overlapped))
if success:
return
last_error = windll.kernel32.GetLastError()
if last_error == ERROR_IO_PENDING:
yield From(wait_for_event(overlapped.hEvent))
# XXX: Call GetOverlappedResult.
return # Connection succeeded.
else:
raise Exception('connect failed with error code' + str(last_error))

83
pymux/rc.py Normal file
View File

@ -0,0 +1,83 @@
"""
Initial configuration.
"""
from __future__ import unicode_literals
__all__ = (
'STARTUP_COMMANDS'
)
STARTUP_COMMANDS = """
bind-key '"' split-window -v
bind-key % split-window -h
bind-key c new-window
bind-key Right select-pane -R
bind-key Left select-pane -L
bind-key Up select-pane -U
bind-key Down select-pane -D
bind-key C-l select-pane -R
bind-key C-h select-pane -L
bind-key C-j select-pane -D
bind-key C-k select-pane -U
bind-key ; last-pane
bind-key ! break-pane
bind-key d detach-client
bind-key t clock-mode
bind-key Space next-layout
bind-key C-z suspend-client
bind-key z resize-pane -Z
bind-key k resize-pane -U 2
bind-key j resize-pane -D 2
bind-key h resize-pane -L 2
bind-key l resize-pane -R 2
bind-key q display-panes
bind-key C-Up resize-pane -U 2
bind-key C-Down resize-pane -D 2
bind-key C-Left resize-pane -L 2
bind-key C-Right resize-pane -R 2
bind-key M-Up resize-pane -U 5
bind-key M-Down resize-pane -D 5
bind-key M-Left resize-pane -L 5
bind-key M-Right resize-pane -R 5
bind-key : command-prompt
bind-key 0 select-window -t :0
bind-key 1 select-window -t :1
bind-key 2 select-window -t :2
bind-key 3 select-window -t :3
bind-key 4 select-window -t :4
bind-key 5 select-window -t :5
bind-key 6 select-window -t :6
bind-key 7 select-window -t :7
bind-key 8 select-window -t :8
bind-key 9 select-window -t :9
bind-key n next-window
bind-key p previous-window
bind-key o select-pane -t :.+
bind-key { swap-pane -U
bind-key } swap-pane -D
bind-key x confirm-before -p "kill-pane #P?" kill-pane
bind-key & confirm-before -p "kill-window #W?" kill-window
bind-key C-o rotate-window
bind-key M-o rotate-window -D
bind-key C-b send-prefix
bind-key . command-prompt "move-window -t '%%'"
bind-key [ copy-mode
bind-key ] paste-buffer
bind-key ? list-keys
bind-key PPage copy-mode -u
# Layouts.
bind-key M-1 select-layout even-horizontal
bind-key M-2 select-layout even-vertical
bind-key M-3 select-layout main-horizontal
bind-key M-4 select-layout main-vertical
bind-key M-5 select-layout tiled
# Renaming stuff.
bind-key , command-prompt -I #W "rename-window '%%'"
#bind-key "'" command-prompt -I #W "rename-pane '%%'"
bind-key "'" command-prompt -p index "select-window -t ':%%'"
bind-key . command-prompt "move-window -t '%%'"
"""

233
pymux/server.py Normal file
View File

@ -0,0 +1,233 @@
from __future__ import unicode_literals
import json
from prompt_toolkit.application.current import set_app
from prompt_toolkit.eventloop import ensure_future, From
from prompt_toolkit.input.vt100_parser import Vt100Parser
from prompt_toolkit.layout.screen import Size
from prompt_toolkit.output.vt100 import Vt100_Output
from prompt_toolkit.utils import is_windows
from .log import logger
from .pipes import BrokenPipeError
__all__ = (
'ServerConnection',
)
class ServerConnection(object):
"""
For each client that connects, we have one instance of this class.
"""
def __init__(self, pymux, pipe_connection):
self.pymux = pymux
self.pipe_connection = pipe_connection
self.size = Size(rows=20, columns=80)
self._closed = False
self._recv_buffer = b''
self.client_state = None
def feed_key(key):
self.client_state.app.key_processor.feed(key)
self.client_state.app.key_processor.process_keys()
self._inputstream = Vt100Parser(feed_key)
self._pipeinput = _ClientInput(self._send_packet)
ensure_future(self._start_reading())
def _start_reading(self):
while True:
try:
data = yield From(self.pipe_connection.read())
self._process(data)
except BrokenPipeError:
self.detach_and_close()
break
except Exception as e:
import traceback; traceback.print_stack()
print('got exception ', repr(e))
break
def _process(self, data):
"""
Process packet received from client.
"""
try:
packet = json.loads(data)
except ValueError:
# So far, this never happened. But it would be good to have some
# protection.
logger.warning('Received invalid JSON from client. Ignoring.')
return
# Handle commands.
if packet['cmd'] == 'run-command':
self._run_command(packet)
# Handle stdin.
elif packet['cmd'] == 'in':
self._pipeinput.send_text(packet['data'])
# elif packet['cmd'] == 'flush-input':
# self._inputstream.flush() # Flush escape key. # XXX: I think we no longer need this.
# Set size. (The client reports the size.)
elif packet['cmd'] == 'size':
data = packet['data']
self.size = Size(rows=data[0], columns=data[1])
self.pymux.invalidate()
# Start GUI. (Create CommandLineInterface front-end for pymux.)
elif packet['cmd'] == 'start-gui':
detach_other_clients = bool(packet['detach-others'])
color_depth = packet['color-depth']
term = packet['term']
if detach_other_clients:
for c in self.pymux.connections:
c.detach_and_close()
print('Create app...')
self._create_app(color_depth=color_depth, term=term)
def _send_packet(self, data):
"""
Send packet to client.
"""
if self._closed:
return
data = json.dumps(data)
def send():
try:
yield From(self.pipe_connection.write(data))
except BrokenPipeError:
self.detach_and_close()
ensure_future(send())
def _run_command(self, packet):
"""
Execute a run command from the client.
"""
create_temp_cli = self.client_states is None
if create_temp_cli:
# If this client doesn't have a CLI. Create a Fake CLI where the
# window containing this pane, is the active one. (The CLI instance
# will be removed before the render function is called, so it doesn't
# hurt too much and makes the code easier.)
pane_id = int(packet['pane_id'])
self._create_app()
with set_app(self.client_state.app):
self.pymux.arrangement.set_active_window_from_pane_id(pane_id)
with set_app(self.client_state.app):
try:
self.pymux.handle_command(packet['data'])
finally:
self._close_connection()
def _create_app(self, color_depth, term='xterm'):
"""
Create CommandLineInterface for this client.
Called when the client wants to attach the UI to the server.
"""
output = Vt100_Output(_SocketStdout(self._send_packet),
lambda: self.size,
term=term,
write_binary=False)
self.client_state = self.pymux.add_client(
input=self._pipeinput, output=output, connection=self, color_depth=color_depth)
print('Start running app...')
future = self.client_state.app.run_async()
print('Start running app got future...', future)
@future.add_done_callback
def done(_):
print('APP DONE.........')
print(future.result())
self._close_connection()
def _close_connection(self):
# This is important. If we would forget this, the server will
# render CLI output for clients that aren't connected anymore.
self.pymux.remove_client(self)
self.client_state = None
self._closed = True
# Remove from eventloop.
self.pipe_connection.close()
def suspend_client_to_background(self):
"""
Ask the client to suspend itself. (Like, when Ctrl-Z is pressed.)
"""
self._send_packet({'cmd': 'suspend'})
def detach_and_close(self):
# Remove from Pymux.
self._close_connection()
class _SocketStdout(object):
"""
Stdout-like object that writes everything through the unix socket to the
client.
"""
def __init__(self, send_packet):
assert callable(send_packet)
self.send_packet = send_packet
self._buffer = []
def write(self, data):
self._buffer.append(data)
def flush(self):
data = {'cmd': 'out', 'data': ''.join(self._buffer)}
self.send_packet(data)
self._buffer = []
if is_windows():
from prompt_toolkit.input.win32_pipe import Win32PipeInput as PipeInput
else:
from prompt_toolkit.input.posix_pipe import PosixPipeInput as PipeInput
class _ClientInput(PipeInput):
"""
Input class that can be given to the CommandLineInterface.
We only need this for turning the client into raw_mode/cooked_mode.
"""
def __init__(self, send_packet):
super(_ClientInput, self).__init__()
assert callable(send_packet)
self.send_packet = send_packet
# Implement raw/cooked mode by sending this to the attached client.
def raw_mode(self):
return self._create_context_manager('raw')
def cooked_mode(self):
return self._create_context_manager('cooked')
def _create_context_manager(self, mode):
" Create a context manager that sends 'mode' commands to the client. "
class mode_context_manager(object):
def __enter__(*a):
self.send_packet({'cmd': 'mode', 'data': mode})
def __exit__(*a):
self.send_packet({'cmd': 'mode', 'data': 'restore'})
return mode_context_manager()

79
pymux/style.py Normal file
View File

@ -0,0 +1,79 @@
"""
The color scheme.
"""
from __future__ import unicode_literals
from prompt_toolkit.styles import Style, Priority
__all__ = (
'ui_style',
)
ui_style = Style.from_dict({
'border': '#888888',
'terminal.focused border': 'ansigreen bold',
#'terminal titleba': 'bg:#aaaaaa #dddddd ',
'terminal titlebar': 'bg:#888888 #ffffff',
# 'terminal titlebar paneindex': 'bg:#888888 #000000',
'terminal.focused titlebar': 'bg:#448844 #ffffff',
'terminal.focused titlebar name': 'bg:#88aa44 #ffffff',
'terminal.focused titlebar paneindex': 'bg:#ff0000',
# 'titlebar title': '',
# 'titlebar name': '#ffffff noitalic',
# 'focused-terminal titlebar name': 'bg:#88aa44',
# 'titlebar.line': '#444444',
# 'titlebar.line focused': '#448844 noinherit',
# 'titlebar focused': 'bg:#5f875f #ffffff bold',
# 'titlebar.title focused': '',
# 'titlebar.zoom': 'bg:#884400 #ffffff',
# 'titlebar paneindex': '',
# 'titlebar.copymode': 'bg:#88aa88 #444444',
# 'titlebar.copymode.position': '',
# 'focused-terminal titlebar.copymode': 'bg:#aaff44 #000000',
# 'titlebar.copymode.position': '#888888',
'commandline': 'bg:#4e4e4e #ffffff',
'commandline.command': 'bold',
'commandline.prompt': 'bold',
#'statusbar': 'noreverse bg:#448844 #000000',
'statusbar': 'noreverse bg:ansigreen #000000',
'statusbar window': '#ffffff',
'statusbar window.current': 'bg:#44ff44 #000000',
'auto-suggestion': 'bg:#4e5e4e #88aa88',
######################################################################################################## DECODED
'decoded': 'bg:#000000 #ff0000',
######################################################################################################## DECODED
'message': 'bg:#bbee88 #222222',
'background': '#888888',
'clock': 'bg:#88aa00',
'panenumber': 'bg:#888888',
'panenumber focused': 'bg:#aa8800',
'terminated': 'bg:#aa0000 #ffffff',
'confirmationtoolbar': 'bg:#880000 #ffffff',
'confirmationtoolbar question': '',
'confirmationtoolbar yesno': 'bg:#440000',
'copy-mode-cursor-position': 'bg:ansiyellow ansiblack',
# 'search-toolbar': 'bg:#88ff44 #444444',
'search-toolbar.prompt': 'bg:#88ff44 #444444',
'search-toolbar.text': 'bg:#88ff44 #000000',
# 'search-toolbar focused': 'bg:#aaff44 #444444',
# 'search-toolbar.text focused': 'bold #000000',
'search-match': '#000000 bg:#88aa88',
'search-match.current': '#000000 bg:#aaffaa underline',
# Pop-up dialog. Ignore built-in style.
'dialog': 'noinherit',
'dialog.body': 'noinherit',
'dialog frame': 'noinherit',
'dialog.body text-area': 'noinherit',
'dialog.body text-area last-line': 'noinherit',
}, priority=Priority.MOST_PRECISE)

106
pymux/utils.py Normal file
View File

@ -0,0 +1,106 @@
"""
Some utilities.
"""
from __future__ import unicode_literals
from prompt_toolkit.utils import is_windows
import os
import sys
__all__ = (
'daemonize',
'nonblocking',
'get_default_shell',
)
def daemonize(stdin='/dev/null', stdout='/dev/null', stderr='/dev/null'):
"""
Double fork-trick. For starting a posix daemon.
This forks the current process into a daemon. The stdin, stdout, and stderr
arguments are file names that will be opened and be used to replace the
standard file descriptors in sys.stdin, sys.stdout, and sys.stderr. These
arguments are optional and default to /dev/null. Note that stderr is opened
unbuffered, so if it shares a file with stdout then interleaved output may
not appear in the order that you expect.
Thanks to:
http://code.activestate.com/recipes/66012-fork-a-daemon-process-on-unix/
"""
# Do first fork.
try:
pid = os.fork()
if pid > 0:
os.waitpid(pid, 0)
return 0 # Return 0 from first parent.
except OSError as e:
sys.stderr.write("fork #1 failed: (%d) %s\n" % (e.errno, e.strerror))
sys.exit(1)
# Decouple from parent environment.
os.chdir("/")
os.umask(0)
os.setsid()
# Do second fork.
try:
pid = os.fork()
if pid > 0:
sys.exit(0) # Exit second parent.
except OSError as e:
sys.stderr.write("fork #2 failed: (%d) %s\n" % (e.errno, e.strerror))
sys.exit(1)
# Now I am a daemon!
# Redirect standard file descriptors.
# NOTE: For debugging, you meight want to take these instead of /dev/null.
# so = open('/tmp/log2', 'ab+')
# se = open('/tmp/log2', 'ab+', 0)
si = open(stdin, 'rb')
so = open(stdout, 'ab+')
se = open(stderr, 'ab+', 0)
os.dup2(si.fileno(), sys.stdin.fileno())
os.dup2(so.fileno(), sys.stdout.fileno())
os.dup2(se.fileno(), sys.stderr.fileno())
# Return 1 from daemon.
return 1
class nonblocking(object):
"""
Make fd non blocking.
"""
def __init__(self, fd):
self.fd = fd
def __enter__(self):
import fcntl
self.orig_fl = fcntl.fcntl(self.fd, fcntl.F_GETFL)
fcntl.fcntl(self.fd, fcntl.F_SETFL, self.orig_fl | os.O_NONBLOCK)
def __exit__(self, *args):
import fcntl
fcntl.fcntl(self.fd, fcntl.F_SETFL, self.orig_fl)
def get_default_shell():
"""
return the path to the default shell for the current user.
"""
if is_windows():
return 'cmd.exe'
else:
import pwd
import getpass
if 'SHELL' in os.environ:
return os.environ['SHELL']
else:
username = getpass.getuser()
shell = pwd.getpwnam(username).pw_shell
return shell

4
requirements.txt Normal file
View File

@ -0,0 +1,4 @@
prompt_toolkit
ptterm
six
docopt