├── .github └── workflows │ └── python-package.yml ├── .gitignore ├── LICENSE ├── README.md ├── poetry.lock ├── pyproject.toml ├── src └── multiplex │ ├── __init__.py │ ├── actions.py │ ├── ansi.py │ ├── box.py │ ├── buffer.py │ ├── commands.py │ ├── controller.py │ ├── enums.py │ ├── exceptions.py │ ├── export.py │ ├── help.py │ ├── ipc.py │ ├── iterator.py │ ├── keys.py │ ├── keys_input.py │ ├── log.py │ ├── main.py │ ├── multiplex.py │ ├── process.py │ ├── refs.py │ ├── resize.py │ └── viewer.py └── tests ├── __init__.py ├── kitchen.py ├── resources ├── __init__.py └── test_decode1.log ├── test_buffer.py ├── test_iterator.py ├── test_keys.py ├── test_keys_input.py └── test_multiplex.py /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] 15 | os: [ubuntu-latest, macos-latest] 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: poetry install 23 | run: | 24 | curl -sSL https://install.python-poetry.org | python - 25 | $HOME/.local/bin/poetry install 26 | - name: black 27 | run: | 28 | $HOME/.local/bin/poetry run black --check --line-length 120 . 29 | - name: pytest 30 | run: | 31 | $HOME/.local/bin/poetry run pytest tests 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.iml 3 | out 4 | outfds 5 | **/.bundle/ 6 | *COMMIT_MSG 7 | 8 | *.py[cod] 9 | 10 | # C extensions 11 | *.so 12 | *.iml 13 | 14 | # Packages 15 | *.egg 16 | *.egg-info 17 | dist 18 | build 19 | eggs 20 | parts 21 | bin 22 | var 23 | sdist 24 | develop-eggs 25 | .installed.cfg 26 | lib 27 | lib64 28 | 29 | # Installer logs 30 | pip-log.txt 31 | 32 | # Unit test / coverage reports 33 | .coverage 34 | .tox 35 | nosetests.xml 36 | 37 | # Translations 38 | *.mo 39 | 40 | # Mr Developer 41 | .mr.developer.cfg 42 | .project 43 | .pydevproject 44 | 45 | docs/_build 46 | data 47 | .DS_Store 48 | 49 | # pyenv 50 | .python-version 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Dan Kilman 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # multiplex 2 | View output of multiple processes, in parallel, in the console, with an interactive TUI 3 | 4 | ## Installation 5 | ```shell script 6 | pip install multiplex 7 | # or better yet 8 | pipx install multiplex 9 | ``` 10 | 11 | Python 3.7 or greater is required. 12 | ## Examples 13 | 14 | ### Parallel Execution Of Commands 15 | 16 | ```shell script 17 | mp \ 18 | './some-long-running-process.py --zone z1' \ 19 | './some-long-running-process.py --zone z2' \ 20 | './some-long-running-process.py --zone z3' 21 | ``` 22 | 23 | ![Par](http://multiplex-static-files.s3-website-us-east-1.amazonaws.com/o.par.gif) 24 | 25 | You can achive the same effect using Python API like this: 26 | 27 | ```python 28 | from multiplex import Multiplex 29 | 30 | mp = Multiplex() 31 | for zone in ['z1', 'z2', 'z3']: 32 | mp.add(f"./some-long-running-process.py --zone {zone}") 33 | mp.run() 34 | ``` 35 | 36 | ### Dynamically Add Commands 37 | 38 | `my-script.sh`: 39 | ```shell script 40 | #!/bin/bash -e 41 | echo Hello There 42 | 43 | export REPO='git@github.com:dankilman/multiplex.git' 44 | 45 | mp 'git clone $REPO' 46 | mp 'pyenv virtualenv 3.8.5 multiplex-demo && pyenv local multiplex-demo' 47 | cd multiplex 48 | mp 'poetry install' 49 | mp 'pytest tests' 50 | 51 | mp @ Goodbye -b 0 52 | ``` 53 | 54 | And then running: 55 | ```shell script 56 | mp ./my-script.sh -b 7 57 | ``` 58 | 59 | ![Seq](http://multiplex-static-files.s3-website-us-east-1.amazonaws.com/o.seq.gif) 60 | 61 | ### Python Controller 62 | An output similar to the first example can be achieved from a single process using 63 | the Python Controller API. 64 | 65 | ```python 66 | import random 67 | import time 68 | import threading 69 | 70 | from multiplex import Multiplex, Controller 71 | 72 | CSI = "\033[" 73 | RESET = CSI + "0m" 74 | RED = CSI + "31m" 75 | GREEN = CSI + "32m" 76 | BLUE = CSI + "34m" 77 | MAG = CSI + "35m" 78 | CYAN = CSI + "36m" 79 | 80 | mp = Multiplex() 81 | 82 | controllers = [Controller(f"zone z{i+1}", thread_safe=True) for i in range(3)] 83 | 84 | for controller in controllers: 85 | mp.add(controller) 86 | 87 | def run(index, c): 88 | c.write( 89 | f"Starting long running process in zone {BLUE}z{index}{RESET}, " 90 | f"that is not really long for demo purposes\n" 91 | ) 92 | count1 = count2 = 0 93 | while True: 94 | count1 += random.randint(0, 1000) 95 | count2 += random.randint(0, 1000) 96 | sleep = random.random() * 3 97 | time.sleep(sleep) 98 | c.write( 99 | f"Processed {RED}{count1}{RESET} orders, " 100 | f"total amount: {GREEN}${count2}{RESET}, " 101 | f"Time it took to process this batch: {MAG}{sleep:0.2f}s{RESET}, " 102 | f"Some more random data: {CYAN}{random.randint(500, 600)}{RESET}\n" 103 | ) 104 | 105 | for index, controller in enumerate(controllers): 106 | thread = threading.Thread(target=run, args=(index+1, controller)) 107 | thread.daemon = True 108 | thread.start() 109 | 110 | mp.run() 111 | ``` 112 | 113 | ![Cont](http://multiplex-static-files.s3-website-us-east-1.amazonaws.com/o.cont.gif) 114 | 115 | ### Help Screen 116 | Type `?` to toggle the help screen. 117 | 118 | ![help](http://multiplex-static-files.s3-website-us-east-1.amazonaws.com/help.png) 119 | 120 | ## Why Not Tmux? 121 | In short, they solve different problems. 122 | 123 | `tmux` is a full blown terminal emulator multiplexer. 124 | `multiplex` on the other hand, tries to optimize for a smooth experience in navigating output from several sources. 125 | 126 | `tmux` doesn't have any notion of scrolling panes. That is to say, the layout contains all panes at any 127 | given moment (unless maximized). 128 | In `multiplex`, current view will display boxes that fit current view, but you can have many more, 129 | and move around boxes using `less` inspired keys such as `j`, `k`, `g`, `G`, etc... 130 | 131 | Another aspect is that keybindigs for moving around are much more ergonomic (as they are in `less`) because 132 | `multiplex` is not a full terminal emulator, so it can afford using single letter keyboard bindings (e.g. `g` for 133 | go to beginning) -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "aiofiles" 3 | version = "0.5.0" 4 | description = "File support for asyncio." 5 | category = "main" 6 | optional = false 7 | python-versions = "*" 8 | 9 | [[package]] 10 | name = "aiostream" 11 | version = "0.4.3" 12 | description = "Generator-based operators for asynchronous iteration" 13 | category = "main" 14 | optional = false 15 | python-versions = "*" 16 | 17 | [[package]] 18 | name = "ansicolors" 19 | version = "1.1.8" 20 | description = "ANSI colors for Python" 21 | category = "dev" 22 | optional = false 23 | python-versions = "*" 24 | 25 | [[package]] 26 | name = "attrs" 27 | version = "21.2.0" 28 | description = "Classes Without Boilerplate" 29 | category = "dev" 30 | optional = false 31 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 32 | 33 | [package.extras] 34 | dev = ["coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "six", "sphinx", "sphinx-notfound-page", "zope.interface"] 35 | docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"] 36 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "mypy", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "six", "zope.interface"] 37 | tests-no-zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "mypy", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "six"] 38 | 39 | [[package]] 40 | name = "black" 41 | version = "22.12.0" 42 | description = "The uncompromising code formatter." 43 | category = "dev" 44 | optional = false 45 | python-versions = ">=3.7" 46 | 47 | [package.dependencies] 48 | click = ">=8.0.0" 49 | mypy-extensions = ">=0.4.3" 50 | pathspec = ">=0.9.0" 51 | platformdirs = ">=2" 52 | tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} 53 | typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""} 54 | typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} 55 | 56 | [package.extras] 57 | colorama = ["colorama (>=0.4.3)"] 58 | d = ["aiohttp (>=3.7.4)"] 59 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 60 | uvloop = ["uvloop (>=0.15.2)"] 61 | 62 | [[package]] 63 | name = "click" 64 | version = "8.0.1" 65 | description = "Composable command line interface toolkit" 66 | category = "main" 67 | optional = false 68 | python-versions = ">=3.6" 69 | 70 | [package.dependencies] 71 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 72 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 73 | 74 | [[package]] 75 | name = "colorama" 76 | version = "0.4.4" 77 | description = "Cross-platform colored terminal text." 78 | category = "main" 79 | optional = false 80 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 81 | 82 | [[package]] 83 | name = "easy-ansi" 84 | version = "0.3" 85 | description = "Easy ANSI is a terminal framework API to give you an easy way to use colors, cursor control movements, and line/box drawing." 86 | category = "main" 87 | optional = false 88 | python-versions = ">=3.6" 89 | 90 | [[package]] 91 | name = "exceptiongroup" 92 | version = "1.1.0" 93 | description = "Backport of PEP 654 (exception groups)" 94 | category = "dev" 95 | optional = false 96 | python-versions = ">=3.7" 97 | 98 | [package.extras] 99 | test = ["pytest (>=6)"] 100 | 101 | [[package]] 102 | name = "importlib-metadata" 103 | version = "4.8.1" 104 | description = "Read metadata from Python packages" 105 | category = "main" 106 | optional = false 107 | python-versions = ">=3.6" 108 | 109 | [package.dependencies] 110 | typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} 111 | zipp = ">=0.5" 112 | 113 | [package.extras] 114 | docs = ["jaraco.packaging (>=8.2)", "rst.linker (>=1.9)", "sphinx"] 115 | perf = ["ipython"] 116 | testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pep517", "pyfakefs", "pytest (>=4.6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-flake8", "pytest-mypy", "pytest-perf (>=0.9.2)"] 117 | 118 | [[package]] 119 | name = "iniconfig" 120 | version = "1.1.1" 121 | description = "iniconfig: brain-dead simple config-ini parsing" 122 | category = "dev" 123 | optional = false 124 | python-versions = "*" 125 | 126 | [[package]] 127 | name = "mypy-extensions" 128 | version = "0.4.3" 129 | description = "Experimental type system extensions for programs checked with the mypy typechecker." 130 | category = "dev" 131 | optional = false 132 | python-versions = "*" 133 | 134 | [[package]] 135 | name = "packaging" 136 | version = "21.0" 137 | description = "Core utilities for Python packages" 138 | category = "dev" 139 | optional = false 140 | python-versions = ">=3.6" 141 | 142 | [package.dependencies] 143 | pyparsing = ">=2.0.2" 144 | 145 | [[package]] 146 | name = "pathspec" 147 | version = "0.10.3" 148 | description = "Utility library for gitignore style pattern matching of file paths." 149 | category = "dev" 150 | optional = false 151 | python-versions = ">=3.7" 152 | 153 | [[package]] 154 | name = "platformdirs" 155 | version = "2.6.0" 156 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 157 | category = "dev" 158 | optional = false 159 | python-versions = ">=3.7" 160 | 161 | [package.extras] 162 | docs = ["furo (>=2022.9.29)", "proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.4)"] 163 | test = ["appdirs (==1.4.4)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] 164 | 165 | [[package]] 166 | name = "pluggy" 167 | version = "0.13.1" 168 | description = "plugin and hook calling mechanisms for python" 169 | category = "dev" 170 | optional = false 171 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 172 | 173 | [package.dependencies] 174 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 175 | 176 | [package.extras] 177 | dev = ["pre-commit", "tox"] 178 | 179 | [[package]] 180 | name = "pyparsing" 181 | version = "2.4.7" 182 | description = "Python parsing module" 183 | category = "dev" 184 | optional = false 185 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 186 | 187 | [[package]] 188 | name = "pyte" 189 | version = "0.8.0" 190 | description = "Simple VTXXX-compatible terminal emulator." 191 | category = "main" 192 | optional = false 193 | python-versions = "*" 194 | 195 | [package.dependencies] 196 | wcwidth = "*" 197 | 198 | [[package]] 199 | name = "pytest" 200 | version = "7.2.0" 201 | description = "pytest: simple powerful testing with Python" 202 | category = "dev" 203 | optional = false 204 | python-versions = ">=3.7" 205 | 206 | [package.dependencies] 207 | attrs = ">=19.2.0" 208 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 209 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 210 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 211 | iniconfig = "*" 212 | packaging = "*" 213 | pluggy = ">=0.12,<2.0" 214 | tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} 215 | 216 | [package.extras] 217 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] 218 | 219 | [[package]] 220 | name = "pytest-asyncio" 221 | version = "0.14.0" 222 | description = "Pytest support for asyncio." 223 | category = "dev" 224 | optional = false 225 | python-versions = ">= 3.5" 226 | 227 | [package.dependencies] 228 | pytest = ">=5.4.0" 229 | 230 | [package.extras] 231 | testing = ["async-generator (>=1.3)", "coverage", "hypothesis (>=5.7.1)"] 232 | 233 | [[package]] 234 | name = "tomli" 235 | version = "2.0.1" 236 | description = "A lil' TOML parser" 237 | category = "dev" 238 | optional = false 239 | python-versions = ">=3.7" 240 | 241 | [[package]] 242 | name = "typed-ast" 243 | version = "1.5.4" 244 | description = "a fork of Python 2 and 3 ast modules with type comment support" 245 | category = "dev" 246 | optional = false 247 | python-versions = ">=3.6" 248 | 249 | [[package]] 250 | name = "typing-extensions" 251 | version = "3.10.0.2" 252 | description = "Backported and Experimental Type Hints for Python 3.5+" 253 | category = "main" 254 | optional = false 255 | python-versions = "*" 256 | 257 | [[package]] 258 | name = "wcwidth" 259 | version = "0.2.5" 260 | description = "Measures the displayed width of unicode strings in a terminal" 261 | category = "main" 262 | optional = false 263 | python-versions = "*" 264 | 265 | [[package]] 266 | name = "zipp" 267 | version = "3.5.0" 268 | description = "Backport of pathlib-compatible object wrapper for zip files" 269 | category = "main" 270 | optional = false 271 | python-versions = ">=3.6" 272 | 273 | [package.extras] 274 | docs = ["jaraco.packaging (>=8.2)", "rst.linker (>=1.9)", "sphinx"] 275 | testing = ["func-timeout", "jaraco.itertools", "pytest (>=4.6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-flake8", "pytest-mypy"] 276 | 277 | [metadata] 278 | lock-version = "1.1" 279 | python-versions = "^3.7" 280 | content-hash = "feb0ef13a45852ad754cd8f9aacbdc858dd5c7f199d740a4b0291de4fa2cda39" 281 | 282 | [metadata.files] 283 | aiofiles = [ 284 | {file = "aiofiles-0.5.0-py3-none-any.whl", hash = "sha256:377fdf7815cc611870c59cbd07b68b180841d2a2b79812d8c218be02448c2acb"}, 285 | {file = "aiofiles-0.5.0.tar.gz", hash = "sha256:98e6bcfd1b50f97db4980e182ddd509b7cc35909e903a8fe50d8849e02d815af"}, 286 | ] 287 | aiostream = [ 288 | {file = "aiostream-0.4.3.tar.gz", hash = "sha256:36345ef1d21bedffe0878210a9123234ca8d29d8f33f0f073bea1271735f63cc"}, 289 | ] 290 | ansicolors = [ 291 | {file = "ansicolors-1.1.8-py2.py3-none-any.whl", hash = "sha256:00d2dde5a675579325902536738dd27e4fac1fd68f773fe36c21044eb559e187"}, 292 | {file = "ansicolors-1.1.8.zip", hash = "sha256:99f94f5e3348a0bcd43c82e5fc4414013ccc19d70bd939ad71e0133ce9c372e0"}, 293 | ] 294 | attrs = [ 295 | {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, 296 | {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, 297 | ] 298 | black = [ 299 | {file = "black-22.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9eedd20838bd5d75b80c9f5487dbcb06836a43833a37846cf1d8c1cc01cef59d"}, 300 | {file = "black-22.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:159a46a4947f73387b4d83e87ea006dbb2337eab6c879620a3ba52699b1f4351"}, 301 | {file = "black-22.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d30b212bffeb1e252b31dd269dfae69dd17e06d92b87ad26e23890f3efea366f"}, 302 | {file = "black-22.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:7412e75863aa5c5411886804678b7d083c7c28421210180d67dfd8cf1221e1f4"}, 303 | {file = "black-22.12.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c116eed0efb9ff870ded8b62fe9f28dd61ef6e9ddd28d83d7d264a38417dcee2"}, 304 | {file = "black-22.12.0-cp37-cp37m-win_amd64.whl", hash = "sha256:1f58cbe16dfe8c12b7434e50ff889fa479072096d79f0a7f25e4ab8e94cd8350"}, 305 | {file = "black-22.12.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77d86c9f3db9b1bf6761244bc0b3572a546f5fe37917a044e02f3166d5aafa7d"}, 306 | {file = "black-22.12.0-cp38-cp38-win_amd64.whl", hash = "sha256:82d9fe8fee3401e02e79767016b4907820a7dc28d70d137eb397b92ef3cc5bfc"}, 307 | {file = "black-22.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:101c69b23df9b44247bd88e1d7e90154336ac4992502d4197bdac35dd7ee3320"}, 308 | {file = "black-22.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:559c7a1ba9a006226f09e4916060982fd27334ae1998e7a38b3f33a37f7a2148"}, 309 | {file = "black-22.12.0-py3-none-any.whl", hash = "sha256:436cc9167dd28040ad90d3b404aec22cedf24a6e4d7de221bec2730ec0c97bcf"}, 310 | {file = "black-22.12.0.tar.gz", hash = "sha256:229351e5a18ca30f447bf724d007f890f97e13af070bb6ad4c0a441cd7596a2f"}, 311 | ] 312 | click = [ 313 | {file = "click-8.0.1-py3-none-any.whl", hash = "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"}, 314 | {file = "click-8.0.1.tar.gz", hash = "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a"}, 315 | ] 316 | colorama = [ 317 | {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, 318 | {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, 319 | ] 320 | easy-ansi = [ 321 | {file = "easy-ansi-0.3.tar.gz", hash = "sha256:d7e1b7cc34ec3f0032405925c8ea0713e6f18099ca6abfe3436c99951169361a"}, 322 | {file = "easy_ansi-0.3-py3-none-any.whl", hash = "sha256:a4efc9411c8cb4e1a9947d516541c5d51b71cfa9d91320b76623c4dbff4b56dc"}, 323 | ] 324 | exceptiongroup = [ 325 | {file = "exceptiongroup-1.1.0-py3-none-any.whl", hash = "sha256:327cbda3da756e2de031a3107b81ab7b3770a602c4d16ca618298c526f4bec1e"}, 326 | {file = "exceptiongroup-1.1.0.tar.gz", hash = "sha256:bcb67d800a4497e1b404c2dd44fca47d3b7a5e5433dbab67f96c1a685cdfdf23"}, 327 | ] 328 | importlib-metadata = [ 329 | {file = "importlib_metadata-4.8.1-py3-none-any.whl", hash = "sha256:b618b6d2d5ffa2f16add5697cf57a46c76a56229b0ed1c438322e4e95645bd15"}, 330 | {file = "importlib_metadata-4.8.1.tar.gz", hash = "sha256:f284b3e11256ad1e5d03ab86bb2ccd6f5339688ff17a4d797a0fe7df326f23b1"}, 331 | ] 332 | iniconfig = [ 333 | {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, 334 | {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, 335 | ] 336 | mypy-extensions = [ 337 | {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, 338 | {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, 339 | ] 340 | packaging = [ 341 | {file = "packaging-21.0-py3-none-any.whl", hash = "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14"}, 342 | {file = "packaging-21.0.tar.gz", hash = "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7"}, 343 | ] 344 | pathspec = [ 345 | {file = "pathspec-0.10.3-py3-none-any.whl", hash = "sha256:3c95343af8b756205e2aba76e843ba9520a24dd84f68c22b9f93251507509dd6"}, 346 | {file = "pathspec-0.10.3.tar.gz", hash = "sha256:56200de4077d9d0791465aa9095a01d421861e405b5096955051deefd697d6f6"}, 347 | ] 348 | platformdirs = [ 349 | {file = "platformdirs-2.6.0-py3-none-any.whl", hash = "sha256:1a89a12377800c81983db6be069ec068eee989748799b946cce2a6e80dcc54ca"}, 350 | {file = "platformdirs-2.6.0.tar.gz", hash = "sha256:b46ffafa316e6b83b47489d240ce17173f123a9b9c83282141c3daf26ad9ac2e"}, 351 | ] 352 | pluggy = [ 353 | {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, 354 | {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, 355 | ] 356 | pyparsing = [ 357 | {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, 358 | {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, 359 | ] 360 | pyte = [ 361 | {file = "pyte-0.8.0-py2.py3-none-any.whl", hash = "sha256:dd16d25e4cd27642cbbe94f9e8eaa1c59b9389ce0943e971dd4786f4e52daee0"}, 362 | {file = "pyte-0.8.0.tar.gz", hash = "sha256:7e71d03e972d6f262cbe8704ff70039855f05ee6f7ad9d7129df9c977b5a88c5"}, 363 | ] 364 | pytest = [ 365 | {file = "pytest-7.2.0-py3-none-any.whl", hash = "sha256:892f933d339f068883b6fd5a459f03d85bfcb355e4981e146d2c7616c21fef71"}, 366 | {file = "pytest-7.2.0.tar.gz", hash = "sha256:c4014eb40e10f11f355ad4e3c2fb2c6c6d1919c73f3b5a433de4708202cade59"}, 367 | ] 368 | pytest-asyncio = [ 369 | {file = "pytest-asyncio-0.14.0.tar.gz", hash = "sha256:9882c0c6b24429449f5f969a5158b528f39bde47dc32e85b9f0403965017e700"}, 370 | {file = "pytest_asyncio-0.14.0-py3-none-any.whl", hash = "sha256:2eae1e34f6c68fc0a9dc12d4bea190483843ff4708d24277c41568d6b6044f1d"}, 371 | ] 372 | tomli = [ 373 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 374 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 375 | ] 376 | typed-ast = [ 377 | {file = "typed_ast-1.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:669dd0c4167f6f2cd9f57041e03c3c2ebf9063d0757dc89f79ba1daa2bfca9d4"}, 378 | {file = "typed_ast-1.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:211260621ab1cd7324e0798d6be953d00b74e0428382991adfddb352252f1d62"}, 379 | {file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:267e3f78697a6c00c689c03db4876dd1efdfea2f251a5ad6555e82a26847b4ac"}, 380 | {file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c542eeda69212fa10a7ada75e668876fdec5f856cd3d06829e6aa64ad17c8dfe"}, 381 | {file = "typed_ast-1.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:a9916d2bb8865f973824fb47436fa45e1ebf2efd920f2b9f99342cb7fab93f72"}, 382 | {file = "typed_ast-1.5.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:79b1e0869db7c830ba6a981d58711c88b6677506e648496b1f64ac7d15633aec"}, 383 | {file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a94d55d142c9265f4ea46fab70977a1944ecae359ae867397757d836ea5a3f47"}, 384 | {file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:183afdf0ec5b1b211724dfef3d2cad2d767cbefac291f24d69b00546c1837fb6"}, 385 | {file = "typed_ast-1.5.4-cp36-cp36m-win_amd64.whl", hash = "sha256:639c5f0b21776605dd6c9dbe592d5228f021404dafd377e2b7ac046b0349b1a1"}, 386 | {file = "typed_ast-1.5.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cf4afcfac006ece570e32d6fa90ab74a17245b83dfd6655a6f68568098345ff6"}, 387 | {file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed855bbe3eb3715fca349c80174cfcfd699c2f9de574d40527b8429acae23a66"}, 388 | {file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6778e1b2f81dfc7bc58e4b259363b83d2e509a65198e85d5700dfae4c6c8ff1c"}, 389 | {file = "typed_ast-1.5.4-cp37-cp37m-win_amd64.whl", hash = "sha256:0261195c2062caf107831e92a76764c81227dae162c4f75192c0d489faf751a2"}, 390 | {file = "typed_ast-1.5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2efae9db7a8c05ad5547d522e7dbe62c83d838d3906a3716d1478b6c1d61388d"}, 391 | {file = "typed_ast-1.5.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7d5d014b7daa8b0bf2eaef684295acae12b036d79f54178b92a2b6a56f92278f"}, 392 | {file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:370788a63915e82fd6f212865a596a0fefcbb7d408bbbb13dea723d971ed8bdc"}, 393 | {file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4e964b4ff86550a7a7d56345c7864b18f403f5bd7380edf44a3c1fb4ee7ac6c6"}, 394 | {file = "typed_ast-1.5.4-cp38-cp38-win_amd64.whl", hash = "sha256:683407d92dc953c8a7347119596f0b0e6c55eb98ebebd9b23437501b28dcbb8e"}, 395 | {file = "typed_ast-1.5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4879da6c9b73443f97e731b617184a596ac1235fe91f98d279a7af36c796da35"}, 396 | {file = "typed_ast-1.5.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3e123d878ba170397916557d31c8f589951e353cc95fb7f24f6bb69adc1a8a97"}, 397 | {file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebd9d7f80ccf7a82ac5f88c521115cc55d84e35bf8b446fcd7836eb6b98929a3"}, 398 | {file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98f80dee3c03455e92796b58b98ff6ca0b2a6f652120c263efdba4d6c5e58f72"}, 399 | {file = "typed_ast-1.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:0fdbcf2fef0ca421a3f5912555804296f0b0960f0418c440f5d6d3abb549f3e1"}, 400 | {file = "typed_ast-1.5.4.tar.gz", hash = "sha256:39e21ceb7388e4bb37f4c679d72707ed46c2fbf2a5609b8b8ebc4b067d977df2"}, 401 | ] 402 | typing-extensions = [ 403 | {file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"}, 404 | {file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"}, 405 | {file = "typing_extensions-3.10.0.2.tar.gz", hash = "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e"}, 406 | ] 407 | wcwidth = [ 408 | {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, 409 | {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, 410 | ] 411 | zipp = [ 412 | {file = "zipp-3.5.0-py3-none-any.whl", hash = "sha256:957cfda87797e389580cb8b9e3870841ca991e2125350677b2ca83a0e99390a3"}, 413 | {file = "zipp-3.5.0.tar.gz", hash = "sha256:f5812b1e007e48cff63449a5e9f4e7ebea716b4111f9c4f9a645f91d579bf0c4"}, 414 | ] 415 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "multiplex" 3 | version = "0.6.1" 4 | description = "View output of multiple processes, in parallel, in the console, with an interactive TUI" 5 | authors = ["Dan Kilman "] 6 | license = "MIT" 7 | readme = "README.md" 8 | homepage = "https://github.com/dankilman/multiplex" 9 | repository = "https://github.com/dankilman/multiplex" 10 | 11 | [tool.poetry.dependencies] 12 | python = "^3.7" 13 | aiostream = "^0.4.1" 14 | easy-ansi = "^0.3" 15 | pyte = "^0.8.0" 16 | aiofiles = "^0.5.0" 17 | click = ">=7.1.2" 18 | 19 | [tool.poetry.dev-dependencies] 20 | ansicolors = "^1.1.8" 21 | pytest-asyncio = "^0.14.0" 22 | 23 | [tool.poetry.scripts] 24 | mp = "multiplex.main:main" 25 | 26 | [tool.poetry.group.dev.dependencies] 27 | black = "^22.12.0" 28 | pytest = "^7.2.0" 29 | 30 | [build-system] 31 | requires = ["poetry_core>=1.0.0"] 32 | build-backend = "poetry.core.masonry.api" 33 | -------------------------------------------------------------------------------- /src/multiplex/__init__.py: -------------------------------------------------------------------------------- 1 | from .controller import Controller 2 | from .iterator import to_iterator 3 | from .multiplex import Multiplex 4 | from .process import Process 5 | -------------------------------------------------------------------------------- /src/multiplex/actions.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List 3 | 4 | from multiplex.refs import SPLIT 5 | 6 | 7 | class Action: 8 | pass 9 | 10 | 11 | class BoxAction: 12 | def run(self, box_holder): 13 | raise NotImplementedError 14 | 15 | 16 | @dataclass 17 | class SetTitle(BoxAction): 18 | title: str 19 | 20 | def run(self, box_holder): 21 | title = self.title 22 | iterator = box_holder.iterator 23 | if iterator.iterator is SPLIT: 24 | title += f" ({iterator.title})" 25 | iterator.title = title 26 | 27 | 28 | class ToggleCollapse(BoxAction): 29 | def __init__(self, value=None): 30 | self.value = value 31 | 32 | def run(self, box_holder): 33 | box_holder.box.toggle_collapse(self.value) 34 | 35 | 36 | class ToggleWrap(BoxAction): 37 | def __init__(self, value=None): 38 | self.value = value 39 | 40 | def run(self, box_holder): 41 | box_holder.box.toggle_wrap(self.value) 42 | 43 | 44 | @dataclass 45 | class UpdateMetadata(BoxAction): 46 | metadata: dict 47 | 48 | def run(self, box_holder): 49 | box_holder.iterator.metadata.update(self.metadata) 50 | 51 | 52 | @dataclass 53 | class BoxActions(BoxAction): 54 | actions: List[BoxAction] 55 | 56 | def run(self, box_holder): 57 | for action in self.actions: 58 | if isinstance(action, BoxAction): 59 | action.run(box_holder) 60 | elif callable(action): 61 | action(box_holder) 62 | else: 63 | raise RuntimeError(f"Invalid action: {action}") 64 | -------------------------------------------------------------------------------- /src/multiplex/ansi.py: -------------------------------------------------------------------------------- 1 | import io 2 | 3 | from easyansi import screen, cursor, drawing, attributes 4 | from easyansi import colors_rgb as colors 5 | from easyansi._core.codes import CSI 6 | 7 | 8 | FULL_REFRESH = object() 9 | 10 | buffer = io.StringIO() 11 | 12 | 13 | def prnt(text): 14 | buffer.write(str(text)) 15 | 16 | 17 | def flush(): 18 | global buffer 19 | if not buffer.tell(): 20 | return 21 | screen.prnt(buffer.getvalue()) 22 | buffer = io.StringIO() 23 | 24 | 25 | NONE = object() 26 | RESET = colors.reset_code() 27 | 28 | BLACK = (0, 0, 0) 29 | RED = (255, 0, 0) 30 | GREEN = (0, 255, 0) 31 | GRAY = (51, 51, 51) 32 | BLUE1 = (37, 94, 132) 33 | GREEN2 = (184, 212, 67) 34 | ORANGE = (240, 140, 52) 35 | 36 | 37 | class Theme: 38 | X_COLOR = (RED, None) 39 | V_COLOR = (GREEN, None) 40 | TITLE_NORMAL = (GRAY, NONE) 41 | TITLE_FOCUS = (BLUE1, NONE) 42 | TITLE_STREAM_DONE = (GRAY, NONE) 43 | STATUS_NORMAL = (NONE, GRAY) 44 | STATUS_SCROLL = (NONE, BLUE1) 45 | STATUS_INPUT = (BLACK, ORANGE) 46 | STATUS_SAVE = (BLACK, GREEN2) 47 | HELP_TITLES = (GREEN2, NONE) 48 | 49 | 50 | theme = Theme 51 | 52 | 53 | CLEAR_LINE = CSI + "2K" 54 | ENABLE_ALT_BUFFER = CSI + "?1049h" 55 | DISABLE_ALT_BUFFER = CSI + "?1049l" 56 | 57 | 58 | def color_code(pair): 59 | fg, bg = pair 60 | if not fg or fg is NONE: 61 | fg = (None, None, None) 62 | if not bg or bg is NONE: 63 | bg = (None, None, None) 64 | return colors.color_code(*fg, *bg) 65 | 66 | 67 | def setup(): 68 | screen.prnt(ENABLE_ALT_BUFFER) 69 | cursor.hide() 70 | 71 | 72 | def restore(): 73 | global buffer 74 | clear() 75 | buffer = io.StringIO() 76 | cursor.show() 77 | screen.clear() 78 | screen.prnt(DISABLE_ALT_BUFFER) 79 | 80 | 81 | def get_size(): 82 | return screen.get_size() 83 | 84 | 85 | def clear(): 86 | prnt(screen.clear_code()) 87 | 88 | 89 | def text_box(from_row, to_row, text): 90 | for line_num in range(to_row, from_row - 1, -1): 91 | prnt(screen.clear_line_code(line_num)) 92 | prnt(text) 93 | 94 | 95 | def title(row, text, cols, hline_color): 96 | prnt(screen.clear_line_code(row)) 97 | prnt(color_code(hline_color)) 98 | prnt(drawing.hline_code(1)) 99 | prnt(RESET) 100 | prnt(text) 101 | prnt(RESET) 102 | offset = 1 + len(text) 103 | prnt(color_code(hline_color)) 104 | prnt(drawing.hline_code(cols - offset)) 105 | prnt(RESET) 106 | 107 | 108 | def status_bar(row, text): 109 | prnt(screen.clear_line_code(row)) 110 | prnt(text) 111 | prnt(RESET) 112 | 113 | 114 | def move_cursor(col, row): 115 | prnt(cursor.locate_code(col, row)) 116 | 117 | 118 | def show_cursor(): 119 | prnt(cursor.show_code()) 120 | 121 | 122 | def hide_cursor(): 123 | prnt(cursor.hide_code()) 124 | 125 | 126 | def help_screen(current_line, lines, cols, descriptions): 127 | prnt(screen.clear_code()) 128 | prnt(drawing.box_code(width=cols, height=lines)) 129 | prnt(cursor.locate_code(2, 1)) 130 | 131 | help_lines = [] 132 | help_line = io.StringIO() 133 | 134 | def next_line(): 135 | nonlocal help_line 136 | help_line.write(cursor.next_line_code()) 137 | help_line.write(cursor.right_code(2)) 138 | help_lines.append(help_line.getvalue()) 139 | help_line = io.StringIO() 140 | 141 | for mode, mode_desciptions in descriptions.items(): 142 | help_line.write(attributes.bright_code()) 143 | help_line.write(color_code(theme.HELP_TITLES)) 144 | help_line.write(mode.capitalize()) 145 | help_line.write(RESET) 146 | next_line() 147 | for keys, description in mode_desciptions.items(): 148 | keys_text = ", ".join(str(k) for k in keys) 149 | line_text = f"{keys_text:<30}{description}" 150 | help_line.write(line_text) 151 | next_line() 152 | next_line() 153 | 154 | help_lines = help_lines[current_line : current_line + lines - 2] 155 | prnt("".join(help_lines)) 156 | 157 | 158 | class C: 159 | def __init__(self, *args, color=None): 160 | color = color or (None, None) 161 | self.color = color 162 | self.fg, self.bg = color 163 | self.parts = [] 164 | for part in args: 165 | self._add(part) 166 | 167 | def copy(self): 168 | parts = [p.copy() if isinstance(p, C) else p for p in self.parts] 169 | return self.__class__(*parts, color=self.color) 170 | 171 | def __len__(self): 172 | return sum(len(p) for p in self.parts) 173 | 174 | def __str__(self): 175 | return self.to_string() 176 | 177 | def to_string(self, no_style=False): 178 | fg, bg = self.fg, self.bg 179 | style = None 180 | if fg is NONE and bg is NONE: 181 | style = RESET 182 | fg, bg = None, None 183 | elif fg is NONE: 184 | fg = (255, 255, 255) 185 | elif bg is NONE: 186 | bg = (0, 0, 0) 187 | 188 | if fg or bg: 189 | style = color_code((fg, bg)) 190 | result = io.StringIO() 191 | if not no_style and style: 192 | result.write(style) 193 | for part in self.parts: 194 | value = part.to_string(no_style) if isinstance(part, C) else str(part) 195 | result.write(value) 196 | if not no_style and isinstance(part, C) and style: 197 | result.write(style) 198 | return result.getvalue() 199 | 200 | def __add__(self, other): 201 | copy = self.copy() 202 | copy._add(other) 203 | return copy 204 | 205 | def __getitem__(self, item): 206 | assert isinstance(item, slice) 207 | assert not item.start 208 | assert not item.step 209 | size = item.stop 210 | result = self.copy() 211 | result._truncate(size) 212 | return result 213 | 214 | def _truncate(self, size): 215 | new_parts = [] 216 | for part in self.parts: 217 | part_len = len(part) 218 | if part_len <= size: 219 | new_parts.append(part) 220 | size -= part_len 221 | else: 222 | new_parts.append(part[:size]) 223 | break 224 | self.parts = new_parts 225 | 226 | def _add(self, part): 227 | if isinstance(part, C): 228 | part = part.copy() 229 | if not part.fg: 230 | part.fg = self.fg 231 | if not part.bg: 232 | part.bg = self.bg 233 | self.parts.append(part) 234 | 235 | def to_dict(self): 236 | return { 237 | "color": self.color, 238 | "parts": [p.to_dict() if isinstance(p, C) else p for p in self.parts], 239 | } 240 | 241 | @staticmethod 242 | def from_dict(dct): 243 | color = dct["color"] 244 | parts = [C.from_dict(p) if isinstance(p, dict) else p for p in dct["parts"]] 245 | return C(*parts, color=color) 246 | -------------------------------------------------------------------------------- /src/multiplex/box.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from multiplex import ansi 4 | from multiplex.buffer import Buffer 5 | from multiplex.enums import ViewLocation 6 | 7 | logger = logging.getLogger("multiplex.box") 8 | 9 | 10 | class BoxHolder: 11 | def __init__(self, index, iterator, box_height, viewer): 12 | self.id = id(self) 13 | self.index = index 14 | self.iterator = iterator 15 | self.buffer = Buffer(buffer_lines=viewer.buffer_lines) 16 | self.state = BoxState(box_height) 17 | self.box = TextBox(viewer, self) 18 | 19 | 20 | class BoxState: 21 | def __init__(self, box_height): 22 | self.wrap = True 23 | self.auto_scroll = True 24 | self.input_mode = False 25 | self.stream_done = False 26 | self.collapsed = False 27 | self.buffer_start_line = 0 28 | self.first_column = 0 29 | self.view_longest_line = 0 30 | self.text = None 31 | self.changed_height = box_height is not None 32 | self.box_height = box_height 33 | 34 | 35 | class TextBox: 36 | def __init__(self, view, holder): 37 | self.view = view 38 | self.holder = holder 39 | self.buffer = self.holder.buffer 40 | self.state = self.holder.state 41 | 42 | @property 43 | def index(self): 44 | return self.holder.index 45 | 46 | def update(self): 47 | lines = self.update_text() 48 | 49 | screen_y_1, location_1 = self.view.get_box_top_line(self.index) 50 | screen_y_2, location_2 = self.view.get_box_bottom_line(self.index) 51 | 52 | if location_1 == ViewLocation.ABOVE: 53 | lines = lines[screen_y_1:] 54 | screen_y_1 = 0 55 | if location_2 == ViewLocation.BELOW: 56 | lines = lines[:-screen_y_2] 57 | screen_y_2 = self.view.get_max_box_line() 58 | 59 | logger.debug( 60 | f"{self.index}:" 61 | f"\t{screen_y_1}" 62 | f"\t{screen_y_2}" 63 | f"\t{location_1}" 64 | f"\t{location_2}" 65 | f"\t[{self.view.lines},{self.view.cols}]" 66 | ) 67 | 68 | ansi.text_box( 69 | from_row=screen_y_1, 70 | to_row=screen_y_2, 71 | text="".join(lines), 72 | ) 73 | 74 | def update_text(self): 75 | if self.state.auto_scroll and not self.state.stream_done: 76 | self.state.buffer_start_line = self.max_start_line 77 | lines = self.buffer.get_lines( 78 | lines=self.num_view_lines, 79 | start_line=self.state.buffer_start_line, 80 | columns=self.view.cols, 81 | start_column=self.state.first_column, 82 | wrap=self.state.wrap, 83 | ) 84 | if not self.state.wrap: 85 | value = max(line_length for line_length, _ in lines) if lines else 0 86 | self.state.view_longest_line = value 87 | return [line for _, line in lines] 88 | 89 | def move_line_up(self): 90 | return self.set_minmax_up_motion(self.state.buffer_start_line - 1) 91 | 92 | def move_line_down(self): 93 | return self.set_minmax_down_motion(self.state.buffer_start_line + 1) 94 | 95 | def move_page_up(self): 96 | return self.set_minmax_up_motion(self.state.buffer_start_line - self.num_view_lines) 97 | 98 | def move_page_down(self): 99 | return self.set_minmax_down_motion(self.state.buffer_start_line + self.num_view_lines) 100 | 101 | def move_half_page_up(self): 102 | return self.set_minmax_up_motion(self.state.buffer_start_line - self.num_view_lines // 2) 103 | 104 | def move_half_page_down(self): 105 | return self.set_minmax_down_motion(self.state.buffer_start_line + self.num_view_lines // 2) 106 | 107 | def move_all_up(self): 108 | self.state.buffer_start_line = self.min_start_line 109 | return self.index 110 | 111 | def move_all_down(self): 112 | self.state.buffer_start_line = self.max_start_line 113 | return self.index 114 | 115 | def move_right(self): 116 | state = self.state 117 | if state.wrap: 118 | return False 119 | state.first_column = min(state.first_column + 1, self.max_first_column) 120 | return self.index 121 | 122 | def move_left(self): 123 | state = self.state 124 | if state.wrap: 125 | return False 126 | state.first_column = max(0, state.first_column - 1) 127 | return self.index 128 | 129 | def move_half_screen_right(self): 130 | state = self.state 131 | if state.wrap: 132 | return False 133 | state.first_column = min(state.first_column + self.view.cols // 2, self.max_first_column) 134 | return self.index 135 | 136 | def move_half_screen_left(self): 137 | state = self.state 138 | if state.wrap: 139 | return False 140 | state.first_column = max(0, state.first_column - self.view.cols // 2) 141 | return self.index 142 | 143 | def move_right_until_end(self): 144 | state = self.state 145 | if state.wrap: 146 | return False 147 | state.first_column = self.max_first_column 148 | return self.index 149 | 150 | def move_left_until_start(self): 151 | state = self.state 152 | if state.wrap: 153 | return False 154 | state.first_column = 0 155 | return self.index 156 | 157 | def increase_box_height(self): 158 | self.state.box_height = min(self.view.get_max_box_line(), self.state.box_height + 1) 159 | self.state.changed_height = True 160 | return True 161 | 162 | def decrease_box_height(self): 163 | self.state.box_height = max(0, self.state.box_height - 1) 164 | self.state.changed_height = True 165 | return ansi.FULL_REFRESH 166 | 167 | def toggle_auto_scroll(self): 168 | self.state.auto_scroll = not self.state.auto_scroll 169 | return True 170 | 171 | def activate_input_mode(self): 172 | self.state.input_mode = True 173 | ansi.show_cursor() 174 | return True 175 | 176 | def exit_input_mode(self): 177 | self.state.input_mode = False 178 | ansi.hide_cursor() 179 | return True 180 | 181 | def toggle_wrap(self, value=None): 182 | initial_value = self.state.wrap 183 | new_value = value if value is not None else not initial_value 184 | if initial_value == new_value: 185 | return False 186 | self.state.first_column = 0 187 | self.state.wrap = new_value 188 | if not self.state.auto_scroll or self.state.stream_done: 189 | line = self.state.buffer_start_line 190 | result = self.buffer.convert_line_number(line, from_wrapped=initial_value) 191 | if result is None: 192 | logger.warning(f"No mathing line for conversion wrap: {new_value}, line: {line}") 193 | result = 0 194 | self.state.buffer_start_line = result 195 | return True 196 | 197 | def toggle_collapse(self, value=None): 198 | if value is not None: 199 | collapsed = value 200 | else: 201 | collapsed = not self.state.collapsed 202 | self.state.collapsed = collapsed 203 | return ansi.FULL_REFRESH if collapsed else True 204 | 205 | def strip_empty_lines(self, include_not_stream_done=False): 206 | if not include_not_stream_done and not self.state.stream_done: 207 | return 208 | self.state.box_height = min(self.state.box_height, self.num_buffer_lines) 209 | 210 | @property 211 | def num_view_lines(self): 212 | return self.view.lines - 1 if self.is_maximized else self.state.box_height 213 | 214 | @property 215 | def is_focused(self): 216 | return self.index == self.view.current_focused_box 217 | 218 | @property 219 | def is_visible(self): 220 | if self.view.maximized and not self.is_focused: 221 | return False 222 | if self.state.collapsed and not self.is_maximized: 223 | return False 224 | _, location = self.view.get_box_top_line(self.index) 225 | if location == ViewLocation.BELOW: 226 | return False 227 | _, location = self.view.get_box_bottom_line(self.index) 228 | if location == ViewLocation.ABOVE: 229 | return False 230 | return True 231 | 232 | @property 233 | def is_maximized(self): 234 | return self.view.maximized and self.is_focused 235 | 236 | @property 237 | def max_first_column(self): 238 | return max(0, self.state.view_longest_line - self.view.cols) 239 | 240 | @property 241 | def num_buffer_lines(self): 242 | buffer_min_line = self.buffer.get_min_line(self.state.wrap) 243 | buffer_max_line = self.buffer.get_max_line(self.state.wrap) 244 | return buffer_max_line - buffer_min_line + 1 245 | 246 | @property 247 | def min_start_line(self): 248 | return self.buffer.get_min_line(self.state.wrap) 249 | 250 | @property 251 | def max_start_line(self): 252 | buffer_min_line = self.buffer.get_min_line(self.state.wrap) 253 | buffer_max_line = self.buffer.get_max_line(self.state.wrap) 254 | return max(buffer_min_line, buffer_max_line - self.num_view_lines + 1) 255 | 256 | def set_minmax_down_motion(self, value): 257 | return self._set_min_max_motion(value, self.state.buffer_start_line) 258 | 259 | def set_minmax_up_motion(self, value): 260 | return self._set_min_max_motion(value, self.min_start_line) 261 | 262 | def set_width(self, width): 263 | raw_line = self.buffer.wrapping_buffer.self_to_raw.get(self.state.buffer_start_line, 0) 264 | self.buffer.width = width 265 | if self.state.wrap and (not self.state.auto_scroll or self.state.stream_done): 266 | self.state.buffer_start_line = self.buffer.wrapping_buffer.raw_to_self[raw_line] 267 | 268 | def _set_min_max_motion(self, value, min_value): 269 | self.state.buffer_start_line = max(min_value, min(self.max_start_line, value)) 270 | return self.index 271 | -------------------------------------------------------------------------------- /src/multiplex/buffer.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import io 3 | import shutil 4 | 5 | import pyte 6 | from pyte import graphics as g 7 | from pyte.screens import Char, wcwidth, Margins 8 | 9 | from multiplex.ansi import CSI 10 | 11 | TERMINATE = "m" 12 | 13 | UNDEFINED = object() 14 | RESET_TEXT_ATTRS = set(list(range(1, 10))) 15 | BOLD = 1 16 | 17 | empty_meta = Char( 18 | None, 19 | fg=None, 20 | bg=None, 21 | bold=(), 22 | italics=None, 23 | underscore=None, 24 | strikethrough=None, 25 | reverse=None, 26 | ) 27 | 28 | reset = f"{CSI}0{TERMINATE}" 29 | index_to_char_meta = {0: empty_meta} 30 | char_meta_to_index = {empty_meta: 0} 31 | index_to_ansi = {0: reset} 32 | 33 | counter = 0 34 | 35 | 36 | class Screen(pyte.Screen): 37 | def __init__(self, columns, lines, line_buffer): 38 | super().__init__(columns, lines) 39 | self.line_buffer = line_buffer 40 | 41 | def reset(self): 42 | original_columns = self.columns 43 | original_lines = self.lines 44 | self.columns = 1 45 | self.lines = 1 46 | super().reset() 47 | self.cursor.attrs = self.default_char 48 | self.dirty.clear() 49 | self.columns = original_columns 50 | self.lines = original_lines 51 | self.tabstops = set(range(8, self.columns, 8)) 52 | 53 | @property 54 | def default_char(self): 55 | return Char(data=" ", fg=0) 56 | 57 | def select_graphic_rendition(self, *attrs): 58 | if not attrs or attrs == (0,): 59 | self.cursor.attrs = self.default_char 60 | return 61 | 62 | fg = UNDEFINED 63 | bg = UNDEFINED 64 | added_text_attrs = set() 65 | removed_text_attrs = set() 66 | 67 | attrs = list(reversed(attrs)) 68 | while attrs: 69 | attr = attrs.pop() 70 | if attr == 0: 71 | fg = None 72 | bg = None 73 | removed_text_attrs = RESET_TEXT_ATTRS 74 | elif attr in g.FG_ANSI: 75 | fg = (attr,) 76 | elif attr in g.BG: 77 | bg = (attr,) 78 | elif attr in g.FG_AIXTERM: 79 | fg = (attr,) 80 | added_text_attrs.add(BOLD) 81 | elif attr in g.BG_AIXTERM: 82 | bg = (attr,) 83 | added_text_attrs.add(BOLD) 84 | elif attr in (g.FG_256, g.BG_256): 85 | n = attrs.pop() 86 | if n == 5: 87 | value = attr, n, attrs.pop() 88 | if attr == g.FG_256: 89 | fg = value 90 | else: 91 | bg = value 92 | elif n == 2: 93 | value = attr, n, attrs.pop(), attrs.pop(), attrs.pop() 94 | if attr == g.FG_256: 95 | fg = value 96 | else: 97 | bg = value 98 | elif 1 <= attr <= 9: 99 | added_text_attrs.add(attr) 100 | elif 21 <= attr <= 29: 101 | removed_text_attrs.add(attr) 102 | 103 | current_meta = index_to_char_meta[self.cursor.attrs.fg] 104 | current_text_attrs = set(current_meta.bold) 105 | new_text_attrs = (current_text_attrs | added_text_attrs) - removed_text_attrs 106 | 107 | replace = {} 108 | if fg is not UNDEFINED: 109 | replace["fg"] = fg 110 | if bg is not UNDEFINED: 111 | replace["bg"] = bg 112 | replace["bold"] = tuple(sorted(new_text_attrs)) 113 | new_char_meta = current_meta._replace(**replace) 114 | 115 | if new_char_meta in char_meta_to_index: 116 | index = char_meta_to_index[new_char_meta] 117 | else: 118 | global counter 119 | counter += 1 120 | index = counter 121 | char_meta_to_index[new_char_meta] = index 122 | index_to_char_meta[index] = new_char_meta 123 | codes = [] 124 | c = new_char_meta 125 | if c.fg: 126 | codes.extend(c.fg) 127 | if c.bg: 128 | codes.extend(c.bg) 129 | if c.bold: 130 | codes.extend(list(c.bold)) 131 | ansi = f'{CSI}{";".join(str(c) for c in codes)}{TERMINATE}' 132 | index_to_ansi[index] = ansi 133 | 134 | self.cursor.attrs = Char(" ", fg=index) 135 | 136 | def erase_in_display(self, how=0, private=False): 137 | interval = None 138 | if how == 0: 139 | interval = range(self.cursor.y + 1, self.line_buffer.max_lines + 1) 140 | elif how == 1: 141 | interval = range(self.cursor.y) 142 | elif how == 2 or how == 3: 143 | interval = range(self.line_buffer.min_line, self.line_buffer.max_lines + 1) 144 | self.dirty.update(interval) 145 | for y in interval: 146 | line = self.buffer[y] 147 | for x in line: 148 | line[x] = self.cursor.attrs 149 | if how == 0 or how == 1: 150 | self.erase_in_line(how) 151 | 152 | def erase_in_line(self, how=0, private=False): 153 | self.dirty.add(self.cursor.y) 154 | line = self.buffer[self.cursor.y] 155 | keys = line.keys() 156 | columns = (max(keys) if keys else 0) + 1 157 | interval = None 158 | if how == 0: 159 | interval = range(self.cursor.x, columns) 160 | elif how == 1: 161 | interval = range(self.cursor.x + 1) 162 | elif how == 2: 163 | interval = range(columns) 164 | line = self.buffer[self.cursor.y] 165 | for x in interval: 166 | line[x] = self.cursor.attrs 167 | 168 | def index(self): 169 | self.cursor_down() 170 | 171 | def reverse_index(self): 172 | max_line = self.line_buffer.max_line 173 | min_line = self.line_buffer.min_line 174 | top, bottom = self.margins or Margins(min_line, max_line) 175 | if self.cursor.y == top: 176 | self.dirty.update(range(min_line, max_line + 1)) 177 | for y in range(bottom, top, -1): 178 | self.buffer[y] = self.buffer[y - 1] 179 | self.buffer.pop(top, None) 180 | else: 181 | self.cursor_up() 182 | 183 | @property 184 | def display(self): 185 | # overriding this so we don't evaluate all virtual lines/columns 186 | # when debugging, etc... 187 | return [] 188 | 189 | 190 | class LinedBuffer: 191 | BIG = 1000000 192 | 193 | def __init__(self, width=None): 194 | self.width = width or self.BIG 195 | self.screen = Screen(lines=self.BIG, columns=self.width, line_buffer=self) 196 | self.stream = pyte.Stream(screen=self.screen) 197 | self.max_line = 0 198 | self.min_line = 0 199 | self.raw_to_self = {} 200 | self.self_to_raw = {} 201 | 202 | def write(self, data): 203 | self.stream.feed(data) 204 | return self._update() 205 | 206 | def _update(self): 207 | dirty = self.screen.dirty 208 | if dirty: 209 | dirty_list = sorted(list(dirty)) 210 | self.max_line = max(self.max_line, *dirty_list) 211 | self.screen.dirty.clear() 212 | return dirty_list 213 | return [] 214 | 215 | def remove_lines(self, lines, start_line): 216 | new_min_line = start_line + lines 217 | for i in range(start_line, new_min_line): 218 | self.screen.buffer.pop(i, None) 219 | return new_min_line 220 | 221 | def get_lines(self, lines, start_line, columns, start_column): 222 | result = [] 223 | if start_line > self.max_line: 224 | return result 225 | last_char_meta_index = 0 226 | buffer = self.screen.buffer 227 | for line_num in range(start_line, lines + start_line): 228 | screen_line = buffer[line_num] 229 | keys = screen_line.keys() 230 | line_length = (max(keys) + 1) if keys else 0 231 | is_wide_char = False 232 | current_line_buffer = io.StringIO() 233 | for x in range(start_column, columns + start_column): 234 | if is_wide_char: # Skip stub 235 | is_wide_char = False 236 | continue 237 | current_char = screen_line[x] 238 | char_meta_index = current_char.fg 239 | if char_meta_index != last_char_meta_index: 240 | current_line_buffer.write(reset) 241 | if char_meta_index: 242 | current_line_buffer.write(index_to_ansi[char_meta_index]) 243 | last_char_meta_index = char_meta_index 244 | char_data = current_char.data 245 | current_line_buffer.write(char_data) 246 | assert sum(map(wcwidth, char_data[1:])) == 0 247 | is_wide_char = wcwidth(char_data[0]) == 2 248 | # add reset at the end 249 | if last_char_meta_index and line_num == lines + start_line - 1: 250 | current_line_buffer.write(reset) 251 | result.append((line_length, current_line_buffer.getvalue())) 252 | return result 253 | 254 | 255 | class CappedRawBuffer: 256 | def __init__(self, buffer_lines): 257 | self._deque = collections.deque(maxlen=buffer_lines + 1) 258 | 259 | def write(self, data): 260 | if self._deque: 261 | data = self._deque.pop() + data 262 | self._deque.append(data) 263 | 264 | def writeline(self, line): 265 | self._deque.append(line) 266 | 267 | def newline(self): 268 | pass 269 | 270 | def getvalue(self): 271 | return "\n".join(self._deque) 272 | 273 | 274 | class UncappedRawBuffer: 275 | def __init__(self): 276 | self._io = io.StringIO() 277 | 278 | def write(self, data): 279 | self._io.write(data) 280 | 281 | def writeline(self, line): 282 | self._io.write(line) 283 | 284 | def newline(self): 285 | self._io.write("\n") 286 | 287 | def getvalue(self): 288 | return self._io.getvalue() 289 | 290 | 291 | class Buffer: 292 | def __init__(self, width=None, buffer_lines=None): 293 | self.buffer_lines = buffer_lines 294 | self.raw_buffer = CappedRawBuffer(buffer_lines) if buffer_lines else UncappedRawBuffer() 295 | self.raw_lines = 0 296 | self.lined_buffer = LinedBuffer() 297 | self.wrapping_buffer = self._new_wrapping_buffer(width) 298 | 299 | @staticmethod 300 | def _new_wrapping_buffer(width): 301 | return LinedBuffer(width or shutil.get_terminal_size().columns) 302 | 303 | def get_lines(self, lines, start_line, columns, start_column, wrap): 304 | return self._get_buffer(wrap).get_lines( 305 | lines=lines, 306 | start_line=start_line, 307 | columns=columns, 308 | start_column=start_column, 309 | ) 310 | 311 | def write(self, data, buffers=None, skip_raw=False): 312 | buffers = buffers or (self.lined_buffer, self.wrapping_buffer) 313 | lines = data.split("\n") 314 | for i, line in enumerate(lines): 315 | if not skip_raw: 316 | if i: 317 | self.raw_lines += 1 318 | self.raw_buffer.writeline(line) 319 | else: 320 | self.raw_buffer.write(line) 321 | if i < len(lines) - 1: 322 | self.raw_buffer.newline() 323 | current_raw_line = self.raw_lines 324 | else: 325 | current_raw_line = self.raw_lines - len(lines) + i + 1 326 | if i < len(lines) - 1: 327 | maybe_slash_r = "" if line and line[-1] == "\r" else "\r" 328 | line = f"{line}{maybe_slash_r}\n" 329 | for buffer in buffers: 330 | dirty_lines = buffer.write(line) 331 | if dirty_lines: 332 | buffer.raw_to_self[current_raw_line] = dirty_lines[0] 333 | for dl in dirty_lines: 334 | buffer.self_to_raw[dl] = current_raw_line 335 | if not skip_raw and self.buffer_lines: 336 | lined_buffer = self.lined_buffer 337 | wrapping_buffer = self.wrapping_buffer 338 | total_lines = lined_buffer.max_line - lined_buffer.min_line + 1 339 | if total_lines > self.buffer_lines: 340 | remove_lined = total_lines - self.buffer_lines 341 | lined_buffer.min_line = lined_buffer.remove_lines(remove_lined, lined_buffer.min_line) 342 | remove_wrapping = ( 343 | self.convert_line_number(lined_buffer.min_line, fail_on_error=True) - wrapping_buffer.min_line 344 | ) 345 | wrapping_buffer.min_line = wrapping_buffer.remove_lines(remove_wrapping, wrapping_buffer.min_line) 346 | 347 | @property 348 | def width(self): 349 | return self.wrapping_buffer.width 350 | 351 | @width.setter 352 | def width(self, value): 353 | self.wrapping_buffer = self._new_wrapping_buffer(value) 354 | self.write(self.raw_buffer.getvalue(), skip_raw=True, buffers=[self.wrapping_buffer]) 355 | 356 | def get_min_line(self, wrap): 357 | return self._get_buffer(wrap).min_line 358 | 359 | def get_max_line(self, wrap): 360 | return self._get_buffer(wrap).max_line 361 | 362 | def get_cursor(self, wrap): 363 | cursor = self._get_buffer(wrap).screen.cursor 364 | return cursor.x, cursor.y 365 | 366 | def _get_buffer(self, wrap): 367 | return self.wrapping_buffer if wrap else self.lined_buffer 368 | 369 | def convert_line_number(self, line_number, from_wrapped=False, fail_on_error=False): 370 | from_buffer = self.wrapping_buffer if from_wrapped else self.lined_buffer 371 | to_buffer = self.lined_buffer if from_wrapped else self.wrapping_buffer 372 | raw_line_number = from_buffer.self_to_raw.get(line_number) 373 | if raw_line_number is None: 374 | if fail_on_error: 375 | raise RuntimeError(f"No raw line for line {line_number}") 376 | else: 377 | # TODO log warning 378 | return 0 379 | to_line_number = to_buffer.raw_to_self.get(raw_line_number) 380 | if to_line_number is None: 381 | if fail_on_error: 382 | raise RuntimeError(f"No to_line for raw_line {raw_line_number} [line={line_number}]") 383 | else: 384 | # TODO log warning 385 | return 0 386 | return to_line_number 387 | -------------------------------------------------------------------------------- /src/multiplex/commands.py: -------------------------------------------------------------------------------- 1 | from multiplex.keys import * 2 | 3 | from .exceptions import EndViewer 4 | 5 | from .ansi import FULL_REFRESH 6 | 7 | 8 | # toggles 9 | @bind(GLOBAL, "s", description="Toggle auto-scoll/manual-scroll for currently focused box") 10 | def toggle_auto_scroll(viewer): 11 | return viewer.focused.toggle_auto_scroll() 12 | 13 | 14 | @bind(GLOBAL, "i", description="Activate input mode for currently focused box") 15 | def activate_input_mode(viewer): 16 | if viewer.focused.state.stream_done: 17 | return 18 | return viewer.focused.activate_input_mode() 19 | 20 | 21 | @bind(GLOBAL, "w", description="Toggle wrap/unwrap for currently focused box") 22 | def toggle_wrap(viewer): 23 | return viewer.focused.toggle_wrap() 24 | 25 | 26 | @bind(GLOBAL, "W", description="Toggle wrap/unwrap for all boxes") 27 | def toggle_wrap_all(viewer): 28 | if all(box.state.wrap for box in viewer.boxes): 29 | new_value = False 30 | elif not any(box.state.wrap for box in viewer.boxes): 31 | new_value = True 32 | else: 33 | new_value = not viewer.wrapped_all 34 | for box in viewer.boxes: 35 | box.toggle_wrap(new_value) 36 | viewer.wrapped_all = new_value 37 | viewer.verify_focused_box_in_view() 38 | return FULL_REFRESH 39 | 40 | 41 | @bind(GLOBAL, "c", description="Toggle expand/collapse for currently focused box") 42 | def toggle_collapse(viewer): 43 | return viewer.focused.toggle_collapse() 44 | 45 | 46 | @bind(GLOBAL, "C", description="Toggle expand/collapse for all boxes") 47 | def toggle_collapse_all(viewer): 48 | if all(box.state.collapsed for box in viewer.boxes): 49 | new_value = False 50 | elif not any(box.state.collapsed for box in viewer.boxes): 51 | new_value = True 52 | else: 53 | new_value = not viewer.collaped_all 54 | for box in viewer.boxes: 55 | box.toggle_collapse(new_value) 56 | viewer.collaped_all = new_value 57 | viewer.verify_focused_box_in_view() 58 | return FULL_REFRESH 59 | 60 | 61 | @bind(GLOBAL, "m", description="Toggle maxmize") 62 | def toggle_maximize(viewer): 63 | viewer.maximized = not viewer.maximized 64 | return FULL_REFRESH 65 | 66 | 67 | @bind(GLOBAL, "=", description="Strip empty lines from boxes that finished processing") 68 | def strip_empty_lines(viewer): 69 | for box in viewer.boxes: 70 | box.strip_empty_lines() 71 | all_up(viewer) 72 | return FULL_REFRESH 73 | 74 | 75 | @bind(GLOBAL, "O", description="Dump boxes to output_dir (default: $PWD)") 76 | async def save(viewer): 77 | await viewer.export.save() 78 | 79 | 80 | @bind(GLOBAL, "?", description="Show/hide this help screen") 81 | def toggle_help(viewer): 82 | return viewer.help.toggle() 83 | 84 | 85 | # quit 86 | @bind(GLOBAL, "q", description="Quit") 87 | def end(*_): 88 | raise EndViewer 89 | 90 | 91 | # global inside box movement 92 | @bind(GLOBAL, "l", RIGHT, description="Move 1 char right") 93 | def move_right(viewer): 94 | return viewer.focused.move_right() 95 | 96 | 97 | @bind(GLOBAL, "h", LEFT, description="Move 1 char left") 98 | def move_left(viewer): 99 | return viewer.focused.move_left() 100 | 101 | 102 | @bind(GLOBAL, ")", description="Move 1/2 screen to the right") 103 | def move_half_screen_right(viewer): 104 | return viewer.focused.move_half_screen_right() 105 | 106 | 107 | @bind(GLOBAL, "(", description="Move 1/2 screen to the left") 108 | def move_half_screen_left(viewer): 109 | return viewer.focused.move_half_screen_left() 110 | 111 | 112 | @bind(GLOBAL, "$", description="Move all the way to the right") 113 | def move_right_until_end(viewer): 114 | return viewer.focused.move_right_until_end() 115 | 116 | 117 | @bind(GLOBAL, "_", "0", description="Move all the way to the left") 118 | def move_left_until_start(viewer): 119 | return viewer.focused.move_left_until_start() 120 | 121 | 122 | # main mode 123 | @bind(NORMAL, "j", DOWN, description="Focus next box") 124 | def next_box(viewer): 125 | viewer.current_focused_box = min(viewer.current_focused_box + 1, viewer.num_boxes - 1) 126 | viewer.verify_focused_box_in_view() 127 | 128 | 129 | @bind(NORMAL, "k", UP, description="Focus previous box") 130 | def previous_box(viewer): 131 | viewer.current_focused_box = max(viewer.current_focused_box - 1, 0) 132 | viewer.verify_focused_box_in_view() 133 | 134 | 135 | @bind(NORMAL, ALT_J, ALT_DOWN, description="Switch places with box below") 136 | def switch_with_next_box(viewer): 137 | if viewer.current_focused_box + 1 >= viewer.num_boxes: 138 | return False 139 | viewer.swap_indices(viewer.current_focused_box, viewer.current_focused_box + 1) 140 | viewer.verify_focused_box_in_view() 141 | return FULL_REFRESH 142 | 143 | 144 | @bind(NORMAL, ALT_K, ALT_UP, description="Switch places with box above") 145 | def switch_with_previous_box(viewer): 146 | if viewer.current_focused_box - 1 < 0: 147 | return False 148 | viewer.swap_indices(viewer.current_focused_box, viewer.current_focused_box - 1) 149 | viewer.verify_focused_box_in_view() 150 | return FULL_REFRESH 151 | 152 | 153 | @bind(NORMAL, "g", HOME, description="Focus first box") 154 | def all_up(viewer): 155 | viewer.current_focused_box = 0 156 | viewer.verify_focused_box_in_view() 157 | 158 | 159 | @bind(NORMAL, "G", END, description="Focus last box") 160 | def all_down(viewer): 161 | viewer.current_focused_box = viewer.num_boxes - 1 162 | viewer.verify_focused_box_in_view() 163 | 164 | 165 | @bind(NORMAL, "f", CTRL_F, PAGEDOWN, description="View 1 page down") 166 | def page_down(viewer): 167 | viewer.current_view_line = min(viewer.max_current_line, viewer.current_view_line + viewer.lines) 168 | 169 | 170 | @bind(NORMAL, "b", CTRL_B, PAGEUP, description="View 1 page up") 171 | def page_up(viewer): 172 | viewer.current_view_line = max(0, viewer.current_view_line - viewer.lines) 173 | 174 | 175 | @bind(NORMAL, "d", CTRL_D, description="View 1/2 page down") 176 | def half_page_down(viewer): 177 | viewer.current_view_line = min(viewer.max_current_line, viewer.current_view_line + viewer.lines // 2) 178 | 179 | 180 | @bind(NORMAL, "u", CTRL_U, description="View 1/2 page up") 181 | def half_page_up(viewer): 182 | viewer.current_view_line = max(0, viewer.current_view_line - viewer.lines // 2) 183 | 184 | 185 | @bind(NORMAL, CTRL_J, description="View 1 line down") 186 | def line_down(viewer): 187 | viewer.current_view_line = min(viewer.max_current_line, viewer.current_view_line + 1) 188 | 189 | 190 | @bind(NORMAL, CTRL_K, description="View 1 line up") 191 | def line_up(viewer): 192 | viewer.current_view_line = max(0, viewer.current_view_line - 1) 193 | 194 | 195 | # change box height 196 | @bind(GLOBAL, ">", description="Increase box height") 197 | def increase_box_height(viewer): 198 | return viewer.focused.increase_box_height() 199 | 200 | 201 | @bind(GLOBAL, "<", description="Decrease box height") 202 | def decrease_box_height(viewer): 203 | return viewer.focused.decrease_box_height() 204 | 205 | 206 | # scroll mode 207 | @bind(SCROLL, "j", DOWN, description="Scroll 1 line down") 208 | def move_line_down(viewer): 209 | return viewer.focused.move_line_down() 210 | 211 | 212 | @bind(SCROLL, "k", UP, description="Scroll 1 line up") 213 | def move_line_up(viewer): 214 | return viewer.focused.move_line_up() 215 | 216 | 217 | @bind(SCROLL, "g", HOME, description="Scroll to start") 218 | def move_all_up(viewer): 219 | return viewer.focused.move_all_up() 220 | 221 | 222 | @bind(SCROLL, "G", END, description="Scroll to end") 223 | def move_all_down(viewer): 224 | return viewer.focused.move_all_down() 225 | 226 | 227 | @bind(SCROLL, "b", CTRL_B, PAGEUP, description="Scroll 1 page up") 228 | def move_page_up(viewer): 229 | return viewer.focused.move_page_up() 230 | 231 | 232 | @bind(SCROLL, "f", CTRL_F, PAGEDOWN, description="Scroll 1 page down") 233 | def move_page_down(viewer): 234 | return viewer.focused.move_page_down() 235 | 236 | 237 | @bind(SCROLL, "u", CTRL_U, description="Scroll 1/2 page up") 238 | def move_half_page_up(viewer): 239 | return viewer.focused.move_half_page_up() 240 | 241 | 242 | @bind(SCROLL, "d", CTRL_D, description="Scroll 1/2 page down") 243 | def move_half_page_down(viewer): 244 | return viewer.focused.move_half_page_down() 245 | 246 | 247 | @bind(INPUT, CTRL__, description="Exit input mode") 248 | def exit_input_mode(viewer): 249 | return viewer.focused.exit_input_mode() 250 | 251 | 252 | # help mode 253 | @bind(HELP, "j", DOWN, description="Scroll 1 line down [in help screen]") 254 | def move_line_down(viewer): 255 | return viewer.help.move_line_down() 256 | 257 | 258 | @bind(HELP, "k", UP, description="Scroll 1 line up [in help screen]") 259 | def move_line_up(viewer): 260 | return viewer.help.move_line_up() 261 | -------------------------------------------------------------------------------- /src/multiplex/controller.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from multiplex.actions import SetTitle, ToggleCollapse, ToggleWrap 4 | from multiplex.refs import STOP 5 | 6 | 7 | class Controller: 8 | def __init__(self, title, thread_safe=False): 9 | self.title = title 10 | self.queue: asyncio.Queue = None 11 | self.thread_safe = thread_safe 12 | self._loop: asyncio.AbstractEventLoop = None 13 | self._pre_init_queue = [] 14 | 15 | def _init(self): 16 | self.queue = asyncio.Queue() 17 | self._loop = asyncio.get_event_loop() 18 | for data in self._pre_init_queue: 19 | self.write(data) 20 | 21 | def write(self, data): 22 | if not self.queue: 23 | self._pre_init_queue.append(data) 24 | return 25 | if self.thread_safe: 26 | self._loop.call_soon_threadsafe(self.queue.put_nowait, data) 27 | else: 28 | self.queue.put_nowait(data) 29 | 30 | def set_title(self, title): 31 | self.write(SetTitle(title)) 32 | 33 | def collapse(self): 34 | self.write(ToggleCollapse(True)) 35 | 36 | def expand(self): 37 | self.write(ToggleCollapse(False)) 38 | 39 | def toggle_collapse(self): 40 | self.write(ToggleCollapse()) 41 | 42 | def wrap(self): 43 | self.write(ToggleWrap(True)) 44 | 45 | def nowrap(self): 46 | self.write(ToggleWrap(False)) 47 | 48 | def toggle_wrap(self): 49 | self.write(ToggleWrap()) 50 | 51 | def done(self): 52 | self.write(STOP) 53 | -------------------------------------------------------------------------------- /src/multiplex/enums.py: -------------------------------------------------------------------------------- 1 | class ViewLocation: 2 | IN_VIEW = 0 3 | ABOVE = 1 4 | BELOW = 2 5 | NOT_FOCUSED = 3 6 | 7 | 8 | class BoxLine: 9 | TITLE = 0 10 | TOP = 1 11 | BOTTOM = 2 12 | -------------------------------------------------------------------------------- /src/multiplex/exceptions.py: -------------------------------------------------------------------------------- 1 | class EndViewer(BaseException): 2 | pass 3 | 4 | 5 | class IPCException(Exception): 6 | pass 7 | -------------------------------------------------------------------------------- /src/multiplex/export.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import string 4 | from datetime import datetime 5 | 6 | import aiofiles 7 | from multiplex.ansi import C 8 | from multiplex.iterator import Descriptor 9 | 10 | 11 | class Export: 12 | def __init__(self, viewer): 13 | self.viewer = viewer 14 | 15 | async def save(self): 16 | viewer = self.viewer 17 | now = datetime.now() 18 | dir_name = f"output-{now.strftime('%Y-%m-%dT%H-%M-%S')}" 19 | output_dir = os.path.join(viewer.output_path, dir_name) 20 | os.makedirs(output_dir, exist_ok=True) 21 | valid_chars = string.ascii_letters + string.digits 22 | holders = viewer.holders 23 | zero_padding = 1 if len(holders) < 10 else 2 24 | metadata = { 25 | "view": { 26 | "maximized": viewer.maximized, 27 | "collapsed": viewer.collaped_all, 28 | "wrapped": viewer.wrapped_all, 29 | }, 30 | "boxes": [], 31 | } 32 | for index, holder in enumerate(holders): 33 | initial_title = holder.iterator.title 34 | state = holder.state 35 | title = initial_title.to_string(no_style=True) if isinstance(initial_title, C) else str(initial_title) 36 | title = "".join(c for c in title if c in valid_chars).lower() 37 | file_name = f"{str(index + 1).zfill(zero_padding)}-{title}" 38 | async with aiofiles.open(os.path.join(output_dir, file_name), "w") as f: 39 | await f.write(holder.buffer.raw_buffer.getvalue()) 40 | metadata["boxes"].append( 41 | { 42 | "title": initial_title.to_dict() if isinstance(initial_title, C) else initial_title, 43 | "box_height": state.box_height, 44 | "collapsed": state.collapsed, 45 | "wrap": state.wrap, 46 | "filename": file_name, 47 | } 48 | ) 49 | async with aiofiles.open(os.path.join(output_dir, "metadata.json"), "w") as f: 50 | await f.write(json.dumps(metadata, indent=2)) 51 | viewer.output_saved = True 52 | viewer.events.send_output_saved() 53 | 54 | async def load(self, export_dir): 55 | viewer = self.viewer 56 | async with aiofiles.open(os.path.join(export_dir, "metadata.json")) as f: 57 | metadata = json.loads(await f.read()) 58 | viewer.initial_add( 59 | [ 60 | Descriptor( 61 | obj=f"file://{export_dir}/{box['filename']}", 62 | title=C.from_dict(box["title"]) if isinstance(box["title"], dict) else box["title"], 63 | box_height=box["box_height"], 64 | collapsed=box["collapsed"], 65 | wrap=box["wrap"], 66 | ) 67 | for box in metadata["boxes"] 68 | ] 69 | ) 70 | view = metadata["view"] 71 | viewer.maximized = view["maximized"] 72 | viewer.collaped_all = view["collapsed"] 73 | viewer.wrapped_all = view["wrapped"] 74 | -------------------------------------------------------------------------------- /src/multiplex/help.py: -------------------------------------------------------------------------------- 1 | from multiplex import keys, ansi 2 | 3 | 4 | class HelpViewState: 5 | def __init__(self, viewer): 6 | self.viewer = viewer 7 | self.show = False 8 | self.current_line = 0 9 | self.max_current_line = ( 10 | 2 # box lines 11 | + len(keys.descriptions) * 2 12 | - 1 # last description new line 13 | + sum(len(mode_desc) for mode_desc in keys.descriptions.values()) 14 | ) 15 | 16 | def toggle(self): 17 | self.show = not self.show 18 | self.current_line = 0 19 | return ansi.FULL_REFRESH 20 | 21 | def move_line_up(self): 22 | self.current_line = max(0, self.current_line - 1) 23 | return True 24 | 25 | def move_line_down(self): 26 | max_line = self.max_current_line 27 | if self.viewer.lines: 28 | max_line -= self.viewer.lines 29 | self.current_line = max(0, min(max_line, self.current_line + 1)) 30 | return True 31 | -------------------------------------------------------------------------------- /src/multiplex/ipc.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import os 4 | import tempfile 5 | from asyncio import StreamWriter, StreamReader 6 | from random import randint 7 | 8 | from multiplex.exceptions import IPCException 9 | from multiplex.viewer import Viewer 10 | from multiplex.iterator import Descriptor, MULTIPLEX_STREAM_ID 11 | 12 | 13 | class Server: 14 | def __init__(self, socket_path=None): 15 | self.socket_path = socket_path or f"{tempfile.gettempdir()}/multiplex-{randint(100000, 999999)}" 16 | self.viewer: Viewer = None 17 | self.server = None 18 | self.server_task = None 19 | self.stopped = False 20 | 21 | async def start(self, viewer): 22 | self.viewer = viewer 23 | self.server = await asyncio.start_unix_server( 24 | client_connected_cb=self._handle_request, 25 | path=self.socket_path, 26 | ) 27 | self.server_task = asyncio.create_task(self.server.serve_forever()) 28 | 29 | def stop(self): 30 | if self.server_task: 31 | self.server_task.cancel() 32 | if os.path.exists(self.socket_path): 33 | os.remove(self.socket_path) 34 | self.stopped = True 35 | 36 | async def _handle_request(self, reader: StreamReader, writer: StreamWriter): 37 | request = await _read_message(reader) 38 | try: 39 | await self._handle_message(request) 40 | response = {"status": "success"} 41 | except Exception as e: 42 | response = {"status": "failure", "error": str(e)} 43 | await _write_message(response, writer) 44 | writer.close() 45 | return response 46 | 47 | async def _handle_message(self, message, batch=False): 48 | action = message.pop("action") 49 | if action == "split": 50 | self.viewer.split(**message) 51 | elif action == "collapse": 52 | self.viewer.focused.toggle_collapse(**message) 53 | self.viewer.events.send_redraw() 54 | elif action == "add": 55 | descriptor = Descriptor(**message, scroll_down=True) 56 | handle = self.viewer.add(descriptor) 57 | if descriptor.wait: 58 | if batch: 59 | return handle 60 | else: 61 | await handle 62 | elif action == "save": 63 | self.viewer.events.send_save() 64 | elif action == "load": 65 | await self.viewer.load(**message) 66 | elif action == "quit": 67 | self.viewer.events.send_quit() 68 | elif action == "batch": 69 | handles = [] 70 | for action in message["actions"]: 71 | handle = self._handle_message(action) 72 | if handle: 73 | handles.append(handle) 74 | if handles: 75 | await asyncio.gather(*handles) 76 | 77 | 78 | class Client: 79 | def __init__(self, socket_path): 80 | self._socket_path = socket_path 81 | 82 | async def add(self, obj, title, box_height, wait, cwd, env): 83 | await self._request(self.add_request_body(obj, title, box_height, wait, cwd, env)) 84 | 85 | @staticmethod 86 | def add_request_body(obj, title, box_height, wait, cwd, env): 87 | return { 88 | "action": "add", 89 | "obj": obj, 90 | "title": title, 91 | "box_height": box_height, 92 | "wait": wait, 93 | "cwd": cwd, 94 | "env": env, 95 | } 96 | 97 | async def split(self, title, box_height, stream_id): 98 | await self._request(self.split_request_body(title, box_height, stream_id)) 99 | 100 | @staticmethod 101 | def split_request_body(title, box_height, stream_id): 102 | return {"action": "split", "title": title, "box_height": box_height, "stream_id": stream_id} 103 | 104 | async def toggle_collapse(self, value=None): 105 | await self._request(self.collapse_request_body(value)) 106 | 107 | @staticmethod 108 | def collapse_request_body(value): 109 | return {"action": "collapse", "value": value} 110 | 111 | async def save(self): 112 | await self._request(self.save_request_body()) 113 | 114 | @staticmethod 115 | def save_request_body(): 116 | return {"action": "save"} 117 | 118 | async def load(self, export_dir): 119 | await self._request(self.load_request_body(export_dir)) 120 | 121 | @staticmethod 122 | def load_request_body(export_dir): 123 | return {"action": "load", "export_dir": export_dir} 124 | 125 | async def quit(self): 126 | await self._request(self.quit_request_body()) 127 | 128 | @staticmethod 129 | def quit_request_body(): 130 | return {"action": "quit"} 131 | 132 | async def batch(self, actions): 133 | await self._request({"action": "batch", "actions": actions}) 134 | 135 | async def _connect(self): 136 | return await asyncio.open_unix_connection(self._socket_path) 137 | 138 | async def _request(self, message): 139 | reader, writer = await self._connect() 140 | await _write_message(message, writer) 141 | response = await _read_message(reader) 142 | writer.close() 143 | if response["status"] == "failure": 144 | raise IPCException(response["error"]) 145 | return response 146 | 147 | 148 | async def _read_message(reader): 149 | return json.loads((await reader.readline()).decode().strip()) 150 | 151 | 152 | async def _write_message(message, writer): 153 | writer.write(f"{json.dumps(message)}\n".encode()) 154 | await writer.drain() 155 | 156 | 157 | def get_env_stream_id(): 158 | return os.environ.get(MULTIPLEX_STREAM_ID) 159 | -------------------------------------------------------------------------------- /src/multiplex/iterator.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import fcntl 3 | import io 4 | import json 5 | import os 6 | import struct 7 | import termios 8 | import time 9 | import types 10 | import pathlib 11 | import pty 12 | from dataclasses import dataclass 13 | from typing import Any 14 | 15 | import aiofiles 16 | from aiostream.stream import create, combine 17 | from multiplex import ansi 18 | from multiplex.actions import SetTitle, BoxActions, UpdateMetadata 19 | from multiplex.ansi import C, theme 20 | from multiplex.controller import Controller 21 | from multiplex.process import Process 22 | from multiplex.refs import SPLIT, STOP 23 | 24 | MULTIPLEX_SOCKET_PATH = "MULTIPLEX_SOCKET_PATH" 25 | MULTIPLEX_STREAM_ID = "MULTIPLEX_STREAM_ID" 26 | 27 | 28 | async def stream_reader_generator(reader): 29 | while True: 30 | try: 31 | b = await reader.read(1000000) 32 | if not b: 33 | break 34 | yield b.decode("utf-8", "replace") 35 | except OSError: 36 | return 37 | 38 | 39 | async def asciinema_recording_iterator(recording_path): 40 | async with aiofiles.open(recording_path, encoding="utf-8") as f: 41 | after_first_line = False 42 | last_abs_time = time.time() 43 | last_rel_time = 0 44 | async for line in f: 45 | if not after_first_line: 46 | after_first_line = True 47 | continue 48 | rel_time, type_, output = json.loads(line) 49 | if type_ != "o": 50 | continue 51 | abs_time = time.time() 52 | sleep_time = (rel_time - last_rel_time) - (abs_time - last_abs_time) 53 | await asyncio.sleep(sleep_time) 54 | last_rel_time = rel_time 55 | last_abs_time = abs_time 56 | yield output 57 | 58 | 59 | @dataclass 60 | class Descriptor: 61 | obj: Any 62 | title: str 63 | box_height: int 64 | wrap: bool = None 65 | collapsed: bool = None 66 | scroll_down: bool = False 67 | # only relevant for ipc requests 68 | wait: bool = False 69 | stream_id: str = None 70 | cwd: str = None 71 | env: dict = None 72 | 73 | 74 | class Iterator: 75 | def __init__(self, iterator, title, inner_type, metadata): 76 | self.iterator = iterator 77 | self.title = title 78 | self.inner_type = inner_type 79 | self.metadata = metadata 80 | 81 | 82 | def _extract_title(current_title, obj): 83 | if current_title is not None: 84 | return current_title 85 | if isinstance(obj, str): 86 | if obj.startswith("file://") or obj.startswith("asciinema://"): 87 | return obj.split("://")[1] 88 | return obj 89 | if isinstance(obj, pathlib.Path): 90 | return str(obj) 91 | title_attr = getattr(obj, "title", None) 92 | if title_attr: 93 | return title_attr 94 | name_attr = getattr(obj, "__name__", None) 95 | if name_attr: 96 | return name_attr 97 | class_attr = getattr(obj, "__class__", None) 98 | if class_attr: 99 | name_attr = getattr(class_attr, "__name__", None) 100 | if name_attr: 101 | return name_attr 102 | return None 103 | 104 | 105 | def _process_descriptor_to_iterator(descriptor: Process, context): 106 | def _setsize(fd): 107 | cols, rows = ansi.get_size() 108 | s = struct.pack("HHHH", rows, cols, 0, 0) 109 | fcntl.ioctl(fd, termios.TIOCSWINSZ, s) 110 | 111 | master, slave = pty.openpty() 112 | _setsize(slave) 113 | cwd = context.get("cwd") 114 | env = (context.get("env") or os.environ).copy() 115 | env.update( 116 | { 117 | MULTIPLEX_SOCKET_PATH: context.get("socket_path", ""), 118 | MULTIPLEX_STREAM_ID: context.get("stream_id", ""), 119 | } 120 | ) 121 | cmd = descriptor.cmd 122 | if isinstance(cmd, str): 123 | obj = asyncio.subprocess.create_subprocess_shell( 124 | cmd, 125 | stdin=slave, 126 | stdout=slave, 127 | stderr=slave, 128 | env=env, 129 | cwd=cwd, 130 | ) 131 | else: 132 | obj = asyncio.subprocess.create_subprocess_exec( 133 | cmd[0], 134 | *cmd[1:], 135 | stdin=slave, 136 | stdout=slave, 137 | stderr=slave, 138 | env=env, 139 | cwd=cwd, 140 | ) 141 | return obj, (master, slave) 142 | 143 | 144 | def _str_to_iterator(str_value, title, context): 145 | title = _extract_title(title, str_value) 146 | master, slave = None, None 147 | if str_value.startswith("asciinema://"): 148 | obj = asciinema_recording_iterator(str_value.split("://", 1)[1]) 149 | elif str_value.startswith("file://"): 150 | obj = pathlib.Path(str_value.split("://")[1]) 151 | else: 152 | obj, (master, slave) = _process_descriptor_to_iterator(Process(str_value), context) 153 | return obj, title, (master, slave) 154 | 155 | 156 | def _coroutine_to_iterator(cor): 157 | loop = asyncio.get_event_loop() 158 | if loop.is_running(): 159 | raise RuntimeError("Event loop is current running. Use the async version") 160 | return loop.run_until_complete(cor) 161 | 162 | 163 | def _process_to_iterator(process, title, master, slave, context): 164 | handle: asyncio.Future = context.get("handle") 165 | 166 | async def exit_stream(): 167 | exit_code = await process.wait() 168 | if handle: 169 | if exit_code: 170 | handle.set_exception(RuntimeError(f"'{title}' failed with code {exit_code}")) 171 | else: 172 | handle.set_result(True) 173 | if slave: 174 | os.close(slave) 175 | yield 176 | 177 | async def g(): 178 | stdout = process.stdout 179 | stderr = process.stderr 180 | streams = [] 181 | if isinstance(stdout, asyncio.streams.StreamReader): 182 | streams.append(stream_reader_generator(stdout)) 183 | if isinstance(stderr, asyncio.streams.StreamReader): 184 | streams.append(stream_reader_generator(stderr)) 185 | if master: 186 | loop = asyncio.get_running_loop() 187 | assert slave 188 | pipe = io.open(master, "w+b", 0) 189 | reader = asyncio.StreamReader() 190 | reader_protocol = asyncio.StreamReaderProtocol(reader) 191 | await loop.connect_read_pipe(lambda: reader_protocol, pipe) 192 | streams.extend([stream_reader_generator(reader), exit_stream()]) 193 | writer_transport, writer_protocol = await loop.connect_write_pipe(asyncio.streams.FlowControlMixin, pipe) 194 | writer = asyncio.StreamWriter(writer_transport, writer_protocol, reader, loop) 195 | yield UpdateMetadata({"input": writer}) 196 | 197 | assert streams 198 | stream = combine.merge(*streams) 199 | async with stream.stream() as s: 200 | async for data in s: 201 | yield data 202 | exit_code = process.returncode if slave else await process.wait() 203 | status = C("✗", color=theme.X_COLOR) if exit_code else C("✓", color=theme.V_COLOR) 204 | yield BoxActions( 205 | [ 206 | UpdateMetadata({"exit_code": exit_code}), 207 | SetTitle(C("[", status, f"] {title}")), 208 | ] 209 | ) 210 | 211 | obj = g() 212 | title = _extract_title(title, process) 213 | inner_type = "async_process" 214 | 215 | def close(): 216 | try: 217 | process.kill() 218 | except ProcessLookupError: 219 | pass 220 | 221 | return obj, title, inner_type, {"close": close} 222 | 223 | 224 | def _path_to_iterator(file_path, title): 225 | async def g(): 226 | async with aiofiles.open(file_path, encoding="utf-8") as f: 227 | yield await f.read() 228 | 229 | obj = g() 230 | title = _extract_title(title, file_path) 231 | inner_type = "path" 232 | return obj, title, inner_type 233 | 234 | 235 | def _controller_to_iterator(controller, title): 236 | async def g(): 237 | controller._init() 238 | while True: 239 | result = await controller.queue.get() 240 | if result is STOP: 241 | controller.queue.task_done() 242 | break 243 | yield result 244 | controller.queue.task_done() 245 | 246 | obj = g() 247 | title = _extract_title(title, controller) 248 | inner_type = "controller" 249 | return obj, title, inner_type 250 | 251 | 252 | def _stream_reader_to_iterator(stream_reader, title): 253 | obj = stream_reader_generator(stream_reader) 254 | title = _extract_title(title, stream_reader) 255 | inner_type = "stream_reader" 256 | return obj, title, inner_type 257 | 258 | 259 | def _async_generator_to_iterator(agen, title): 260 | obj = agen 261 | title = _extract_title(title, agen) 262 | inner_type = "async_generator" 263 | return obj, title, inner_type 264 | 265 | 266 | def _generator_to_iterator(gen, title): 267 | obj = create.from_iterable.raw(gen) 268 | title = _extract_title(title, gen) 269 | inner_type = "generator" 270 | return obj, title, inner_type 271 | 272 | 273 | def _async_iterable_to_iterator(aiter, title): 274 | obj = aiter 275 | title = _extract_title(title, aiter) 276 | inner_type = "async_iterable" 277 | return obj, title, inner_type 278 | 279 | 280 | def _iterable_to_iterator(_iter, title): 281 | obj = create.from_iterable.raw(_iter) 282 | title = _extract_title(title, _iter) 283 | inner_type = "iterable" 284 | return obj, title, inner_type 285 | 286 | 287 | def _callable_to_iterator(cb, title): 288 | obj = cb() 289 | title = _extract_title(title, cb) 290 | return obj, title 291 | 292 | 293 | async def _to_iterator(obj, title, context): 294 | master, slave = None, None 295 | if isinstance(obj, str): 296 | obj, title, (master, slave) = _str_to_iterator(obj, title, context) 297 | elif isinstance(obj, Process): 298 | title = _extract_title(title, obj) 299 | obj, (master, slave) = _process_descriptor_to_iterator(obj, context) 300 | elif isinstance(obj, (types.FunctionType, types.MethodType)): 301 | obj, title = _callable_to_iterator(obj, title) 302 | 303 | if isinstance(obj, types.CoroutineType): 304 | obj = await obj 305 | 306 | if isinstance(obj, asyncio.streams.StreamReader): 307 | result = _stream_reader_to_iterator(obj, title) 308 | elif isinstance(obj, types.AsyncGeneratorType): 309 | result = _async_generator_to_iterator(obj, title) 310 | elif isinstance(obj, types.GeneratorType): 311 | result = _generator_to_iterator(obj, title) 312 | elif hasattr(obj, "__aiter__"): 313 | result = _async_iterable_to_iterator(obj, title) 314 | elif hasattr(obj, "__iter__"): 315 | result = _iterable_to_iterator(obj, title) 316 | elif isinstance(obj, asyncio.subprocess.Process): 317 | result = _process_to_iterator(obj, title, master, slave, context) 318 | elif isinstance(obj, pathlib.Path): 319 | result = _path_to_iterator(obj, title) 320 | elif isinstance(obj, Controller): 321 | result = _controller_to_iterator(obj, title) 322 | else: 323 | raise RuntimeError(f"Cannot create iterator from {obj}") 324 | 325 | metadata = {} 326 | if len(result) == 3: 327 | obj, title, inner_type = result 328 | else: 329 | obj, title, inner_type, metadata = result 330 | 331 | if title is None: 332 | title = "N/A" 333 | 334 | return obj, title, inner_type, metadata 335 | 336 | 337 | async def to_iterator(obj, title=None, context=None) -> Iterator: 338 | if obj is SPLIT: 339 | return Iterator(SPLIT, title, "split", {}) 340 | return Iterator(*(await _to_iterator(obj, title, context or {}))) 341 | -------------------------------------------------------------------------------- /src/multiplex/keys.py: -------------------------------------------------------------------------------- 1 | BACKSPACE = 8 2 | DEL = 127 3 | BACKSPACE_OR_DEL = {BACKSPACE, DEL} 4 | 5 | ESC = 27 6 | ESC_MOVE = [ESC, 91] 7 | CTRL_MOVE = ESC_MOVE + [49, 59, 53] 8 | 9 | UP = ESC_MOVE + [65] 10 | DOWN = ESC_MOVE + [66] 11 | LEFT = ESC_MOVE + [67] 12 | RIGHT = ESC_MOVE + [68] 13 | 14 | HOME = ESC_MOVE + [72] 15 | END = ESC_MOVE + [70] 16 | 17 | PAGEUP = ESC_MOVE + [53, 126] 18 | PAGEDOWN = ESC_MOVE + [54, 126] 19 | 20 | CTRL_UP = CTRL_MOVE + [65] 21 | CTRL_DOWN = CTRL_MOVE + [66] 22 | CTRL_LEFT = CTRL_MOVE + [67] 23 | CTRL_RIGHT = CTRL_MOVE + [68] 24 | 25 | ALT_UP = [ESC] + UP 26 | ALT_DOWN = [ESC] + DOWN 27 | ALT_LEFT = [ESC] + LEFT 28 | ALT_RIGHT = [ESC] + RIGHT 29 | 30 | ALT_J = [ESC] + [ord("j")] 31 | ALT_K = [ESC] + [ord("k")] 32 | 33 | CTRL_K = (11,) 34 | CTRL_J = (10,) 35 | CTRL_B = (2,) 36 | CTRL_F = (6,) 37 | CTRL_U = (21,) 38 | CTRL_D = (4,) 39 | CTRL__ = (27,) 40 | 41 | CTRL_TO_NAME = { 42 | CTRL_K: "K", 43 | CTRL_J: "J", 44 | CTRL_B: "B", 45 | CTRL_F: "F", 46 | CTRL_U: "U", 47 | CTRL_D: "D", 48 | } 49 | 50 | UP_NAME = "↑" 51 | DOWN_NAME = "↓" 52 | LEFT_NAME = "←" 53 | RIGHT_NAME = "→" 54 | ALT_NAME = "⌥" 55 | 56 | SEQ_TO_NAME = { 57 | tuple(UP): UP_NAME, 58 | tuple(DOWN): DOWN_NAME, 59 | tuple(LEFT): LEFT_NAME, 60 | tuple(RIGHT): RIGHT_NAME, 61 | tuple(HOME): "Home", 62 | tuple(END): "End", 63 | tuple(PAGEUP): "Page Up", 64 | tuple(PAGEDOWN): "Page Down", 65 | tuple(CTRL_UP): f"^{UP_NAME}", 66 | tuple(CTRL_DOWN): f"^{DOWN_NAME}", 67 | tuple(CTRL_LEFT): f"^{LEFT_NAME}", 68 | tuple(CTRL_RIGHT): f"^{RIGHT_NAME}", 69 | tuple(CTRL__): "Esc", 70 | tuple(ALT_UP): f"{ALT_NAME}{UP_NAME}", 71 | tuple(ALT_DOWN): f"{ALT_NAME}{DOWN_NAME}", 72 | tuple(ALT_LEFT): f"{ALT_NAME}{LEFT_NAME}", 73 | tuple(ALT_RIGHT): f"{ALT_NAME}{RIGHT_NAME}", 74 | tuple(ALT_J): f"{ALT_NAME}j", 75 | tuple(ALT_K): f"{ALT_NAME}k", 76 | } 77 | 78 | NORMAL = "normal" 79 | SCROLL = "scroll" 80 | INPUT = "input" 81 | GLOBAL = "global" 82 | HELP = "help" 83 | 84 | bindings = { 85 | NORMAL: {}, 86 | SCROLL: {}, 87 | INPUT: {}, 88 | GLOBAL: {}, 89 | HELP: {}, 90 | } 91 | 92 | descriptions = { 93 | HELP: {}, 94 | NORMAL: {}, 95 | SCROLL: {}, 96 | INPUT: {}, 97 | GLOBAL: {}, 98 | } 99 | 100 | 101 | def generic_key_to_name(key): 102 | name = chr(key) 103 | if not name.isprintable(): 104 | name = f"[{ord(name)}]" 105 | return name 106 | 107 | 108 | def seq_to_name(seq, fallback=True): 109 | if not isinstance(seq, tuple): 110 | seq = tuple(seq) 111 | name = None 112 | if seq in CTRL_TO_NAME: 113 | name = f"^{CTRL_TO_NAME[seq]}" 114 | elif seq in SEQ_TO_NAME: 115 | name = SEQ_TO_NAME[seq] 116 | elif len(seq) == 1: 117 | key = seq[0] 118 | name = generic_key_to_name(key) 119 | elif fallback: 120 | name = "".join(generic_key_to_name(o) for o in seq) 121 | return name 122 | 123 | 124 | def is_multi(k): 125 | return isinstance(k, list) and isinstance(k[0], (list, tuple, str)) 126 | 127 | 128 | def key_to_seq(k): 129 | if isinstance(k, str): 130 | return tuple(ord(c) for c in k) 131 | elif is_multi(k): 132 | result = [] 133 | for e in k: 134 | result.extend(key_to_seq(e)) 135 | return tuple(result) 136 | elif isinstance(k, (list, tuple)): 137 | return tuple(k) 138 | else: 139 | raise RuntimeError(k) 140 | 141 | 142 | def bind(mode, *keys, description=None, custom_bindings=None, custom_descriptions=None): 143 | used_bindings = custom_bindings if custom_bindings is not None else bindings 144 | used_descriptions = custom_descriptions if custom_descriptions is not None else descriptions 145 | 146 | def wrapper(fn): 147 | description_keys = [] 148 | for key in keys: 149 | if not is_multi(key): 150 | key = [key] 151 | sequences = [key_to_seq(k) for k in key] 152 | name = "".join(seq_to_name(seq) for seq in sequences) 153 | description_keys.append(name) 154 | description_keys = tuple(description_keys) 155 | used_descriptions[mode][description_keys] = description or fn.__name__ 156 | for k in keys: 157 | used_bindings[mode][key_to_seq(k)] = fn 158 | return fn 159 | 160 | return wrapper 161 | -------------------------------------------------------------------------------- /src/multiplex/keys_input.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import functools 3 | import os 4 | import sys 5 | import select 6 | import termios 7 | import tty 8 | 9 | from multiplex import keys as _keys 10 | 11 | initial_stdin_settings = None 12 | 13 | 14 | def setup(): 15 | global initial_stdin_settings 16 | initial_stdin_settings = termios.tcgetattr(sys.stdin) 17 | tty.setcbreak(sys.stdin.fileno()) 18 | 19 | 20 | def restore(): 21 | termios.tcsetattr(sys.stdin, termios.TCSADRAIN, initial_stdin_settings) 22 | 23 | 24 | def _has_data(): 25 | return select.select([sys.stdin], [], [], 0) == ([sys.stdin], [], []) 26 | 27 | 28 | class InputReader: 29 | def __init__(self, viewer, bindings): 30 | self.viewer = viewer 31 | self.bindings = bindings 32 | self.pending = [] 33 | 34 | async def read(self): 35 | while True: 36 | result = self._read_iteration() 37 | if result is not None: 38 | yield -1, result 39 | await asyncio.sleep(0.02) 40 | 41 | def _read_iteration(self): 42 | if not _has_data(): 43 | return None 44 | skip_process = False 45 | is_input = self.viewer.is_input_mode 46 | keys = [] 47 | while _has_data(): 48 | key = os.read(sys.stdin.fileno(), 1) 49 | key_ord = ord(key) 50 | if is_input: 51 | keys.append(key) 52 | if not is_input and key_ord in _keys.BACKSPACE_OR_DEL: 53 | self.pending = self.pending[:-1] 54 | skip_process = True 55 | break 56 | else: 57 | self.pending.append(key_ord) 58 | if not skip_process: 59 | result, pending = self._process(self.pending) 60 | self.pending = pending 61 | else: 62 | result = [] 63 | if is_input and keys: 64 | result.append(functools.partial(self._read_input_handler, keys)) 65 | return result 66 | 67 | @staticmethod 68 | async def _read_input_handler(keys, viewer): 69 | if not viewer.is_input_mode: 70 | return 71 | writer = viewer.focused.holder.iterator.metadata.get("input") 72 | if not writer: 73 | return 74 | writer.write(b"".join(keys)) 75 | await writer.drain() 76 | 77 | def _process(self, keys): 78 | viewer = self.viewer 79 | bindings = self.bindings 80 | if viewer.help.show: 81 | mode = _keys.HELP 82 | elif viewer.is_input_mode: 83 | mode = _keys.INPUT 84 | elif viewer.is_scrolling: 85 | mode = _keys.SCROLL 86 | else: 87 | mode = _keys.NORMAL 88 | sequences = [bindings[mode]] 89 | if mode != _keys.INPUT: 90 | sequences.append(bindings[_keys.GLOBAL]) 91 | 92 | result = [] 93 | while keys: 94 | has_pending = False 95 | found_sequence = False 96 | for mode_sequences in sequences: 97 | for key_sequence, fn in mode_sequences.items(): 98 | common_prefix = os.path.commonprefix([tuple(keys), key_sequence]) 99 | if common_prefix: 100 | if len(common_prefix) == len(key_sequence): 101 | result.append(fn) 102 | keys = keys[len(key_sequence) :] 103 | found_sequence = True 104 | break 105 | elif list(key_sequence)[: len(keys)] == keys: 106 | has_pending = True 107 | if has_pending: 108 | break 109 | if not found_sequence: 110 | keys = keys[1:] 111 | return result, keys 112 | -------------------------------------------------------------------------------- /src/multiplex/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import tempfile 4 | 5 | 6 | def init_logging(level=logging.INFO, log_path=None): 7 | formatter = logging.Formatter("{asctime} - {name:<20} - {levelname} - {message}", style="{") 8 | log_path = log_path or os.path.join(tempfile.gettempdir(), "multiplex.log") 9 | fh = logging.FileHandler(log_path) 10 | fh.setLevel(logging.DEBUG) 11 | fh.setFormatter(formatter) 12 | logger = logging.getLogger() 13 | for h in logger.handlers: 14 | logger.removeHandler(h) 15 | logger.setLevel(level) 16 | logger.addHandler(fh) 17 | -------------------------------------------------------------------------------- /src/multiplex/main.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | import sys 4 | from itertools import cycle 5 | 6 | import click 7 | from multiplex.exceptions import IPCException 8 | from multiplex.ipc import Client, get_env_stream_id 9 | from multiplex.iterator import MULTIPLEX_SOCKET_PATH 10 | from multiplex.multiplex import Multiplex 11 | 12 | 13 | async def ipc_mode(socket_path, process, title, box_height, wait, load): 14 | client = Client(socket_path) 15 | first = process[0] if not load else None 16 | env = os.environ.copy() 17 | cwd = os.getcwd() 18 | if load: 19 | await client.load(load) 20 | elif first == "@": 21 | stream_id = get_env_stream_id() 22 | title = title[0] or " ".join(process[1:]) 23 | await client.split(title, box_height[0], stream_id) 24 | elif first == "@save": 25 | await client.save() 26 | elif first == ":": 27 | await client.quit() 28 | elif first in {"/", "+", "-"}: 29 | value = None if first == "/" else True if first == "-" else False 30 | await client.toggle_collapse(value) 31 | elif len(process) == 1: 32 | await client.add(first, title[0], box_height[0], wait[0], cwd, env) 33 | else: 34 | actions = [] 35 | for p, t, h, w in zip(process, cycle(title), cycle(box_height), cycle(wait)): 36 | actions.append(client.add_request_body(p, title=t, box_height=h, wait=w, cwd=cwd, env=env)) 37 | await client.batch(actions) 38 | 39 | 40 | def direct_mode(process, title, verbose, box_height, auto_collapse, output_path, load, socket_path, buffer_lines): 41 | multiplex = Multiplex( 42 | verbose=verbose, 43 | box_height=box_height[0], 44 | auto_collapse=auto_collapse, 45 | output_path=output_path, 46 | socket_path=socket_path, 47 | buffer_lines=buffer_lines, 48 | ) 49 | for p, t, h in zip(process, cycle(title), cycle(box_height)): 50 | multiplex.add(p, title=t, box_height=h) 51 | multiplex.run(load) 52 | for iterator in multiplex.viewer.iterators: 53 | exit_code = iterator.metadata.get("exit_code") 54 | if exit_code: 55 | sys.exit(exit_code) 56 | 57 | 58 | def validate(box_height, process, socket_path, title, wait, load): 59 | if load: 60 | if not os.path.isdir(load): 61 | raise click.ClickException(f"No such dir: {load}") 62 | if not os.path.exists(os.path.join(load, "metadata.json")): 63 | raise click.ClickException(f"Invalid directory: {load}") 64 | return 65 | if not process: 66 | raise click.ClickException("At least one command is required") 67 | if title and len(title) != len(process): 68 | raise click.ClickException( 69 | f"Each process should have a title, but {len(title)} titles and {len(process)} processes were supplied" 70 | ) 71 | if wait and not socket_path: 72 | raise click.ClickException(f"wait can only be used when running inside a process started by multiplex") 73 | if len(wait) > 1 and len(wait) != len(process): 74 | raise click.ClickException( 75 | f"wait should be supplied either once to apply to all boxes or once for each process, " 76 | f"but {len(wait)} wait's and {len(process)} processes were supplied" 77 | ) 78 | if len(box_height) > 1 and len(box_height) != len(process): 79 | raise click.ClickException( 80 | f"Box height should be supplied either once to apply to all boxes or once for each process, " 81 | f"but {len(box_height)} box heights and {len(process)} processes were supplied" 82 | ) 83 | 84 | 85 | @click.command() 86 | @click.argument("process", nargs=-1) 87 | @click.option("-t", "--title", multiple=True) 88 | @click.option("-b", "--box-height", type=int, multiple=True) 89 | @click.option("--wait/--no-wait", "-w/-W", multiple=True, default=None) 90 | @click.option("-l", "--load") 91 | @click.option( 92 | "--buffer-lines", 93 | type=int, 94 | envvar="MULTIPLEX_BUFFER_LINES", 95 | help="By default, buffer length is unbounded. Use this to have a maximum number of lines for each " "buffer.", 96 | ) 97 | @click.option( 98 | "-a/-A", 99 | "--auto-collapse/--no-auto-collapse", 100 | type=bool, 101 | envvar="MULTIPLEX_AUTO_COLLAPSE", 102 | help="Collapse buffers automatically upon successful completion", 103 | ) 104 | @click.option( 105 | "-o", 106 | "--output-path", 107 | help="Root directory to use when saving output", 108 | default=os.getcwd(), 109 | envvar="MULTIPLEX_OUTPUT_PATH", 110 | ) 111 | @click.option( 112 | "-s", 113 | "--socket-path", 114 | envvar=MULTIPLEX_SOCKET_PATH, 115 | ) 116 | @click.option( 117 | "--server", 118 | is_flag=True, 119 | help="This should only be used when socket path is provided explicity to instantiate the server. " 120 | "Otherwise, the command is assumed to be executed in IPC mode", 121 | ) 122 | @click.help_option("-h", "--help") 123 | @click.version_option(None, "--version") 124 | @click.option("-v", "--verbose", is_flag=True) 125 | def main( 126 | process, title, verbose, box_height, auto_collapse, output_path, wait, load, socket_path, buffer_lines, server 127 | ): 128 | validate( 129 | box_height=box_height, 130 | process=process, 131 | socket_path=socket_path, 132 | title=title, 133 | wait=wait, 134 | load=load, 135 | ) 136 | 137 | title = title or [None] 138 | box_height = box_height or [None] 139 | wait = wait or [True] 140 | 141 | if socket_path and not server: 142 | try: 143 | asyncio.run( 144 | ipc_mode( 145 | socket_path=socket_path, 146 | process=process, 147 | title=title, 148 | box_height=box_height, 149 | wait=wait, 150 | load=load, 151 | ) 152 | ) 153 | except IPCException as e: 154 | raise click.ClickException(str(e)) 155 | else: 156 | direct_mode( 157 | process=process, 158 | title=title, 159 | verbose=verbose, 160 | box_height=box_height, 161 | auto_collapse=auto_collapse, 162 | output_path=output_path, 163 | load=load, 164 | socket_path=socket_path, 165 | buffer_lines=buffer_lines, 166 | ) 167 | 168 | 169 | if __name__ == "__main__": 170 | main() 171 | -------------------------------------------------------------------------------- /src/multiplex/multiplex.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | from typing import List 4 | 5 | from multiplex.viewer import Viewer 6 | from multiplex.iterator import Descriptor 7 | from multiplex.ipc import Server 8 | 9 | 10 | class Multiplex: 11 | def __init__( 12 | self, verbose=False, box_height=None, auto_collapse=False, output_path=None, socket_path=None, buffer_lines=None 13 | ): 14 | self.descriptors: List[Descriptor] = [] 15 | self.verbose = verbose 16 | self.box_height = box_height 17 | self.auto_collapse = auto_collapse 18 | self.buffer_lines = buffer_lines 19 | self.output_path = output_path or os.getcwd() 20 | self.server = Server(socket_path) 21 | self.viewer: Viewer = None 22 | 23 | def run(self, load=None): 24 | try: 25 | asyncio.run(self.run_async(load)) 26 | except KeyboardInterrupt: 27 | pass 28 | finally: 29 | self.cleanup() 30 | 31 | async def run_async(self, load=None): 32 | assert not self.viewer 33 | self.viewer = Viewer( 34 | descriptors=self.descriptors, 35 | verbose=self.verbose, 36 | box_height=self.box_height, 37 | auto_collapse=self.auto_collapse, 38 | socket_path=self.server.socket_path, 39 | output_path=self.output_path, 40 | buffer_lines=self.buffer_lines, 41 | ) 42 | if load: 43 | await self.viewer.load(load) 44 | await self.server.start(viewer=self.viewer) 45 | try: 46 | await self.viewer.run() 47 | finally: 48 | self.server.stop() 49 | 50 | def add(self, obj, title=None, box_height=None, thread_safe=False): 51 | descriptor = Descriptor(obj=obj, title=title, box_height=box_height, scroll_down=self.viewer is not None) 52 | self.descriptors.append(descriptor) 53 | if self.viewer: 54 | self.viewer.add(descriptor, thread_safe=thread_safe) 55 | 56 | def add_thread_safe(self, obj, title=None, box_height=None): 57 | self.add(obj, title, box_height, thread_safe=True) 58 | 59 | def cleanup(self): 60 | if self.viewer: 61 | if not self.viewer.stopped: 62 | self.viewer.restore() 63 | for it in self.viewer.iterators: 64 | close = it.metadata.get("close") 65 | if close: 66 | close() 67 | if self.server and not self.server.stopped: 68 | self.server.stop() 69 | 70 | @property 71 | def socket_path(self): 72 | return self.server.socket_path 73 | -------------------------------------------------------------------------------- /src/multiplex/process.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Union 3 | 4 | 5 | @dataclass 6 | class Process: 7 | cmd: Union[str, list, tuple] 8 | 9 | @property 10 | def title(self): 11 | return self.cmd if isinstance(self.cmd, str) else " ".join(self.cmd) 12 | -------------------------------------------------------------------------------- /src/multiplex/refs.py: -------------------------------------------------------------------------------- 1 | def obj(name): 2 | return type(name, (), {})() 3 | 4 | 5 | REDRAW = obj("REDRAW") 6 | RECALC = obj("RECALC") 7 | SPLIT = obj("SPLIT") 8 | STOP = obj("STOP") 9 | QUIT = obj("QUIT") 10 | ALL_DOWN = obj("ALL_DOWN") 11 | SAVE = obj("SAVE") 12 | OUTPUT_SAVED = obj("OUTPUT_SAVED") 13 | STREAM_DONE = obj("STREM_DONE") 14 | -------------------------------------------------------------------------------- /src/multiplex/resize.py: -------------------------------------------------------------------------------- 1 | import signal 2 | 3 | 4 | def setup(viewer_events, loop): 5 | def sigwinch(): 6 | viewer_events.send_redraw() 7 | 8 | loop.add_signal_handler(signal.SIGWINCH, sigwinch) 9 | 10 | 11 | def restore(loop): 12 | loop.remove_signal_handler(signal.SIGWINCH) 13 | -------------------------------------------------------------------------------- /src/multiplex/viewer.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import types 4 | import uuid 5 | from dataclasses import dataclass 6 | from typing import Any 7 | 8 | import aiostream 9 | 10 | from multiplex import ansi, to_iterator 11 | from multiplex import keys 12 | from multiplex import keys_input 13 | from multiplex import resize 14 | from multiplex import commands 15 | from multiplex.actions import BoxAction 16 | from multiplex.ansi import C, NONE 17 | from multiplex.box import BoxHolder 18 | from multiplex.enums import ViewLocation, BoxLine 19 | from multiplex.exceptions import EndViewer 20 | from multiplex.export import Export 21 | from multiplex.help import HelpViewState 22 | from multiplex.iterator import Descriptor 23 | from multiplex.refs import REDRAW, RECALC, SPLIT, QUIT, ALL_DOWN, OUTPUT_SAVED, SAVE, STREAM_DONE 24 | 25 | logger = logging.getLogger("multiplex.view") 26 | 27 | MIN_BOX_HEIGHT = 7 28 | 29 | 30 | class ViewerEvents: 31 | def __init__(self): 32 | self.queue = asyncio.Queue() 33 | 34 | async def receive(self): 35 | while True: 36 | yield await self.queue.get() 37 | self.queue.task_done() 38 | 39 | def send(self, message): 40 | self.queue.put_nowait(message) 41 | 42 | def send_redraw(self): 43 | self.queue.put_nowait((REDRAW, None)) 44 | 45 | def send_recalc(self, num_boxes): 46 | self.queue.put_nowait((RECALC, num_boxes)) 47 | 48 | def send_quit(self): 49 | self.queue.put_nowait((QUIT, None)) 50 | 51 | def send_all_down(self): 52 | self.queue.put_nowait((ALL_DOWN, None)) 53 | 54 | def send_save(self): 55 | self.queue.put_nowait((SAVE, None)) 56 | 57 | def send_output_saved(self): 58 | self.queue.put_nowait((OUTPUT_SAVED, None)) 59 | 60 | 61 | @dataclass 62 | class DescriptorQueueItem: 63 | descriptor: Descriptor 64 | redraw: bool 65 | num_boxes: int 66 | handle: Any = None 67 | 68 | 69 | class Viewer: 70 | def __init__(self, descriptors, box_height, auto_collapse, verbose, socket_path, output_path, buffer_lines): 71 | self.holders = [] 72 | self.stream_id_to_holder = {} 73 | self.holder_to_stream_id = {} 74 | self.loop = asyncio.get_event_loop() 75 | self.input_reader = keys_input.InputReader(viewer=self, bindings=keys.bindings) 76 | self.descriptors_queue: "asyncio.Queue[DescriptorQueueItem]" = asyncio.Queue() 77 | self.help = HelpViewState(self) 78 | self.events = ViewerEvents() 79 | self.export = Export(self) 80 | self.box_height = box_height 81 | self.auto_collapse = auto_collapse 82 | self.buffer_lines = buffer_lines 83 | self.verbose = verbose 84 | self.socket_path = socket_path 85 | self.output_path = output_path 86 | self.current_focused_box = 0 87 | self.current_view_line = 0 88 | self.maximized = False 89 | self.collaped_all = False 90 | self.wrapped_all = True 91 | self.cols = None 92 | self.lines = None 93 | self.stopped = False 94 | self.output_saved = False 95 | self.initial_add(descriptors) 96 | 97 | def initial_add(self, descriptors): 98 | for i, descriptor in enumerate(descriptors): 99 | redraw = i == 0 or i + 1 == len(descriptors) 100 | self.add(descriptor, redraw=redraw, num_boxes=len(descriptors)) 101 | 102 | def add(self, descriptor, thread_safe=False, redraw=True, num_boxes=None): 103 | handle = None 104 | if descriptor.wait: 105 | handle = asyncio.Future() 106 | 107 | def action(): 108 | self.descriptors_queue.put_nowait( 109 | DescriptorQueueItem( 110 | descriptor=descriptor, 111 | redraw=redraw, 112 | num_boxes=num_boxes, 113 | handle=handle, 114 | ) 115 | ) 116 | 117 | if thread_safe: 118 | self.loop.call_soon_threadsafe(action) 119 | else: 120 | action() 121 | 122 | return handle 123 | 124 | def split(self, title, box_height, stream_id): 125 | self.descriptors_queue.put_nowait( 126 | DescriptorQueueItem( 127 | descriptor=Descriptor(SPLIT, title, box_height, stream_id=stream_id), 128 | redraw=True, 129 | num_boxes=None, 130 | ) 131 | ) 132 | 133 | async def load(self, export_dir): 134 | await self.export.load(export_dir) 135 | 136 | def swap_indices(self, index1, index2): 137 | holder1 = self.get_holder(index1) 138 | holder2 = self.get_holder(index2) 139 | self.holders[index1] = holder2 140 | self.holders[index2] = holder1 141 | holder1.index = index2 142 | holder2.index = index1 143 | if self.current_focused_box == index1: 144 | self.current_focused_box = index2 145 | elif self.current_focused_box == index2: 146 | self.current_focused_box = index1 147 | 148 | @property 149 | def num_boxes(self): 150 | return len(self.holders) 151 | 152 | @property 153 | def iterators(self): 154 | return [h.iterator for h in self.holders] 155 | 156 | @property 157 | def buffers(self): 158 | return [h.buffer for h in self.holders] 159 | 160 | @property 161 | def states(self): 162 | return [h.state for h in self.holders] 163 | 164 | @property 165 | def boxes(self): 166 | return [h.box for h in self.holders] 167 | 168 | def get_holder(self, index): 169 | return self.holders[index] 170 | 171 | def get_buffer(self, index): 172 | return self.holders[index].buffer 173 | 174 | def get_state(self, index): 175 | return self.holders[index].state 176 | 177 | def get_iterator(self, index): 178 | return self.holders[index].iterator 179 | 180 | def get_box(self, index): 181 | return self.holders[index].box 182 | 183 | async def run(self): 184 | try: 185 | self._setup() 186 | await self._main() 187 | finally: 188 | self.restore() 189 | self.stopped = True 190 | 191 | def _setup(self): 192 | loop = asyncio.get_event_loop() 193 | keys_input.setup() 194 | resize_notifier = resize.setup(self.events, loop) 195 | ansi.setup() 196 | return resize_notifier 197 | 198 | @staticmethod 199 | def restore(): 200 | loop = asyncio.get_event_loop() 201 | keys_input.restore() 202 | resize.restore(loop) 203 | ansi.restore() 204 | 205 | async def _main(self): 206 | self._init() 207 | async with aiostream.stream.advanced.flatten(self._sources()).stream() as streamer: 208 | async for obj, output in streamer: 209 | try: 210 | await self._handle_event(obj, output) 211 | except EndViewer: 212 | return 213 | 214 | async def _sources(self): 215 | yield self.input_reader.read() 216 | yield self.events.receive() 217 | while True: 218 | item = await self.descriptors_queue.get() 219 | source = await self._process_descriptor(item) 220 | if source: 221 | yield source 222 | self.descriptors_queue.task_done() 223 | 224 | async def _process_descriptor(self, descriptor_queue_item): 225 | descriptor = descriptor_queue_item.descriptor 226 | redraw = descriptor_queue_item.redraw 227 | recalc_num_boxes = descriptor_queue_item.num_boxes 228 | handle = descriptor_queue_item.handle 229 | index = self.num_boxes 230 | stream_id = str(uuid.uuid4()) 231 | iterator = await to_iterator( 232 | obj=descriptor.obj, 233 | title=descriptor.title, 234 | context={ 235 | "handle": handle, 236 | "socket_path": self.socket_path, 237 | "stream_id": stream_id, 238 | "cwd": descriptor.cwd, 239 | "env": descriptor.env, 240 | }, 241 | ) 242 | box_height = descriptor.box_height or self.box_height 243 | holder = BoxHolder(index, iterator=iterator, box_height=box_height, viewer=self) 244 | state = holder.state 245 | if descriptor.wrap is not None: 246 | state.wrap = descriptor.wrap 247 | if descriptor.collapsed is not None: 248 | state.collapsed = descriptor.collapsed 249 | self.holders.append(holder) 250 | if redraw: 251 | self.events.send_redraw() 252 | else: 253 | self.events.send_recalc(recalc_num_boxes) 254 | if redraw and not self.is_scrolling and descriptor.scroll_down: 255 | self.events.send_all_down() 256 | if iterator.iterator is SPLIT: 257 | if descriptor.stream_id: 258 | previous_holder = self.stream_id_to_holder[descriptor.stream_id] 259 | else: 260 | previous_holder = self.get_holder(index - 1) 261 | previous_holder.state.stream_done = True 262 | stream_id = self.holder_to_stream_id.pop(id(previous_holder)) 263 | self.holder_to_stream_id[id(holder)] = stream_id 264 | self.stream_id_to_holder[stream_id] = holder 265 | if iterator.iterator is not SPLIT: 266 | return self._wrapped_iterator(stream_id, iterator.iterator) 267 | 268 | @staticmethod 269 | async def _wrapped_iterator(stream_id, iterator): 270 | async for elem in iterator: 271 | yield stream_id, elem 272 | yield stream_id, STREAM_DONE 273 | 274 | def _init(self): 275 | changed_cols, _ = self._update_lines_cols() 276 | if not self.holders: 277 | return 278 | ansi.clear() 279 | self._update_holders(changed_cols=changed_cols) 280 | self._update_view() 281 | ansi.flush() 282 | 283 | def _update_holders(self, num_boxes=None, changed_cols=None): 284 | num_boxes = num_boxes or self.num_boxes 285 | default_box_height = max(MIN_BOX_HEIGHT, (self.lines - num_boxes - 1) // num_boxes) 286 | for holder in self.holders: 287 | if changed_cols: 288 | holder.box.set_width(self.cols) 289 | if not holder.state.changed_height: 290 | holder.state.box_height = default_box_height 291 | 292 | def _update_lines_cols(self): 293 | cols, lines = ansi.get_size() 294 | prev_cols = self.cols 295 | prev_lines = self.lines 296 | self.cols = cols 297 | self.lines = lines 298 | changed_cols = prev_cols != self.cols 299 | changed_lines = prev_lines != self.lines 300 | if changed_cols or changed_lines: 301 | logger.debug(f"sizes: prev [{prev_lines}, {prev_cols}], new [{self.lines}, {self.cols}]") 302 | return changed_cols, changed_lines 303 | 304 | async def _handle_event(self, obj, output): 305 | if obj is REDRAW: 306 | self._init() 307 | return 308 | if obj is RECALC: 309 | self._update_holders(output) 310 | return 311 | if obj is QUIT: 312 | raise EndViewer 313 | 314 | if obj is SAVE: 315 | await commands.save(self) 316 | self._update_status_bar() 317 | elif obj is OUTPUT_SAVED: 318 | await asyncio.sleep(0.1) 319 | self.output_saved = False 320 | self._update_status_bar() 321 | elif obj is ALL_DOWN: 322 | commands.all_down(self) 323 | ansi.clear() 324 | self._update_view() 325 | elif isinstance(obj, str): 326 | holder = self.stream_id_to_holder[obj] 327 | self._update_box(holder.index, output) 328 | if isinstance(output, BoxAction) or callable(output) or output is STREAM_DONE: 329 | ansi.clear() 330 | self._update_view() 331 | if self.is_input_mode: 332 | self._update_cursor() 333 | else: 334 | key_changed = False 335 | boxes_changed = set() 336 | full_refresh = False 337 | for fn in output: 338 | current_key_changed = await self._process_key_handler(fn) 339 | if current_key_changed is ansi.FULL_REFRESH: 340 | full_refresh = True 341 | key_changed = True 342 | elif type(current_key_changed) == int: 343 | boxes_changed.add(current_key_changed) 344 | else: 345 | key_changed = key_changed or current_key_changed 346 | if full_refresh: 347 | ansi.clear() 348 | if key_changed: 349 | self._update_view() 350 | elif boxes_changed: 351 | self._update_status_bar() 352 | for index in boxes_changed: 353 | self._update_box(index, data=None) 354 | elif not self.is_input_mode: 355 | self._update_status_bar() 356 | ansi.flush() 357 | 358 | def _update_box(self, i, data): 359 | if data is not None: 360 | holder = self.get_holder(i) 361 | if isinstance(data, BoxAction): 362 | data.run(holder) 363 | elif callable(data): 364 | data(holder) 365 | elif data is STREAM_DONE: 366 | holder.state.stream_done = True 367 | if self.auto_collapse and not holder.iterator.metadata.get("exit_code"): 368 | holder.box.toggle_collapse(value=True) 369 | holder.box.exit_input_mode() 370 | else: 371 | holder.buffer.write(data) 372 | if self.help.show: 373 | return 374 | self._update_title_line(i) 375 | box = self.get_box(i) 376 | if box.is_visible: 377 | box.update() 378 | 379 | async def _process_key_handler(self, fn): 380 | prev_current_line = self.current_view_line 381 | prev_focused_box = self.current_focused_box 382 | update_view = fn(self) 383 | if isinstance(update_view, types.CoroutineType): 384 | update_view = await update_view 385 | if update_view is not None and not isinstance(update_view, bool): 386 | return update_view 387 | if prev_focused_box != self.current_focused_box: 388 | return ansi.FULL_REFRESH 389 | if prev_current_line != self.current_view_line: 390 | return ansi.FULL_REFRESH 391 | return update_view 392 | 393 | def _update_view(self): 394 | if self.help.show: 395 | ansi.help_screen( 396 | current_line=self.help.current_line, 397 | lines=self.lines, 398 | cols=self.cols - 1, 399 | descriptions=keys.descriptions, 400 | ) 401 | else: 402 | self._update_status_bar() 403 | self._update_title_lines() 404 | self._update_boxes() 405 | self._update_cursor() 406 | 407 | def _update_boxes(self): 408 | if self.maximized: 409 | self._update_box(self.current_focused_box, data=None) 410 | else: 411 | for i in range(self.num_boxes): 412 | self._update_box(i, data=None) 413 | 414 | def _update_title_lines(self): 415 | if self.maximized: 416 | self._update_title_line(self.current_focused_box) 417 | else: 418 | for i in range(self.num_boxes): 419 | self._update_title_line(i) 420 | 421 | def _update_title_line(self, index): 422 | screen_y, location = self.get_title_line(index) 423 | if location != ViewLocation.IN_VIEW: 424 | return 425 | 426 | holder = self.get_holder(index) 427 | iterator = holder.iterator 428 | box_state = holder.state 429 | suffix = "" 430 | if self.verbose: 431 | wrap = box_state.wrap 432 | auto_scroll = box_state.auto_scroll 433 | collapsed = box_state.collapsed 434 | input_mode = box_state.input_mode 435 | buffer_line = box_state.buffer_start_line 436 | box_height = box_state.box_height 437 | state = [ 438 | "W" if wrap else "-", 439 | "-" if auto_scroll else "S", 440 | "C" if collapsed else "-", 441 | "I" if input_mode else "-", 442 | ] 443 | state = f"{''.join(state)} [{buffer_line},{box_height}]" 444 | suffix = f" [{state}]" 445 | suffix_len = len(suffix) 446 | title = iterator.title 447 | if not isinstance(title, C): 448 | title = C(title) 449 | title_len = len(title) 450 | hr_space = 4 451 | _ellipsis = "..." 452 | if hr_space + title_len + suffix_len > self.cols: 453 | title = title[: self.cols - suffix_len - len(_ellipsis) - hr_space] 454 | title += _ellipsis 455 | text = C(" ", title, suffix, " ", color=(NONE, NONE)) 456 | 457 | logger.debug(f"s{index}:\t{screen_y}\t{location}\t[{self.lines},{self.cols}]") 458 | 459 | if index == self.current_focused_box: 460 | hline_color = ansi.theme.TITLE_FOCUS 461 | elif box_state.stream_done: 462 | hline_color = ansi.theme.TITLE_STREAM_DONE 463 | else: 464 | hline_color = ansi.theme.TITLE_NORMAL 465 | ansi.title( 466 | row=screen_y, 467 | text=text, 468 | hline_color=hline_color, 469 | cols=self.cols, 470 | ) 471 | 472 | def _update_status_bar(self): 473 | if self.help.show: 474 | return 475 | 476 | focused = self.focused 477 | index = focused.index 478 | iterator = self.get_iterator(index) 479 | title = iterator.title 480 | box_state = focused.state 481 | wrap = box_state.wrap 482 | auto_scroll = box_state.auto_scroll 483 | input_mode = box_state.input_mode 484 | 485 | modes = [] 486 | if not auto_scroll: 487 | modes.append("SCROLL") 488 | if self.maximized: 489 | modes.append("MAXIMIZED") 490 | if wrap: 491 | modes.append("WRAP") 492 | if input_mode: 493 | modes.append("INPUT") 494 | mode = "|".join(modes) 495 | mode_paren = f"({mode})" if mode else "" 496 | 497 | pending_keys = self.input_reader.pending 498 | pending_name_parts = [] 499 | while pending_keys: 500 | cur_offset = len(pending_keys) 501 | name = None 502 | while not name and cur_offset: 503 | name = keys.seq_to_name(pending_keys[:cur_offset], fallback=False) 504 | if not name: 505 | cur_offset -= 1 506 | pending_name_parts.append(name) 507 | pending_keys = pending_keys[cur_offset:] 508 | 509 | pending_text = "".join(pending_name_parts) 510 | if pending_text: 511 | pending_text = f"{pending_text} " 512 | 513 | prefix = f"[{index + 1}] " 514 | prefix_len = len(prefix) 515 | 516 | if not isinstance(title, C): 517 | title = C(title) 518 | title_len = len(title) 519 | mode_len = len(mode_paren) 520 | pending_len = len(pending_text) 521 | space_between = self.cols - title_len - mode_len - pending_len - prefix_len 522 | if space_between < 0: 523 | _ellipsis = "... " 524 | title = title[: (self.cols - mode_len - pending_len - prefix_len) - len(_ellipsis)] 525 | title += _ellipsis 526 | space_between = 0 527 | if self.output_saved: 528 | color = ansi.theme.STATUS_SAVE 529 | elif input_mode: 530 | color = ansi.theme.STATUS_INPUT 531 | elif not auto_scroll: 532 | color = ansi.theme.STATUS_SCROLL 533 | else: 534 | color = ansi.theme.STATUS_NORMAL 535 | text = C(prefix, title, " " * space_between, pending_text, mode_paren, color=color) 536 | 537 | ansi.status_bar( 538 | row=self.get_status_bar_line(), 539 | text=text, 540 | ) 541 | 542 | def _update_cursor(self): 543 | if not self.is_input_mode: 544 | return 545 | box = self.focused 546 | screen_y, view_location = self.get_box_top_line(box.index) 547 | if view_location != ViewLocation.IN_VIEW: 548 | return 549 | col, row = box.buffer.get_cursor(box.state.wrap) 550 | row += screen_y 551 | row -= box.state.buffer_start_line 552 | if row < 0 or col < 0: 553 | return 554 | ansi.move_cursor(col, row) 555 | 556 | def verify_focused_box_in_view(self): 557 | if self.maximized: 558 | return 559 | screen_y, location = self.get_box_bottom_line(self.current_focused_box) 560 | if location == ViewLocation.BELOW: 561 | offset = screen_y 562 | self.current_view_line += offset 563 | return 564 | screen_y, location = self.get_title_line(self.current_focused_box) 565 | if location == ViewLocation.ABOVE: 566 | offset = screen_y 567 | self.current_view_line -= offset 568 | elif location == ViewLocation.BELOW: 569 | offset = screen_y 570 | self.current_view_line += offset 571 | 572 | @property 573 | def focused(self): 574 | return self.get_box(self.current_focused_box) 575 | 576 | @property 577 | def is_scrolling(self): 578 | return not self.focused.state.auto_scroll 579 | 580 | @property 581 | def is_input_mode(self): 582 | return self.focused.state.input_mode 583 | 584 | @property 585 | def max_current_line(self): 586 | result = 0 587 | for state in self.states: 588 | result += 1 589 | if not state.collapsed: 590 | result += state.box_height 591 | result -= self.get_max_box_line() 592 | return max(0, result) 593 | 594 | def get_status_bar_line(self): 595 | return self.lines - 1 596 | 597 | def get_max_box_line(self): 598 | return self.lines - 2 599 | 600 | def get_title_line(self, index): 601 | return self._get_box_line(index, BoxLine.TITLE) 602 | 603 | def get_box_top_line(self, index): 604 | return self._get_box_line(index, BoxLine.TOP) 605 | 606 | def get_box_bottom_line(self, index): 607 | return self._get_box_line(index, BoxLine.BOTTOM) 608 | 609 | def _get_box_line(self, index, box_line): 610 | if self.maximized: 611 | if index != self.current_focused_box: 612 | return 0, ViewLocation.NOT_FOCUSED 613 | if box_line == BoxLine.TITLE: 614 | return 0, ViewLocation.NOT_FOCUSED 615 | elif box_line == BoxLine.TOP: 616 | screen_y = 0 617 | elif box_line == BoxLine.BOTTOM: 618 | screen_y = self.get_max_box_line() 619 | else: 620 | raise RuntimeError(box_line) 621 | return screen_y, ViewLocation.IN_VIEW 622 | else: 623 | state = self.get_state(index) 624 | if state.collapsed and box_line != BoxLine.TITLE: 625 | return 0, ViewLocation.NOT_FOCUSED 626 | view_y = 0 627 | for i in range(index): 628 | # title line 629 | view_y += 1 630 | other_state = self.states[i] 631 | if not other_state.collapsed: 632 | view_y += other_state.box_height 633 | if box_line == BoxLine.TOP: 634 | view_y += 1 635 | elif box_line == BoxLine.BOTTOM: 636 | view_y += state.box_height 637 | screen_y = view_y - self.current_view_line 638 | max_line = self.get_max_box_line() 639 | if screen_y > max_line: 640 | offest = screen_y - max_line 641 | return offest, ViewLocation.BELOW 642 | elif screen_y < 0: 643 | offset = abs(screen_y) 644 | return offset, ViewLocation.ABOVE 645 | else: 646 | return screen_y, ViewLocation.IN_VIEW 647 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dankilman/multiplex/25a47d52e7799cb990fe60d995cf5e8de6fa1d37/tests/__init__.py -------------------------------------------------------------------------------- /tests/kitchen.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import asyncio 3 | import io 4 | import random 5 | import threading 6 | import time 7 | 8 | import colors 9 | from colors.colors import _color_code as cc 10 | 11 | from multiplex import ansi, Process 12 | from multiplex import Multiplex, Controller 13 | from multiplex.log import init_logging 14 | 15 | 16 | def run_simple(): 17 | async def text_generator(index): 18 | for i in range(num_iterations): 19 | output = f"iterator-number-#{index + 1}-({i + 1})|" * 7 20 | output = f"{output}\n" 21 | yield output 22 | await asyncio.sleep(random.random() / 10) 23 | await asyncio.sleep(0.5) 24 | yield "done" 25 | 26 | num_iterations = 2000 27 | num_iterators = 20 28 | iterators = [(text_generator(i), f"It #{i + 1}") for i in range(num_iterators)] 29 | return iterators 30 | 31 | 32 | def run_colors(): 33 | async def text_generator(): 34 | for i in range(num_iterations): 35 | r = random.randint(0, 256) 36 | g = random.randint(0, 256) 37 | b = random.randint(0, 256) 38 | output = "".join(f'{colors.color(f"hello-{i}-", (r, g, b))}' for i in range(30)) 39 | output = f"{output}\n" 40 | yield output 41 | await asyncio.sleep(random.random() / 10) 42 | yield "done" 43 | 44 | num_iterations = 2000 45 | num_iterators = 3 46 | iterators = [(text_generator, f"It #{i + 1}") for i in range(num_iterators)] 47 | return iterators 48 | 49 | 50 | def run_dynamic(): 51 | async def text_generator(): 52 | for i in range(num_iterations): 53 | for j in range(10000): 54 | yield "hello" 55 | await asyncio.sleep(0.1) 56 | yield "\rgoodbye" 57 | await asyncio.sleep(0.1) 58 | yield "\r" + ansi.CLEAR_LINE 59 | yield "\n" 60 | await asyncio.sleep(random.random() / 10) 61 | await asyncio.sleep(0.5) 62 | yield "done" 63 | 64 | num_iterations = 2000 65 | num_iterators = 3 66 | iterators = [(text_generator, f"It #{i + 1}") for i in range(num_iterators)] 67 | return iterators 68 | 69 | 70 | def run_style(): 71 | async def text_generator(): 72 | for i in range(num_iterations): 73 | 74 | fr = random.randint(0, 256) 75 | fg = random.randint(0, 256) 76 | fb = random.randint(0, 256) 77 | 78 | br = random.randint(0, 256) 79 | bg = random.randint(0, 256) 80 | bb = random.randint(0, 256) 81 | 82 | def code(*codes): 83 | return f'{ansi.CSI}{";".join(str(c) for c in codes)}m' 84 | 85 | reset = code(0) 86 | 87 | text_buffer = io.StringIO() 88 | for j in range(100): 89 | text_buffer.write(code(cc("red", 30))) 90 | text_buffer.write(code(cc("green", 40))) 91 | text_buffer.write(f"some text {j} ") 92 | text_buffer.write(code(cc((fr, fg, fb), 30))) 93 | text_buffer.write(code(cc((br, bg, bb), 40))) 94 | text_buffer.write(f"some text {j} ") 95 | text_buffer.write(code(3, 4, 7)) 96 | text_buffer.write(f"some text {j} ") 97 | text_buffer.write(code(24, 9, 1)) 98 | text_buffer.write(f"some text {j} ") 99 | text_buffer.write(reset) 100 | output = text_buffer.getvalue() 101 | output = f"{output}\n" 102 | yield output 103 | await asyncio.sleep(random.random() / 10) 104 | await asyncio.sleep(0.5) 105 | yield "done" 106 | 107 | num_iterations = 2000 108 | num_iterators = 3 109 | iterators = [(text_generator, f"It #{i + 1}") for i in range(num_iterators)] 110 | return iterators 111 | 112 | 113 | def run_processes(): 114 | return ["gls -la --group-directories-first --color=always"] 115 | 116 | 117 | def run_process_desc(): 118 | return [ 119 | Process("gls"), 120 | Process("gls -la --group-directories-first --color=always".split(" ")), 121 | Process("gls -la".split(" ")), 122 | ] 123 | 124 | 125 | def run_controller(): 126 | multplex = Multiplex() 127 | c1 = Controller("runner1") 128 | c2 = Controller("runner2") 129 | multplex.add(c1) 130 | multplex.add(c2) 131 | 132 | async def runner(c): 133 | c.write("some data 1\n") 134 | await asyncio.sleep(1) 135 | c.write("some data 2\n") 136 | await asyncio.sleep(1) 137 | c.write("some data 2\n") 138 | await asyncio.sleep(1) 139 | c.write("some data 2\n") 140 | c.set_title(f"{c.title} [done]") 141 | c.collapse() 142 | 143 | run(multplex, runner(c1), runner(c2)) 144 | 145 | 146 | def run_controller_thread_safe(): 147 | multiplex = Multiplex() 148 | c1 = Controller("runner1", thread_safe=True) 149 | c2 = Controller("runner2", thread_safe=True) 150 | multiplex.add(c1) 151 | multiplex.add(c2) 152 | 153 | def runner(c): 154 | c.write("some data 1\n") 155 | time.sleep(1) 156 | c.write("some data 2\n") 157 | time.sleep(1) 158 | c.write("some data 2\n") 159 | time.sleep(1) 160 | c.write("some data 2\n") 161 | c.set_title(f"{c.title} [done]") 162 | c.collapse() 163 | 164 | threads = [threading.Thread(target=runner, args=(c,)) for c in [c1, c2]] 165 | for t in threads: 166 | t.daemon = True 167 | t.start() 168 | multiplex.run() 169 | 170 | 171 | def run_live(): 172 | obj = "echo $RANDOM; sleep 5; echo $RANDOM" 173 | 174 | multi = Multiplex(box_height=3) 175 | 176 | async def runner(): 177 | while not multi.viewer or not multi.viewer.stopped: 178 | multi.add(obj) 179 | await asyncio.sleep(0.1) 180 | 181 | run(multi, runner()) 182 | 183 | 184 | def run_live_thread_safe(): 185 | obj = "echo $RANDOM; sleep 5; echo $RANDOM" 186 | 187 | multi = Multiplex(box_height=3) 188 | 189 | def runner(_): 190 | while not multi.viewer or not multi.viewer.stopped: 191 | multi.add_thread_safe(obj) 192 | time.sleep(1) 193 | 194 | threads = [threading.Thread(target=runner, args=(c,)) for c in [1]] 195 | for t in threads: 196 | t.daemon = True 197 | t.start() 198 | multi.run() 199 | 200 | 201 | def run_multiline(): 202 | from easyansi import cursor, screen 203 | 204 | m = Multiplex(verbose=True) 205 | 206 | async def lines(): 207 | yield "one\n" 208 | yield "two\n" 209 | await asyncio.sleep(0.3) 210 | yield "three" 211 | yield screen.clear_line_code(2) 212 | yield "ten" 213 | yield cursor.up_code(1) 214 | yield cursor.locate_column_code(4) 215 | yield "new two" 216 | yield cursor.up_code(1) 217 | yield cursor.locate_column_code(0) 218 | yield "n\n" 219 | yield "b\n" 220 | yield "c\n" 221 | yield "r\r\r\r\r\r\n" 222 | 223 | m.add(lines) 224 | m.run() 225 | 226 | 227 | whats = { 228 | "1": run_simple, 229 | "2": run_processes, 230 | "3": run_colors, 231 | "4": run_dynamic, 232 | "5": run_style, 233 | "6": run_controller, 234 | "7": run_controller_thread_safe, 235 | "8": run_live, 236 | "9": run_live_thread_safe, 237 | "10": run_multiline, 238 | "11": run_process_desc, 239 | } 240 | 241 | 242 | def parse_args(): 243 | parser = argparse.ArgumentParser() 244 | parser.add_argument("what") 245 | return parser.parse_args() 246 | 247 | 248 | def run(multi, *other): 249 | async def cor(): 250 | return await asyncio.gather(multi.run_async(), *other) 251 | 252 | try: 253 | asyncio.run(cor()) 254 | except KeyboardInterrupt: 255 | pass 256 | finally: 257 | multi.cleanup() 258 | 259 | 260 | def main(): 261 | init_logging() 262 | fn = whats.get(parse_args().what, run_simple) 263 | result = fn() 264 | if isinstance(result, list): 265 | multi = Multiplex(verbose=True) 266 | for i in result: 267 | title = None 268 | if isinstance(i, tuple): 269 | i, title = i 270 | multi.add(i, title) 271 | multi.run() 272 | 273 | 274 | if __name__ == "__main__": 275 | main() 276 | -------------------------------------------------------------------------------- /tests/resources/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | DIR = os.path.dirname(__file__) 4 | -------------------------------------------------------------------------------- /tests/resources/test_decode1.log: -------------------------------------------------------------------------------- 1 | (B)0[?1049h[?1h=[?1000h[?1h=         ┌──────────────────────────────────────────────────────────┐  │ │   │ │   ├──────────────────────────────────────────────────────────┤   │  < No > │   └──────────────────────────────────────────────────────────┘               [1@ Do you have python3-multiplex > 0.6 installed and wantto use it?<Yes, use multiplex> < No >[?1000l[?1049l [?1l>(B)0[?1049h[?1h=[?1000h[?1h=       ┌────────────────────────────┐  │ use_multiplex = False │   │ │   │ │   │ │   │ │   │ │   │ │   │ │   └────────────────────────────┘             [1@ [?1000l[?1049l [?1l>(B)0[?1049h[?1h=[?1000h[?1h=[?1h=   ┌────────────────────────────────────────────────────────────────────────────┐ │ Please choose operation to run on inserted USB flash drives: │ │ ┌────────────────────────────────────────────────────────────────────────┐ │ │ │ INFO run live-stick-info │ │ │ │ SCRIPT Execute script with preset parameters │ │ │ │ BMAPTOOL bmaptool copy an image file │ │ │ │ HOTFIX Hotfix stick issues post-iso │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └────────────────────────────────────────────────────────────────────────┘ │ ├────────────────────────────────────────────────────────────────────────────┤ │ │ └────────────────────────────────────────────────────────────────────────────┘        [1@ < OK ><Cancel>[?1000l[?1049l -------------------------------------------------------------------------------- /tests/test_buffer.py: -------------------------------------------------------------------------------- 1 | from multiplex.buffer import Buffer 2 | 3 | 4 | def test_buffer_kitchen(): 5 | width = 5 6 | buffer = Buffer(width) 7 | assert buffer.width == width 8 | text = "123456789" 9 | buffer.write(text) 10 | assert buffer.get_max_line(wrap=False) == 0 11 | assert buffer.get_max_line(wrap=True) == 1 12 | assert buffer.get_lines(1, 0, width, 0, wrap=False) == [(9, "12345")] 13 | assert buffer.get_lines(1, 0, width, 0, wrap=True) == [(5, "12345")] 14 | assert buffer.get_lines(2, 0, width, 0, wrap=False) == [(9, "12345"), (0, " " * 5)] 15 | assert buffer.get_lines(2, 0, width, 0, wrap=True) == [(5, "12345"), (4, "6789 ")] 16 | assert buffer.get_lines(1, 1, width, 0, wrap=True) == [(4, "6789 ")] 17 | assert buffer.get_lines(1, 0, width, 3, wrap=False) == [(9, "45678")] 18 | 19 | width = 3 20 | buffer.width = width 21 | assert buffer.get_max_line(wrap=False) == 0 22 | assert buffer.get_max_line(wrap=True) == 2 23 | assert buffer.get_lines(1, 0, width, 0, wrap=False) == [(9, "123")] 24 | assert buffer.get_lines(3, 0, width, 0, wrap=True) == [(3, "123"), (3, "456"), (3, "789")] 25 | 26 | assert buffer.raw_buffer.getvalue() == text 27 | 28 | buffer.write("\nabcd") 29 | assert buffer.convert_line_number(0, from_wrapped=False) == 0 30 | assert buffer.convert_line_number(1, from_wrapped=False) == 3 31 | assert buffer.convert_line_number(0, from_wrapped=True) == 0 32 | assert buffer.convert_line_number(2, from_wrapped=True) == 0 33 | assert buffer.convert_line_number(3, from_wrapped=True) == 1 34 | assert buffer.convert_line_number(4, from_wrapped=True) == 1 35 | -------------------------------------------------------------------------------- /tests/test_iterator.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os.path 3 | 4 | from aiostream import streamcontext 5 | from aiostream.test_utils import assert_aiter 6 | import pytest 7 | 8 | from multiplex import iterator as _iterator 9 | from multiplex.controller import Controller 10 | from multiplex.iterator import to_iterator, STOP 11 | from tests import resources 12 | 13 | pytestmark = pytest.mark.asyncio 14 | 15 | ACTION = object() 16 | 17 | 18 | async def collect(source): 19 | result = [] 20 | async with streamcontext(source) as streamer: 21 | async for item in streamer: 22 | result.append(item) 23 | return result 24 | 25 | 26 | @pytest.fixture 27 | def patch_actions(monkeypatch): 28 | monkeypatch.setattr(_iterator, "BoxActions", lambda *_, **__: ACTION) 29 | monkeypatch.setattr(_iterator, "UpdateMetadata", lambda *_, **__: ACTION) 30 | 31 | 32 | async def test_async_generator_input(): 33 | async def g(): 34 | for i in range(1): 35 | yield i 36 | 37 | iterator = await to_iterator(g()) 38 | assert iterator.title == "g" 39 | assert iterator.inner_type == "async_generator" 40 | await assert_aiter(iterator.iterator, [0]) 41 | 42 | 43 | async def test_generator_input(): 44 | def g(): 45 | for i in range(1): 46 | yield i 47 | 48 | iterator = await to_iterator(g()) 49 | assert iterator.title == "g" 50 | assert iterator.inner_type == "generator" 51 | await assert_aiter(iterator.iterator, [0]) 52 | 53 | 54 | async def test_async_process_pipe_stdout(patch_actions): 55 | p = await asyncio.subprocess.create_subprocess_shell( 56 | cmd="printf 1; sleep 0.01; printf 2", 57 | stdout=asyncio.subprocess.PIPE, 58 | ) 59 | iterator = await to_iterator(p) 60 | assert iterator.title == "Process" 61 | assert iterator.inner_type == "async_process" 62 | await assert_aiter(iterator.iterator, ["1", "2", ACTION]) 63 | 64 | 65 | async def test_async_process_pipe_stderr(patch_actions): 66 | p = await asyncio.subprocess.create_subprocess_shell( 67 | cmd="printf 1 1>&2", 68 | stderr=asyncio.subprocess.PIPE, 69 | ) 70 | iterator = await to_iterator(p) 71 | assert iterator.title == "Process" 72 | assert iterator.inner_type == "async_process" 73 | await assert_aiter(iterator.iterator, ["1", ACTION]) 74 | 75 | 76 | async def test_async_process_pipe_stdout_stederr(patch_actions): 77 | p = await asyncio.subprocess.create_subprocess_shell( 78 | cmd=""" 79 | printf 1 1>&2 && \ 80 | sleep 0.01 && \ 81 | printf 2 && \ 82 | sleep 0.01 && \ 83 | printf 3 1>&2 && \ 84 | sleep 0.01 && \ 85 | printf 4 86 | """, 87 | stdout=asyncio.subprocess.PIPE, 88 | stderr=asyncio.subprocess.PIPE, 89 | ) 90 | iterator = await to_iterator(p) 91 | assert iterator.title == "Process" 92 | assert iterator.inner_type == "async_process" 93 | await assert_aiter(iterator.iterator, ["1", "2", "3", "4", ACTION]) 94 | 95 | 96 | async def test_async_process_pipe_backslash_r(patch_actions): 97 | p = await asyncio.subprocess.create_subprocess_shell( 98 | cmd=f"printf hello; sleep 0.01; printf \r; sleep 0.01; echo goodbye", 99 | stdout=asyncio.subprocess.PIPE, 100 | ) 101 | iterator = await to_iterator(p) 102 | assert iterator.title == "Process" 103 | assert iterator.inner_type == "async_process" 104 | await assert_aiter( 105 | iterator.iterator, 106 | [ 107 | "hello", 108 | "\r", 109 | "goodbye\n", 110 | ACTION, 111 | ], 112 | ) 113 | 114 | 115 | async def test_async_stream_reader(): 116 | p = await asyncio.subprocess.create_subprocess_shell( 117 | cmd="printf 1; sleep 0.01; printf 2", 118 | stdout=asyncio.subprocess.PIPE, 119 | ) 120 | iterator = await to_iterator(p.stdout) 121 | assert iterator.title == "StreamReader" 122 | assert iterator.inner_type == "stream_reader" 123 | await assert_aiter(iterator.iterator, ["1", "2"]) 124 | 125 | 126 | async def test_async_iter(): 127 | class It: 128 | def __init__(self, values): 129 | self.current_index = 0 130 | self.values = values 131 | 132 | def __aiter__(self): 133 | return self 134 | 135 | async def __anext__(self): 136 | if self.current_index < len(self.values): 137 | index = self.current_index 138 | self.current_index += 1 139 | return self.values[index] 140 | raise StopAsyncIteration 141 | 142 | vals = [1, 2, 3] 143 | iterator = await to_iterator(It(vals)) 144 | assert iterator.title == "It" 145 | assert iterator.inner_type == "async_iterable" 146 | await assert_aiter(iterator.iterator, vals) 147 | 148 | 149 | async def test_iterable(): 150 | class It: 151 | def __init__(self, values): 152 | self.current_index = 0 153 | self.values = values 154 | 155 | def __iter__(self): 156 | return self 157 | 158 | def __next__(self): 159 | if self.current_index < len(self.values): 160 | index = self.current_index 161 | self.current_index += 1 162 | return self.values[index] 163 | raise StopIteration 164 | 165 | vals = [1, 2, 3] 166 | iterator = await to_iterator(It(vals)) 167 | assert iterator.title == "It" 168 | assert iterator.inner_type == "iterable" 169 | await assert_aiter(iterator.iterator, vals) 170 | 171 | 172 | async def test_coroutine(patch_actions): 173 | c = asyncio.subprocess.create_subprocess_shell( 174 | cmd="printf hello", 175 | stdout=asyncio.subprocess.PIPE, 176 | ) 177 | iterator = await to_iterator(c) 178 | assert iterator.title == "Process" 179 | assert iterator.inner_type == "async_process" 180 | await assert_aiter(iterator.iterator, ["hello", ACTION]) 181 | 182 | 183 | async def test_function(): 184 | async def fn(): 185 | yield "1" 186 | 187 | iterator = await to_iterator(fn) 188 | assert iterator.inner_type == "async_generator" 189 | assert iterator.title == "fn" 190 | await assert_aiter(iterator.iterator, ["1"]) 191 | 192 | 193 | async def test_method(): 194 | class C: 195 | val1 = "1" 196 | val2 = "2" 197 | 198 | async def fn1(self): 199 | yield self.val1 200 | 201 | @classmethod 202 | async def fn2(cls): 203 | yield cls.val2 204 | 205 | @staticmethod 206 | async def fn3(): 207 | yield "3" 208 | 209 | c = C() 210 | iterator = await to_iterator(c.fn1) 211 | assert iterator.title == "fn1" 212 | assert iterator.inner_type == "async_generator" 213 | await assert_aiter(iterator.iterator, ["1"]) 214 | 215 | iterator = await to_iterator(c.fn2) 216 | assert iterator.title == "fn2" 217 | assert iterator.inner_type == "async_generator" 218 | await assert_aiter(iterator.iterator, ["2"]) 219 | 220 | iterator = await to_iterator(c.fn3) 221 | assert iterator.title == "fn3" 222 | assert iterator.inner_type == "async_generator" 223 | await assert_aiter(iterator.iterator, ["3"]) 224 | 225 | 226 | async def test_path(tmp_path): 227 | path = tmp_path / "mock.txt" 228 | text = "hello\ngoodbye" 229 | path.write_text(text) 230 | iterator = await to_iterator(path) 231 | assert iterator.inner_type == "path" 232 | assert iterator.title == str(path) 233 | await assert_aiter(iterator.iterator, [text]) 234 | 235 | 236 | async def test_str_process(patch_actions): 237 | cmd = "printf hello" 238 | iterator = await to_iterator(cmd) 239 | assert iterator.inner_type == "async_process" 240 | assert iterator.title == cmd 241 | await assert_aiter(iterator.iterator, [ACTION, "hello", None, ACTION]) 242 | 243 | 244 | async def test_str_path(tmp_path): 245 | path = tmp_path / "mock.txt" 246 | text = "hello\ngoodbye" 247 | path.write_text(text) 248 | iterator = await to_iterator(f"file://{path}") 249 | assert iterator.inner_type == "path" 250 | assert iterator.title == str(path) 251 | await assert_aiter(iterator.iterator, [text]) 252 | 253 | 254 | async def test_setsize(patch_actions, monkeypatch): 255 | cols = 100 256 | rows = 50 257 | monkeypatch.setenv("COLUMNS", str(cols)) 258 | monkeypatch.setenv("LINES", str(rows)) 259 | 260 | cmd = "stty size" 261 | iterator = await to_iterator(cmd) 262 | assert iterator.inner_type == "async_process" 263 | assert iterator.title == cmd 264 | items = await collect(iterator.iterator) 265 | assert items[1].strip() == f"{rows} {cols}" 266 | 267 | 268 | async def test_controller(): 269 | value = "data1" 270 | title = "title1" 271 | c = Controller(title) 272 | c.write(value) 273 | c.write(STOP) 274 | iterator = await to_iterator(c) 275 | assert iterator.inner_type == "controller" 276 | assert iterator.title == title 277 | await assert_aiter(iterator.iterator, [value]) 278 | 279 | 280 | async def test_controller_thead_safe(): 281 | value = "data1" 282 | title = "title1" 283 | c = Controller(title, thread_safe=True) 284 | c.write(value) 285 | c.write(STOP) 286 | iterator = await to_iterator(c) 287 | assert iterator.inner_type == "controller" 288 | assert iterator.title == title 289 | await assert_aiter(iterator.iterator, [value]) 290 | 291 | 292 | async def test_decode1(): 293 | iterator = await to_iterator(f"cat {os.path.join(resources.DIR, 'test_decode1.log')}") 294 | async with streamcontext(iterator.iterator) as streamer: 295 | async for _ in streamer: 296 | pass 297 | -------------------------------------------------------------------------------- /tests/test_keys.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from multiplex.keys import generic_key_to_name, seq_to_name, HOME, is_multi, key_to_seq, bind 4 | 5 | 6 | def test_generic_key_to_name_printable(): 7 | assert generic_key_to_name(ord("k")) == "k" 8 | 9 | 10 | def test_generic_key_to_name_non_printable(): 11 | assert generic_key_to_name(20) == "[20]" 12 | 13 | 14 | def test_seq_to_name_list_input(): 15 | assert seq_to_name([ord("k")]) == "k" 16 | 17 | 18 | def test_seq_to_name_ctrl_name(): 19 | assert seq_to_name((11,)) == "^K" 20 | 21 | 22 | def test_seq_to_name_predefined(): 23 | assert seq_to_name(HOME) == "Home" 24 | 25 | 26 | def test_seq_to_name_single_char(): 27 | assert seq_to_name((ord("k"),)) == "k" 28 | 29 | 30 | def test_seq_to_name_fallback(): 31 | assert seq_to_name((ord("k"), 20)) == "k[20]" 32 | 33 | 34 | def test_seq_to_name_no_fallback(): 35 | assert seq_to_name((ord("k"), 20), fallback=False) is None 36 | 37 | 38 | def test_is_multi(): 39 | assert not is_multi((11,)) 40 | assert not is_multi((11, 12)) 41 | assert not is_multi([11, 12]) 42 | assert is_multi([[11], [12]]) 43 | assert is_multi([(11,), (12,)]) 44 | assert is_multi(["one", "two"]) 45 | 46 | 47 | def test_key_to_seq_str_input(): 48 | assert key_to_seq("abc") == (ord("a"), ord("b"), ord("c")) 49 | 50 | 51 | def test_key_to_seq_multi_input(): 52 | assert key_to_seq(["gg", (11,)]) == (ord("g"), ord("g"), 11) 53 | 54 | 55 | def test_key_to_seq_standard_input(): 56 | assert key_to_seq([1, 2, 3]) == (1, 2, 3) 57 | assert key_to_seq((1, 2, 3)) == (1, 2, 3) 58 | 59 | 60 | def test_key_to_seq_invalid_input(): 61 | with pytest.raises(RuntimeError): 62 | key_to_seq(True) 63 | 64 | 65 | def test_bind_basic(): 66 | mode = "a" 67 | bindings = {mode: {}} 68 | descriptions = {mode: {}} 69 | 70 | @bind(mode, "a", "b", description="c", custom_bindings=bindings, custom_descriptions=descriptions) 71 | def fn1(): 72 | pass 73 | 74 | assert bindings[mode][(ord("a"),)] is fn1 75 | assert bindings[mode][(ord("b"),)] is fn1 76 | assert descriptions[mode][("a", "b")] == "c" 77 | 78 | 79 | def test_bind_description_fallback(): 80 | mode = "a" 81 | bindings = {mode: {}} 82 | descriptions = {mode: {}} 83 | 84 | @bind(mode, "a", custom_bindings=bindings, custom_descriptions=descriptions) 85 | def fn1(): 86 | pass 87 | 88 | assert descriptions[mode][("a",)] == "fn1" 89 | 90 | 91 | def test_bind_multi_description(): 92 | mode = "a" 93 | bindings = {mode: {}} 94 | descriptions = {mode: {}} 95 | 96 | @bind(mode, ["a", "b"], custom_bindings=bindings, custom_descriptions=descriptions) 97 | def fn1(): 98 | pass 99 | 100 | assert descriptions[mode][("ab",)] == "fn1" 101 | 102 | 103 | def test_bind_seq_to_name_description(): 104 | mode = "a" 105 | bindings = {mode: {}} 106 | descriptions = {mode: {}} 107 | 108 | @bind(mode, ["a", (27,)], custom_bindings=bindings, custom_descriptions=descriptions) 109 | def fn1(): 110 | pass 111 | 112 | assert descriptions[mode][("aEsc",)] == "fn1" 113 | -------------------------------------------------------------------------------- /tests/test_keys_input.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from functools import partial 3 | from unittest import mock 4 | from unittest.mock import Mock 5 | 6 | from multiplex.keys import bind as _bind, HELP, SCROLL, NORMAL, GLOBAL, INPUT 7 | from multiplex.keys_input import InputReader 8 | 9 | 10 | def _test_set(show_help=False, auto_scroll=True, input_mode=False): 11 | bindings = {GLOBAL: {}, NORMAL: {}, HELP: {}, SCROLL: {}, INPUT: {}} 12 | descriptions = {GLOBAL: {}, NORMAL: {}, HELP: {}, SCROLL: {}, INPUT: {}} 13 | bind = partial(_bind, custom_bindings=bindings, custom_descriptions=descriptions) 14 | viewer = MockViewer(show_help, auto_scroll, input_mode) 15 | return bind, InputReader(viewer, bindings) 16 | 17 | 18 | class MockViewer: 19 | def __init__(self, show_help=False, auto_scroll=True, input_mode=False): 20 | self.help = MockHelp(show_help) 21 | self.is_input_mode = input_mode 22 | self.is_scrolling = not auto_scroll 23 | 24 | 25 | class MockHelp: 26 | def __init__(self, show=False): 27 | self.show = show 28 | 29 | 30 | class MockBox: 31 | def __init__(self, auto_scroll): 32 | self.state = MockBoxState(auto_scroll) 33 | pass 34 | 35 | 36 | class MockBoxState: 37 | def __init__(self, auto_scroll): 38 | self.auto_scroll = auto_scroll 39 | 40 | 41 | def _test_process_single_found_sequence(mode): 42 | show_help = mode == HELP 43 | auto_scroll = mode != SCROLL 44 | input_mode = mode == INPUT 45 | bind, reader = _test_set(show_help, auto_scroll, input_mode) 46 | 47 | @bind(mode, "a") 48 | def fn1(): 49 | pass 50 | 51 | expected = [fn1] 52 | 53 | if mode != GLOBAL: 54 | 55 | @bind(GLOBAL, "a") 56 | def fn2(): 57 | pass 58 | 59 | @bind(GLOBAL, "b") 60 | def fn3(): 61 | pass 62 | 63 | if mode != INPUT: 64 | expected.append(fn3) 65 | 66 | assert reader._process([ord("a"), ord("b")]) == (expected, []) 67 | 68 | 69 | def test_process_single_found_sequence_normal(): 70 | _test_process_single_found_sequence(NORMAL) 71 | 72 | 73 | def test_process_single_found_sequence_scroll(): 74 | _test_process_single_found_sequence(SCROLL) 75 | 76 | 77 | def test_process_single_found_sequence_input(): 78 | _test_process_single_found_sequence(INPUT) 79 | 80 | 81 | def test_process_single_found_sequence_help(): 82 | _test_process_single_found_sequence(HELP) 83 | 84 | 85 | def test_process_single_found_sequence_global(): 86 | _test_process_single_found_sequence(GLOBAL) 87 | 88 | 89 | def test_process_pending(): 90 | bind, reader = _test_set() 91 | 92 | @bind(NORMAL, "ab") 93 | def fn1(): 94 | pass 95 | 96 | keys1 = [ord("a")] 97 | keys2 = [ord("a"), ord("b")] 98 | keys3 = [ord("a"), ord("c"), ord("d")] 99 | assert reader._process(keys1) == ([], keys1) 100 | assert reader._process(keys2) == ([fn1], []) 101 | assert reader._process(keys3) == ([], []) 102 | 103 | @bind(NORMAL, "def") 104 | def fn2(): 105 | pass 106 | 107 | keys1 = [ord("d")] 108 | keys2 = [ord("d"), ord("b")] 109 | keys3 = [ord("d"), ord("e")] 110 | assert reader._process(keys1) == ([], keys1) 111 | assert reader._process(keys2) == ([], []) 112 | assert reader._process(keys3) == ([], keys3) 113 | 114 | 115 | def test_process_more_than_one_sequence(): 116 | bind, reader = _test_set() 117 | 118 | @bind(NORMAL, "ab") 119 | def fn1(): 120 | pass 121 | 122 | @bind(NORMAL, "cd") 123 | def fn2(): 124 | pass 125 | 126 | keys1 = [ord("a"), ord("b"), ord("a")] 127 | keys2 = [ord("a"), ord("b"), ord("a"), ord("b")] 128 | keys3 = [ord("a"), ord("b"), ord("c"), ord("d")] 129 | assert reader._process(keys1) == ([fn1], keys1[2:]) 130 | assert reader._process(keys2) == ([fn1, fn1], []) 131 | assert reader._process(keys3) == ([fn1, fn2], []) 132 | 133 | 134 | @contextmanager 135 | def patch_read(data=None, read=None): 136 | mock_has_data = Mock() 137 | mock_has_data.side_effect = data or [False] 138 | mock_read = Mock() 139 | mock_read.side_effect = read or [] 140 | with mock.patch("sys.stdin.fileno", Mock()): 141 | with mock.patch("os.read", mock_read): 142 | with mock.patch("multiplex.keys_input._has_data", mock_has_data): 143 | yield 144 | 145 | 146 | def test_read_iteration_no_data(): 147 | bind, reader = _test_set() 148 | assert reader.pending == [] 149 | with patch_read(data=[False]): 150 | assert reader._read_iteration() is None 151 | assert reader.pending == [] 152 | 153 | 154 | def test_read_iteration_process_one(): 155 | bind, reader = _test_set() 156 | 157 | @bind(NORMAL, "a") 158 | def fn1(): 159 | pass 160 | 161 | with patch_read(data=[True, True, False], read=["a"]): 162 | assert reader._read_iteration() == [fn1] 163 | 164 | assert reader.pending == [] 165 | 166 | 167 | def test_read_iteration_has_pending(): 168 | bind, reader = _test_set() 169 | 170 | @bind(NORMAL, "ab") 171 | def fn1(): 172 | pass 173 | 174 | with patch_read(data=[True, True, False], read=["a"]): 175 | assert reader._read_iteration() == [] 176 | 177 | assert reader.pending == [ord("a")] 178 | 179 | 180 | def test_read_iteration_backspace_and_delete(): 181 | bind, reader = _test_set() 182 | 183 | @bind(NORMAL, "abcdef") 184 | def fn1(): 185 | pass 186 | 187 | with patch_read(data=[True, True, True, True, True, True, True, True], read=["a", "b", "c", chr(8), chr(127)]): 188 | assert reader._read_iteration() == [] 189 | assert reader.pending == [ord("a"), ord("b")] 190 | assert reader._read_iteration() == [] 191 | assert reader.pending == [ord("a")] 192 | -------------------------------------------------------------------------------- /tests/test_multiplex.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | import subprocess 4 | 5 | import pytest 6 | from multiplex import Multiplex, to_iterator, Controller 7 | from multiplex.ipc import Client 8 | 9 | pytestmark = pytest.mark.asyncio 10 | 11 | 12 | def test_exports(): 13 | assert Controller 14 | assert Multiplex 15 | assert to_iterator 16 | 17 | 18 | @pytest.mark.skipif("os.environ.get('MULTIPLEX_SOCKET_PATH')", reason="Running in demo") 19 | async def test_sanity(tmpdir): 20 | output_dir = tmpdir / "output" 21 | socket_echo = tmpdir / "socket_loc" 22 | output_dir.mkdir() 23 | cmd = f"mp 'echo 1' 'echo 2' 'echo $MULTIPLEX_SOCKET_PATH > {socket_echo}' -o {output_dir}" 24 | master, slave = os.openpty() 25 | proc = await asyncio.subprocess.create_subprocess_shell( 26 | cmd, 27 | stdin=slave, 28 | stdout=subprocess.PIPE, 29 | stderr=subprocess.PIPE, 30 | ) 31 | await asyncio.sleep(0.5) 32 | socket_path = socket_echo.read_text("utf-8").strip() 33 | client = Client(socket_path) 34 | await client.save() 35 | await client.quit() 36 | await proc.wait() 37 | output_dir = output_dir.listdir()[0] 38 | expected = ["1", "2", ""] 39 | listing = output_dir.listdir(sort=True) 40 | assert len(listing) == len(expected) + 1 41 | for i, ex in enumerate(expected): 42 | assert listing[i].read_text("utf-8").strip() == ex 43 | --------------------------------------------------------------------------------