Skip to content

Instantly share code, notes, and snippets.

@lotabout
Created June 23, 2017 10:31
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save lotabout/a2dc8778a3e534aa998e0062f7e15ea3 to your computer and use it in GitHub Desktop.
Save lotabout/a2dc8778a3e534aa998e0062f7e15ea3 to your computer and use it in GitHub Desktop.
DWM like pane management for tmux
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import subprocess
import re
import json
import sys
from math import ceil
TMUX_BUFFER_NAME = 'dwm'
LAYOUTS = ['horizontal', 'vertical']
def default_window(win_id):
"""return default config for one window"""
win = {}
win['win_id'] = win_id
win['mfact'] = 0.75
win['nmaster'] = 1
win['layout'] = 'horizontal'
win['zoomed'] = False
win['num_panes'] = 1
win['last_master_pane_id'] = None
win['last_non_master_pane_id'] = None
return win
def get_windows_status():
"""Retrieve current window status from tmux"""
print_format = '{"win_id": "#{session_id}:#{window_id}", ' \
'"is_active": #{window_active}, ' \
'"is_zoomed": #{window_zoomed_flag},' \
'"width": #{window_width},' \
'"height": #{window_height},' \
'"num_panes": #{window_panes},' \
'"layout_string": "#{window_layout}"' \
'}'
result = subprocess.run(['tmux', 'list-windows', '-F', print_format], stdout=subprocess.PIPE)
windows = [json.loads(l) for l in result.stdout.splitlines()]
return {win["win_id"]: win for win in windows}
def get_panes_status():
"""Retrieve current_pane status from tmux"""
print_format = '{"win_id": "#{session_id}:#{window_id}", ' \
'"pane_index": #{pane_index},' \
'"pane_id": "#{pane_id}",' \
'"is_active": #{pane_active}' \
'}'
result = subprocess.run(['tmux', 'list-panes', '-F', print_format], stdout=subprocess.PIPE)
panes = [json.loads(l) for l in result.stdout.splitlines()]
# get active pane id
active_pane = None
for pane in panes:
if pane['is_active'] == 1:
active_pane = pane
break
return {'panes': panes, 'active_pane': active_pane}
def load_config():
"""Load saved config"""
try:
result = subprocess.run(['tmux', 'save-buffer', '-b', TMUX_BUFFER_NAME, '-'], check=True, stdout=subprocess.PIPE)
config = json.loads(result.stdout)
except subprocess.CalledProcessError:
config = {'windows': {}, 'active_window': None}
current_windows = get_windows_status()
# find active window
active_window = None
for window in current_windows.values():
if window['is_active'] == 1:
active_window = window
break
if active_window is not None:
win_id = active_window['win_id']
if win_id not in config['windows']:
config['windows'][win_id] = default_window(win_id)
config['windows'][win_id].update(active_window)
config['active_window'] = config['windows'][win_id]
# win_id is $xxx:@xxx
current_session = active_window['win_id'].split(':')[0]
window_ids = list(config['windows'].keys())
for win_id in window_ids:
if win_id.startswith(current_session) and win_id not in current_windows:
del config['windows'][win_id]
return config
def save_config(config):
"""Save current config to tmux buffer as json"""
subprocess.run(['tmux', 'set-buffer', '-b', TMUX_BUFFER_NAME, json.dumps(config)])
re_panel = re.compile(r'(\d+)x(\d+),(\d+),(\d+)(?:,(\d+)(?!x))?')
def _parse_layout(layout, next_index = 0):
"""panes from
:layout: The layout string
:next_index: the index of next character to be parsed
:returns: (root, next_index)
"""
match = re_panel.match(layout[next_index:])
width, height, x, y, id = match.groups()
next_index += match.span()[1]
panel = Pane(id, int(x), int(y), int(width), int(height))
if next_index == len(layout):
return (panel, next_index)
next_char = layout[next_index]
if next_char == '[':
panel.type = 'TOP-BOTTOM'
elif next_char == '{':
panel.type = 'LEFT-RIGHT'
if next_char == '[' or next_char == '{':
next_index += 1
while len(layout[next_index:]) > 0 and layout[next_index] != '}' and layout[next_index] != ']':
child, next_index = _parse_layout(layout, next_index)
panel.children.append(child)
# consume the last ']' or '}'
next_index += 1
if next_index < len(layout) and layout[next_index] == ',':
next_index += 1
return (panel, next_index)
def parse_layout(layout):
index = layout.find(',')
root, _ = _parse_layout(layout[index+1:])
return root
def layout_checksum(layout):
csum = 0
for c in layout:
csum = ((csum >> 1) + ((csum & 1) << 15) + ord(c)) & 0xFFFF
return '%04x' % (csum)
class Pane(object):
"""Represents a pane, mainly used for handling layout"""
def __init__(self, id, x=0,y=0,width=0,height=0, parent=None, type='CHILD'):
super(Pane, self).__init__()
self.id = 0 if id is None else id
self.x = x
self.y = y
self.width = width
self.height = height
self.minimized = False
self.type = type
self.children = []
def split(self, n=2):
if self.type == 'CHILD':
return
if n <= 1:
self.type = 'CHILD'
return
if self.type == 'LEFT-RIGHT':
# y and height is the same.
width_unit = (self.width - (n-1)*1) // n
next_x = self.x
for _ in range(n-1):
pane = Pane(None, next_x, self.y, width_unit, self.height, self)
self.children.append(pane)
next_x += width_unit + 1
# the last one
pane = Pane(None, next_x, self.y, self.width - next_x, self.height, self)
self.children.append(pane)
elif self.type == 'TOP-BOTTOM':
# x and width is the same.
height_unit = (self.height - (n-1)*1) // n
next_y = self.y
for _ in range(n-1):
pane = Pane(None, self.x, next_y, self.width, height_unit, self)
self.children.append(pane)
next_y += height_unit + 1
# the last one
pane = Pane(None, self.x, next_y, self.width, self.height - next_y, self)
self.children.append(pane)
def dump_layout(self):
if self.type == "CHILD":
return "{}x{},{},{},{}".format(self.width, self.height, self.x, self.y, self.id)
else:
lparen = '[' if self.type == 'TOP-BOTTOM' else '{'
rparen = ']' if self.type == 'TOP-BOTTOM' else '}'
return '{}x{},{},{}{lparen}{children}{rparen}'.format(self.width, self.height, self.x, self.y,
lparen=lparen,
children = ','.join([c.dump_layout() for c in self.children]),
rparen=rparen)
def __repr__(self, level=0):
lines = ['{}{}: {}x{},{},{}({})'.format(' '*level, self.id, self.width, self.height, self.x, self.y, self.type)]
for c in self.children:
lines.append(c.__repr__(level+1))
return '\n'.join(lines)
def reconstruct_layout(win):
n = win['num_panes']
if win['layout'] == 'vertical':
if n <= win['nmaster']:
root_pane = Pane(None, 0, 0, win['width'], win['height'], type='LEFT-RIGHT')
root_pane.split(n)
else:
root_pane = Pane(None, 0, 0, win['width'], win['height'], type='TOP-BOTTOM')
up_pane = Pane(None, 0, 0, win['width'], ceil(win['height']*win['mfact']), type='LEFT-RIGHT')
down_pane = Pane(None, 0, ceil(win['height']*win['mfact'])+1, win['width'], win['height']-ceil(win['height']*win['mfact'])-1, type='LEFT-RIGHT')
up_pane.split(win['nmaster'])
down_pane.split(n - win['nmaster'])
root_pane.children = [up_pane, down_pane]
elif win['layout'] == 'horizontal':
if n <= win['nmaster']:
root_pane = Pane(None, 0, 0, win['width'], win['height'], type='TOP-BOTTOM')
root_pane.split(n)
else:
root_pane = Pane(None, 0, 0, win['width'], win['height'], type='LEFT-RIGHT')
left_pane = Pane(None, 0, 0, int(win['width']*win['mfact']), win['height'], type='TOP-BOTTOM')
right_pane = Pane(None, int(win['width']*win['mfact'])+1, 0, win['width']-int(win['width']*win['mfact'])-1, win['height'], type='TOP-BOTTOM')
left_pane.split(win['nmaster'])
right_pane.split(n - win['nmaster'])
root_pane.children = [left_pane, right_pane]
return root_pane.dump_layout()
def reconstruct(config):
layout = reconstruct_layout(config['active_window'])
subprocess.run(['tmux', 'selectl', '{},{}'.format(layout_checksum(layout),layout)])
#==============================================================================
# Commands
class LayoutManager(object):
def __init__(self, config):
super(LayoutManager, self).__init__()
self.config = config
def cmd_increase_nmaster(self, n=1):
current_nmaster = self.config['active_window']['nmaster']
target_nmaster = max(1, current_nmaster + n)
self.config['active_window']['nmaster'] = target_nmaster
self.on_layout_change()
def cmd_decrease_nmaster(self, n=1):
self.cmd_increase_nmaster(-n)
def _update_last_pane_status(self):
pane_status = get_panes_status()
active_pane = pane_status['active_pane']
active_window = self.config['active_window']
if active_pane['pane_index'] < active_window['nmaster']:
self.config['active_window']['last_master_pane_id'] = active_pane['pane_id']
else:
self.config['active_window']['last_non_master_pane_id'] = active_pane['pane_id']
def before_select_pane(self):
"""record last pane id of current_window"""
self._update_last_pane_status()
def pane_exited(self):
self.on_layout_change()
# update pane status
pane_status = get_panes_status()
active_pane = pane_status['active_pane']
active_window = self.config['active_window']
nmaster = active_window['nmaster']
last_master_pane_id_found = False
last_non_master_pane_id_found = False
for pane in pane_status['panes']:
if pane['pane_id'] == self.config['active_window']['last_master_pane_id'] and pane['pane_index'] < nmaster:
last_master_pane_id_found = True
if pane['pane_id'] == self.config['active_window']['last_non_master_pane_id'] and pane['pane_index'] >= nmaster:
last_non_master_pane_id_found = True
if not last_master_pane_id_found:
self.config['active_window']['last_master_pane_id'] = None
if not last_non_master_pane_id_found:
self.config['active_window']['last_non_master_pane_id'] = None
def cmd_swap_pane_with_master(self):
pane_status = get_panes_status()
panes = pane_status['panes']
active_pane = pane_status['active_pane']
active_window = self.config['active_window']
nmaster = active_window['nmaster']
src_pane_id = active_pane['pane_id']
if active_pane['pane_index'] < nmaster:
# active pane is master window, swap with last pane
last_pane_id = active_window['last_non_master_pane_id']
if last_pane_id is not None or len(panes) > nmaster:
# last pane exist, or pane number > nmaster
dst_pane_id = last_pane_id if last_pane_id is not None else panes[nmaster]['pane_id']
subprocess.run(['tmux', 'swap-pane', '-s', src_pane_id, '-t', dst_pane_id])
self.config['active_window']['last_non_master_pane_id'] = src_pane_id
else:
# active pane is not master
last_pane_id = active_window['last_master_pane_id']
dst_pane_id = last_pane_id if last_pane_id is not None else panes[0]['pane_id']
subprocess.run(['tmux', 'swap-pane', '-s', dst_pane_id, '-t', src_pane_id])
self.config['active_window']['last_non_master_pane_id'] = dst_pane_id
def before_split_window(self):
self._update_last_pane_status()
pane_status = get_panes_status()
active_pane = pane_status['active_pane']
def after_split_window(self):
pane_status = get_panes_status()
active_pane = pane_status['active_pane']
self._update_last_pane_status()
if active_pane['pane_index'] >= self.config['active_window']['nmaster']:
self.cmd_swap_pane_with_master()
self.on_layout_change()
def on_layout_change(self):
reconstruct(self.config)
def cmd_next_layout(self):
index = LAYOUTS.index(self.config['active_window']['layout'])
self.config['active_window']['layout'] = LAYOUTS[(index+1) % len(LAYOUTS)]
self.on_layout_change()
def cmd_increase_mfact(self, n):
win = self.config['active_window']
if win['layout'] == 'horizontal':
new_fact = max(2, win['width'] * win['mfact'] + n) / win['width']
elif win['layout'] == 'vertical':
new_fact = max(2, win['height'] * win['mfact'] + n) / win['height']
else:
return
new_fact = max(0.01, min(new_fact, 0.99))
win['mfact'] = new_fact
self.on_layout_change()
def cmd_decrease_mfact(self, n):
self.cmd_increase_mfact(-n)
def after_resize_pane(self):
win = self.config['active_window']
if win['num_panes'] <= win['nmaster']:
return
root = parse_layout(win['layout_string'])
if win['layout'] == 'horizontal':
new_fact = root.children[0].width / root.width
elif win['layout'] == 'vertical':
new_fact = root.children[0].height / root.height
win['mfact'] = new_fact
if __name__ == '__main__':
config = load_config()
layout_manager = LayoutManager(config)
if len(sys.argv) == 1:
layout_manager.on_layout_change()
elif sys.argv[1] == 'increase-nmaster':
try:
n = int(sys.argv[2])
except:
n = 1
layout_manager.cmd_increase_nmaster(n)
elif sys.argv[1] == 'decrease-nmaster':
try:
n = int(sys.argv[2])
except:
n = 1
layout_manager.cmd_decrease_nmaster(n)
elif sys.argv[1] == 'increase-mfact':
try:
n = int(sys.argv[2])
except:
n = 2
layout_manager.cmd_increase_mfact(n)
elif sys.argv[1] == 'decrease-mfact':
try:
n = int(sys.argv[2])
except:
n = 2
layout_manager.cmd_decrease_mfact(n)
elif sys.argv[1] == 'before-select-pane':
layout_manager.before_select_pane()
elif sys.argv[1] == 'pane-exited':
layout_manager.pane_exited()
elif sys.argv[1] == 'swap-master':
layout_manager.cmd_swap_pane_with_master()
elif sys.argv[1] == 'before-split-window':
layout_manager.before_split_window()
elif sys.argv[1] == 'after-split-window':
layout_manager.after_split_window()
elif sys.argv[1] == 'next-layout':
layout_manager.cmd_next_layout()
elif sys.argv[1] == 'after-resize-pane':
layout_manager.after_resize_pane()
save_config(config)
@lotabout
Copy link
Author

