├── .gitignore ├── LICENSE ├── README.md ├── docs └── assets │ └── kitti3_screenshot.jpg ├── pyproject.toml └── src └── kitti3 ├── __init__.py ├── cli.py ├── kitt.py └── util.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # Kdevelop project settings 101 | *.kdev4 102 | 103 | # mkdocs documentation 104 | /site 105 | 106 | # mypy 107 | .mypy_cache/ 108 | 109 | # PyCharm 110 | .idea/ 111 | 112 | # Py.test 113 | .pytest_cache/ 114 | 115 | # VSCode 116 | .vscode/ 117 | 118 | # Poetry 119 | poetry.lock 120 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2019, Ariel Ladegaard 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kitti3 - Kitty drop-down manager for i3 & Sway 2 | Kitti3 turns [Kitty](https://sw.kovidgoyal.net/kitty/) into a drop-down, Quake-style 3 | floating terminal for the [i3](https://i3wm.org/) and [Sway](https://swaywm.org/) window 4 | managers. 5 | 6 | #### Features 7 | - i3 & Sway native, *flicker-free* visibility toggling 8 | - Multi-monitor support with adaptive resizing and alignment to the active monitor 9 | - Flexible choice of terminal position; freely selectable dimensions 10 | - Great responsiveness by leveraging the i3/Sway IPC API 11 | - Support for multiple concurrent instances 12 | - Kitty argument forwarding (e.g. `--session`) 13 | 14 | ![Image of Kitti3](docs/assets/kitti3_screenshot.jpg) 15 | 16 | 17 | ## Installation and setup 18 | Kitti3 is a Python 3 package that [lives on PYPI](https://pypi.org/project/kitti3/). 19 | 1. To install Kitti3, either: 20 | - use [pipx](https://github.com/pypa/pipx) (recommended): 21 | ```commandline 22 | $ pipx install kitti3 23 | ``` 24 | - or use pip: 25 | ```commandline 26 | $ pip install kitti3 --user 27 | ``` 28 | - or copy [main.py](https://github.com/LandingEllipse/Kitti3/blob/master/src/kitti3/main.py) 29 | to somewhere on your $PATH, rename it to `kitti3` and make it executable. *(Note: 30 | in this case it's your responsibility to satisfy the Python [dependencies](#dependencies))* 31 | 32 | 2. Ensure that Kitti3 is reachable (e.g. `$ which kitti3`); the WM won't necessarily complain later 33 | if it isn't! 34 | 35 | 3. Add the following to your `~/.config//config`: 36 | ```commandline 37 | exec_always --no-startup-id kitti3 38 | bindsym $mod+n nop kitti3 39 | ``` 40 | where `$mod+n` refers to your keyboard shortcut of choice. Take a look at the 41 | [configuration](#configuration) section below for a list of the CLI options that 42 | `kitti3` accepts. 43 | 44 | 4. Restart i3/Sway inplace (e.g. `$mod+Shift+r`) 45 | 46 | 5. Trigger the shortcut to verify that the terminal appears (slight flicker / tiling 47 | noise is normal on the first toggle when Kitty is spawned and floated by Kitti3) 48 | 49 | 50 | ## Configuration 51 | Kitti3 does not make use of a dedicated configuration file, but its behaviour can be 52 | modified via commandline options: 53 | ```commanline 54 | $ kitti3 -h 55 | usage: kitti3 [-h] [-n NAME] [-p {LT,LC,LB,CT,CC,CB,RT,RC,RB}] 56 | [-s SHAPE SHAPE] [-v] 57 | 58 | Kitti3: i3/Sway drop-down manager for Kitty. Arguments following '--' are 59 | forwarded to the Kitty instance 60 | 61 | optional arguments: 62 | -h, --help show this help message and exit 63 | -n NAME, --name NAME name/tag used to identify this Kitti3 instance. Must 64 | match the keybinding used in the i3/Sway config (e.g. 65 | `bindsym $mod+n nop NAME`) 66 | -p {LT,LC,LB,CT,CC,CB,RT,RC,RB}, --position {LT,LC,LB,CT,CC,CB,RT,RC,RB} 67 | where to position the Kitty window within the active 68 | workspace, e.g. 'TL' for Top Left, or 'BC' for Bottom 69 | Center (character order does not matter) 70 | -s SHAPE SHAPE, --shape SHAPE SHAPE 71 | dimensions (x, y) of the Kitty window, each as a 72 | fraction of the workspace size, e.g. '1.0 0.5' for 73 | full width, half height 74 | -v, --version show kitti3's version number and exit 75 | ``` 76 | ### Command line options 77 | #### `-n, --name` (default: `kitti3`) 78 | The name option provides the string identifier used to connect a user-defined i3/Sway 79 | keybinding to the Kitti3 instance. Specifically, Kitti3 will listen to IPC events 80 | and toggle the visibility of Kitty when it encounters the bindsym command `nop NAME` - 81 | hence the requirement to include a "no-op" bindsym declaration in your config. 82 | 83 | The name option value is also used internally to associate Kitti3 with the Kitty 84 | instance it manages (the latter is forwarded the argument `--name NAME`). For this 85 | reason it is worth ensuring that an instance name is chosen which is unlikely to collide 86 | with that of another application's window; it would be wise to avoid the likes of 87 | `slack` or `discord`. 88 | 89 | #### `-p, --position` (default: `RT`) 90 | The position option accepts a 2-char ID, which sets the Kitty window's location within 91 | the workspace (and implicitly in which directions to grow the window's dimensions). 92 | The window can be placed in one of nine locations: 93 | 94 | |   | Left | Center | Right | 95 | |------------|------|--------|-------| 96 | | **Top** | LT | CT | RT | 97 | | **Center** | LC | CC | RC | 98 | | **Bottom** | LB | CB | RB | 99 | 100 | The case and order of the characters are inconsequential (i.e. `LB` == `bl`). 101 | 102 | Note that for backwards compatibility, the position option additionally accepts an older 103 | location format, with the following mapping. The default position if none is provided is 104 | actually `right` (refer to the note on shape below for why this matters). These choices 105 | will be removed in a future 106 | release. 107 | 108 | | Old | New | 109 | |--------|-----| 110 | | top | LT | 111 | | bottom | LB | 112 | | left | LT | 113 | | right | RT | 114 | 115 | #### `-s, --shape` (default: `1.0 0.3`) 116 | The shape option specifies the (x, y) dimensions of the Kitty window relative to its 117 | workspace. Allowed values are in the range [0, 1], where `1.0` corresponds to the full 118 | extent of the given workspace axis. 119 | 120 | Note that for backwards compatibility, shape values will be interpreted in (y, x) order 121 | when position is set to `left` or `right`. 122 | 123 | ### Examples 124 | #### Centered terminal with custom name and argument forwarding 125 | The following i3/Sway configuration snippet produces a Kitty terminal positioned at the 126 | center of the workspace, filling half its height and 30% of its width. It is assigned 127 | the custom name "caterwaul", and the argument `--session ~/.kitty_session` is forwarded 128 | to Kitty when it is spawned. 129 | ```commandline 130 | exec_always --no-startup-id kitti3 -n caterwaul -p CC -s 0.5 0.3 -- --session ~/.kitty_session 131 | bindsym $mod+n nop caterwaul 132 | ``` 133 | Note that any arguments following `--` are ignored by Kitti3 and forwarded to the 134 | terminal when it is spawned. 135 | 136 | #### Multiple instances 137 | Multiple Kitti3 instances (and hence Kitty windows) can be run concurrently; they just 138 | need to be distinguished by unique instance names to avoid crosstalk, e.g.: 139 | ```commandline 140 | exec_always --no-startup-id kitti3 -n almond -p CT -s 0.5 0.25 141 | bindsym $mod+t nop almond 142 | exec_always --no-startup-id kitti3 -n bubblegum -p CB -s 1.0 0.4 143 | bindsym $mod+b nop bubblegum 144 | ``` 145 | 146 | ### Updating the configuration 147 | Kitti3 must be respawned to trigger any changes made to its command line arguments in 148 | the i3/Sway config file. This can most easily be achieved by restarting the WM inplace 149 | (e.g. `$mod+Shift+r`), which because of the use of `exec_always` will spawn a new 150 | instance of Kitti3. The old instance will automatically exit when it detects a restart 151 | event, so you should not see any stray instances hanging around. 152 | 153 | ## Dependencies 154 | - [Kitty](https://sw.kovidgoyal.net/kitty/) (duh) 155 | - i3 > 4 or Sway >= 1.6 (you should also be fine on the latest git) 156 | - Python >= 3.6 157 | - [i3ipc-python](https://github.com/altdesktop/i3ipc-python) (pip(x) will pull in >=2.0) 158 | 159 | ## Alternatives 160 | *The following ~~rant~~ discussion was written some years ago and might not accurately 161 | represent the current day lay of the land.* 162 | ### The natives 163 | If you're not too fussed about which terminal you're using then there are several 164 | alternatives out there that do drop-down out of the box, like 165 | [guake](http://guake-project.org/) and [tilda](https://github.com/lanoxx/tilda). However, 166 | if you find yourself wanting to experiment with fonts that support programming ligatures 167 | (like the excellent [FiraCode](https://github.com/tonsky/FiraCode)), your options 168 | quickly dwindle as terminals based on the VTE library (like the two above) still don't 169 | play well with ligatures. 170 | 171 | ### The other bolt-ons 172 | But you're here because you want to use Kitty, so forget about the natives for a second 173 | and instead ask yourself why you shouldn't just be using one of the other "drop-downifiers". 174 | Two notable mentions in this space are [tdrop](https://github.com/noctuid/tdrop) and 175 | [i3-quickterm](https://github.com/lbonn/i3-quickterm). tdrop is a swiss army knife 176 | that could probably turn a potato into a drop-down if you worked hard enough, but while 177 | feature rich it can be prohibitively slow and cause substantial flicker artifacts in 178 | i3wm during visibility toggling. 179 | 180 | Kitti3 was actually inspired by the approach taken by i3-quickterm, which issues 181 | show/hide commands to i3 via IPC. It also supports other terminals than just Kitty, 182 | however its single-shot, mark-based design leads to some speed penalties and unwanted 183 | behaviour when spawning terminals. If you're open to using other terminals than Kitty 184 | (and have somehow made it this far into the readme), you should try it out. It was 185 | i3-quickterm's inability to display terminals as slide-ins (as opposed to drop-down or 186 | pop-up) that prompted the creation of Kitti3. 187 | 188 | Kitti3 runs as a daemon and listens to events through the i3/Sway IPC API, using 189 | information about the active workspace to dynamically direct the WM in how to best 190 | resize and position Kitty when visibility is toggled. This leads to excellent 191 | responsiveness and no flicker artifacts, as well as a seamless experience in 192 | multi-monitor, multi-resolution setups. 193 | 194 | ### Bare-metal i3/Sway config 195 | *"But I don't have a hundred external monitors on my desk!"* you cry out. Well, if you're 196 | running a single-monitor setup, or you're simply content with having the terminal 197 | displayed on your main monitor only, then you don't actually need Kitti3 or any of the 198 | other bolt-ons. The WM is happy to take care of container floating and positioning if 199 | you're happy to work with absolute pixel values. This is where you start (add to 200 | `~/.config//config`): 201 | ```commandline 202 | exec --no-startup-id kitty --name dropdown 203 | for_window [instance="dropdown"] floating enable, border none, move absolute \ 204 | position 0px 0px, resize set 1920px 384px, move scratchpad 205 | bindsym $mod+n [instance="dropdown"] scratchpad show 206 | ``` 207 | and the [i3 user's guide](https://i3wm.org/docs/userguide.html) will lead you the rest 208 | of the way. 209 | 210 | ## Development 211 | Found a bug? Feel like a feature is missing? Create an issue on GitHub! 212 | 213 | Want to get your hands dirty and contribute? Great! Clone the repository and dig in. 214 | 215 | The project follows a `setuptools` based structure and can be installed in 216 | development mode using pip (from the project root directory): 217 | 218 | $ pip install -e . 219 | 220 | This exposes the `kitti3` entry point console script, which starts the Kitty manager. 221 | 222 | ## License 223 | Kitti3 is released under a BSD 3-clause license; see 224 | [LICENSE](https://github.com/LandingEllipse/Kitti3/blob/master/LICENSE) for the details. 225 | -------------------------------------------------------------------------------- /docs/assets/kitti3_screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LandingEllipse/kitti3/f9f94c8b9f8b61a9d085206ada470cfe755a2a92/docs/assets/kitti3_screenshot.jpg -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "kitti3" 3 | version = "0.5.1" 4 | description = "Kitti3 - i3/sway floating window handler" 5 | authors = ["Ariel Ladegaard "] 6 | license = "BSD 3-Clause" 7 | repository = "https://github.com/LandingEllipse/kitti3" 8 | keywords = ["floating", "terminal", "kitty", "i3", "sway"] 9 | classifiers = [ 10 | "Development Status :: 4 - Beta", 11 | "Environment :: Console", 12 | "Intended Audience :: End Users/Desktop", 13 | "License :: OSI Approved :: BSD License", 14 | "Natural Language :: English", 15 | "Operating System :: POSIX :: Linux", 16 | "Environment :: X11 Applications", 17 | "Programming Language :: Python :: 3 :: Only", 18 | "Topic :: Terminals :: Terminal Emulators/X Terminals", 19 | "Topic :: Desktop Environment :: Window Managers", 20 | ] 21 | 22 | [tool.poetry.dependencies] 23 | python = "^3.6" 24 | importlib-metadata = { version = "^3.7.3", python = "<3.8" } # fallback backport for Python 3.6/3.7 25 | i3ipc = ">=2.0.0" 26 | 27 | [tool.poetry.dev-dependencies] 28 | # pytest = ">=3.5" 29 | black = "^20.8b1" 30 | isort = "^5.8.0" 31 | pylint = "^2.10.2" 32 | 33 | [tool.poetry.scripts] 34 | kitti3 = "kitti3.cli:cli" 35 | 36 | 37 | [tool.black] 38 | line-length = 88 39 | target-version = ['py36'] 40 | include = '\.pyi?$' 41 | exclude = ''' 42 | ( 43 | /( 44 | | \.git 45 | | build 46 | | dist 47 | )/ 48 | ) 49 | ''' 50 | experimental-string-processing = true 51 | 52 | [tool.isort] # black compatibility 53 | multi_line_output = 3 54 | include_trailing_comma = true 55 | force_grid_wrap = 0 56 | use_parentheses = true 57 | ensure_newline_before_comments = true 58 | line_length = 88 59 | -------------------------------------------------------------------------------- /src/kitti3/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | import importlib.metadata as importlib_metadata 3 | except ImportError: 4 | import importlib_metadata 5 | _dist_meta = importlib_metadata.metadata("kitti3") 6 | __author__ = _dist_meta["Author"] 7 | __description__ = _dist_meta["Summary"] 8 | __project__ = _dist_meta["Name"] 9 | __url__ = _dist_meta["Home-Page"] 10 | __version__ = _dist_meta["Version"] 11 | del _dist_meta 12 | -------------------------------------------------------------------------------- /src/kitti3/cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | import sys 4 | from typing import Callable, Iterable, List, Optional, Tuple, Type, TypeVar 5 | 6 | import i3ipc 7 | 8 | from .kitt import Kitti3, Kitts 9 | from .util import AnimParams, Client, Cattr, Pos, Shape 10 | 11 | try: 12 | from . import __version__ 13 | except ImportError: 14 | __version__ = "N/A" 15 | 16 | 17 | DEFAULTS = { 18 | "crosstalk_delay": 0.015, 19 | "name": "kitti3", 20 | "shape": (1.0, 0.4), 21 | "position": "RIGHT", 22 | "anim_show": 0.1, 23 | "anim_hide": 0.1, 24 | "anim_fps": 60, 25 | } 26 | 27 | CLIENTS = { 28 | "kitty": { 29 | "i3": { 30 | "cmd": "--no-startup-id kitty --name {}", 31 | "cattr": Cattr.INSTANCE, 32 | }, 33 | "sway": { 34 | "cmd": "kitty --class {}", 35 | "cattr": Cattr.APP_ID, 36 | }, 37 | }, 38 | "alacritty": { 39 | "i3": { 40 | "cmd": "--no-startup-id alacritty --class {}", 41 | "cattr": Cattr.INSTANCE, 42 | }, 43 | "sway": { 44 | "cmd": "alacritty --class {}", 45 | "cattr": Cattr.APP_ID, 46 | }, 47 | }, 48 | "firefox": { 49 | "i3": { 50 | "cmd": "firefox --class {}", 51 | "cattr": Cattr.CLASS, 52 | }, 53 | "sway": { 54 | "cmd": "GDK_BACKEND=wayland firefox --name {}", 55 | "cattr": Cattr.APP_ID, 56 | }, 57 | }, 58 | } 59 | 60 | 61 | class _ListClientsAction(argparse.Action): 62 | def __init__( 63 | self, 64 | option_strings, 65 | dest=argparse.SUPPRESS, 66 | default=argparse.SUPPRESS, 67 | help=None, 68 | ): 69 | super().__init__( 70 | option_strings=option_strings, 71 | dest=dest, 72 | default=default, 73 | nargs=0, 74 | help=help, 75 | ) 76 | 77 | def __call__(self, parser, namespace, values, option_string=None): 78 | print("Kitti3 known clients") 79 | for client, hosts in CLIENTS.items(): 80 | print(f"\n{client}") 81 | for host, props in hosts.items(): 82 | print(f" {host}") 83 | for prop, val in props.items(): 84 | print(f" {prop}: {val}") 85 | parser.exit() 86 | 87 | 88 | def _try_ipc(conn: i3ipc.Connection, cmd: str): 89 | try: 90 | conn.command(cmd) 91 | except BrokenPipeError: 92 | pass 93 | 94 | 95 | def _split_args(args: List[str]) -> Tuple[List, Optional[List]]: 96 | try: 97 | split = args.index("--") 98 | return args[:split], args[split + 1 :] 99 | except ValueError: 100 | return args, None 101 | 102 | 103 | def _format_choices(choices: Iterable): 104 | choice_strs = ",".join([str(choice) for choice in choices]) 105 | return f"{{{choice_strs}}}" 106 | 107 | 108 | T = TypeVar("T", int, float) 109 | 110 | 111 | def _num_in(type_: Type[T], min_: T, max_: T) -> Callable[[str], T]: 112 | def validator(arg: str) -> T: 113 | try: 114 | val = type_(arg) 115 | except ValueError as e: 116 | raise argparse.ArgumentTypeError(f"'{arg}': {e}") from None 117 | if not (min_ <= val <= max_): 118 | raise argparse.ArgumentTypeError( 119 | f"'{arg}': {val} is not in the range [{min_}, {max_}]" 120 | ) 121 | return val 122 | 123 | return validator 124 | 125 | 126 | def _parse_args(argv: List[str], host: str, defaults: dict) -> argparse.Namespace: 127 | ap = argparse.ArgumentParser( 128 | add_help=False, 129 | description=( 130 | "Kitti3: i3/sway floating window handler. Arguments following '--' are" 131 | " forwarded to the client when spawning" 132 | ), 133 | ) 134 | ap.set_defaults(**defaults) 135 | 136 | ag_look = ap.add_argument_group(title="look and feel") 137 | ag_look.add_argument( 138 | "-a", 139 | "--animate", 140 | action="store_true", 141 | help="[flag] enable slide-in animation", 142 | ) 143 | ag_look.add_argument( 144 | "-p", 145 | "--position", 146 | type=Pos.from_str, 147 | choices=list(Pos), 148 | help=( 149 | f"POSITION ({_format_choices(list(Pos))}, default:" 150 | f" '{DEFAULTS['position']}'): where to position the client window within" 151 | " the workspace, e.g. 'TL' for Top Left, or 'BC' for Bottom Center" 152 | " (first character anchors animation)" 153 | ), 154 | metavar="", 155 | ) 156 | _sh = ag_look.add_argument( 157 | "-s", 158 | "--shape", 159 | nargs=2, 160 | help=( 161 | "SHAPE SHAPE (x y, default:" 162 | f" '{' '.join(str(s) for s in reversed(DEFAULTS['shape']))}'): size of the" 163 | " client window relative to its workspace. Values can be given as decimals" 164 | " or fractions, e.g., '1 0.25' and '1.0 1/4' are both interpreted as full" 165 | " width, quarter height. Note: for backwards compatibility, if POSITION is" 166 | " 'left' or 'right' (default), the dimensions are interpreted in (y, x)" 167 | " order" 168 | ), 169 | metavar="", 170 | ) 171 | _anim_show = ag_look.add_mutually_exclusive_group() 172 | _anim_show.add_argument( 173 | "--anim-show", 174 | type=_num_in(float, 0.01, 1), 175 | help=( 176 | f"DURATION ([0.01, 1], default: {DEFAULTS['anim_show']}):" 177 | " duration of animated slide-in. Disable with --no-anim-show" 178 | ), 179 | metavar="", 180 | ) 181 | _anim_show.add_argument( 182 | "--no-anim-show", 183 | action="store_const", 184 | const=None, 185 | dest="anim_show", 186 | help=argparse.SUPPRESS, 187 | ) 188 | _anim_hide = ag_look.add_mutually_exclusive_group() 189 | _anim_hide.add_argument( 190 | "--anim-hide", 191 | type=_num_in(float, 0.01, 1), 192 | help=( 193 | f"DURATION ([0.01, 1], default: {DEFAULTS['anim_hide']}):" 194 | " duration of animated slide-out. Disable with --no-anim-hide" 195 | ), 196 | metavar="", 197 | ) 198 | _anim_hide.add_argument( 199 | "--no-anim-hide", 200 | action="store_const", 201 | const=None, 202 | dest="anim_hide", 203 | help=argparse.SUPPRESS, 204 | ) 205 | 206 | ag_look.add_argument( 207 | "--anim-fps", 208 | type=_num_in(int, 1, 100), 209 | help=( 210 | f"FPS ([1, 100], default: {DEFAULTS['anim_fps']}):" 211 | " target animation frames per second" 212 | ), 213 | metavar="", 214 | ) 215 | 216 | ag_id = ap.add_argument_group(title="identification") 217 | _bs = ag_id.add_argument( 218 | "-b", 219 | "--bindsym", 220 | help=( 221 | f"KEYCOMBO (config format, default: disabled): (sway) let Kitti3" 222 | f" dynamically set its own keyboard shortcut to KEYCOMBO" 223 | ), 224 | metavar="", 225 | ) 226 | 227 | _cl = ag_id.add_argument( 228 | "-c", 229 | "--client", 230 | dest="cmd", 231 | help=( 232 | f"CLIENT (expression or {_format_choices(CLIENTS.keys())}, default:" 233 | " 'kitty'): a custom command expression or shorthand for one of Kitti3's" 234 | " known clients. For the former, a placeholder for NAME is required, e.g." 235 | " 'myapp --class {}" 236 | ), 237 | metavar="", 238 | ) 239 | ag_id.add_argument( 240 | "-l", 241 | "--loyal", 242 | action="store_true", 243 | help=( 244 | "[flag] once a CLIENT instance has been associated, ignore new candidates" 245 | " when they spawn. If CATTR is con_mark, don't validate the associated" 246 | " instance's mark on refresh" 247 | ), 248 | ) 249 | ag_id.add_argument( 250 | "-n", 251 | "--name", 252 | help=( 253 | f"NAME (string, default: '{DEFAULTS['name']}'): name used to identify the" 254 | " CLIENT via CATTR. Must match the keybinding used in the i3/Sway config" 255 | " (e.g. `bindsym $mod+n nop NAME`)" 256 | ), 257 | metavar="", 258 | ) 259 | ag_id.add_argument( 260 | "-t", 261 | "--cattr", 262 | type=Cattr.from_str, 263 | choices=list(Cattr), 264 | help=( 265 | f"CATTR ({_format_choices(list(Cattr))}): criterium attribute used to" 266 | " match a CLIENT instance to its NAME. Only required if a custom" 267 | " expression is provided for CLIENT. If CATTR is provided but no CLIENT," 268 | " spawning is diabled and assumed to be handled by the user" 269 | ), 270 | metavar="", 271 | ) 272 | 273 | ag_misc = ap.add_argument_group(title="misc / advanced") 274 | _crosstalk = ag_misc.add_mutually_exclusive_group() 275 | _crosstalk.add_argument( 276 | "--crosstalk-delay", 277 | type=_num_in(float, 0.001, 0.2), 278 | dest="crosstalk_delay", 279 | help=( 280 | f"MS ([0.001, 0.2], default: {DEFAULTS['crosstalk_delay']} seconds): (sway)" 281 | " atomic transaction crosstalk mitigation. Experiment with this if" 282 | " re-floated windows don't resize properly. Disable with" 283 | " --no-crosstalk-delay" 284 | ), 285 | metavar="", 286 | ) 287 | _crosstalk.add_argument( 288 | "--no-crosstalk-delay", 289 | action="store_const", 290 | const=None, 291 | dest="crosstalk_delay", 292 | help=argparse.SUPPRESS, 293 | ) 294 | ag_misc.add_argument( 295 | "--debug", 296 | action="store_true", 297 | help="[flag] enable diagnostic messages", 298 | ) 299 | ag_misc.add_argument( 300 | "--list-clients", 301 | action=_ListClientsAction, 302 | help="[flag] show %(prog)s's known clients and exit", 303 | ) 304 | ag_misc.add_argument( 305 | "-h", 306 | "--help", 307 | action="help", 308 | help="[flag] show this help message and exit", 309 | ) 310 | ag_misc.add_argument( 311 | "-v", 312 | "--version", 313 | action="version", 314 | version=f"%(prog)s {__version__}", 315 | help="[flag] show %(prog)s's version number and exit", 316 | ) 317 | 318 | args = ap.parse_args(argv) 319 | 320 | try: 321 | args.shape = Shape.from_strs(args.shape, args.position.compat) 322 | except argparse.ArgumentTypeError as e: 323 | ap.error(str(argparse.ArgumentError(_sh, str(e)))) 324 | 325 | if args.cmd is None: 326 | # default to Kitty for backwards compatibility 327 | if args.cattr is None: 328 | args.cmd = "kitty" 329 | elif args.cmd not in CLIENTS: 330 | if args.cattr is None: 331 | msg = ( 332 | f"'{args.cmd}' is not a known client; if it is a custom expression," 333 | " CATTR must also be provided" 334 | ) 335 | ap.error(str(argparse.ArgumentError(_cl, msg))) 336 | elif "{}" not in args.cmd: 337 | msg = ( 338 | f"custom client expression '{args.cmd}' must contain a '{{}}'" 339 | " placeholder for NAME" 340 | ) 341 | ap.error(str(argparse.ArgumentError(_cl, msg))) 342 | if args.cmd in CLIENTS: 343 | c = CLIENTS[args.cmd][host] 344 | args.cmd = c["cmd"] 345 | args.cattr = c["cattr"] 346 | args.client = Client(args.cmd, args.cattr) 347 | 348 | args.anim_params = AnimParams( 349 | (args.animate and args.position.anchor is not None), 350 | args.position.anchor, 351 | args.anim_show, 352 | args.anim_hide, 353 | args.anim_fps, 354 | ) 355 | 356 | # basic guardrails; otherwise very easy to override alnum keys if not escaping 357 | if args.bindsym is not None and args.bindsym.startswith("+"): 358 | msg = ( 359 | f"'{args.bindsym}' looks malformed - remember to escape $ on the" 360 | f" commandline (e.g. '\\$mod{args.bindsym}')" 361 | ) 362 | ap.error(str(argparse.ArgumentError(_bs, msg))) 363 | 364 | return args 365 | 366 | 367 | def cli() -> None: 368 | # FIXME: half-baked way of checking what host we're running on. 369 | conn = i3ipc.Connection() 370 | host, _Kitt = { 371 | True: ("sway", Kitts), 372 | False: ("i3", Kitti3), 373 | }["sway" in conn.socket_path] 374 | 375 | argv_kitti3, argv_client = _split_args(sys.argv[1:]) 376 | args = _parse_args(argv_kitti3, host, DEFAULTS) 377 | 378 | if args.debug: 379 | logging.basicConfig( 380 | datefmt="%Y-%m-%dT%H:%M:%S", 381 | format=( 382 | "%(asctime)s.%(msecs)03d %(levelname)-7s" 383 | " %(filename) 4s:%(lineno)03d" 384 | " %(name)s.%(funcName)-12s %(message)s" 385 | ), 386 | level=logging.DEBUG, 387 | ) 388 | if args.bindsym is not None and host == "sway": 389 | cmd = f'bindsym "{args.bindsym}" "nop {args.name}"' 390 | ret = conn.command(cmd)[0] 391 | logging.debug("%s -> %s", cmd, ret.success and "OK" or ret.error) 392 | import atexit 393 | 394 | # cleanup (only effective on user exit) 395 | atexit.register(lambda: _try_ipc(conn, f"un{cmd}")) 396 | 397 | kitt = _Kitt( 398 | conn=conn, 399 | name=args.name, 400 | shape=args.shape, 401 | pos=args.position, 402 | client=args.client, 403 | client_argv=argv_client, 404 | anim=args.anim_params, 405 | loyal=args.loyal, 406 | crosstalk_delay=args.crosstalk_delay, 407 | ) 408 | kitt.loop() 409 | -------------------------------------------------------------------------------- /src/kitti3/kitt.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import functools 3 | import logging 4 | import time 5 | from types import SimpleNamespace 6 | from typing import List, Optional 7 | 8 | import i3ipc 9 | 10 | from .util import AnimParams, Client, Cattr, Loc, Pos, Rect, Shape, animate 11 | 12 | 13 | class Event(enum.Enum): 14 | HIDE = enum.auto() 15 | FLOATED = enum.auto() 16 | MOVED = enum.auto() 17 | SHOW = enum.auto() 18 | SPAWNED = enum.auto() 19 | 20 | 21 | class Kitt: 22 | def __init__( 23 | self, 24 | conn: i3ipc.Connection, 25 | name: str, 26 | shape: Shape, 27 | pos: Pos, 28 | client: Client, 29 | client_argv: Optional[List[str]], 30 | anim: AnimParams, 31 | loyal: bool, 32 | crosstalk_delay: Optional[float], 33 | ): 34 | self.i3 = conn 35 | self.name = name 36 | self.shape = shape 37 | self.pos = pos 38 | self.client = client 39 | self.client_argv = client_argv 40 | self.anim = anim 41 | self.loyal = loyal 42 | self.crosstalk_delay = crosstalk_delay 43 | 44 | self.log = logging.getLogger(self.__class__.__name__) 45 | self.debug = self.log.getEffectiveLevel() == logging.DEBUG 46 | self.con_id: Optional[int] = None 47 | self.con_ws: Optional[i3ipc.Con] = None 48 | self.focused_ws: Optional[i3ipc.Con] = None 49 | self.commands = SimpleNamespace( 50 | crit="[{}={}]", 51 | fetch="move container to workspace {}", 52 | float_="floating enable, border none", 53 | focus="focus", 54 | hide="floating enable, move scratchpad", 55 | move="move position {}ppt {}ppt", 56 | move_abs="move absolute position {}px {}px", 57 | resize="resize set {}ppt {}ppt", 58 | resize_abs="resize set {}px {}px", 59 | rule="for_window", 60 | ) 61 | 62 | self.i3.on("binding", self.on_keybind) 63 | self.i3.on("window::new", self.on_spawned) 64 | self.i3.on("window::floating", self.on_floated) 65 | self.i3.on("window::move", self.on_moved) 66 | self.i3.on("shutdown::exit", self.on_shutdown) 67 | 68 | def align_to_ws(self, context: Event) -> None: 69 | raise NotImplementedError 70 | 71 | def loop(self) -> None: 72 | """Enter listening mode, awaiting IPC events.""" 73 | try: 74 | self.i3.main() 75 | finally: 76 | self.i3.main_quit() 77 | 78 | def on_keybind(self, _, be: i3ipc.BindingEvent) -> None: 79 | """Toggle the visibility of the client window when the appropriate keybind 80 | command is triggered by the user. Attempt to spawn a client if none is found. 81 | """ 82 | if be.binding.command != f"nop {self.name}": 83 | return 84 | self.log.debug("%s", be.binding.command) 85 | if not self.refresh(): 86 | if self.con_id is None: 87 | self.spawn() 88 | elif self.con_ws.name == self.focused_ws.name: 89 | self.align_to_ws(Event.HIDE) 90 | else: 91 | self.align_to_ws(Event.SHOW) 92 | 93 | def on_spawned(self, _, we: i3ipc.WindowEvent) -> None: 94 | """Bind to a client with a criterium attribute matching Kitti3's instance name.""" 95 | con = we.container 96 | if not self._cattr_matches(con): 97 | return 98 | self.log.debug( 99 | '[%s="%s"] matched on con_id: %s', 100 | self.client.cattr.value, 101 | self.name, 102 | con.id, 103 | ) 104 | if self.con_id is not None and self.loyal: 105 | self.log.warning("loyal to %s; ignoring %s", self.con_id, con.id) 106 | return 107 | self.con_id = con.id 108 | if self.refresh(): 109 | self.align_to_ws(Event.SPAWNED) 110 | 111 | def on_floated(self, _, we: i3ipc.WindowEvent) -> None: 112 | """Ensure that the client window is aligned to its workspace when transitioning 113 | from tiled to floated. 114 | """ 115 | con = we.container 116 | # note: cf on_moved, for i3 con is our target, but .type == "floating_con" is 117 | # only set on the floating wrapper. Hence the need to check .floating. 118 | if (con.type != "floating_con" and con.floating != "user_on") or ( 119 | # marks are unique; want to associate to new client if mark has moved... 120 | not self._cattr_matches(con) 121 | # ...but only if we're not loyal to an existing association 122 | if self.client.cattr == Cattr.CON_MARK and not self.loyal 123 | else con.id != self.con_id 124 | ): 125 | return 126 | if ( 127 | not self.refresh() 128 | # toggle-while-tiled trigger repression 129 | or self.con_ws.name == "__i3_scratch" 130 | ): 131 | return 132 | self.align_to_ws(Event.FLOATED) 133 | 134 | def on_moved(self, _, we: i3ipc.WindowEvent) -> None: 135 | """Ensure that the client window is positioned and resized when moved to a 136 | different sized workspace (e.g. on a different monitor). 137 | 138 | If the client has been manually tiled by the user it will not be re-floated. 139 | """ 140 | con = we.container 141 | if con.type != "floating_con" or ( 142 | # marks are unique; want to associate to new client if mark has moved... 143 | not self._cattr_matches(con) 144 | # ...but only if we're not loyal to an existing association 145 | if self.client.cattr == Cattr.CON_MARK and not self.loyal 146 | # note: event's con is floating wrapper for i3, but target con for sway 147 | else (con.id != self.con_id and not con.find_by_id(self.con_id)) 148 | ): 149 | return 150 | if ( 151 | not self.refresh() 152 | # avoid double-triggering 153 | or self.con_ws.name == self.focused_ws.name 154 | # avoid triggering on a move to the scratchpad 155 | or self.con_ws.name == "__i3_scratch" 156 | ): 157 | return 158 | self.align_to_ws(Event.MOVED) 159 | 160 | def on_shutdown(self, _, se: i3ipc.ShutdownEvent): 161 | self.log.debug("received IPC shutdown command; exiting...") 162 | exit(0) 163 | 164 | def spawn(self) -> None: 165 | """Spawn a new client window associated with the name of this Kitti3 instance.""" 166 | if self.client.cmd is None: 167 | self.log.warning("unable to comply: spawning is disabled") 168 | return 169 | cmd = f"exec {self.client.cmd.format(self._escape(self.name))}" 170 | if self.client_argv: 171 | cmd = f"{cmd} {' '.join(self.client_argv)}" 172 | reply = self.i3.command(cmd)[0] 173 | self.log.debug("%s -> %s", cmd, reply.success and "OK" or reply.error) 174 | 175 | def send(self, *cmds: str) -> None: 176 | c = self.commands 177 | crit = c.crit.format("con_id", self.con_id) 178 | payload = f"{crit} {', '.join(cmds)}" 179 | replies = self.i3.command(payload) 180 | if self.debug: 181 | self.log.debug(crit) 182 | for cmd, reply in zip(cmds, replies): 183 | self.log.debug(" %s -> %s", cmd, reply.success and "OK" or reply.error) 184 | 185 | def send_rule(self, *cmds: str) -> None: 186 | c = self.commands 187 | crit = c.crit.format(self.client.cattr, self._escape(self.name)) 188 | pre = f"{c.rule} {crit}" 189 | cmd_str = ", ".join(cmds) 190 | payload = f"{pre} '{cmd_str}'" 191 | reply = self.i3.command(payload)[0] 192 | if self.debug: 193 | self.log.debug(pre) 194 | self.log.debug( 195 | " '%s' -> %s", cmd_str, reply.success and "OK" or reply.error 196 | ) 197 | 198 | def refresh(self) -> bool: 199 | """Update the information on the presence of the associated client instance, 200 | its workspace and the focused workspace. 201 | """ 202 | eager = self.con_id is None or ( 203 | self.client.cattr == Cattr.CON_MARK and not self.loyal 204 | ) 205 | con = None 206 | for candidate in self.i3.get_tree(): 207 | if (eager and self._cattr_matches(candidate)) or ( 208 | not eager and candidate.id == self.con_id 209 | ): 210 | con = candidate 211 | break 212 | if con is None: 213 | _old_id = self.con_id 214 | self.con_id = self.con_ws = self.con_rect = None 215 | if not eager: 216 | self.log.info( 217 | "con_id: %s has despawned; looking for an alternative", _old_id 218 | ) 219 | return self.refresh() 220 | else: 221 | self.con_id = con.id 222 | self.con_ws = con.workspace() 223 | self.con_rect = Rect.from_i3ipc(con.rect) 224 | 225 | # WS refs from get_tree() are stubs with no focus info, 226 | # so have to perform a second query 227 | for ws in self.i3.get_workspaces(): 228 | if ws.focused: 229 | self.focused_ws = ws 230 | break 231 | else: 232 | self.focused_ws = None 233 | 234 | self.log.debug( 235 | "con_id: %s, con_ws: %s, focused_ws: %s", 236 | self.con_id, 237 | getattr(self.con_ws, "name", None), 238 | getattr(self.focused_ws, "name", None), 239 | ) 240 | ok = None not in (self.con_id, self.con_ws, self.focused_ws) 241 | if not ok: 242 | if self.con_id is None: 243 | self.log.info('no con matching [%s="%s"]', self.client.cattr, self.name) 244 | else: 245 | self.log.warning("missing workspace guard tripped") 246 | return ok 247 | 248 | @functools.lru_cache() 249 | def target_rect(self, abs_ref: Rect = None) -> Rect: 250 | # relative/ppt 251 | if abs_ref is None: 252 | width = round(self.shape.x * 100) 253 | height = round(self.shape.y * 100) 254 | x = { 255 | Loc.LEFT: 0, 256 | Loc.CENTER: round(50 - (width / 2)), 257 | Loc.RIGHT: 100 - width, 258 | }[self.pos.x] 259 | y = { 260 | Loc.TOP: 0, 261 | Loc.CENTER: round(50 - (height / 2)), 262 | Loc.BOTTOM: 100 - height, 263 | }[self.pos.y] 264 | # absolute/px 265 | else: 266 | width = round(abs_ref.w * self.shape.x) 267 | height = round(abs_ref.h * self.shape.y) 268 | x = { 269 | Loc.LEFT: abs_ref.x, 270 | Loc.CENTER: abs_ref.x + round((abs_ref.w / 2) - (width / 2)), 271 | Loc.RIGHT: abs_ref.x + abs_ref.w - width, 272 | }[self.pos.x] 273 | y = { 274 | Loc.TOP: abs_ref.y, 275 | Loc.CENTER: abs_ref.y + round((abs_ref.h / 2) - (height / 2)), 276 | Loc.BOTTOM: abs_ref.y + abs_ref.h - height, 277 | }[self.pos.y] 278 | return Rect(x, y, width, height) 279 | 280 | def _cattr_matches(self, con: i3ipc.Con) -> bool: 281 | cval = getattr(con, self.client.cattr.value) 282 | if cval is None: 283 | return False 284 | if (isinstance(cval, str) and cval == self.name) or ( 285 | isinstance(cval, list) and self.name in cval 286 | ): 287 | return True 288 | return False 289 | 290 | @staticmethod 291 | def _escape(arg: str) -> str: 292 | if " " in arg: 293 | arg = f'"{arg}"' 294 | return arg 295 | 296 | 297 | class Kitts(Kitt): 298 | def __init__(self, *args, **kwargs): 299 | super().__init__(*args, **kwargs) 300 | 301 | def on_moved(self, _, we: i3ipc.WindowEvent) -> None: 302 | # Currently under Sway, if a container on an inactive workspace is moved, it 303 | # is forcibly reparented to its output's active workspace. Therefore, this 304 | # feature is diabled, pending https://github.com/swaywm/sway/issues/6465 . 305 | return 306 | 307 | def spawn(self) -> None: 308 | if self.client.cmd is None: 309 | return 310 | r = self.target_rect() 311 | c = self.commands 312 | self.send_rule(c.float_, c.resize.format(r.w, r.h), c.move.format(r.x, r.y)) 313 | super().spawn() 314 | 315 | def align_to_ws(self, event: Event) -> None: 316 | if event == Event.SPAWNED: 317 | return 318 | if event == Event.FLOATED and self.crosstalk_delay is not None: 319 | time.sleep(self.crosstalk_delay) 320 | self.log.debug(event) 321 | r = self.target_rect() 322 | c = self.commands 323 | if event == Event.SHOW: 324 | if self.anim.enabled and self.anim.show is not None: 325 | self._animate() 326 | else: 327 | self.send( 328 | c.fetch.format(self.focused_ws.name), 329 | c.resize.format(r.w, r.h), 330 | c.move.format(r.x, r.y), 331 | c.focus, 332 | ) 333 | elif event == Event.HIDE: 334 | if self.anim.enabled and self.anim.hide is not None and self._undisturbed(): 335 | self._animate(hide=True) 336 | else: 337 | self.send(self.commands.hide) 338 | else: 339 | self.send(c.resize.format(r.w, r.h), c.move.format(r.x, r.y)) 340 | 341 | def _animate(self, hide: bool = False) -> None: 342 | r = self.target_rect() 343 | c = self.commands 344 | role_x, role_y, start, end = { 345 | Loc.LEFT: ("{}", r.y, 0 - r.w, r.x), 346 | Loc.RIGHT: ("{}", r.y, 100, r.x), 347 | Loc.TOP: (r.x, "{}", 0 - r.h, r.y), 348 | Loc.BOTTOM: (r.x, "{}", 100, r.y), 349 | }[self.anim.anchor] 350 | if hide: 351 | start, end = end, start 352 | move_partial = c.move.format(role_x, role_y) 353 | 354 | def move_cb(pos: int, first: bool, last: bool) -> None: 355 | # when entering, ensure first frame move lands in same transaction as fetch 356 | if first and not hide: 357 | self.send( 358 | c.fetch.format(self.focused_ws.name), 359 | c.resize.format(r.w, r.h), 360 | move_partial.format(pos), 361 | c.focus, 362 | ) 363 | elif last and hide: 364 | self.send(self.commands.hide) 365 | else: 366 | self.send(move_partial.format(pos)) 367 | 368 | duration = hide and self.anim.hide or self.anim.show # type: ignore 369 | animate(move_cb, start, end, duration, self.anim.fps, hide) 370 | 371 | def _undisturbed(self) -> bool: 372 | tr = self.target_rect() 373 | cr = self.con_rect 374 | wr = self.focused_ws.rect 375 | # note: sway truncates when doing ppt->px conversion 376 | # (see e.g. resize.c:resize_set_floating, struct movement_amount) 377 | return False not in [ 378 | cr.x == int((tr.x / 100) * wr.width + wr.x), 379 | cr.y == int((tr.y / 100) * wr.height + wr.y), 380 | cr.w == int(wr.width * (tr.w / 100)), 381 | cr.h == int(wr.height * (tr.h / 100)), 382 | ] 383 | 384 | 385 | class Kitti3(Kitt): 386 | def align_to_ws(self, event: Event) -> None: 387 | # Under i3, in multi-output configurations, a ppt move is considered relative 388 | # to the rect defined by the bounding box of all outputs, not by the con's 389 | # workspace. Yes, this is madness, and so we have to do absolute moves (and 390 | # therefore we also do absolute resizes to stay consistent). 391 | c = self.commands 392 | if event == Event.SPAWNED: 393 | # floating will trigger on_floated to do the actual alignment 394 | self.send(c.float_) 395 | return 396 | ws = self.focused_ws if event == Event.SHOW else self.con_ws 397 | r = self.target_rect(Rect.from_i3ipc(ws.rect)) 398 | if event == Event.SHOW: 399 | self.send( 400 | c.fetch.format(ws.name), 401 | c.resize_abs.format(r.w, r.h), 402 | c.move_abs.format(r.x, r.y), 403 | c.focus, 404 | ) 405 | elif event == Event.HIDE: 406 | self.send(self.commands.hide) 407 | else: 408 | self.send(c.resize_abs.format(r.w, r.h), c.move_abs.format(r.x, r.y)) 409 | -------------------------------------------------------------------------------- /src/kitti3/util.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import enum 3 | import time 4 | from typing import Callable, List, NamedTuple, Optional 5 | 6 | import i3ipc 7 | 8 | 9 | class AnimParams(NamedTuple): 10 | enabled: bool 11 | anchor: "Loc" 12 | show: Optional[float] 13 | hide: Optional[float] 14 | fps: int 15 | 16 | 17 | class Cattr(enum.Enum): 18 | """Criteria attributes used to target client instances. 19 | 20 | Values are the corresponding i3ipc.Container attribute names. 21 | """ 22 | 23 | APP_ID = "app_id" 24 | CLASS = "window_class" 25 | CON_MARK = "marks" 26 | INSTANCE = "window_instance" 27 | TITLE = "name" 28 | 29 | def __str__(self): 30 | return self.name.lower() 31 | 32 | @classmethod 33 | def from_str(cls, name): 34 | try: 35 | attr = cls[name.upper()] 36 | except KeyError: 37 | raise ValueError(f"'{name}' is not a valid criterium attribute") from None 38 | return attr 39 | 40 | 41 | class Client(NamedTuple): 42 | cmd: str 43 | cattr: Cattr 44 | 45 | 46 | class Loc(enum.Enum): 47 | LEFT = L = enum.auto() 48 | RIGHT = R = enum.auto() 49 | TOP = T = enum.auto() 50 | BOTTOM = B = enum.auto() 51 | CENTER = C = enum.auto() 52 | 53 | 54 | class Pos(enum.Enum): 55 | LT = TL = LEFT = TOP = enum.auto() 56 | LC = CL = enum.auto() 57 | LB = BL = BOTTOM = enum.auto() 58 | CT = TC = enum.auto() 59 | CC = enum.auto() 60 | CB = BC = enum.auto() 61 | RT = TR = RIGHT = enum.auto() 62 | RC = CR = enum.auto() 63 | RB = BR = enum.auto() 64 | 65 | def __init__(self, _): 66 | self.compat: bool = False 67 | self.anchor: Optional[Loc] = None 68 | 69 | def __str__(self): 70 | return self.name 71 | 72 | @classmethod 73 | def from_str(cls, name) -> "Pos": 74 | try: 75 | pos = cls[name.upper()] 76 | except KeyError: 77 | raise argparse.ArgumentTypeError( 78 | f"'{name}' is not a valid position" 79 | ) from None 80 | if name.upper() in ("LEFT", "RIGHT"): 81 | pos.compat = True 82 | pos.anchor = cls._anchor_for(name.upper()) 83 | return pos 84 | 85 | @staticmethod 86 | def _anchor_for(name): 87 | if name == "CC": 88 | return None 89 | elif name[0] == "C": 90 | return Loc[name[1]] 91 | # also works for name in ("LEFT", "RIGHT") 92 | return Loc[name[0]] 93 | 94 | @property 95 | def x(self): 96 | return Loc[self.name[0]] 97 | 98 | @property 99 | def y(self): 100 | return Loc[self.name[1]] 101 | 102 | 103 | class Rect(NamedTuple): 104 | x: int 105 | y: int 106 | w: int 107 | h: int 108 | 109 | @classmethod 110 | def from_i3ipc(cls, r: i3ipc.Rect): 111 | return cls(r.x, r.y, r.width, r.height) 112 | 113 | 114 | class Shape(NamedTuple): 115 | x: float 116 | y: float 117 | 118 | @classmethod 119 | def from_strs(cls, strs: List[str], compat: bool = False) -> "Shape": 120 | fracts = [cls._proper_fraction(v) for v in strs] 121 | if compat: 122 | fracts = reversed(fracts) 123 | return cls(*fracts) 124 | 125 | @staticmethod 126 | def _proper_fraction(arg: str) -> float: 127 | val = None 128 | try: 129 | val = float(arg) 130 | except ValueError as e: 131 | val = e 132 | factors = arg.split("/") 133 | if len(factors) == 2: 134 | try: 135 | val = float(factors[0]) / float(factors[1]) 136 | except (ValueError, ZeroDivisionError) as e: 137 | val = e 138 | if isinstance(val, Exception): 139 | raise argparse.ArgumentTypeError(f"'{arg}': {val}") from None 140 | if not (0 <= val <= 1): 141 | raise argparse.ArgumentTypeError( 142 | f"'{arg}': {val:.3f} is not in the range [0, 1]" 143 | ) 144 | return val 145 | 146 | 147 | def animate( 148 | callback: Callable[[int, bool, bool], None], 149 | start: int, 150 | end: int, 151 | duration: float, 152 | fps: int, 153 | offset: bool, 154 | ): 155 | num_steps = round(duration * fps) 156 | if num_steps < 2: 157 | callback(end, True, True) 158 | return 159 | step_size = (end - start) / (num_steps - 1) 160 | linspaced = [round(start + step_size * (i + int(offset))) for i in range(num_steps)] 161 | steps = [] 162 | for pos in linspaced: 163 | if pos not in steps: 164 | steps.append(pos) 165 | # compromise fps if duplicate positions removed, to ensure duration 166 | delay = duration / (len(steps) - 1) 167 | final_frame = len(steps) - 1 168 | t_0 = t_curr = time.time() 169 | for frame, pos in enumerate(steps): 170 | t_target = t_0 + delay * frame 171 | while True: 172 | if t_curr >= t_target: 173 | callback(pos, (frame == 0), (frame == final_frame)) 174 | break 175 | else: 176 | # ok, as t_target approach prevents wakeup overhead from accumulating 177 | time.sleep(t_target - t_curr) 178 | t_curr = time.time() 179 | --------------------------------------------------------------------------------