├── .gitignore ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── assets ├── base.css ├── js │ ├── animation.js │ ├── scroll.js │ └── stream.js └── tmux.html ├── package.json ├── setup.cfg ├── setup.py └── tmux2html ├── __init__.py ├── color.py ├── main.py ├── tmux_layout.py ├── tpl.py └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | /tmux2html/templates 2 | /.styles.css 3 | /README.rst 4 | 5 | ### Python ### 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | env/ 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *,cover 51 | .hypothesis/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | 61 | # Flask instance folder 62 | instance/ 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # IPython Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | venv/ 87 | ENV/ 88 | 89 | # Spyder project settings 90 | .spyderproject 91 | 92 | # Rope project settings 93 | .ropeproject 94 | 95 | 96 | ### Node ### 97 | # Logs 98 | logs 99 | *.log 100 | npm-debug.log* 101 | 102 | # Runtime data 103 | pids 104 | *.pid 105 | *.seed 106 | 107 | # Directory for instrumented libs generated by jscoverage/JSCover 108 | lib-cov 109 | 110 | # Coverage directory used by tools like istanbul 111 | coverage 112 | 113 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 114 | .grunt 115 | 116 | # node-waf configuration 117 | .lock-wscript 118 | 119 | # Compiled binary addons (http://nodejs.org/api/addons.html) 120 | build/Release 121 | 122 | # Dependency directories 123 | node_modules 124 | jspm_packages 125 | 126 | # Optional npm cache directory 127 | .npm 128 | 129 | # Optional REPL history 130 | .node_repl_history 131 | 132 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Tommy Allen 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | exclude README.md 4 | 5 | recursive-include tmux2html *.html 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /bin/bash 2 | PATH := node_modules/.bin:$(PATH) 3 | TPL_PATH = tmux2html/templates 4 | JS = $(wildcard assets/js/*.js) 5 | HTML = $(JS:assets/js/%.js=$(TPL_PATH)/%.html) 6 | STATIC = $(TPL_PATH)/static.html 7 | CSS = .styles.css 8 | 9 | .PHONY: help all clean js bump upload 10 | 11 | help: ## This help message 12 | @echo -e "$$(grep -hE '^\S+:.*##' $(MAKEFILE_LIST) \ 13 | | sed -e 's/:.*##\s*/:/' -e 's/^\(.\+\):\(.*\)/\\x1b[36m\1\\x1b[m:\2/' \ 14 | | column -c2 -t -s :)" 15 | 16 | all: ## Build everything 17 | all: $(CSS) $(HTML) $(STATIC) 18 | @: 19 | 20 | watch: ## Grandpa's change monitoring 21 | @while [ 1 ]; do \ 22 | $(MAKE) --no-print-directory all; \ 23 | sleep 0.5; \ 24 | done; \ 25 | true 26 | 27 | clean: ## Cleanup 28 | rm -f $(HTML) $(CSS) $(STATIC) 29 | 30 | bump: 31 | $(eval V := $(shell echo -n "$$(grep 'version=' setup.py | sed -ne "s/.*='\(.\+\)'\,/\1/p")")) 32 | $(eval NV := $(shell echo -n "$(V)" | awk -F'.' '{print $$1"."$$2"."$$3+1}')) 33 | sed -i -e 's/$(V)/$(NV)/g' setup.py 34 | git add setup.py 35 | git commit -m "Bump" 36 | git tag -a -m 'Auto bumped to $(NV)' $(NV) 37 | 38 | upload: 39 | cat README.md | sed 's/@/\\@/' | pandoc -f markdown -t rst > README.rst 40 | python setup.py sdist upload -r pypi 41 | rm README.rst 42 | 43 | $(CSS): assets/base.css 44 | cat $< | postcss --use autoprefixer --autoprefixer.browsers "last 4 versions" --use cssnano > $@ 45 | 46 | $(HTML): $(CSS) assets/tmux.html 47 | $(HTML):$(TPL_PATH)/%.html:assets/js/%.js 48 | mkdir -p $(@D) 49 | cat assets/tmux.html > $@ 50 | browserify -p bundle-collapser/plugin $< | uglifyjs -m -c warnings=false -o .script.js 51 | sed -i -e '/%CSS%/{ ' -e 'r .styles.css' -e 'd}' $@ 52 | sed -i -e '/%JS%/{ ' -e 'r .script.js' -e 'd}' $@ 53 | rm .script.js 54 | 55 | $(STATIC): $(CSS) 56 | $(STATIC): assets/tmux.html 57 | mkdir -p $(@D) 58 | cat $< > $@ 59 | sed -i -e '/%CSS%/{ ' -e 'r .styles.css' -e 'd}' $@ 60 | sed -i -e '/$$data/d' $@ 61 | sed -i -e '/ 17 | 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tmux2html", 3 | "version": "9000.0.1", 4 | "description": "tmux2html captures full tmux windows or individual panes then parses their contents into HTML in living ![color](https://cloud.githubusercontent.com/assets/111942/14111051/2aa0927e-f597-11e5-85d8-e529c803ec61.png). The output can either be still snapshots, or animated sequences.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/tweekmonster/tmux2html.git" 12 | }, 13 | "author": "", 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/tweekmonster/tmux2html/issues" 17 | }, 18 | "homepage": "https://github.com/tweekmonster/tmux2html#readme", 19 | "dependencies": {}, 20 | "devDependencies": { 21 | "autoprefixer": "^6.3.6", 22 | "bundle-collapser": "^1.2.1", 23 | "cssnano": "^3.5.2", 24 | "pako": "^1.0.1", 25 | "postcss": "^5.0.19", 26 | "postcss-cli": "^2.5.1", 27 | "raf": "^3.2.0", 28 | "uglifyjs": "^2.4.10" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | desc = ''' 4 | tmux2html captures full tmux windows or individual panes 5 | then parses their contents into HTML. 6 | '''.strip() 7 | 8 | setup( 9 | name='tmux2html', 10 | version='0.1.11', 11 | author='Tommy Allen', 12 | author_email='tommy@esdf.io', 13 | description=desc, 14 | packages=find_packages(), 15 | url='https://github.com/tweekmonster/tmux2html', 16 | install_requires=[], 17 | include_package_data=True, 18 | entry_points={ 19 | 'console_scripts': [ 20 | 'tmux2html=tmux2html.main:main', 21 | ] 22 | }, 23 | keywords='tmux html cool hip neat rad', 24 | license='MIT', 25 | classifiers=[ 26 | 'Development Status :: 4 - Beta', 27 | 'Intended Audience :: Developers', 28 | 'Intended Audience :: Information Technology', 29 | 'Intended Audience :: System Administrators', 30 | 'Topic :: Terminals :: Terminal Emulators/X Terminals', 31 | 'Topic :: Utilities', 32 | 'License :: OSI Approved :: MIT License', 33 | 'Programming Language :: Python :: 2', 34 | 'Programming Language :: Python :: 2.7', 35 | 'Programming Language :: Python :: 3', 36 | 'Programming Language :: Python :: 3.4', 37 | ] 38 | ) 39 | -------------------------------------------------------------------------------- /tmux2html/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tweekmonster/tmux2html/20fb2409e7350d45814135a7421df35a98a61834/tmux2html/__init__.py -------------------------------------------------------------------------------- /tmux2html/color.py: -------------------------------------------------------------------------------- 1 | _colors_8 = [ 2 | (0x00, 0x00, 0x00), 3 | (0xbb, 0x00, 0x00), 4 | (0x00, 0xbb, 0x00), 5 | (0xbb, 0xbb, 0x00), 6 | (0x00, 0x00, 0xbb), 7 | (0xbb, 0x00, 0xbb), 8 | (0x00, 0xbb, 0xbb), 9 | (0xbb, 0xbb, 0xbb), 10 | ] 11 | 12 | _cube_6 = (0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff) 13 | 14 | 15 | def term_to_rgb(n, style=None): 16 | '''Get the R/G/B values for a terminal color index 17 | 18 | 0 - 15 are the basic colors 19 | 16 - 231 are the 6x6 RGB colors 20 | 232 - 255 are the gray scale colors 21 | ''' 22 | if style is None: 23 | style = [] 24 | 25 | if n < 8 and 1 in style and 22 not in style: 26 | n += 8 27 | elif n > 7 and 22 in style: 28 | n -= 8 29 | 30 | if n < 16: 31 | rgb = _colors_8[n % 8] 32 | if n > 7: 33 | return tuple(map(lambda i: min(255, i + 0x55), rgb)) 34 | return rgb 35 | 36 | if n < 232: 37 | n -= 16 38 | if n == 0: 39 | return (0x00, 0x00, 0x00) 40 | return (_cube_6[n // 36], _cube_6[(n // 6) % 6], _cube_6[n % 6]) 41 | 42 | n -= 232 43 | c = 8 + (n * 10) 44 | return (c, c, c) 45 | 46 | 47 | def _parse_colors(parts): 48 | type_ = next(parts) 49 | if type_ == 2: 50 | return ( 51 | int(next(parts)), 52 | int(next(parts)), 53 | int(next(parts)), 54 | ) 55 | elif type_ == 5: 56 | return int(next(parts)) 57 | 58 | 59 | def _iter_escape(s): 60 | for p in s.split(';'): 61 | try: 62 | yield int(p) 63 | except ValueError: 64 | print(p) 65 | 66 | 67 | def parse_escape(s, fg=None, bg=None, style=None): 68 | """Parses an escape sequence. 69 | 70 | Not sure if this is very accurate. 71 | """ 72 | if style is None: 73 | style = [] 74 | 75 | if not s: 76 | return (None, None) 77 | 78 | parts = _iter_escape(s) 79 | for p in parts: 80 | if p == 38: 81 | fg = _parse_colors(parts) 82 | elif p == 48: 83 | bg = _parse_colors(parts) 84 | elif p == 39: 85 | fg = None 86 | elif p == 49: 87 | bg = None 88 | else: 89 | if p >= 30 and p <= 37: 90 | fg = p - 30 91 | elif p >= 40 and p <= 47: 92 | bg = p - 40 93 | elif p >= 90 and p <= 97: 94 | fg = (p - 90) + 8 95 | elif p >= 100 and p <= 107: 96 | bg = (p - 100) + 8 97 | elif p not in style: 98 | if p == 0: 99 | style[:] = [] 100 | else: 101 | style.append(p) 102 | 103 | return (fg, bg) 104 | -------------------------------------------------------------------------------- /tmux2html/main.py: -------------------------------------------------------------------------------- 1 | # coding: utf8 2 | from __future__ import print_function, unicode_literals, division 3 | 4 | import os 5 | import re 6 | import sys 7 | import json 8 | import time 9 | import argparse 10 | import tempfile 11 | import unicodedata 12 | from collections import defaultdict 13 | 14 | from . import color, utils, tpl 15 | 16 | try: 17 | from html import escape 18 | except ImportError: 19 | from cgi import escape 20 | 21 | try: 22 | str_ = unicode 23 | chr_ = unichr 24 | py_v = 2 25 | except NameError: 26 | str_ = str 27 | chr_ = chr 28 | py_v = 3 29 | 30 | 31 | class IncompatibleOptionError(Exception): 32 | pass 33 | 34 | 35 | basedir = os.path.dirname(__file__) 36 | classname = 'tmux-html' 37 | 38 | 39 | font_stack = ( 40 | 'Anonymice Powerline', 41 | 'Arimo for Powerline', 42 | 'Aurulent Sans Mono', 43 | 'Bitstream Vera Sans Mono', 44 | 'Cousine for Powerline', 45 | 'DejaVu Sans Mono for Powerline', 46 | 'Droid Sans Mono Dotted for Powerline', 47 | 'Droid Sans Mono Slashed for Powerline', 48 | 'Droid Sans Mono for Powerline', 49 | 'Fira Mono Medium for Powerline', 50 | 'Fira Mono for Powerline', 51 | 'Fura Mono Medium for Powerline', 52 | 'Fura Mono for Powerline', 53 | 'Hack', 54 | 'Heavy Data', 55 | 'Hurmit', 56 | 'IBM 3270', 57 | 'IBM 3270 Narrow', 58 | 'Inconsolata for Powerline', 59 | 'Inconsolata-dz for Powerline', 60 | 'Inconsolata-g for Powerline', 61 | 'Knack', 62 | 'Lekton', 63 | 'Literation Mono Powerline', 64 | 'M+ 1m', 65 | 'Meslo LG L DZ for Powerline', 66 | 'Meslo LG L for Powerline', 67 | 'Meslo LG M DZ for Powerline', 68 | 'Meslo LG M for Powerline', 69 | 'Meslo LG S DZ for Powerline', 70 | 'Meslo LG S for Powerline', 71 | 'ProFontWindows', 72 | 'ProggyCleanTT', 73 | 'ProggyCleanTT CE', 74 | 'Roboto Mono Light for Powerline', 75 | 'Roboto Mono Medium for Powerline', 76 | 'Roboto Mono Thin for Powerline', 77 | 'Roboto Mono for Powerline', 78 | 'Sauce Code Powerline', 79 | 'Sauce Code Pro', 80 | 'Sauce Code Pro Black', 81 | 'Sauce Code Pro ExtraLight', 82 | 'Sauce Code Pro Light', 83 | 'Sauce Code Pro Medium', 84 | 'Sauce Code Pro Semibold', 85 | 'Source Code Pro Black for Powerline', 86 | 'Source Code Pro ExtraLight for Powerline', 87 | 'Source Code Pro Light for Powerline', 88 | 'Source Code Pro Medium for Powerline', 89 | 'Source Code Pro Semibold for Powerline', 90 | 'Symbol Neu for Powerline', 91 | 'Tinos for Powerline', 92 | 'Ubuntu Mono for Powerline', 93 | 'Ubuntu Mono derivative Powerlin', 94 | 'Ubuntu Mono derivative Powerline', 95 | 'monofur for Powerline', 96 | ) 97 | 98 | # The following table is referenced from: 99 | # https://en.wikipedia.org/wiki/Talk%3AVT100#Alternate_character_set 100 | vt100_alt_charset = { 101 | 'enabled': False, 102 | 'table': [ 103 | # 0 1 2 3 4 5 6 7 104 | 0x25c6, 0x2592, 0x2409, 0x240c, 0x240d, 0x240a, 0x00b0, 0x00b1, 105 | # 8 9 A B C D E F 106 | 0x2424, 0x240b, 0x2518, 0x2510, 0x250c, 0x2514, 0x253c, 0x23ba, 107 | # 0 1 2 3 4 5 6 7 108 | 0x23bb, 0x2500, 0x23bc, 0x23bd, 0x251c, 0x2524, 0x2534, 0x252c, 109 | # 8 9 A B C D E F 110 | 0x2502, 0x2264, 0x2265, 0x03c0, 0x2260, 0x00a3, 0x00b7, 0x0020 111 | ], 112 | } 113 | 114 | 115 | class Pane(object): 116 | def __init__(self, size, max_lines=0): 117 | self.size = size 118 | self.max_lines = max_lines 119 | self.lines = [] 120 | 121 | def add_line(self, line): 122 | self.lines.append(line) 123 | 124 | def __len__(self): 125 | return len(self.lines) 126 | 127 | def __str__(self): 128 | html_lines = [str_(x) for x in self.lines] 129 | if self.max_lines and len(self.lines) > self.size[1]: 130 | visible = html_lines[-self.size[1]:] 131 | hidden = html_lines 132 | else: 133 | visible = html_lines 134 | hidden = [] 135 | 136 | out = '
{}
'.format(''.join(visible)) 137 | if hidden: 138 | out += '' \ 139 | .format(''.join(utils.compress_data('\n'.join(hidden)))) 140 | return out 141 | 142 | 143 | class ChunkedLine(object): 144 | def __init__(self, renderer, width=0, line=0): 145 | self.col = renderer.column 146 | self.line = line 147 | self.renderer = renderer 148 | self.width = width 149 | self.length = 0 150 | self.chunks = [] 151 | self.tag_stack = [] 152 | self._curtag_args = [] 153 | 154 | def _style_classes(self, styles): 155 | """Set an equivalent CSS style.""" 156 | out = [] 157 | if 1 in styles and 22 not in styles: 158 | # Bold 159 | out.append('sb') 160 | if 3 in styles and 23 not in styles: 161 | # Italic 162 | out.append('si') 163 | if 4 in styles and 24 not in styles: 164 | # Underline 165 | out.append('su') 166 | return out 167 | 168 | def _escape_text(self, s): 169 | """Escape text 170 | 171 | In addition to escaping text, unicode characters are replaced with a 172 | span that will display the glyph using CSS. This is to ensure that the 173 | text has a consistent width. 174 | """ 175 | tpl = ('&#x{0:x};' 176 | '{1}') 177 | out = '' 178 | for c in s: 179 | w = utils.str_width(c) 180 | if unicodedata.category(c) in ('Co', 'Cn', 'So'): 181 | out += tpl.format(ord(c), ' ') 182 | elif w > 1 or ord(c) > 255: 183 | out += tpl.format(ord(c), ' ' * w) 184 | else: 185 | out += escape(c) 186 | return out 187 | 188 | def open_tag(self, fg, bg, seq=None, tag='span', cls=None, styles=None): 189 | """Opens a tag. 190 | 191 | This tracks how many tags are opened so they can all be closed at once 192 | if needed. 193 | """ 194 | self._curtag_args = (fg, bg, seq, tag, cls, styles) 195 | 196 | classes = [] 197 | if cls: 198 | classes.append(cls) 199 | 200 | if styles is None: 201 | styles = self.renderer.esc_style 202 | 203 | if 7 in styles: 204 | fg, bg = bg, fg 205 | classes.append('r') 206 | 207 | k = self.renderer.update_css('f', fg) 208 | if k: 209 | classes.append(k) 210 | k = self.renderer.update_css('b', bg) 211 | if k: 212 | classes.append(k) 213 | 214 | classes.extend(self._style_classes(styles)) 215 | if (isinstance(fg, int) and (fg < 16 or fg == 39)) \ 216 | and 1 in styles and 'sb' in classes: 217 | # Don't actually bold the basic colors since "bold" means to 218 | # brighten the color. 219 | classes.remove('sb') 220 | 221 | attrs = [] 222 | if classes: 223 | attrs.append('class="{0}"'.format(' '.join(classes))) 224 | if seq: 225 | attrs.append('data-seq="{0}"'.format(seq)) 226 | 227 | self.tag_stack.append(tag) 228 | self.chunks.append('<{tag} {attrs}>'.format(tag=tag, 229 | attrs=' '.join(attrs))) 230 | 231 | def close_tag(self): 232 | """Closes a tag.""" 233 | if self.tag_stack: 234 | tag = self.tag_stack.pop() 235 | self.chunks.append(''.format(tag)) 236 | 237 | def add_cursor(self, c): 238 | """Append a cursor to the chunk list.""" 239 | fg, bg, seq, tag, cls, styles = self._curtag_args 240 | self.open_tag(bg, fg, seq, tag, 'cu', styles) 241 | self.chunks.append(c) 242 | self.close_tag() 243 | 244 | def add_text(self, s): 245 | """Add text to the line. 246 | 247 | If the added text is longer than self.width, cut it and return the 248 | remaining text. Since double width characters may be encountered, add 249 | up to the width cut the string from there. 250 | """ 251 | keep = '' 252 | remainder = '' 253 | for i, c in enumerate(s): 254 | if ord(c) == 0x0e: 255 | # Shift out to alternate character set 256 | vt100_alt_charset['enabled'] = True 257 | continue 258 | elif ord(c) == 0x0f: 259 | # Shift back into standard character set 260 | vt100_alt_charset['enabled'] = False 261 | continue 262 | elif vt100_alt_charset['enabled']: 263 | x = ord(c) % 16 264 | y = ord(c) // 16 - 6 265 | if x >= 0 and x < 16 and y >= 0 and y < 2: 266 | c = chr_(vt100_alt_charset['table'][x + (y * 16)]) 267 | 268 | cw = utils.str_width(c) 269 | if self.length + cw > self.width: 270 | remainder = s[i:] 271 | break 272 | 273 | self.length += cw 274 | 275 | if self.col + i == self.renderer.cursor_x \ 276 | and self.line == self.renderer.cursor_y: 277 | self.chunks.append(self._escape_text(keep)) 278 | self.add_cursor(c) 279 | self.renderer.column += len(keep) + 1 280 | self.col = self.renderer.column 281 | keep = '' 282 | continue 283 | 284 | keep += c 285 | 286 | if keep: 287 | self.renderer.column += len(keep) 288 | self.col = self.renderer.column 289 | self.chunks.append(self._escape_text(keep)) 290 | return remainder 291 | 292 | def finalize(self): 293 | """Finalize the chunked line. 294 | 295 | Padding is added if the length is under self.width. 296 | """ 297 | while self.tag_stack: 298 | self.close_tag() 299 | 300 | if self.length < self.width: 301 | self.open_tag(None, None, cls='ns', styles=[]) 302 | self.add_text(' ' * (self.width - self.length)) 303 | self.close_tag() 304 | 305 | text = ''.join(self.chunks) 306 | return '
{1}
'.format(self.line, text) 307 | 308 | __str__ = finalize 309 | __unicode__ = __str__ 310 | 311 | def __hash__(self): 312 | return hash(tuple(self.chunks)) 313 | 314 | 315 | class Separator(object): 316 | def __init__(self, parent, size, vertical=True): 317 | self.parent = parent 318 | self.size = size 319 | self.vertical = vertical 320 | 321 | def __str__(self): 322 | if self.vertical: 323 | n = self.size[0] 324 | rep = ('' 325 | ' ') 326 | else: 327 | n = self.size[1] 328 | rep = ('
' 329 | '
') 330 | 331 | return '
{}
' \ 332 | .format(rep * n) 333 | 334 | 335 | class Renderer(object): 336 | opened = 0 337 | lines = [] 338 | css = {} 339 | esc_style = [] 340 | 341 | def __init__(self, fg=(0xfa, 0xfa, 0xfa), bg=0): 342 | self.default_fg = fg 343 | self.default_bg = bg 344 | 345 | def rgbhex(self, c, style=None): 346 | """Converts a color to hex RGB.""" 347 | if c is None: 348 | return 'none' 349 | if isinstance(c, int): 350 | c = color.term_to_rgb(c, style) 351 | return '#{:02x}{:02x}{:02x}'.format(*c) 352 | 353 | def update_css(self, prefix, color_code): 354 | """Updates the CSS with a color.""" 355 | if color_code is None: 356 | return '' 357 | style = 'color' if prefix == 'f' else 'background-color' 358 | seq_style = self.esc_style 359 | if isinstance(color_code, int): 360 | if prefix == 'f' and 1 in seq_style and color_code < 8: 361 | color_code += 8 362 | else: 363 | seq_style = None 364 | key = '{0}{1:d}'.format(prefix, color_code) 365 | else: 366 | key = '{0}-rgb_{1}'.format(prefix, '_'.join(map(str_, color_code))) 367 | 368 | self.css[key] = ':'.join((style, self.rgbhex(color_code, seq_style))) 369 | return key 370 | 371 | def render_css(self): 372 | """Render stylesheet. 373 | 374 | If an item is a list or tuple, it is joined. 375 | """ 376 | out = '' 377 | ctx = { 378 | 'fonts': ','.join('"{}"'.format(x) for x in font_stack), 379 | 'fg': self.rgbhex(self.default_fg), 380 | 'bg': self.rgbhex(self.default_bg), 381 | 'prefix': classname, 382 | } 383 | out = ('div.{prefix} pre {{font-family:{fonts},monospace;' 384 | 'background-color:{bg};}}' 385 | 'div.{prefix} pre span {{color:{fg};' 386 | 'background-color:{bg};}}' 387 | 'div.{prefix} pre span.r {{color:{bg};' 388 | 'background-color:{fg};}}' 389 | ).format(**ctx) 390 | out += ('div.{prefix} pre span.cu{{color:{bg};' 391 | 'background-color:{fg}}}').format(**ctx) 392 | 393 | fmt = 'div.{prefix} pre span.{cls} {{{style};}}' 394 | for k, v in self.css.items(): 395 | out += fmt.format(prefix=classname, cls=k, 396 | style=';'.join(v) if isinstance(v, (tuple, list)) else v) 397 | return out 398 | 399 | def reset_css(self): 400 | """Reset the CSS to the default state.""" 401 | self.css = { 402 | 'si': 'font-style:italic', 403 | 'sb': 'font-weight:bold', 404 | 'ns': [ 405 | '-webkit-user-select:none', 406 | '-moz-user-select:none', 407 | '-ms-user-select:none', 408 | 'user-select:none', 409 | ], 410 | 'cu': [ 411 | 'color:{0}'.format(self.default_bg), 412 | 'background-color:{0}'.format(self.default_fg), 413 | ], 414 | } 415 | 416 | def _render(self, s, size, max_lines=0): 417 | """Render the content and return a Pane instance. 418 | """ 419 | cur_fg = None 420 | cur_bg = None 421 | self.esc_style = [] 422 | pane = Pane(size, max_lines) 423 | 424 | prev_seq = '' 425 | lines = s.split('\n') 426 | line_c = len(lines) - 1 427 | for line_i, line in enumerate(lines): 428 | self.column = 0 429 | last_i = 0 430 | chunk = ChunkedLine(self, size[0], len(pane)) 431 | chunk.open_tag(cur_fg, cur_bg, seq=prev_seq) 432 | for m in re.finditer(r'\x1b\[([^m]*)m', line): 433 | start, end = m.span() 434 | seq = m.group(1) 435 | c = line[last_i:start] 436 | last_i = end 437 | 438 | while True: 439 | c = chunk.add_text(c) 440 | if not c: 441 | break 442 | pane.add_line(chunk) 443 | self.column = 0 444 | line_c += 1 445 | chunk = ChunkedLine(self, size[0], len(pane)) 446 | chunk.open_tag(cur_fg, cur_bg, seq=prev_seq) 447 | chunk.close_tag() 448 | 449 | cur_fg, cur_bg = color.parse_escape(seq, fg=cur_fg, bg=cur_bg, 450 | style=self.esc_style) 451 | 452 | chunk.open_tag(cur_fg, cur_bg, seq=seq) 453 | prev_seq = seq 454 | 455 | c = line[last_i:] 456 | if c: 457 | if last_i == 0 and not chunk.tag_stack: 458 | chunk.open_tag(cur_fg, cur_bg, seq=prev_seq) 459 | while True: 460 | c = chunk.add_text(c) 461 | if not c: 462 | break 463 | pane.add_line(chunk) 464 | self.column = 0 465 | line_c += 1 466 | chunk = ChunkedLine(self, size[0], len(pane)) 467 | chunk.open_tag(cur_fg, cur_bg, seq=prev_seq) 468 | chunk.close_tag() 469 | if len(pane) < size[1] or (len(lines) > size[1] and len(pane) < line_c): 470 | pane.add_line(chunk) 471 | 472 | while len(pane) < size[1] or (len(lines) > size[1] and len(pane) < line_c): 473 | self.column = 0 474 | pane.add_line(ChunkedLine(self, size[0], len(pane))) 475 | return pane 476 | 477 | def _update_cursor(self, pane): 478 | self.cursor_x, self.cursor_y = utils.get_cursor( 479 | '%{}'.format(pane.identifier)) 480 | 481 | def _render_pane(self, pane, empty=False, full=False, max_lines=0): 482 | """Recursively render a pane as HTML. 483 | 484 | Panes without sub-panes are grouped. Panes with sub-panes are grouped 485 | by their orientation. 486 | """ 487 | if pane.panes: 488 | if pane.vertical: 489 | self.lines.append('
') 490 | else: 491 | self.lines.append('
') 492 | for i, p in enumerate(pane.panes): 493 | if p.x != 0 and p.x > pane.x: 494 | self.lines.append(Separator(self, p.size, False)) 495 | if p.y != 0 and p.y > pane.y: 496 | self.lines.append(Separator(self, p.size, True)) 497 | self._render_pane(p, empty, full=full, max_lines=max_lines) 498 | 499 | self.lines.append('
') 500 | else: 501 | self.lines.append('
' 502 | .format(pane.identifier, *pane.size)) 503 | if not empty: 504 | vt100_alt_charset['enabled'] = False 505 | self._update_cursor(pane) 506 | pane = self._render( 507 | utils.get_contents('%{}'.format(pane.identifier), 508 | full=full, max_lines=max_lines), 509 | pane.size, max_lines=max_lines) 510 | self.lines.append(pane) 511 | else: 512 | self.lines.append('
')
513 |             self.lines.append('
') 514 | 515 | def render_pane(self, pane, script_reload=False, full=False, max_lines=0): 516 | """Render a pane as HTML.""" 517 | self.lines = [] 518 | self.win_size = pane.size 519 | self.reset_css() 520 | self._render_pane(pane, full=full, max_lines=max_lines) 521 | script = '' 522 | template = 'static.html' 523 | if script_reload: 524 | template = 'stream.html' 525 | elif full and (pane.identifier == -1 or max_lines): 526 | template = 'scroll.html' 527 | return tpl.render(template, panes=''.join(str_(x) for x in self.lines), 528 | css=self.render_css(), prefix=classname, 529 | script=script, fg=self.rgbhex(self.default_fg), 530 | bg=self.rgbhex(self.default_bg), data='', 531 | interval=script_reload) 532 | 533 | def record(self, pane, interval, duration, window=None, session=None): 534 | panes = [] 535 | frames = [] 536 | start = time.time() 537 | changes = defaultdict(dict) 538 | frame = defaultdict(dict) 539 | last_frame = start 540 | frame_sizes = tuple() 541 | 542 | while True: 543 | try: 544 | n = time.time() 545 | if duration and n - start >= duration: 546 | break 547 | 548 | frame.clear() 549 | new_pane, new_panes, new_frame_sizes = \ 550 | utils.update_pane_list(pane, window, session, ignore_error=True) 551 | 552 | if pane.dimensions != new_pane.dimensions \ 553 | or frame_sizes != new_frame_sizes \ 554 | or hash(tuple(panes)) != hash(tuple(new_panes)): 555 | changes.clear() 556 | self.lines[:] = [] 557 | self.win_size = new_pane.size 558 | self._render_pane(new_pane, empty=True) 559 | containers = ''.join(str_(x) for x in self.lines) 560 | frames.append({ 561 | 'delay': 0, 562 | 'reset': True, 563 | 'layout': containers, 564 | }) 565 | 566 | pane = new_pane 567 | panes = new_panes 568 | frame_sizes = new_frame_sizes 569 | 570 | for p in panes: 571 | self.opened = 0 572 | self.lines = [] 573 | self.win_size = p.size 574 | content = utils.get_contents('%{}'.format(p.identifier)) 575 | if not content: 576 | continue 577 | 578 | self._update_cursor(p) 579 | rendered = self._render(content, p.size) 580 | 581 | if p.dimensions not in changes: 582 | changes[p.dimensions] = {} 583 | 584 | ch_pane = changes.get(p.dimensions) 585 | for lc in rendered.lines: 586 | line_str = str_(lc) 587 | cl = ch_pane.get(lc.line) 588 | if cl is None or cl != line_str: 589 | ch_pane[lc.line] = line_str 590 | frame[p.identifier][lc.line] = line_str 591 | 592 | if frame: 593 | n += (time.time() - n) 594 | frames.append({ 595 | 'delay': max(0, n - last_frame), 596 | 'lines': frame.copy(), 597 | }) 598 | last_frame = n 599 | time.sleep(interval) 600 | except KeyboardInterrupt: 601 | break 602 | except Exception as e: 603 | print('Stopped recording due to an encountered error: %s' % e) 604 | break 605 | 606 | # Close the loop 607 | if len(frames) > 2: 608 | n = time.time() 609 | frames.append({ 610 | 'delay': n - last_frame, 611 | }) 612 | frame.clear() 613 | 614 | str_data = [] 615 | 616 | first, frames = frames[:50], frames[50:] 617 | str_data.append('' 618 | .format(''.join(utils.compress_data(json.dumps(first))))) 619 | 620 | for i in range(0, len(frames), 500): 621 | str_data.append( 622 | '' 623 | .format(''.join(utils.compress_data(json.dumps(frames[i:i+500]))))) 624 | 625 | return tpl.render('animation.html', panes='', css=self.render_css(), 626 | prefix=classname, data='\n'.join(str_data), 627 | fg=self.rgbhex(self.default_fg), 628 | bg=self.rgbhex(self.default_bg)) 629 | 630 | 631 | def color_type(val): 632 | parts = tuple(map(int, val.split(','))) 633 | if len(parts) == 1: 634 | return parts[0] 635 | elif len(parts) == 3: 636 | return parts 637 | raise ValueError('Bad format') 638 | 639 | 640 | def sil_int(val): 641 | """Silent int(). 642 | 643 | Get it? 644 | """ 645 | try: 646 | return int(val) 647 | except ValueError: 648 | return 0 649 | 650 | 651 | def atomic_output(output, filename=None, mode=0o0644, quiet=False): 652 | if filename: 653 | tmp = None 654 | try: 655 | tmp = tempfile.NamedTemporaryFile(prefix='tmp2html.', 656 | dir=os.path.dirname(filename), 657 | delete=False) 658 | tmp.write(output.encode('utf8')) 659 | tmp.flush() 660 | os.fsync(tmp.fileno()) 661 | except IOError as e: 662 | print(e) 663 | except Exception: 664 | pass 665 | finally: 666 | if tmp: 667 | tmp.close() 668 | os.chmod(tmp.name, mode) 669 | os.rename(tmp.name, filename) 670 | if not quiet: 671 | print('Wrote HTML to: {}'.format(filename)) 672 | else: 673 | print(output.encode('utf8')) 674 | 675 | 676 | def main(): 677 | parser = argparse.ArgumentParser(description='Render tmux panes as HTML') 678 | parser.add_argument('target', default='', help='Target window or pane') 679 | parser.add_argument('-o', '--output', default='', 680 | help='Output file, required with --stream') 681 | parser.add_argument('-m', '--mode', default='644', 682 | type=lambda x: int(x, 8), help='Output file permissions') 683 | parser.add_argument('--light', action='store_true', help='Light background') 684 | parser.add_argument('--stream', action='store_true', 685 | help='Continuously renders until stopped and adds a ' 686 | 'script to auto refresh based on --interval') 687 | parser.add_argument('--interval', default=0.5, type=float, 688 | help='Number of seconds between captures') 689 | parser.add_argument('--duration', default=-1, type=float, 690 | help='Number of seconds to capture (0 for indefinite, ' 691 | '-1 to disable, ignored with --stream)') 692 | parser.add_argument('--fg', type=color_type, default=None, 693 | help='Foreground color') 694 | parser.add_argument('--bg', type=color_type, default=None, 695 | help='Background color') 696 | parser.add_argument('--full', action='store_true', 697 | help='Renders the full history of a single pane') 698 | parser.add_argument('--history', type=int, default=0, 699 | help='Specifies the maximum number of pane history ' 700 | 'lines to include (implies --full)') 701 | args = parser.parse_args() 702 | 703 | if args.interval <= 0: 704 | print('Interval must be positive non-zero') 705 | sys.exit(1) 706 | 707 | window = args.target 708 | pane = None 709 | session = None 710 | if window.find(':') != -1: 711 | session, window = window.split(':', 1) 712 | 713 | if window.find('.') != -1: 714 | window, pane = window.split('.', 1) 715 | window = sil_int(window) 716 | pane = sil_int(pane) 717 | else: 718 | window = sil_int(window) 719 | 720 | root = utils.get_layout(window, session) 721 | target_pane = root 722 | if isinstance(pane, int): 723 | panes = utils.pane_list(root) 724 | target_pane = panes[pane] 725 | 726 | args.full = args.full or args.history > 0 727 | 728 | if args.full: 729 | try: 730 | # if target_pane.panes: 731 | # raise IncompatibleOptionError('Full history can only target a ' 732 | # 'pane without splits') 733 | if args.duration > 0: 734 | raise IncompatibleOptionError('Animation is not allowed in ' 735 | 'full history renders') 736 | if args.stream: 737 | raise IncompatibleOptionError('Streaming is not allowed in ' 738 | 'full history renders') 739 | except IncompatibleOptionError as e: 740 | print(e) 741 | sys.exit(1) 742 | 743 | # Dark backgrounds are very common for terminal emulators and porn sites. 744 | # The use of dark backgrounds for anything else just looks weird. I was 745 | # able to scientifically prove this through the use of the finest 746 | # recreational drugs and special goggles I made out of toilet paper rolls. 747 | fg = (0xfa, 0xfa, 0xfa) 748 | bg = (0, 0, 0) 749 | 750 | if args.light: 751 | fg, bg = bg, fg 752 | 753 | if args.fg: 754 | fg = args.fg 755 | if args.bg: 756 | bg = args.bg 757 | 758 | r = Renderer(fg, bg) 759 | 760 | if args.stream: 761 | if not args.output: 762 | print('Streaming requires an output file', file=sys.stdout) 763 | sys.exit(1) 764 | 765 | print('Streaming ({0:0.2f}s) to {1}.\nPress Ctrl-C to stop.' 766 | .format(args.interval, args.output)) 767 | target_panes = [] 768 | target_frame_sizes = tuple() 769 | last_output = '' 770 | while True: 771 | try: 772 | new_pane, new_panes, new_frame_sizes = \ 773 | utils.update_pane_list(target_pane, window, session) 774 | if target_pane.dimensions != new_pane.dimensions \ 775 | or target_frame_sizes != new_frame_sizes \ 776 | or hash(tuple(target_panes)) != hash(tuple(new_panes)): 777 | output = r.render_pane(target_pane, script_reload=args.interval) 778 | if output != last_output: 779 | last_output = output 780 | atomic_output(output, args.output, quiet=True, 781 | mode=args.mode) 782 | time.sleep(args.interval) 783 | except KeyboardInterrupt: 784 | break 785 | 786 | return 787 | 788 | if args.duration != -1: 789 | if args.duration == 0: 790 | print('Recording indefinitely. Press Ctrl-C to stop.') 791 | else: 792 | print('Recording for {:0.2f} seconds. Press Ctrl-C to stop.' 793 | .format(args.duration)) 794 | output = r.record(target_pane, args.interval, args.duration, window, 795 | session) 796 | else: 797 | output = r.render_pane(target_pane, full=args.full, 798 | max_lines=args.history) 799 | 800 | atomic_output(output, args.output, mode=args.mode) 801 | -------------------------------------------------------------------------------- /tmux2html/tmux_layout.py: -------------------------------------------------------------------------------- 1 | """A utility for dealing with tmux layout information.""" 2 | import re 3 | 4 | 5 | class Layout(object): 6 | def __init__(self, x, y, size, identifier=-1, vertical=False): 7 | self._depth = -1 8 | self.parent = None 9 | self.x = x 10 | self.y = y 11 | self.size = tuple(size) 12 | self.x2 = x + self.size[0] 13 | self.y2 = y + self.size[1] 14 | self.identifier = identifier 15 | self.vertical = vertical 16 | self.panes = [] 17 | 18 | def copy(self): 19 | l = Layout(self.x, self.y, self.size, self.identifier, 20 | vertical=self.vertical) 21 | l.parent = self.parent 22 | return l 23 | 24 | @property 25 | def depth(self): 26 | if self._depth == -1: 27 | self._depth = 0 28 | p = self 29 | while p.parent is not None: 30 | p = p.parent 31 | self._depth += 1 32 | return self._depth 33 | 34 | @property 35 | def coords(self): 36 | return (self.x, self.y) 37 | 38 | @property 39 | def dimensions(self): 40 | return (self.x, self.y, self.x2, self.y2) 41 | 42 | def is_intersect(self, other): 43 | return self.x < other.x2 and self.x2 > other.x \ 44 | and self.y < other.y2 and self.y2 > other.y 45 | 46 | def is_inside(self, other): 47 | return self.x >= other.x and self.y >= other.y \ 48 | and self.x2 <= other.x2 and self.y2 <= other.y2 49 | 50 | def __hash__(self): 51 | return hash(('layout', self.identifier, self.x, self.y) + self.size) 52 | 53 | def __eq__(self, other): 54 | return isinstance(other, Layout) \ 55 | and hash(other.identifier) == hash(self.identifier) 56 | 57 | def _describe(self, depth=0): 58 | out = ' ' * depth 59 | pv = '-' if not self.parent else self.parent.vertical 60 | out += '{}Layout(id:{} x:{} y:{} x2:{}, y2:{}) (pv: {})\n' \ 61 | .format('Vertical' if self.vertical else 'Horizontal', 62 | self.identifier, self.x, self.y, self.x2, self.y2, pv) 63 | for p in self.panes: 64 | out += p._describe(depth + 1) 65 | return out 66 | 67 | def __repr__(self): 68 | return self._describe() 69 | 70 | 71 | def layout_end(layout): 72 | """Find the ending token for the layout.""" 73 | tok = layout[0] 74 | if tok == '{': 75 | end = '}' 76 | elif tok == '[': 77 | end = ']' 78 | else: 79 | return -1 80 | 81 | skip = -1 82 | for i, c in enumerate(layout): 83 | if c == end and skip == 0: 84 | return i 85 | elif c == end and skip > 0: 86 | skip -= 1 87 | elif c == tok: 88 | skip += 1 89 | return -1 90 | 91 | 92 | def layout_split(layout): 93 | """Break the layout into segments that are easier to parse. 94 | 95 | The layout is: size, x, y, identifier. The identifier is not a number when 96 | the pane is split. A square bracket is a vertical split, and a curly 97 | bracket is a horizontal split. 98 | """ 99 | parts = [] 100 | last_i = 0 101 | i = 0 102 | l = len(layout) 103 | 104 | while i < l: 105 | c = layout[i] 106 | if c in (',', '{', '['): 107 | m = re.match(r'((?:\d+x)?\d+)', layout[last_i:]) 108 | if m: 109 | parts.append(m.group(1)) 110 | last_i = i + 1 111 | c = layout[i] 112 | else: 113 | last_i = i + 1 114 | if c in ('{', '['): 115 | end = layout_end(layout[i:]) 116 | parts.append(layout[i:i+end+1]) 117 | i += end + 1 118 | last_i = i 119 | continue 120 | i += 1 121 | 122 | if last_i < l: 123 | trailing = layout[last_i:] 124 | m = re.match(r'(\d+)', trailing) 125 | if m: 126 | parts.append(m.group(1)) 127 | return parts 128 | 129 | 130 | def make_layout(size, x, y, identifier, parent=None): 131 | """Create a layout object. 132 | 133 | Extract more layouts if the identifier is not a number. 134 | """ 135 | size = map(int, size.split('x', 1)) 136 | x = int(x) 137 | y = int(y) 138 | 139 | layout = Layout(x, y, size) 140 | layout.parent = parent 141 | if identifier.startswith(('{', '[')): 142 | layout.vertical = identifier[0] == '[' 143 | layout.panes = extract_layout(identifier[1:-1], layout) 144 | else: 145 | if parent: 146 | layout.vertical = parent.vertical 147 | layout.identifier = int(identifier) 148 | return layout 149 | 150 | 151 | def extract_layout(layout, parent=None): 152 | """Extract layout information from a layout string.""" 153 | layout = layout_split(layout) 154 | panes = [] 155 | for i in range(0, len(layout), 4): 156 | args = layout[i:i+4][:] + [parent] 157 | panes.append(make_layout(*args)) 158 | return panes 159 | 160 | 161 | def parse_layout(layout): 162 | """Parse the main layout string. 163 | 164 | The first segment is a unique window ID or something. 165 | """ 166 | # Main x,y should be 0 167 | _, layout = layout.split(',', 1) 168 | root = extract_layout(layout)[0] 169 | return root 170 | -------------------------------------------------------------------------------- /tmux2html/tpl.py: -------------------------------------------------------------------------------- 1 | import os 2 | from string import Template 3 | 4 | 5 | _cache = {} 6 | _basedir = os.path.dirname(__file__) 7 | 8 | 9 | def load(name): 10 | """Load a template and cache it. 11 | """ 12 | if name in _cache: 13 | return _cache.get(name) 14 | 15 | with open(os.path.join(_basedir, 'templates', name), 'rt') as fp: 16 | _cache[name] = Template(fp.read()) 17 | 18 | return _cache.get(name) 19 | 20 | 21 | def render(name, **kwargs): 22 | return load(name).safe_substitute(**kwargs) 23 | -------------------------------------------------------------------------------- /tmux2html/utils.py: -------------------------------------------------------------------------------- 1 | # coding: utf8 2 | from __future__ import print_function 3 | 4 | import io 5 | import sys 6 | import gzip 7 | import subprocess 8 | import unicodedata 9 | from base64 import b64encode 10 | 11 | from . import tmux_layout 12 | 13 | 14 | def compress_data(s, line_len=200): 15 | b = io.BytesIO() 16 | with gzip.GzipFile(fileobj=b, mode='w') as fp: 17 | fp.write(s.encode('utf8')) 18 | hunks = [] 19 | data = b64encode(b.getvalue()).decode('utf8') 20 | for i in range(0, len(data), line_len): 21 | hunks.append(data[i:i+line_len]) 22 | return hunks 23 | 24 | 25 | def shell_cmd(cmd, ignore_error=False): 26 | """Execute a command. 27 | 28 | Exits if the command fails. 29 | """ 30 | p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 31 | stdout, stderr = p.communicate() 32 | if not ignore_error and p.returncode != 0: 33 | print(stderr.decode('utf8'), file=sys.stderr) 34 | sys.exit(1) 35 | return stdout.decode('utf8') 36 | 37 | 38 | def get_contents(target, full=False, max_lines=0): 39 | """Get the contents of a target pane. 40 | 41 | The content is unwrapped lines and may be longer than the pane width. 42 | """ 43 | if full: 44 | if max_lines: 45 | args = ['-S', str(-max_lines), '-E', '-'] 46 | else: 47 | args = ['-S', '-', '-E', '-'] 48 | else: 49 | args = ['-S', '-0'] 50 | pos = shell_cmd([ 51 | 'tmux', 52 | 'display-message', 53 | '-p', '-t', str(target), 54 | '-F', '#{scroll_position}/#{scroll_region_lower}' 55 | ], ignore_error=True) 56 | 57 | if pos: 58 | pos, height = pos.split('/') 59 | if pos: 60 | pos = int(pos) * -1 61 | height = int(height) 62 | args = ['-S', str(pos), '-E', str(pos + height)] 63 | 64 | content = shell_cmd([ 65 | 'tmux', 66 | 'capture-pane', 67 | '-epJ', 68 | '-t', str(target), 69 | ] + args, ignore_error=True) 70 | 71 | lines = content.split('\n') 72 | return '\n'.join(lines) 73 | 74 | 75 | def get_cursor(target): 76 | cmd = ['tmux', 'display-message', '-p', '-t', str(target), 77 | '#{pane_active},#{cursor_x},#{cursor_y}'] 78 | output = shell_cmd(cmd, ignore_error=True) 79 | try: 80 | output = [int(x) for x in output.split(',')] 81 | except ValueError: 82 | print(cmd, output) 83 | return (-1, -1) 84 | if output[0]: 85 | return [x for x in output[1:]] 86 | return (-1, -1) 87 | 88 | 89 | def str_width(s): 90 | """Return the width of the string. 91 | 92 | Takes the width of East Asian characters into account 93 | """ 94 | return sum([2 if unicodedata.east_asian_width(c) == 'W' else 1 for c in s]) 95 | 96 | 97 | def pane_list(pane, ids=None, list_all=False): 98 | """Get a list of panes. 99 | 100 | This makes it easier to target panes from the command line. 101 | """ 102 | if ids is None: 103 | ids = [] 104 | if list_all or pane.identifier != -1: 105 | ids.append(pane) 106 | for p in pane.panes: 107 | pane_list(p, ids, list_all=list_all) 108 | 109 | return ids 110 | 111 | 112 | def update_pane_list(pane, window=None, session=None, ignore_error=True): 113 | """Updates the pane list. 114 | 115 | This searches for a pane that matches the dimensions of the supplied (old) 116 | pane. When a pane is not split, it will not have panes and the size will 117 | take up its entire block. When it's split, the pane is moved into pane 118 | that wraps it and the new split. The new pane will now take the dimensions 119 | of the old pane. Naïvely matching the pane identifier would result in a 120 | shrinking pane when capturing an animation. 121 | """ 122 | root = get_layout(window, session, ignore_error=ignore_error) 123 | panes = pane_list(root, list_all=True) 124 | n_pane = pane.copy() 125 | n_pane.identifier = -1 126 | # n_pane.vertical = False 127 | collected = [] 128 | panes2 = [] 129 | x = 99999 130 | y = 99999 131 | x2 = 0 132 | y2 = 0 133 | for p in panes: 134 | if p.is_inside(n_pane) and p.dimensions not in collected: 135 | for p2 in pane_list(p, list_all=True): 136 | collected.append(p2.dimensions) 137 | panes2.append(p) 138 | x = min(x, p.x) 139 | y = min(y, p.y) 140 | x2 = max(x2, p.x2) 141 | y2 = max(y2, p.y2) 142 | 143 | n_pane.panes = panes2 144 | n_pane.x = x 145 | n_pane.y = y 146 | n_pane.x2 = x2 147 | n_pane.y2 = y2 148 | n_pane.size = (n_pane.x2 - n_pane.x, n_pane.y2 - n_pane.y) 149 | return n_pane, pane_list(n_pane), tuple(collected) 150 | 151 | 152 | def get_layout(window=None, session=None, ignore_error=False): 153 | """Get the tmux layout string. 154 | 155 | Defaults to the current session and/or current window. 156 | """ 157 | cmd = ['tmux', 'list-windows', '-F', 158 | '#F,#{window_layout}'] 159 | if session is not None: 160 | cmd.extend(['-t', str(session)]) 161 | lines = shell_cmd(cmd, ignore_error=ignore_error) 162 | windows = [] 163 | active = None 164 | for line in lines.strip().split('\n'): 165 | flag, layout = line.split(',', 1) 166 | root = tmux_layout.parse_layout(layout) 167 | if flag == '*': 168 | root.active = True 169 | active = root 170 | windows.append(root) 171 | 172 | if window is None: 173 | return active 174 | 175 | return windows[window] 176 | --------------------------------------------------------------------------------