├── .editorconfig ├── .github └── workflows │ ├── pypi.yml │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── aur └── raiseorlaunch │ ├── .SRCINFO │ ├── .gitignore │ └── PKGBUILD ├── conftest.py ├── raiseorlaunch ├── __init__.py ├── __main__.py └── raiseorlaunch.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── test_cli.py ├── test_raiseorlaunch.py └── tree.py └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | insert_final_newline = true 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | 9 | [*.py] 10 | indent_style = space 11 | indent_size = 4 12 | max_line_length = 88 13 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | name: PyPI 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - name: Setup Python 13 | uses: actions/setup-python@v4 14 | with: 15 | python-version: "3.10" 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade pip 19 | pip install setuptools wheel twine 20 | - name: Remove existing packages 21 | run: rm -rf ./dist/ ./raiseorlaunch.egg-info/ ./build/ 22 | - name: Run twine 23 | env: 24 | TWINE_USERNAME: __token__ 25 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 26 | TWINE_NON_INTERACTIVE: true 27 | run: | 28 | python setup.py sdist bdist_wheel 29 | twine upload dist/* 30 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 0 * * 0' 8 | 9 | jobs: 10 | tests: 11 | 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12.0-alpha - 3.12.0", "pypy3.9"] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Setup Python 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: ${{ matrix.python }} 23 | - name: Install Tox and any other packages 24 | run: pip install tox 25 | - name: Run Tox 26 | run: tox -e py # Run tox using the version of Python in `PATH` 27 | 28 | flake8: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v3 32 | - name: Setup Python 33 | uses: actions/setup-python@v4 34 | with: 35 | python-version: "3.11" 36 | - name: Install Tox and any other packages 37 | run: pip install tox 38 | - name: Run Tox 39 | run: tox -e flake8 40 | 41 | black: 42 | runs-on: ubuntu-latest 43 | steps: 44 | - uses: actions/checkout@v3 45 | - name: Setup Python 46 | uses: actions/setup-python@v4 47 | with: 48 | python-version: "3.11" 49 | - name: Install Tox and any other packages 50 | run: pip install tox 51 | - name: Run Tox 52 | run: tox -e black 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # Distribution / packaging 7 | build/ 8 | dist/ 9 | *.egg-info/ 10 | 11 | # AUR build products 12 | aur/*/src/ 13 | aur/*/pkg/ 14 | aur/*/*.tar.gz 15 | aur/*/*.tar.xz 16 | 17 | # Environments 18 | pyenv/ 19 | pyenv.bak/ 20 | 21 | # tests 22 | .tox/ 23 | .coverage 24 | 25 | # custom files 26 | test.py 27 | pyenv/ 28 | pyenv27/ 29 | 30 | .idea/ 31 | .python-version 32 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: black 5 | name: black 6 | language: system 7 | entry: black 8 | types: [python] 9 | - id: isort 10 | name: isort 11 | language: system 12 | entry: isort 13 | types: [python] 14 | - id: flake8 15 | name: flake8 16 | language: system 17 | entry: flake8 18 | exclude: migrations 19 | types: [python] 20 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v2.3.5 2 | 3 | ### Fix 4 | * Only retain workspace_back_and_forth if workspace is set ([`d8ae56f`](https://github.com/open-dynaMIX/raiseorlaunch/commit/d8ae56fd382705eaa6fa2d0648a12526e0a86035)) 5 | 6 | 7 | ## v2.3.4 8 | 9 | ### Fixes 10 | - handle empty window properties (4679401e2858c261bc5b403cdd04644019b8508e) 11 | 12 | 13 | ## v2.3.3 14 | 15 | ### Fixes 16 | - We do no longer automatically move windows to the workspace we think it belongs, 17 | because that interfered with i3s `assign`. #38 18 | 19 | 20 | ## v2.3.2 21 | 22 | ### Fixes 23 | - [tests] fix i3ipc socket mock 24 | 25 | 26 | ## v2.3.1 27 | 28 | ### Features 29 | - Run tests with py37, py38 and pypy3 30 | - Merge `.coveragerc`, `.flake8` and `.isort.cfg` into `setup.cfg` 31 | - Add `LICENSE.txt`, tests and `tox.ini` to sdist 32 | 33 | 34 | ## v2.3.0 35 | 36 | ### Features 37 | - Add tests with 100% coverage 38 | - Introduce [black](https://github.com/python/black) 39 | - Introduce [isort](https://github.com/timothycrosley/isort) 40 | - Introduce [flake8](https://gitlab.com/pycqa/flake8) 41 | - Use Travis CI to ensure and enforce all of the above 42 | 43 | 44 | ## v2.2.1 45 | 46 | ### Fixes 47 | - `--leave-fullscreen` doesn't work if no workspace has been provided #30 48 | 49 | 50 | ## v2.2.0 51 | 52 | ### Features 53 | - Add flag to disable existing fullscreen #27 54 | - Rename --init-workspace to --target-workspace #28 55 | 56 | 57 | ## v2.1.0 58 | 59 | ### Features 60 | - Added flag to set initial workspace #15 61 | - Move created windows to expected workspace #16 62 | - Window cycling #17 63 | - Use the new `timeout` argument for i3ipc main loop #19 64 | - Move to markdown for README #23 65 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2017-2020 Fabio Ambauen 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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt 2 | include README.md 3 | recursive-include tests * 4 | include tox.ini 5 | global-exclude __pycache__ 6 | global-exclude *.py[co] 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # raiseorlaunch 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/raiseorlaunch.svg)](https://pypi.org/project/raiseorlaunch/) 4 | [![Python versions](https://img.shields.io/pypi/pyversions/raiseorlaunch.svg)](https://pypi.org/project/raiseorlaunch/) 5 | [![Build Status](https://github.com/open-dynaMIX/raiseorlaunch/workflows/Tests/badge.svg)](https://github.com/open-dynaMIX/raiseorlaunch/actions?query=workflow%3ATests) 6 | [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](https://github.com/open-dynaMIX/raiseorlaunch/blob/master/.coveragerc#L9) 7 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) 8 | [![License](https://img.shields.io/github/license/open-dynaMIX/raiseorlaunch.svg)](https://opensource.org/licenses/MIT) 9 | 10 | A run-or-raise-application-launcher for [i3 window manager](https://i3wm.org/). 11 | 12 | ## Features 13 | 14 | - If a provided application is running, focus it's window, otherwise 15 | run it 16 | - Provide a regex for window class, instance and/or title to compare 17 | with running windows 18 | - Optionally enable case-insensitive comparison 19 | - Optionally provide a workspace to use for raising and running 20 | - Optionally provide an initial workspace to run the application 21 | - Optionally use the scratchpad for raising and running 22 | - Optionally provide a con_mark for raising and running 23 | - workspace_auto_back_and_forth (if enabled) remains functional 24 | - Optionally cycle through matching windows (this will break 25 | workspace_auto_back_and_forth if more than one window matches 26 | the given properties) 27 | - Optionally leave fullscreen on target workspace 28 | 29 | ## Installation 30 | 31 | ### Repositories 32 | 33 | raiseorlaunch is in [PyPI](https://pypi.org/project/raiseorlaunch/), 34 | so you can just 35 | 36 | pip install raiseorlaunch 37 | 38 | For Arch Linux users it's also available in the 39 | [AUR](https://aur.archlinux.org/packages/raiseorlaunch/). 40 | 41 | ### Manual 42 | 43 | #### Dependencies 44 | 45 | - python3 or pypy3 46 | - [i3ipc-python](https://github.com/acrisci/i3ipc-python) 47 | 48 | #### Install 49 | 50 | Installing it directly with the setup.py creates a script-entry-point 51 | that adds ~150ms delay. That's not acceptable for this kind of 52 | application. 53 | 54 | This can be prevented, if creating a wheel first and installing that 55 | (needs [wheel](https://pypi.org/project/wheel) and 56 | [pip](https://pypi.org/project/pip)): 57 | 58 | ``` shell 59 | python setup.py bdist_wheel 60 | pip install ./dist/raiseorlaunch-${VERSION}-py3-none-any.whl 61 | ``` 62 | 63 | #### Run without installation 64 | 65 | You can also just run raiseorlaunch without installing it: 66 | 67 | ``` shell 68 | python -m raiseorlaunch ${ARGUMENTS} 69 | ``` 70 | 71 | or: 72 | 73 | ``` shell 74 | ./raiseorlaunch/__main__.py ${ARGUMENTS} 75 | ``` 76 | 77 | ## Usage and options 78 | 79 | ``` 80 | usage: raiseorlaunch [-h] [-c WM_CLASS] [-s WM_INSTANCE] [-t WM_TITLE] 81 | [-e COMMAND] [-w WORKSPACE | -W TARGET_WORKSPACE | -r] 82 | [-m CON_MARK] [-l EVENT_TIME_LIMIT] [-i] [-C] [-f] [-d] 83 | [-v] 84 | 85 | A run-or-raise-application-launcher for i3 window manager. 86 | 87 | optional arguments: 88 | -h, --help show this help message and exit 89 | -c WM_CLASS, --class WM_CLASS 90 | the window class regex 91 | -s WM_INSTANCE, --instance WM_INSTANCE 92 | the window instance regex 93 | -t WM_TITLE, --title WM_TITLE 94 | the window title regex 95 | -e COMMAND, --exec COMMAND 96 | command to run with exec. If omitted, -c, -s or -t 97 | will be used (lower-case). Careful: The command will 98 | not be checked prior to execution! 99 | -w WORKSPACE, --workspace WORKSPACE 100 | workspace to use 101 | -W TARGET_WORKSPACE, --target-workspace TARGET_WORKSPACE, --init-workspace TARGET_WORKSPACE 102 | target workspace 103 | -r, --scratch use scratchpad 104 | -m CON_MARK, --mark CON_MARK 105 | con_mark to use when raising and set when launching 106 | -l EVENT_TIME_LIMIT, --event-time-limit EVENT_TIME_LIMIT 107 | Time limit in seconds to listen to window events after 108 | exec. This is needed for setting a con_mark, or moving 109 | the window to a specific workspace or the scratchpad. 110 | Defaults to 2 111 | -i, --ignore-case ignore case when comparing 112 | -C, --cycle cycle through matching windows (this will break 113 | workspace_back_and_forth if more than one window 114 | matches the given properties) 115 | -f, --leave-fullscreen 116 | Leave fullscreen on target workspace 117 | -d, --debug display debug messages 118 | -v, --version show program's version number and exit 119 | 120 | ``` 121 | 122 | ## Examples 123 | 124 | ### CLI 125 | 126 | Run or raise Firefox: 127 | 128 | ``` shell 129 | raiseorlaunch -c Firefox -s Navigator 130 | ``` 131 | 132 | Use the workspace `SL` for sublime text: 133 | 134 | ``` shell 135 | raiseorlaunch -w SL -c "^Sublime" -s sublime_text -e subl 136 | ``` 137 | 138 | Raise or launch SpeedCrunch and use the scratchpad: 139 | 140 | ``` shell 141 | raiseorlaunch -r -c SpeedCrunch 142 | ``` 143 | 144 | Use a script to start application: 145 | 146 | ``` shell 147 | raiseorlaunch -r -c SpeedCrunch -e "--no-startup-id /path/to/my-cool-script.sh" 148 | ``` 149 | 150 | Raise the window with the con_mark `wiki`. If not found, 151 | execute command and mark the new window matching the provided 152 | properties. Set the time limit to wait for a new window to 3 seconds: 153 | 154 | ``` shell 155 | raiseorlaunch -c Firefox -s Navigator -e "firefox --new-window https://wiki.archlinux.org/" -m wiki -l 3 156 | ``` 157 | 158 | ### i3 bindsym 159 | 160 | In i3 config you can define a bindsym like that: 161 | 162 | ``` 163 | bindsym ${KEYS} exec --no-startup-id raiseorlaunch ${ARGUMENTS} 164 | ``` 165 | 166 | e.g. 167 | 168 | ``` 169 | bindsym $mod+s exec --no-startup-id raiseorlaunch -w SL -c "^Sublime" -s sublime_text -e subl 170 | ``` 171 | 172 | for binding `$mod+s` to raise or launch sublime text. 173 | 174 | ## Quotation marks 175 | 176 | The command will not be quoted when calling `exec`. Make 177 | sure you properly escape any needed quotation marks. For simple commands 178 | there is no need to do anything. 179 | 180 | ## Known problems 181 | 182 | Keybindings steal focus when fired. This can have a negative impact with 183 | applications that listen to FocusOut events and hide. This is due to 184 | [how X works](https://github.com/i3/i3/issues/2843#issuecomment-316173601). 185 | 186 | ### Example: 187 | 188 | When using Guake Terminal with "Hide on lose focus" enabled, 189 | raiseorlaunch behaves as if the underlying window is focused. 190 | -------------------------------------------------------------------------------- /aur/raiseorlaunch/.SRCINFO: -------------------------------------------------------------------------------- 1 | pkgbase = raiseorlaunch 2 | pkgdesc = A run-or-raise-application-launcher for i3 window manager. 3 | pkgver = 2.3.5 4 | pkgrel = 1 5 | url = https://github.com/open-dynaMIX/raiseorlaunch 6 | arch = any 7 | license = MIT 8 | makedepends = python-setuptools 9 | makedepends = python-pip 10 | makedepends = python-wheel 11 | depends = python 12 | depends = python-i3ipc 13 | source = https://github.com/open-dynaMIX/raiseorlaunch/archive/v2.3.5.tar.gz 14 | sha256sums = dcb7d2587ebcf5cfb32f0369ab8aa936c82e1ac3fc29e3f0faf9ecc07a512ed3 15 | 16 | pkgname = raiseorlaunch 17 | -------------------------------------------------------------------------------- /aur/raiseorlaunch/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | !PKGBUILD 4 | !.SRCINFO 5 | -------------------------------------------------------------------------------- /aur/raiseorlaunch/PKGBUILD: -------------------------------------------------------------------------------- 1 | # Maintainer: Fabio Ambauen 2 | 3 | pkgname=raiseorlaunch 4 | pkgver=2.3.5 5 | pkgrel=1 6 | pkgdesc="A run-or-raise-application-launcher for i3 window manager." 7 | arch=(any) 8 | url="https://github.com/open-dynaMIX/raiseorlaunch" 9 | license=('MIT') 10 | depends=('python' 'python-i3ipc') 11 | makedepends=('python-setuptools' 'python-pip' 'python-wheel') 12 | source=("https://github.com/open-dynaMIX/${pkgname}/archive/v${pkgver}.tar.gz") 13 | sha256sums=('dcb7d2587ebcf5cfb32f0369ab8aa936c82e1ac3fc29e3f0faf9ecc07a512ed3') 14 | 15 | build() { 16 | cd "${srcdir}/${pkgname}-${pkgver}" 17 | # Need to create the wheel first, otherwise the generated entry script will be too slow 18 | python setup.py bdist_wheel 19 | } 20 | 21 | package() { 22 | cd "${srcdir}/${pkgname}-${pkgver}" 23 | PIP_CONFIG_FILE=/dev/null pip install --isolated --root="${pkgdir}" --ignore-installed --no-deps --no-warn-script-location dist/${pkgname}-${pkgver}-py3-none-any.whl 24 | install -Dm644 LICENSE.txt "${pkgdir}"/usr/share/licenses/${pkgname}/LICENSE 25 | } 26 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from collections import namedtuple 4 | 5 | import i3ipc 6 | import pytest 7 | 8 | from raiseorlaunch import Raiseorlaunch, raiseorlaunch 9 | from tests.tree import tree 10 | 11 | 12 | @pytest.fixture() 13 | def default_args(): 14 | return { 15 | "wm_class": None, 16 | "wm_instance": None, 17 | "wm_title": None, 18 | "command": None, 19 | "workspace": None, 20 | "target_workspace": None, 21 | "scratch": False, 22 | "con_mark": None, 23 | "event_time_limit": 2.0, 24 | "ignore_case": False, 25 | "cycle": False, 26 | "leave_fullscreen": False, 27 | } 28 | 29 | 30 | @pytest.fixture() 31 | def default_args_cli(default_args): 32 | default_args["debug"] = False 33 | return default_args 34 | 35 | 36 | @pytest.fixture() 37 | def minimal_args(default_args): 38 | default_args["wm_class"] = "some_class" 39 | return default_args 40 | 41 | 42 | @pytest.fixture 43 | def Workspace(): 44 | return namedtuple("Workspace", ("name")) 45 | 46 | 47 | @pytest.fixture() 48 | def Con(Workspace): 49 | class CreateCon: 50 | def __init__( 51 | self, 52 | window_class="some_class", 53 | window_instance="some_instance", 54 | name="some_name", 55 | id="some_id", 56 | workspace_name="some_workspace", 57 | focused=False, 58 | ): 59 | self.window_class = window_class 60 | self.window_instance = window_instance 61 | self.name = name 62 | self.id = id 63 | self.workspace_name = workspace_name 64 | self.focused = focused 65 | self.calls = [] 66 | 67 | def workspace(self): 68 | return Workspace(name=self.workspace_name) 69 | 70 | def command(self, *args, **kwargs): 71 | self.calls += [args, kwargs] 72 | 73 | return CreateCon 74 | 75 | 76 | @pytest.fixture() 77 | def sys_argv_handler(): 78 | old_sys_argv = sys.argv 79 | yield 80 | sys.argv = old_sys_argv 81 | 82 | 83 | @pytest.fixture 84 | def tree_mock(): 85 | return i3ipc.Con(tree, None, None) 86 | 87 | 88 | @pytest.fixture 89 | def run_command_mock(mocker): 90 | return mocker.patch.object(raiseorlaunch.i3ipc.Connection, "command") 91 | 92 | 93 | @pytest.fixture 94 | def i3ipc_mock(mocker, tree_mock): 95 | os.environ["I3SOCK"] = "/dev/null" 96 | mocker.patch.object( 97 | raiseorlaunch.i3ipc.Connection, "get_tree", return_value=tree_mock 98 | ) 99 | mocker.patch.object(i3ipc.connection.socket.socket, "connect") 100 | 101 | 102 | @pytest.fixture 103 | def rol(minimal_args, i3ipc_mock): 104 | rol = Raiseorlaunch(**minimal_args) 105 | return rol 106 | -------------------------------------------------------------------------------- /raiseorlaunch/__init__.py: -------------------------------------------------------------------------------- 1 | from raiseorlaunch.raiseorlaunch import ( # noqa: F401 2 | Raiseorlaunch, 3 | RaiseorlaunchError, 4 | __author__, 5 | __description__, 6 | __license__, 7 | __title__, 8 | __version__, 9 | check_positive, 10 | ) 11 | -------------------------------------------------------------------------------- /raiseorlaunch/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | This is the CLI for raiseorlaunch. A run-or-raise-application-launcher 6 | for i3 window manager. 7 | """ 8 | 9 | import argparse 10 | import logging 11 | import os 12 | import sys 13 | 14 | from raiseorlaunch import ( 15 | Raiseorlaunch, 16 | RaiseorlaunchError, 17 | __description__, 18 | __title__, 19 | __version__, 20 | check_positive, 21 | ) 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | 26 | def verify_app(parser, application): 27 | """ 28 | Verify the executable if not provided with -e. 29 | """ 30 | 31 | def raise_exception(): 32 | """ 33 | Raise a parser error. 34 | """ 35 | parser.error( 36 | '"{}" is not an executable! Did you forget to supply -e?'.format( 37 | application 38 | ) 39 | ) 40 | 41 | is_exe = os.access(application, os.X_OK) 42 | if not is_exe: 43 | raise_exception() 44 | elif is_exe == application: # pragma: no cover 45 | if not os.access(application, os.X_OK): 46 | raise_exception() 47 | return application 48 | 49 | 50 | def set_command(parser, args): 51 | """ 52 | Set args.command, if --exec is omitted. 53 | In this order: 54 | class, instance, title. 55 | """ 56 | if not args.command: 57 | for i in ["wm_class", "wm_instance", "wm_title"]: 58 | if getattr(args, i): 59 | args.command = getattr(args, i).lower() 60 | break 61 | 62 | if not args.command: 63 | parser.error("No executable provided!") 64 | verify_app(parser, args.command) 65 | logger.debug("Set command to: {}".format(args.command)) 66 | 67 | return args 68 | 69 | 70 | def check_time_limit(value): 71 | """ 72 | Validate value for --event-time-limit 73 | 74 | Args: 75 | value: provided value 76 | 77 | Returns: 78 | float if valid otherwise raises Exception 79 | """ 80 | new_value = check_positive(value) 81 | if not new_value: 82 | raise argparse.ArgumentTypeError( 83 | "event-time-limit is not a positive integer or float!" 84 | ) 85 | else: 86 | return new_value 87 | 88 | 89 | def parse_arguments(args): 90 | """ 91 | Parse all arguments. 92 | """ 93 | parser = argparse.ArgumentParser( 94 | prog=__title__, 95 | description=__description__, 96 | formatter_class=argparse.RawDescriptionHelpFormatter, 97 | ) 98 | 99 | parser.add_argument("-c", "--class", dest="wm_class", help="the window class regex") 100 | 101 | parser.add_argument( 102 | "-s", "--instance", dest="wm_instance", help="the window instance regex" 103 | ) 104 | 105 | parser.add_argument("-t", "--title", dest="wm_title", help="the window title regex") 106 | 107 | parser.add_argument( 108 | "-e", 109 | "--exec", 110 | dest="command", 111 | help="command to run with exec. If omitted, -c, -s or " 112 | "-t will be used (lower-case). " 113 | "Careful: The command will not be checked " 114 | "prior to execution!", 115 | ) 116 | parser.set_defaults(command=None) 117 | 118 | group = parser.add_mutually_exclusive_group() 119 | group.add_argument("-w", "--workspace", dest="workspace", help="workspace to use") 120 | group.add_argument( 121 | "-W", 122 | "--target-workspace", 123 | "--init-workspace", 124 | dest="target_workspace", 125 | help="target workspace", 126 | ) 127 | group.add_argument( 128 | "-r", "--scratch", dest="scratch", action="store_true", help="use scratchpad" 129 | ) 130 | 131 | parser.add_argument( 132 | "-m", 133 | "--mark", 134 | dest="con_mark", 135 | help="con_mark to use when raising and set when launching", 136 | ) 137 | 138 | parser.add_argument( 139 | "-l", 140 | "--event-time-limit", 141 | dest="event_time_limit", 142 | type=check_time_limit, 143 | help="Time limit in seconds to listen to window events after exec. " 144 | "This is needed for setting a con_mark, or moving the window to a " 145 | "specific workspace or the scratchpad. Defaults to 2", 146 | default=2, 147 | ) 148 | 149 | parser.add_argument( 150 | "-i", 151 | "--ignore-case", 152 | dest="ignore_case", 153 | action="store_true", 154 | help="ignore case when comparing", 155 | ) 156 | 157 | parser.add_argument( 158 | "-C", 159 | "--cycle", 160 | dest="cycle", 161 | action="store_true", 162 | help="cycle through matching " 163 | "windows (this will break workspace_back_and_forth if " 164 | "more than one window matches the given properties)", 165 | ) 166 | 167 | parser.add_argument( 168 | "-f", 169 | "--leave-fullscreen", 170 | dest="leave_fullscreen", 171 | action="store_true", 172 | help="Leave fullscreen on target workspace", 173 | ) 174 | 175 | parser.add_argument( 176 | "-d", 177 | "--debug", 178 | dest="debug", 179 | help="display debug messages", 180 | action="store_true", 181 | ) 182 | 183 | parser.add_argument("-v", "--version", action="version", version=__version__) 184 | 185 | args = parser.parse_args(args) 186 | 187 | if args.debug: 188 | logging.basicConfig(level=logging.DEBUG) 189 | 190 | args = set_command(parser, args) 191 | 192 | return args, parser 193 | 194 | 195 | def main(): 196 | """ 197 | Main CLI function for raiseorlaunch. 198 | """ 199 | args, parser = parse_arguments(sys.argv[1:]) 200 | 201 | logger.debug("Provided arguments: {}".format(args)) 202 | 203 | try: 204 | rol = Raiseorlaunch( 205 | command=args.command, 206 | wm_class=args.wm_class, 207 | wm_instance=args.wm_instance, 208 | wm_title=args.wm_title, 209 | scratch=args.scratch, 210 | con_mark=args.con_mark, 211 | workspace=args.workspace, 212 | target_workspace=args.target_workspace, 213 | ignore_case=args.ignore_case, 214 | event_time_limit=args.event_time_limit, 215 | cycle=args.cycle, 216 | leave_fullscreen=args.leave_fullscreen, 217 | ) 218 | except RaiseorlaunchError as e: 219 | parser.error(str(e)) 220 | else: 221 | rol.run() 222 | 223 | 224 | if __name__ == "__main__": # pragma: no cover 225 | main() 226 | -------------------------------------------------------------------------------- /raiseorlaunch/raiseorlaunch.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | This is the module for raiseorlaunch. A run-or-raise-application-launcher 5 | for i3 window manager. 6 | """ 7 | 8 | 9 | __title__ = "raiseorlaunch" 10 | __description__ = "A run-or-raise-application-launcher for i3 window manager." 11 | __version__ = "2.3.5" 12 | __license__ = "MIT" 13 | __author__ = "Fabio Ambauen" 14 | 15 | 16 | import logging 17 | import re 18 | import sys 19 | 20 | try: 21 | import i3ipc 22 | except ImportError: # pragma: no cover 23 | print("\033[31;1mError: Module i3ipc not found.\033[0m", file=sys.stderr) 24 | sys.exit(1) 25 | 26 | 27 | logger = logging.getLogger(__name__) 28 | 29 | 30 | def check_positive(value): 31 | try: 32 | fvalue = float(value) 33 | except ValueError: 34 | return False 35 | else: 36 | if fvalue <= 0: 37 | return False 38 | return fvalue 39 | 40 | 41 | class RaiseorlaunchError(Exception): 42 | pass 43 | 44 | 45 | class Raiseorlaunch(object): 46 | """ 47 | Main class for raiseorlaunch. 48 | 49 | Args: 50 | command (str): The command to execute, if no matching window was found. 51 | wm_class (str, optional): Regex for the the window class. 52 | wm_instance (str, optional): Regex for the the window instance. 53 | wm_title (str, optional): Regex for the the window title. 54 | workspace (str, optional): The workspace that should be used for the application. 55 | target_workspace (str, optional): The workspace that should be used for the 56 | application. 57 | scratch (bool, optional): Use the scratchpad. 58 | ignore_case (bool, optional): Ignore case when comparing 59 | window-properties with provided 60 | arguments. 61 | event_time_limit (int or float, optional): Time limit in seconds to 62 | listen to window events 63 | when using the scratchpad. 64 | cycle (bool, optional): Cycle through matching windows. 65 | leave_fullscreen (bool, optional): Leave fullscreen on target 66 | workspace. 67 | """ 68 | 69 | def __init__( 70 | self, 71 | command, 72 | wm_class=None, 73 | wm_instance=None, 74 | wm_title=None, 75 | workspace=None, 76 | target_workspace=None, 77 | scratch=False, 78 | con_mark=None, 79 | ignore_case=False, 80 | event_time_limit=2, 81 | cycle=False, 82 | leave_fullscreen=False, 83 | ): 84 | self.command = command 85 | self.wm_class = wm_class 86 | self.wm_instance = wm_instance 87 | self.wm_title = wm_title 88 | self.workspace = workspace 89 | self.target_workspace = target_workspace or workspace 90 | self.scratch = scratch 91 | self.con_mark = con_mark 92 | self.ignore_case = ignore_case 93 | self.event_time_limit = event_time_limit 94 | self.cycle = cycle 95 | self.leave_fullscreen = leave_fullscreen 96 | 97 | self.regex_flags = [re.IGNORECASE] if self.ignore_case else [] 98 | 99 | self._check_args() 100 | 101 | self.i3 = i3ipc.Connection() 102 | self.tree = self.i3.get_tree() 103 | self.current_ws = self.get_current_workspace() 104 | 105 | def _check_args(self): 106 | """ 107 | Verify that... 108 | - ...window properties are provided. 109 | - ...there is no workspace provided when using the scratchpad 110 | - ...event_time_limit, if provided, is a positive int or float 111 | - ...workspace and init_workspace are not set to something different 112 | """ 113 | if not self.wm_class and not self.wm_instance and not self.wm_title: 114 | raise RaiseorlaunchError( 115 | "You need to specify " '"wm_class", "wm_instance" or "wm_title.' 116 | ) 117 | if (self.workspace or self.target_workspace) and self.scratch: 118 | raise RaiseorlaunchError( 119 | "You cannot use the scratchpad on a specific workspace." 120 | ) 121 | if not check_positive(self.event_time_limit): 122 | raise RaiseorlaunchError( 123 | "The event time limit must be a positive integer or float!" 124 | ) 125 | if self.workspace and self.target_workspace: 126 | if not self.workspace == self.target_workspace: 127 | raise RaiseorlaunchError( 128 | "Setting workspace and initial workspace is ambiguous!" 129 | ) 130 | 131 | def _need_to_listen_to_events(self): 132 | """ 133 | Evaluate if we need to listen to window events. 134 | 135 | :return: bool 136 | """ 137 | return any([self.scratch, self.con_mark, self.target_workspace]) 138 | 139 | @staticmethod 140 | def _log_format_con(window): 141 | """ 142 | Create an informatinal string for logging leaves. 143 | 144 | Returns: 145 | str: '' 146 | """ 147 | 148 | def quote(value): 149 | if isinstance(value, str): 150 | return '"{}"'.format(value) 151 | return value 152 | 153 | return "".format( 154 | quote(window.window_class), 155 | quote(window.window_instance), 156 | quote(window.name), 157 | window.id, 158 | ) 159 | 160 | def _match_regex(self, regex, string_to_match): 161 | """ 162 | Match a regex with provided flags. 163 | 164 | Args: 165 | regex: The regex to use 166 | string_to_match: The string we should match 167 | 168 | Returns: 169 | bool: True for match, False otherwise. 170 | """ 171 | matchlist = [regex, string_to_match, *self.regex_flags] 172 | return True if re.match(*matchlist) else False 173 | 174 | def _compare_running(self, window): 175 | """ 176 | Compare the properties of a running window with the ones provided. 177 | 178 | Args: 179 | window: Instance of Con(). 180 | 181 | Returns: 182 | bool: True for match, False otherwise. 183 | """ 184 | for pattern, value in [ 185 | (self.wm_class, window.window_class), 186 | (self.wm_instance, window.window_instance), 187 | (self.wm_title, window.name), 188 | ]: 189 | if pattern and (not value or not self._match_regex(pattern, value)): 190 | return False 191 | 192 | logger.debug("Window match: {}".format(self._log_format_con(window))) 193 | return True 194 | 195 | def _get_window_list(self): 196 | """ 197 | Get the list of windows. 198 | 199 | Returns: 200 | list: Instances of Con() 201 | """ 202 | if not self.workspace: 203 | logger.debug("Getting list of windows.") 204 | leaves = self.tree.leaves() 205 | if self.scratch: 206 | return [ 207 | leave 208 | for leave in leaves 209 | if leave.parent.scratchpad_state in ["changed", "fresh"] 210 | ] 211 | else: 212 | return leaves 213 | else: 214 | logger.debug( 215 | "Getting list of windows on workspace: {}.".format(self.workspace) 216 | ) 217 | workspaces = self.tree.workspaces() 218 | for workspace in workspaces: 219 | if workspace.name == self.workspace: 220 | return workspace.leaves() 221 | return [] 222 | 223 | def _find_marked_window(self): 224 | """ 225 | Find window with given mark. Restrict to given workspace 226 | if self.workspace is set. 227 | 228 | Returns: 229 | list: Containing one instance of Con() if found, None otherwise 230 | """ 231 | found = self.tree.find_marked(self.con_mark) 232 | if found and self.workspace: 233 | if not found[0].workspace().name == self.workspace: 234 | found = None 235 | return found 236 | 237 | def _is_running(self): 238 | """ 239 | Compare windows in list with provided properties. 240 | 241 | Returns: 242 | List of Con() instances if found, None otherwise. 243 | """ 244 | if self.con_mark: 245 | return self._find_marked_window() 246 | 247 | window_list = self._get_window_list() 248 | found = [] 249 | for leave in window_list: 250 | if ( 251 | leave.window_class 252 | == leave.window_instance 253 | == leave.window_title 254 | is None 255 | ): 256 | logger.debug("Window without any properties found.") 257 | continue 258 | if self._compare_running(leave): 259 | found.append(leave) 260 | 261 | if len(found) > 1: # pragma: no cover 262 | logger.debug("Multiple windows match the properties.") 263 | 264 | return found if found else None 265 | 266 | def run_command(self): 267 | """ 268 | Run the specified command with exec. 269 | """ 270 | command = "exec {}".format(self.command) 271 | logger.debug("Executing command: {}".format(command)) 272 | self.i3.command(command) 273 | 274 | def set_con_mark(self, window): 275 | """ 276 | Set con_mark on window. 277 | 278 | Args: 279 | window: Instance of Con() 280 | """ 281 | logger.debug( 282 | 'Setting con_mark "{}" on window: {}'.format( 283 | self.con_mark, self._log_format_con(window) 284 | ) 285 | ) 286 | window.command("mark {}".format(self.con_mark)) 287 | 288 | def focus_window(self, window): 289 | """ 290 | Focus window. 291 | 292 | Args: 293 | window: Instance of Con() 294 | """ 295 | logger.debug("Focusing window: {}".format(self._log_format_con(window))) 296 | window.command("focus") 297 | 298 | def get_current_workspace(self): 299 | """ 300 | Get the current workspace name. 301 | 302 | Returns: 303 | obj: The workspace Con() 304 | """ 305 | return self.tree.find_focused().workspace() 306 | 307 | def move_scratch(self, window): 308 | """ 309 | Move window to scratchpad. 310 | 311 | Args: 312 | window: Instance of Con(). 313 | """ 314 | logger.debug( 315 | "Enabling floating mode on newly created window: {}".format( 316 | self._log_format_con(window) 317 | ) 318 | ) 319 | # Somehow this is needed to retain window geometry 320 | # (e.g. when using xterm -geometry) 321 | window.command("floating enable") 322 | logger.debug( 323 | "Moving newly created window to the scratchpad: {}".format( 324 | self._log_format_con(window) 325 | ) 326 | ) 327 | window.command("move scratchpad") 328 | 329 | def show_scratch(self, window): 330 | """ 331 | Show scratchpad window. 332 | 333 | Args: 334 | window: Instance of Con(). 335 | """ 336 | logger.debug( 337 | "Toggling visibility of scratch window: {}".format( 338 | self._log_format_con(window) 339 | ) 340 | ) 341 | window.command("scratchpad show") 342 | 343 | def switch_to_workspace_by_name(self, name): 344 | """ 345 | Focus another workspace. 346 | 347 | Args: 348 | name (str): workspace name 349 | """ 350 | logger.debug("Switching to workspace: {}".format(name)) 351 | self.i3.command("workspace {}".format(name)) 352 | 353 | @staticmethod 354 | def move_con_to_workspace_by_name(window, workspace): 355 | """ 356 | Move window to workspace. 357 | 358 | Args: 359 | window: Instance of Con(). 360 | workspace: str 361 | """ 362 | logger.debug("Moving window to workspace: {}".format(workspace)) 363 | window.command("move container to workspace {}".format(workspace)) 364 | 365 | def leave_fullscreen_on_workspace(self, workspace_name, exceptions=None): 366 | """ 367 | Make sure no application is in fullscreen mode on provided workspace. 368 | 369 | Args: 370 | workspace_name: str 371 | exceptions: list of Con() 372 | 373 | Returns: 374 | None 375 | """ 376 | exceptions = exceptions if exceptions else [] 377 | cons_on_ws = self.tree.find_named("^{}$".format(workspace_name)) 378 | cons = cons_on_ws[0].find_fullscreen() if cons_on_ws else [] 379 | for con in cons: 380 | if con.type == "workspace" or con in exceptions: 381 | continue 382 | logger.debug( 383 | "Leaving fullscreen for con: {}".format(self._log_format_con(con)) 384 | ) 385 | con.command("fullscreen") 386 | 387 | def _choose_if_multiple(self, running): 388 | """ 389 | If multiple windows are found, determine which one to raise. 390 | 391 | If init_workspace is set, prefer a window on that workspace, 392 | otherwise use the first in the tree. 393 | 394 | Args: 395 | running: list of Con() 396 | 397 | Returns: 398 | Instance of Con(). 399 | """ 400 | if len(running) == 1: 401 | return running[0] 402 | 403 | window = running[0] 404 | if self.target_workspace: 405 | multi_msg = ( 406 | "Found multiple windows that match the " 407 | "properties. Using the first in the tree, " 408 | "preferably on target workspace." 409 | ) 410 | for w in running: 411 | if w.workspace().name == self.target_workspace: 412 | window = w 413 | break 414 | else: 415 | multi_msg = ( 416 | "Found multiple windows that match the " 417 | "properties. Using the first in the tree." 418 | ) 419 | 420 | logger.debug(multi_msg) 421 | return window 422 | 423 | def _handle_running(self, running): 424 | """ 425 | Handle app is running one or multiple times. 426 | 427 | Args: 428 | running: List of Con() instances. 429 | """ 430 | # there is no need to do anything if self.leave_fullscreen is True, 431 | # because focussing the window will take care of that. 432 | 433 | if self.cycle and len(running) > 1: 434 | for w in running: 435 | if w.focused: 436 | self._handle_running_cycle(running) 437 | return 438 | 439 | window = self._choose_if_multiple(running) 440 | 441 | logger.debug( 442 | 'Application is running on workspace "{}": {}'.format( 443 | window.workspace().name, self._log_format_con(window) 444 | ) 445 | ) 446 | if self.scratch: 447 | self._handle_running_scratch(window) 448 | else: 449 | self._handle_running_no_scratch(window) 450 | 451 | def _handle_running_no_scratch(self, window): 452 | """ 453 | Handle app is running and not explicitly using scratchpad. 454 | 455 | Args: 456 | window: Instance of Con(). 457 | """ 458 | if not window.focused: 459 | self.focus_window(window) 460 | else: 461 | if ( 462 | self.workspace 463 | and self.current_ws.name == self.get_current_workspace().name 464 | ): 465 | logger.debug( 466 | "We're on the right workspace. " 467 | "Switching anyway to retain " 468 | "workspace_back_and_forth " 469 | "functionality." 470 | ) 471 | self.switch_to_workspace_by_name(self.current_ws.name) 472 | 473 | def _handle_running_scratch(self, window): 474 | """ 475 | Handle app is running and explicitly using scratchpad. 476 | 477 | Args: 478 | window: Instance of Con(). 479 | """ 480 | if not window.focused: 481 | if self.current_ws.name == window.workspace().name: 482 | self.focus_window(window) 483 | else: 484 | self.show_scratch(window) 485 | else: 486 | self.show_scratch(window) 487 | 488 | def _handle_running_cycle(self, windows): 489 | """ 490 | Handle cycling through running apps. 491 | 492 | Args: 493 | windows: List with instances of Con(). 494 | """ 495 | logger.debug("Cycling through matching windows.") 496 | switch = False 497 | w = None 498 | windows.append(windows[0]) 499 | for window in windows: # pragma: no branch 500 | if switch: 501 | w = window 502 | break 503 | if window.focused: 504 | switch = True 505 | 506 | if w: 507 | logger.debug( 508 | 'Application is running on workspace "{}": {}'.format( 509 | w.workspace().name, self._log_format_con(w) 510 | ) 511 | ) 512 | 513 | self.focus_window(w) 514 | else: # pragma: no cover 515 | logger.error("No running windows received. This should not happen!") 516 | 517 | def _handle_not_running(self): 518 | """ 519 | Handle app is not running. 520 | """ 521 | if self.target_workspace: 522 | if not self.current_ws.name == self.target_workspace: 523 | self.switch_to_workspace_by_name(self.target_workspace) 524 | 525 | if self.leave_fullscreen: 526 | self.leave_fullscreen_on_workspace( 527 | self.target_workspace or self.current_ws.name 528 | ) 529 | 530 | if self._need_to_listen_to_events(): 531 | self.i3.on("window::new", self._callback_new_window) 532 | self.run_command() 533 | 534 | if self._need_to_listen_to_events(): 535 | self.i3.main(timeout=self.event_time_limit) 536 | 537 | def _callback_new_window(self, connection, event): 538 | """ 539 | Callback function for window::new events. 540 | 541 | This handles moving new windows to the desired workspace or 542 | the scratchpad and setting con_marks. 543 | """ 544 | window = event.container 545 | logger.debug("Event callback: {}".format(self._log_format_con(window))) 546 | 547 | if self._compare_running(window): 548 | if self.scratch: 549 | self.move_scratch(window) 550 | self.show_scratch(window) 551 | if self.con_mark: 552 | self.set_con_mark(window) 553 | 554 | if self.target_workspace: 555 | # This is necessary, because window.workspace() returns None 556 | w = connection.get_tree().find_by_id(window.id) 557 | 558 | if not w.workspace().name == self.target_workspace: 559 | self.move_con_to_workspace_by_name(w, self.target_workspace) 560 | 561 | def run(self): 562 | """ 563 | Search for running window that matches provided properties 564 | and act accordingly. 565 | """ 566 | running = self._is_running() 567 | if running: 568 | self._handle_running(running) 569 | else: 570 | logger.debug("Application is not running.") 571 | self._handle_not_running() 572 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | license_files = 4 | LICENSE.txt 5 | 6 | [flake8] 7 | ignore = 8 | # whitespace before ':' 9 | E203, 10 | # too many leading ### in a block comment 11 | E266, 12 | # line too long (managed by black) 13 | E501, 14 | # Line break occurred before a binary operator (this is not PEP8 compatible) 15 | W503, 16 | # do not enforce existence of docstrings 17 | D100, 18 | D101, 19 | D102, 20 | D103, 21 | D104, 22 | D105, 23 | D106, 24 | D107, 25 | # needed because of https://github.com/ambv/black/issues/144 26 | D202, 27 | # other string does contain unindexed parameters 28 | P103 29 | max-line-length = 88 30 | max-complexity = 10 31 | doctests = True 32 | show-source = True 33 | statistics = True 34 | 35 | [tool:isort] 36 | known_first_party=raiseorlaunch 37 | multi_line_output=3 38 | include_trailing_comma=True 39 | force_grid_wrap=0 40 | combine_as_imports=True 41 | line_length=88 42 | 43 | [tool:pytest] 44 | addopts = --cov=raiseorlaunch --cov-report=term-missing --no-cov-on-fail 45 | 46 | [coverage:paths] 47 | source=. 48 | 49 | [coverage:run] 50 | branch = True 51 | 52 | [coverage:report] 53 | precision = 2 54 | fail_under = 100 55 | show_missing = True 56 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | setup.py for raiseorlaunch 6 | """ 7 | 8 | from codecs import open 9 | from os import path 10 | 11 | from setuptools import setup 12 | 13 | here = path.abspath(path.dirname(__file__)) 14 | 15 | with open(path.join(here, "README.md"), encoding="utf-8") as f: 16 | long_description = f.read() 17 | 18 | setup( 19 | name="raiseorlaunch", 20 | version="2.3.5", 21 | description="A run-or-raise-application-launcher for i3 window manager.", 22 | long_description=long_description, 23 | long_description_content_type="text/markdown", 24 | url="https://github.com/open-dynaMIX/raiseorlaunch", 25 | author="Fabio Ambauen", 26 | license="MIT", 27 | classifiers=[ 28 | "Development Status :: 4 - Beta", 29 | "Intended Audience :: End Users/Desktop", 30 | "License :: OSI Approved :: MIT License", 31 | "Programming Language :: Python :: 3.6", 32 | "Programming Language :: Python :: 3.7", 33 | "Programming Language :: Python :: 3.8", 34 | "Programming Language :: Python :: 3.9", 35 | "Programming Language :: Python :: 3.10", 36 | "Programming Language :: Python :: Implementation :: PyPy", 37 | ], 38 | keywords="i3 i3wm launcher run-or-raise navigation workspace scratchpad", 39 | install_requires=["i3ipc"], 40 | packages=["raiseorlaunch"], 41 | entry_points={"console_scripts": ["raiseorlaunch = raiseorlaunch.__main__:main"]}, 42 | ) 43 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-dynaMIX/raiseorlaunch/1a0edd52159ad3821810acee02eaf8f2e3d4610b/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from argparse import ArgumentParser, ArgumentTypeError, Namespace 3 | 4 | import pytest 5 | 6 | from raiseorlaunch import Raiseorlaunch, __main__ as main 7 | 8 | 9 | def test_arguments_all(default_args_cli, mocker): 10 | initial_args = [ 11 | "--class", 12 | "Coolapp", 13 | "--instance", 14 | "instance", 15 | "--title", 16 | "title", 17 | "--workspace", 18 | "CA", 19 | "--event-time-limit", 20 | "7", 21 | "--ignore-case", 22 | "--cycle", 23 | "--leave-fullscreen", 24 | "--debug", 25 | ] 26 | default_args_cli.update( 27 | { 28 | "wm_class": "Coolapp", 29 | "wm_instance": "instance", 30 | "wm_title": "title", 31 | "command": "coolapp", 32 | "workspace": "CA", 33 | "event_time_limit": 7.0, 34 | "ignore_case": True, 35 | "cycle": True, 36 | "leave_fullscreen": True, 37 | "debug": True, 38 | } 39 | ) 40 | mocker.patch.object(main.os, "access", return_value=True) 41 | expected_args = Namespace(**default_args_cli) 42 | args = main.parse_arguments(initial_args)[0] 43 | assert args == expected_args 44 | 45 | 46 | def test_verify_app(mocker): 47 | mocker.patch.object(ArgumentParser, "error") 48 | assert main.verify_app(ArgumentParser(), "not an executable") == "not an executable" 49 | ArgumentParser.error.assert_called_with( 50 | '"not an executable" is not an executable! Did you forget to supply -e?' 51 | ) 52 | 53 | 54 | def test_set_command_provided(mocker, default_args_cli): 55 | default_args_cli.update({"command": "ls"}) 56 | args = Namespace(**default_args_cli) 57 | assert main.set_command(ArgumentParser(), args) == args 58 | 59 | 60 | def test_set_command_no_executable(mocker, default_args_cli): 61 | args = Namespace(**default_args_cli) 62 | mocker.patch.object(ArgumentParser, "error", side_effect=Exception("mocked error")) 63 | with pytest.raises(Exception) as excinfo: 64 | main.set_command(ArgumentParser(), args) 65 | assert str(excinfo.value) == "mocked error" 66 | ArgumentParser.error.assert_called_with("No executable provided!") 67 | 68 | 69 | def test_check_time_limit(): 70 | assert main.check_time_limit("3.0") == 3.0 71 | assert main.check_time_limit("5") == 5.0 72 | assert main.check_time_limit("13.56") == 13.56 73 | 74 | with pytest.raises(ArgumentTypeError) as excinfo: 75 | main.check_time_limit("not a float") 76 | assert str(excinfo.value) == "event-time-limit is not a positive integer or float!" 77 | 78 | 79 | def test_main(mocker, sys_argv_handler): 80 | mocker.patch.object(main.os, "access", return_value=True) 81 | mocker.patch.object(Raiseorlaunch, "__init__") 82 | mocker.patch.object(Raiseorlaunch, "run") 83 | Raiseorlaunch.__init__.return_value = None 84 | sys.argv = ["__main__.py", "-c", "coolapp", "-d"] 85 | main.main() 86 | 87 | 88 | def test_main_exception(mocker, sys_argv_handler): 89 | def side_effect(parser, args): 90 | return args 91 | 92 | mocker.patch("raiseorlaunch.__main__.set_command", side_effect=side_effect) 93 | sys.argv = ["__main__.py"] 94 | with pytest.raises(SystemExit) as excinfo: 95 | main.main() 96 | assert excinfo.type == SystemExit 97 | assert str(excinfo.value) == "2" 98 | -------------------------------------------------------------------------------- /tests/test_raiseorlaunch.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import i3ipc 4 | import pytest 5 | 6 | from raiseorlaunch import ( 7 | Raiseorlaunch, 8 | RaiseorlaunchError, 9 | check_positive, 10 | raiseorlaunch, 11 | ) 12 | 13 | 14 | def test_init_success(default_args, mocker): 15 | mocker.patch.object(i3ipc, "Connection") 16 | default_args.update({"wm_class": "some_class"}) 17 | assert Raiseorlaunch(**default_args) 18 | 19 | 20 | @pytest.mark.parametrize( 21 | "args,error_string", 22 | [ 23 | ({}, 'You need to specify "wm_class", "wm_instance" or "wm_title.'), 24 | ( 25 | {"workspace": "ws", "scratch": True, "wm_class": "some_class"}, 26 | "You cannot use the scratchpad on a specific workspace.", 27 | ), 28 | ( 29 | {"event_time_limit": "string", "wm_class": "some_class"}, 30 | "The event time limit must be a positive integer or float!", 31 | ), 32 | ( 33 | { 34 | "workspace": "ws", 35 | "target_workspace": "other_ws", 36 | "wm_class": "some_class", 37 | }, 38 | "Setting workspace and initial workspace is ambiguous!", 39 | ), 40 | ({"workspace": "ws", "target_workspace": "ws", "wm_class": "some_class"}, None), 41 | ], 42 | ) 43 | def test__check_args(args, error_string, default_args, mocker): 44 | default_args.update(args) 45 | mocker.patch.object(i3ipc, "Connection") 46 | 47 | if not error_string: 48 | Raiseorlaunch(**default_args) 49 | return 50 | 51 | with pytest.raises(RaiseorlaunchError) as excinfo: 52 | Raiseorlaunch(**default_args) 53 | assert str(excinfo.value) == error_string 54 | 55 | 56 | @pytest.mark.parametrize( 57 | "value,result", 58 | [ 59 | (1, 1.0), 60 | (1.0, 1.0), 61 | ("1", 1.0), 62 | ("1.0", 1.0), 63 | (0, False), 64 | (-1, False), 65 | ("0", False), 66 | ("-1", False), 67 | ("not a number", False), 68 | ], 69 | ) 70 | def test_check_positive(value, result): 71 | assert check_positive(value) == result 72 | 73 | 74 | def test__log_format_con(minimal_args, Con, mocker): 75 | mocker.patch.object(i3ipc, "Connection") 76 | 77 | rol = Raiseorlaunch(**minimal_args) 78 | assert ( 79 | rol._log_format_con(Con(window_class=None)) 80 | == '' 81 | ) 82 | 83 | 84 | @pytest.mark.parametrize( 85 | "regex,string,success,ignore_case", 86 | [ 87 | ("qutebrowser", "Qutebrowser", True, True), 88 | ("qutebrowser", "qutebrowser", True, False), 89 | ("qutebrowser", "Qutebrowser", False, False), 90 | ("^qutebrowser", "something_qutebrowser", False, True), 91 | ("^qutebrowser$", "qutebrowser_something", False, False), 92 | ], 93 | ) 94 | def test__match_regex(minimal_args, ignore_case, regex, string, success, mocker): 95 | mocker.patch.object(i3ipc, "Connection") 96 | minimal_args.update({"ignore_case": ignore_case}) 97 | rol = Raiseorlaunch(**minimal_args) 98 | assert rol._match_regex(regex, string) is success 99 | 100 | 101 | @pytest.mark.parametrize( 102 | "config,con_values,success", 103 | [ 104 | ({"wm_class": "qutebrowser"}, {"window_class": "qutebrowser"}, True), 105 | ( 106 | {"wm_class": "qutebrowser", "ignore_case": True}, 107 | {"window_class": "Qutebrowser"}, 108 | True, 109 | ), 110 | ({"wm_class": "foo"}, {"window_class": "Qutebrowser"}, False), 111 | ({"wm_class": "foo"}, {"window_class": None}, False), 112 | ], 113 | ) 114 | def test__compare_running(minimal_args, rol, Con, config, con_values, success): 115 | rol.__dict__.update(config) 116 | if "ignore_case" in config: 117 | rol.regex_flags = [re.IGNORECASE] 118 | con = Con(**con_values) 119 | 120 | assert rol._compare_running(con) == success 121 | 122 | 123 | @pytest.mark.parametrize( 124 | "scratch,workspace,count,names", 125 | [ 126 | ( 127 | False, 128 | None, 129 | 5, 130 | ["Home", None, "i3 - improved tiling wm - qutebrowser", "notes", "htop"], 131 | ), 132 | (True, None, 2, ["notes", "htop"]), 133 | (False, "workspace_1", 2, ["i3 - improved tiling wm - qutebrowser", "htop"]), 134 | (False, "not a workspace", 0, []), 135 | ], 136 | ) 137 | def test__get_window_list(scratch, workspace, count, names, rol): 138 | rol.scratch = scratch 139 | rol.workspace = workspace 140 | window_list = rol._get_window_list() 141 | assert len(window_list) == count 142 | assert [w.name for w in window_list] == names 143 | 144 | 145 | @pytest.mark.parametrize( 146 | "mark,workspace,success", 147 | [ 148 | ("my_mark", None, True), 149 | ("my_mark", "workspace_1", False), 150 | ("my_qb_mark", "workspace_1", True), 151 | ("my_mark", "not a workspace", False), 152 | ("not a mark", None, False), 153 | ], 154 | ) 155 | def test__find_marked_window(mark, workspace, success, rol): 156 | rol.con_mark = mark 157 | rol.workspace = workspace 158 | marked = rol._find_marked_window() 159 | assert bool(marked) == success 160 | 161 | 162 | @pytest.mark.parametrize( 163 | "mark,workspace,wm_class,wm_instance,wm_title,success", 164 | [ 165 | (None, None, "qutebrowser", None, None, True), 166 | (None, None, None, "test-qutebrowser", None, True), 167 | (None, None, None, None, "i3 - improved tiling wm - qutebrowser", True), 168 | (None, None, "non_existing_class", None, None, False), 169 | ("my_mark", None, None, None, None, True), 170 | ("not a mark", None, None, None, None, False), 171 | ], 172 | ) 173 | def test__is_running(mark, workspace, wm_class, wm_instance, wm_title, success, rol): 174 | rol.con_mark = mark 175 | rol.workspace = workspace 176 | rol.wm_class = wm_class 177 | rol.wm_instance = wm_instance 178 | rol.wm_title = wm_title 179 | running = rol._is_running() 180 | assert bool(running) == success 181 | 182 | 183 | def test_run_command(run_command_mock, rol): 184 | rol.command = "worldpeace.py --now" 185 | rol.run_command() 186 | run_command_mock.assert_called_once_with("exec worldpeace.py --now") 187 | 188 | 189 | @pytest.mark.parametrize( 190 | "workspace,exceptions,called", 191 | [ 192 | ("workspace_1", None, True), 193 | ("workspace_1", ["qutebrowser"], False), 194 | ("not_a_workspace", None, False), 195 | ], 196 | ) 197 | def test_leave_fullscreen_on_workspace(workspace, exceptions, called, rol, mocker): 198 | con_command = mocker.patch.object(raiseorlaunch.i3ipc.Con, "command") 199 | 200 | if exceptions: 201 | exceptions = [c for c in rol._get_window_list() if c.window_class in exceptions] 202 | 203 | rol.leave_fullscreen_on_workspace(workspace, exceptions) 204 | 205 | if called: 206 | con_command.assert_called_once_with("fullscreen") 207 | else: 208 | con_command.assert_not_called() 209 | 210 | 211 | @pytest.mark.parametrize( 212 | "target_workspace,multi", 213 | [ 214 | ("workspace_1", True), 215 | ("workspace_1", False), 216 | ("not a workspace", True), 217 | (None, True), 218 | (None, False), 219 | ], 220 | ) 221 | def test__choose_if_multiple(target_workspace, multi, rol): 222 | rol.target_workspace = target_workspace 223 | cons = [ 224 | c 225 | for c in rol._get_window_list() 226 | if c.window_instance and c.window_instance.startswith("test") 227 | ] 228 | if not multi: 229 | cons = [cons[0]] 230 | con = rol._choose_if_multiple(cons) 231 | assert con.window_class == "qutebrowser" 232 | 233 | 234 | @pytest.mark.parametrize( 235 | "cycle,multi,scratch,unfocus,called_method_name", 236 | [ 237 | (True, True, False, False, "_handle_running_cycle"), 238 | (True, True, False, True, "_handle_running_no_scratch"), 239 | (True, False, True, False, "_handle_running_scratch"), 240 | (True, False, False, False, "_handle_running_no_scratch"), 241 | ], 242 | ) 243 | def test__handle_running( 244 | cycle, scratch, multi, unfocus, called_method_name, rol, mocker 245 | ): 246 | mock = mocker.patch.object(raiseorlaunch.Raiseorlaunch, called_method_name) 247 | rol.cycle = cycle 248 | rol.scratch = scratch 249 | running = [ 250 | c 251 | for c in rol._get_window_list() 252 | if c.window_instance and c.window_instance.startswith("test") 253 | ] 254 | if not multi: 255 | running = [running[0]] 256 | 257 | if unfocus: 258 | for con in running: 259 | con.focused = False 260 | 261 | rol._handle_running(running) 262 | 263 | if not cycle: 264 | assert mock.called 265 | 266 | 267 | @pytest.mark.parametrize( 268 | "focused,current_ws_name,called,called_with,con", 269 | [ 270 | (True, "workspace_1", True, "workspace workspace_1", False), 271 | (True, "workspace_2", False, None, False), 272 | (False, "workspace_2", True, "focus", True), 273 | (False, "workspace_1", True, "focus", True), 274 | ], 275 | ) 276 | def test__handle_running_no_scratch( 277 | focused, current_ws_name, called, called_with, con, rol, Con, Workspace, mocker 278 | ): 279 | if con: 280 | command = mocker.patch.object(Con, "command") 281 | else: 282 | command = mocker.patch.object(raiseorlaunch.i3ipc.Connection, "command") 283 | 284 | window = Con(focused=focused) 285 | 286 | rol.workspace = "ws" 287 | rol.current_ws = Workspace(name=current_ws_name) 288 | rol._handle_running_no_scratch(window) 289 | 290 | if called: 291 | command.assert_called_once_with(called_with) 292 | else: 293 | assert not command.called 294 | 295 | 296 | @pytest.mark.parametrize( 297 | "focused,current_workspace_name,con_workspace_name,called_with", 298 | [ 299 | (True, "notes", "notes", "scratchpad show"), 300 | (False, "notes", "notes", "focus"), 301 | (False, "notes", "not_notes", "scratchpad show"), 302 | ], 303 | ) 304 | def test__handle_running_scratch( 305 | focused, 306 | current_workspace_name, 307 | con_workspace_name, 308 | called_with, 309 | rol, 310 | Con, 311 | Workspace, 312 | mocker, 313 | ): 314 | mocker.patch.object(Con, "command") 315 | rol.scratch = True 316 | rol.current_ws = Workspace(name=current_workspace_name) 317 | window = Con(focused=focused, workspace_name=con_workspace_name) 318 | 319 | rol._handle_running_scratch(window) 320 | 321 | window.command.assert_called_once_with(called_with) 322 | 323 | 324 | @pytest.mark.parametrize( 325 | "focused,match", 326 | [([True, False, False], 1), ([False, True, False], 2), ([False, False, True], 0)], 327 | ) 328 | def test__handle_running_cycle(focused, match, Con, rol, mocker): 329 | mock = mocker.patch.object(raiseorlaunch.Raiseorlaunch, "focus_window") 330 | windows = [ 331 | Con(window_class="test-{0}".format(i), focused=focused[i]) for i in range(3) 332 | ] 333 | 334 | rol._handle_running_cycle(windows) 335 | 336 | mock.assert_called_once_with(windows[match]) 337 | 338 | 339 | @pytest.mark.parametrize( 340 | "current_ws,target_ws,should_call_switch_ws,handle_event", 341 | [ 342 | ("ws1", "ws1", False, True), 343 | ("ws1", None, False, False), 344 | ("ws1", "ws2", True, True), 345 | ], 346 | ) 347 | @pytest.mark.parametrize("leave_fullscreen", [True, False]) 348 | def test__handle_not_running( 349 | current_ws, 350 | target_ws, 351 | should_call_switch_ws, 352 | handle_event, 353 | leave_fullscreen, 354 | rol, 355 | Workspace, 356 | mocker, 357 | ): 358 | switch_to_workspace_by_name = mocker.patch.object( 359 | raiseorlaunch.Raiseorlaunch, "switch_to_workspace_by_name" 360 | ) 361 | leave_fullscreen_on_workspace = mocker.patch.object( 362 | raiseorlaunch.Raiseorlaunch, "leave_fullscreen_on_workspace" 363 | ) 364 | on = mocker.patch.object(raiseorlaunch.i3ipc.Connection, "on") 365 | run_command = mocker.patch.object(raiseorlaunch.Raiseorlaunch, "run_command") 366 | main = mocker.patch.object(raiseorlaunch.i3ipc.Connection, "main") 367 | 368 | rol.current_ws = Workspace(name=current_ws) 369 | rol.target_workspace = target_ws 370 | 371 | if leave_fullscreen: 372 | rol.leave_fullscreen = True 373 | 374 | rol._handle_not_running() 375 | 376 | if should_call_switch_ws: 377 | switch_to_workspace_by_name.assert_called_once_with(target_ws) 378 | else: 379 | assert not switch_to_workspace_by_name.called 380 | 381 | if leave_fullscreen: 382 | leave_fullscreen_on_workspace.assert_called_once_with(target_ws or current_ws) 383 | else: 384 | assert not leave_fullscreen_on_workspace.called 385 | 386 | assert on.called == handle_event 387 | assert run_command.called 388 | assert main.called == handle_event 389 | 390 | 391 | @pytest.mark.parametrize("class1,class2", [("class1", "class2")]) 392 | @pytest.mark.parametrize( 393 | "current_ws,target_ws,should_call_move_to_ws", 394 | [("ws1", "ws1", False), ("ws1", None, False), ("ws1", "ws2", True)], 395 | ) 396 | @pytest.mark.parametrize("compare_running", [True, False]) 397 | @pytest.mark.parametrize("scratch", [True, False]) 398 | @pytest.mark.parametrize("con_mark", [None, "test_con_mark"]) 399 | def test__callback_new_window( 400 | class1, 401 | class2, 402 | current_ws, 403 | target_ws, 404 | should_call_move_to_ws, 405 | compare_running, 406 | scratch, 407 | con_mark, 408 | Con, 409 | Workspace, 410 | rol, 411 | mocker, 412 | ): 413 | con_mock = mocker.patch.object(Con, "command") 414 | con1 = Con(workspace_name="ws1") 415 | event = mocker.patch.object(raiseorlaunch.i3ipc, "Event") 416 | event.container = con1 417 | connection = mocker.patch.object(raiseorlaunch.i3ipc, "Connection") 418 | connection.get_tree.return_value.find_by_id.return_value = con1 419 | mocker.patch.object( 420 | raiseorlaunch.Raiseorlaunch, "_compare_running", return_value=compare_running 421 | ) 422 | 423 | rol.scratch = scratch 424 | rol.con_mark = con_mark 425 | rol.current_ws = Workspace(name=current_ws) 426 | rol.target_workspace = target_ws 427 | 428 | rol._callback_new_window(connection, event) 429 | 430 | if not compare_running: 431 | assert con1.calls == [] 432 | return 433 | 434 | called = [] 435 | not_called = [] 436 | 437 | if scratch: 438 | called += ["floating enable", "move scratchpad", "scratchpad show"] 439 | else: 440 | not_called += ["floating enable", "move scratchpad", "scratchpad show"] 441 | 442 | if con_mark: 443 | called.append("mark {}".format(con_mark)) 444 | else: 445 | not_called.append("mark {}".format(con_mark)) 446 | 447 | if should_call_move_to_ws: 448 | called.append("move container to workspace {}".format(target_ws)) 449 | else: 450 | not_called.append("move container to workspace {}".format(target_ws)) 451 | 452 | con_mock.assert_has_calls([mocker.call(cmd) for cmd in called]) 453 | for call in not_called: 454 | assert call not in con_mock.call_args_list 455 | 456 | 457 | @pytest.mark.parametrize("is_running", [True, False]) 458 | def test_run(is_running, rol, mocker): 459 | mocker.patch.object( 460 | raiseorlaunch.Raiseorlaunch, "_is_running", return_value=is_running 461 | ) 462 | handle_running_mock = mocker.patch.object( 463 | raiseorlaunch.Raiseorlaunch, "_handle_running" 464 | ) 465 | handle_not_running_mock = mocker.patch.object( 466 | raiseorlaunch.Raiseorlaunch, "_handle_not_running" 467 | ) 468 | 469 | rol.run() 470 | 471 | if is_running: 472 | handle_running_mock.assert_called_once_with(is_running) 473 | assert not handle_not_running_mock.called 474 | else: 475 | assert handle_not_running_mock.called 476 | assert not handle_running_mock.called 477 | -------------------------------------------------------------------------------- /tests/tree.py: -------------------------------------------------------------------------------- 1 | tree = { 2 | "id": 94271844554144, 3 | "type": "root", 4 | "orientation": "horizontal", 5 | "scratchpad_state": "none", 6 | "percent": 1.0, 7 | "urgent": False, 8 | "focused": False, 9 | "layout": "splith", 10 | "workspace_layout": "default", 11 | "last_split_layout": "splith", 12 | "border": "normal", 13 | "current_border_width": -1, 14 | "rect": {"x": 0, "y": 0, "width": 1920, "height": 1080}, 15 | "deco_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, 16 | "window_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, 17 | "geometry": {"x": 0, "y": 0, "width": 0, "height": 0}, 18 | "name": "root", 19 | "window": None, 20 | "nodes": [ 21 | { 22 | "id": 94271844556896, 23 | "type": "output", 24 | "orientation": "none", 25 | "scratchpad_state": "none", 26 | "percent": 0.5, 27 | "urgent": False, 28 | "focused": False, 29 | "layout": "output", 30 | "workspace_layout": "default", 31 | "last_split_layout": "splith", 32 | "border": "normal", 33 | "current_border_width": -1, 34 | "rect": {"x": 0, "y": 0, "width": 1920, "height": 1080}, 35 | "deco_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, 36 | "window_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, 37 | "geometry": {"x": 0, "y": 0, "width": 0, "height": 0}, 38 | "name": "__i3", 39 | "window": None, 40 | "nodes": [ 41 | { 42 | "id": 94271844557392, 43 | "type": "con", 44 | "orientation": "horizontal", 45 | "scratchpad_state": "none", 46 | "percent": 1.0, 47 | "urgent": False, 48 | "focused": False, 49 | "output": "__i3", 50 | "layout": "splith", 51 | "workspace_layout": "default", 52 | "last_split_layout": "splith", 53 | "border": "normal", 54 | "current_border_width": -1, 55 | "rect": {"x": 0, "y": 0, "width": 0, "height": 0}, 56 | "deco_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, 57 | "window_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, 58 | "geometry": {"x": 0, "y": 0, "width": 0, "height": 0}, 59 | "name": "content", 60 | "window": None, 61 | "nodes": [ 62 | { 63 | "id": 94271844557888, 64 | "type": "workspace", 65 | "orientation": "none", 66 | "scratchpad_state": "none", 67 | "percent": 1.0, 68 | "urgent": False, 69 | "focused": False, 70 | "output": "__i3", 71 | "layout": "splith", 72 | "workspace_layout": "default", 73 | "last_split_layout": "splith", 74 | "border": "normal", 75 | "current_border_width": -1, 76 | "rect": {"x": 0, "y": 0, "width": 0, "height": 0}, 77 | "deco_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, 78 | "window_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, 79 | "geometry": {"x": 0, "y": 0, "width": 0, "height": 0}, 80 | "name": "__i3_scratch", 81 | "num": -1, 82 | "window": None, 83 | "nodes": [], 84 | "floating_nodes": [ 85 | { 86 | "id": 94271844692688, 87 | "type": "floating_con", 88 | "orientation": "horizontal", 89 | "scratchpad_state": "changed", 90 | "percent": None, 91 | "urgent": False, 92 | "focused": False, 93 | "output": "__i3", 94 | "layout": "splith", 95 | "workspace_layout": "default", 96 | "last_split_layout": "splith", 97 | "border": "normal", 98 | "current_border_width": -1, 99 | "rect": { 100 | "x": 589, 101 | "y": 333, 102 | "width": 743, 103 | "height": 440, 104 | }, 105 | "deco_rect": { 106 | "x": 0, 107 | "y": 0, 108 | "width": 0, 109 | "height": 0, 110 | }, 111 | "window_rect": { 112 | "x": 0, 113 | "y": 0, 114 | "width": 0, 115 | "height": 0, 116 | }, 117 | "geometry": { 118 | "x": 0, 119 | "y": 0, 120 | "width": 0, 121 | "height": 0, 122 | }, 123 | "name": None, 124 | "window": None, 125 | "nodes": [ 126 | { 127 | "id": 94271844689472, 128 | "type": "con", 129 | "orientation": "none", 130 | "scratchpad_state": "none", 131 | "percent": 1.0, 132 | "urgent": False, 133 | "marks": ["my_mark"], 134 | "focused": False, 135 | "output": "__i3", 136 | "layout": "splith", 137 | "workspace_layout": "default", 138 | "last_split_layout": "splith", 139 | "border": "none", 140 | "current_border_width": 0, 141 | "rect": { 142 | "x": 589, 143 | "y": 333, 144 | "width": 743, 145 | "height": 440, 146 | }, 147 | "deco_rect": { 148 | "x": 0, 149 | "y": 0, 150 | "width": 0, 151 | "height": 0, 152 | }, 153 | "window_rect": { 154 | "x": 0, 155 | "y": 0, 156 | "width": 743, 157 | "height": 440, 158 | }, 159 | "geometry": { 160 | "x": 0, 161 | "y": 0, 162 | "width": 743, 163 | "height": 440, 164 | }, 165 | "name": "notes", 166 | "window": 39845891, 167 | "window_properties": { 168 | "class": "Termite", 169 | "instance": "test-termite", 170 | "title": "notes", 171 | "transient_for": None, 172 | }, 173 | "nodes": [], 174 | "floating_nodes": [], 175 | "focus": [], 176 | "fullscreen_mode": 0, 177 | "sticky": False, 178 | "floating": "user_on", 179 | "swallows": [], 180 | } 181 | ], 182 | "floating_nodes": [], 183 | "focus": [94271844689472], 184 | "fullscreen_mode": 0, 185 | "sticky": False, 186 | "floating": "auto_off", 187 | "swallows": [], 188 | } 189 | ], 190 | "focus": [94271844692688], 191 | "fullscreen_mode": 1, 192 | "sticky": False, 193 | "floating": "auto_off", 194 | "swallows": [], 195 | } 196 | ], 197 | "floating_nodes": [], 198 | "focus": [94271844557888], 199 | "fullscreen_mode": 0, 200 | "sticky": False, 201 | "floating": "auto_off", 202 | "swallows": [], 203 | } 204 | ], 205 | "floating_nodes": [], 206 | "focus": [94271844557392], 207 | "fullscreen_mode": 0, 208 | "sticky": False, 209 | "floating": "auto_off", 210 | "swallows": [], 211 | }, 212 | { 213 | "id": 94271844625344, 214 | "type": "output", 215 | "orientation": "none", 216 | "scratchpad_state": "none", 217 | "percent": 0.5, 218 | "urgent": False, 219 | "focused": False, 220 | "layout": "output", 221 | "workspace_layout": "default", 222 | "last_split_layout": "splith", 223 | "border": "normal", 224 | "current_border_width": -1, 225 | "rect": {"x": 0, "y": 0, "width": 1920, "height": 1080}, 226 | "deco_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, 227 | "window_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, 228 | "geometry": {"x": 0, "y": 0, "width": 0, "height": 0}, 229 | "name": "eDP1", 230 | "window": None, 231 | "nodes": [ 232 | { 233 | "id": 94271844651552, 234 | "type": "con", 235 | "orientation": "horizontal", 236 | "scratchpad_state": "none", 237 | "percent": None, 238 | "urgent": False, 239 | "focused": False, 240 | "output": "eDP1", 241 | "layout": "splith", 242 | "workspace_layout": "default", 243 | "last_split_layout": "splith", 244 | "border": "normal", 245 | "current_border_width": -1, 246 | "rect": {"x": 0, "y": 27, "width": 1920, "height": 1053}, 247 | "deco_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, 248 | "window_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, 249 | "geometry": {"x": 0, "y": 0, "width": 0, "height": 0}, 250 | "name": "content", 251 | "window": None, 252 | "nodes": [ 253 | { 254 | "id": 94271844693152, 255 | "type": "workspace", 256 | "orientation": "horizontal", 257 | "scratchpad_state": "none", 258 | "percent": None, 259 | "urgent": False, 260 | "focused": False, 261 | "output": "eDP1", 262 | "layout": "splith", 263 | "workspace_layout": "default", 264 | "last_split_layout": "splith", 265 | "border": "normal", 266 | "current_border_width": -1, 267 | "rect": {"x": 0, "y": 27, "width": 1920, "height": 1053}, 268 | "deco_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, 269 | "window_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, 270 | "geometry": {"x": 0, "y": 0, "width": 0, "height": 0}, 271 | "name": "workspace_2", 272 | "num": -1, 273 | "window": None, 274 | "nodes": [ 275 | { 276 | "id": 94271844692688, 277 | "type": "con", 278 | "orientation": "none", 279 | "scratchpad_state": "none", 280 | "percent": 1.0, 281 | "urgent": False, 282 | "focused": False, 283 | "output": "eDP1", 284 | "layout": "splith", 285 | "workspace_layout": "default", 286 | "last_split_layout": "splith", 287 | "border": "none", 288 | "current_border_width": 0, 289 | "rect": { 290 | "x": 0, 291 | "y": 27, 292 | "width": 1920, 293 | "height": 1053, 294 | }, 295 | "deco_rect": { 296 | "x": 0, 297 | "y": 0, 298 | "width": 0, 299 | "height": 0, 300 | }, 301 | "window_rect": { 302 | "x": 0, 303 | "y": 0, 304 | "width": 1920, 305 | "height": 1053, 306 | }, 307 | "geometry": { 308 | "x": 0, 309 | "y": 0, 310 | "width": 1920, 311 | "height": 1053, 312 | }, 313 | "name": "Home", 314 | "window": 46153256, 315 | "window_properties": { 316 | "class": "Nemo", 317 | "instance": "nemo", 318 | "title": "Home", 319 | "transient_for": None, 320 | }, 321 | "nodes": [], 322 | "floating_nodes": [], 323 | "focus": [], 324 | "fullscreen_mode": 0, 325 | "sticky": False, 326 | "floating": "auto_off", 327 | "swallows": [], 328 | }, 329 | { 330 | "id": 94083980242960, 331 | "type": "con", 332 | "orientation": "none", 333 | "scratchpad_state": "none", 334 | "percent": 0.5, 335 | "urgent": False, 336 | "focused": False, 337 | "output": "eDP1", 338 | "layout": "splith", 339 | "workspace_layout": "default", 340 | "last_split_layout": "splith", 341 | "border": "normal", 342 | "current_border_width": -1, 343 | "rect": { 344 | "x": 0, 345 | "y": 50, 346 | "width": 960, 347 | "height": 1030, 348 | }, 349 | "deco_rect": { 350 | "x": 0, 351 | "y": 0, 352 | "width": 960, 353 | "height": 24, 354 | }, 355 | "window_rect": { 356 | "x": 0, 357 | "y": 0, 358 | "width": 0, 359 | "height": 0, 360 | }, 361 | "geometry": { 362 | "x": 0, 363 | "y": 0, 364 | "width": 0, 365 | "height": 0, 366 | }, 367 | "name": None, 368 | "window": None, 369 | "nodes": [], 370 | "floating_nodes": [], 371 | "focus": [], 372 | "fullscreen_mode": 0, 373 | "sticky": False, 374 | "floating": "auto_off", 375 | "swallows": [], 376 | }, 377 | ], 378 | "floating_nodes": [], 379 | "focus": [94271844826512, 94271844692688], 380 | "fullscreen_mode": 1, 381 | "sticky": False, 382 | "floating": "auto_off", 383 | "swallows": [], 384 | }, 385 | { 386 | "id": 94271844615376, 387 | "type": "workspace", 388 | "orientation": "horizontal", 389 | "scratchpad_state": "none", 390 | "percent": 1.0, 391 | "urgent": False, 392 | "focused": False, 393 | "output": "eDP1", 394 | "layout": "splith", 395 | "workspace_layout": "default", 396 | "last_split_layout": "splith", 397 | "border": "normal", 398 | "current_border_width": -1, 399 | "rect": {"x": 0, "y": 27, "width": 1920, "height": 1053}, 400 | "deco_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, 401 | "window_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, 402 | "geometry": {"x": 0, "y": 0, "width": 0, "height": 0}, 403 | "name": "workspace_1", 404 | "num": -1, 405 | "window": None, 406 | "nodes": [ 407 | { 408 | "id": 94271844619504, 409 | "type": "con", 410 | "orientation": "none", 411 | "scratchpad_state": "none", 412 | "percent": 0.5, 413 | "urgent": False, 414 | "marks": ["my_qb_mark"], 415 | "focused": True, 416 | "output": "eDP1", 417 | "layout": "splith", 418 | "workspace_layout": "default", 419 | "last_split_layout": "splith", 420 | "border": "none", 421 | "current_border_width": 0, 422 | "rect": { 423 | "x": 0, 424 | "y": 27, 425 | "width": 960, 426 | "height": 1053, 427 | }, 428 | "deco_rect": { 429 | "x": 0, 430 | "y": 0, 431 | "width": 0, 432 | "height": 0, 433 | }, 434 | "window_rect": { 435 | "x": 0, 436 | "y": 0, 437 | "width": 960, 438 | "height": 1053, 439 | }, 440 | "geometry": { 441 | "x": 0, 442 | "y": 27, 443 | "width": 1920, 444 | "height": 1053, 445 | }, 446 | "name": "i3 - improved tiling wm - qutebrowser", 447 | "window": 33554459, 448 | "window_properties": { 449 | "class": "qutebrowser", 450 | "instance": "test-qutebrowser", 451 | "title": "i3 - improved tiling wm - qutebrowser", 452 | "transient_for": None, 453 | }, 454 | "nodes": [], 455 | "floating_nodes": [], 456 | "focus": [], 457 | "fullscreen_mode": 1, 458 | "sticky": False, 459 | "floating": "auto_off", 460 | "swallows": [], 461 | } 462 | ], 463 | "floating_nodes": [ 464 | { 465 | "id": 94271844826512, 466 | "type": "floating_con", 467 | "orientation": "horizontal", 468 | "scratchpad_state": "changed", 469 | "percent": None, 470 | "urgent": False, 471 | "focused": False, 472 | "output": "eDP1", 473 | "layout": "splith", 474 | "workspace_layout": "default", 475 | "last_split_layout": "splith", 476 | "border": "normal", 477 | "current_border_width": -1, 478 | "rect": { 479 | "x": 589, 480 | "y": 333, 481 | "width": 743, 482 | "height": 440, 483 | }, 484 | "deco_rect": { 485 | "x": 0, 486 | "y": 0, 487 | "width": 0, 488 | "height": 0, 489 | }, 490 | "window_rect": { 491 | "x": 0, 492 | "y": 0, 493 | "width": 0, 494 | "height": 0, 495 | }, 496 | "geometry": { 497 | "x": 0, 498 | "y": 0, 499 | "width": 0, 500 | "height": 0, 501 | }, 502 | "name": None, 503 | "window": None, 504 | "nodes": [ 505 | { 506 | "id": 94271844845984, 507 | "type": "con", 508 | "orientation": "none", 509 | "scratchpad_state": "none", 510 | "percent": 1.0, 511 | "urgent": False, 512 | "focused": False, 513 | "output": "eDP1", 514 | "layout": "splith", 515 | "workspace_layout": "default", 516 | "last_split_layout": "splith", 517 | "border": "none", 518 | "current_border_width": 0, 519 | "rect": { 520 | "x": 589, 521 | "y": 333, 522 | "width": 743, 523 | "height": 440, 524 | }, 525 | "deco_rect": { 526 | "x": 0, 527 | "y": 0, 528 | "width": 0, 529 | "height": 0, 530 | }, 531 | "window_rect": { 532 | "x": 0, 533 | "y": 0, 534 | "width": 743, 535 | "height": 440, 536 | }, 537 | "geometry": { 538 | "x": 0, 539 | "y": 0, 540 | "width": 743, 541 | "height": 440, 542 | }, 543 | "name": "htop", 544 | "window": 50331651, 545 | "window_properties": { 546 | "class": "Termite", 547 | "instance": "termite", 548 | "title": "htop", 549 | "transient_for": None, 550 | }, 551 | "nodes": [], 552 | "floating_nodes": [], 553 | "focus": [], 554 | "fullscreen_mode": 0, 555 | "sticky": False, 556 | "floating": "user_on", 557 | "swallows": [], 558 | } 559 | ], 560 | "floating_nodes": [], 561 | "focus": [94271844845984], 562 | "fullscreen_mode": 0, 563 | "sticky": False, 564 | "floating": "auto_off", 565 | "swallows": [], 566 | } 567 | ], 568 | "focus": [94271844644304, 94271844619504], 569 | "fullscreen_mode": 1, 570 | "sticky": False, 571 | "floating": "auto_off", 572 | "swallows": [], 573 | }, 574 | ], 575 | "floating_nodes": [], 576 | "focus": [94271844615376], 577 | "fullscreen_mode": 0, 578 | "sticky": False, 579 | "floating": "auto_off", 580 | "swallows": [], 581 | }, 582 | { 583 | "id": 94271844673008, 584 | "type": "dockarea", 585 | "orientation": "none", 586 | "scratchpad_state": "none", 587 | "percent": None, 588 | "urgent": False, 589 | "focused": False, 590 | "output": "eDP1", 591 | "layout": "dockarea", 592 | "workspace_layout": "default", 593 | "last_split_layout": "splith", 594 | "border": "normal", 595 | "current_border_width": -1, 596 | "rect": {"x": 0, "y": 1080, "width": 1920, "height": 0}, 597 | "deco_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, 598 | "window_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, 599 | "geometry": {"x": 0, "y": 0, "width": 0, "height": 0}, 600 | "name": "bottomdock", 601 | "window": None, 602 | "nodes": [], 603 | "floating_nodes": [], 604 | "focus": [], 605 | "fullscreen_mode": 0, 606 | "sticky": False, 607 | "floating": "auto_off", 608 | "swallows": [{"dock": 3, "insert_where": 2}], 609 | }, 610 | ], 611 | "floating_nodes": [], 612 | "focus": [94271844651552, 94271844651088, 94271844673008], 613 | "fullscreen_mode": 0, 614 | "sticky": False, 615 | "floating": "auto_off", 616 | "swallows": [], 617 | }, 618 | ], 619 | "floating_nodes": [], 620 | "focus": [94271844625344, 94271844556896], 621 | "fullscreen_mode": 0, 622 | "sticky": False, 623 | "floating": "auto_off", 624 | "swallows": [], 625 | } 626 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (https://tox.readthedocs.io/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | # 6 | # See also https://tox.readthedocs.io/en/latest/config.html for more 7 | # configuration options. 8 | 9 | [tox] 10 | envlist = py{36,37,38,39,310,311,312}, pypy3, flake8, black 11 | 12 | [testenv] 13 | deps= 14 | pytest 15 | pytest-cov 16 | pytest-mock 17 | commands=pytest -r a -vv 18 | 19 | [testenv:flake8] 20 | deps= 21 | pytest 22 | flake8 23 | flake8-isort 24 | # can be removed, once flake8-isort dependency is resolved (https://github.com/gforcada/flake8-isort/issues/88) 25 | isort<5 26 | flake8-bugbear 27 | flake8-comprehensions 28 | commands=flake8 29 | 30 | [testenv:black] 31 | deps= 32 | black 33 | commands=black --check --diff ./ 34 | --------------------------------------------------------------------------------