lotabout commented Jun 23, 2017

You can find my tmux configuration in my dotfiles repo

tmux-tiled

Tmux is great in various way such as session management and scripting support (I use a vim plugin slimux that allows sending the text in vim to another tmux pane). But its default pane management is a bit tedious to working with.

Luckily with its scripting support, we are able to add DWM like pane management system to it.

Usage

First you will need to add hooks to tmux, so that tmux-tiled.py could update the necessary status. You can do it by:

# ~/.tmux.conf
set-hook -g before-split-window 'run "tmux-tiled.py before-split-window"'
set-hook -g after-split-window 'run "tmux-tiled.py after-split-window"'
set-hook -g pane-exited 'run "tmux-tiled.py pane-exited"'
set-hook -g before-select-pane 'run "tmux-tiled.py before-select-pane"'
set-hook -g after-resize-pane 'run "tmux-tiled.py after-resize-pane"'

Then you can bind keys to various commands:

Command feature
swap-master swap the focused pane with master pane, if it is the master pane that was focused, swap it with the last focused non-master pane
increase-nmaster increase the number of master pane
next-layout next layout, only two supported "vertical" and "horizontal"
increase-mfact increase the factor of the master area by characters

Example configuration:

# ~/.tmux.conf
bind Enter run "tmux-tiled.py swap-master"
bind i run "tmux-tiled.py increase-nmaster"
bind o run "tmux-tiled.py decrease-nmaster"
bind e run "tmux-tiled.py next-layout"
bind -r > run "tmux-tiled.py increase-mfact 5"
bind -r < run "tmux-tiled.py decrease-mfact 5"
bind s split-window -v -p 0 -c "#{pane_current_path}"
bind v split-window -h -p 0 -c "#{pane_current_path}"

@lotabout
Copy link
Author

Demo

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment