├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── setup.py ├── tests ├── __init__.py ├── test_yabai_layout_details.py ├── test_yabai_navigator.py └── test_yabai_stacked_window_provider.py └── yabai_stack_navigator ├── __init__.py ├── __main__.py ├── cli.py ├── yabai_layout_details.py ├── yabai_navigator.py ├── yabai_provider.py └── yabai_stacked_window_provider.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/python,macos,linux 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=python,macos,linux 3 | 4 | ### Linux ### 5 | *~ 6 | 7 | # temporary files which can be created if a process still has a handle open of a deleted file 8 | .fuse_hidden* 9 | 10 | # KDE directory preferences 11 | .directory 12 | 13 | # Linux trash folder which might appear on any partition or disk 14 | .Trash-* 15 | 16 | # .nfs files are created when an open file is removed but is still being accessed 17 | .nfs* 18 | 19 | ### macOS ### 20 | # General 21 | .DS_Store 22 | .AppleDouble 23 | .LSOverride 24 | 25 | # Icon must end with two \r 26 | Icon 27 | 28 | 29 | # Thumbnails 30 | ._* 31 | 32 | # Files that might appear in the root of a volume 33 | .DocumentRevisions-V100 34 | .fseventsd 35 | .Spotlight-V100 36 | .TemporaryItems 37 | .Trashes 38 | .VolumeIcon.icns 39 | .com.apple.timemachine.donotpresent 40 | 41 | # Directories potentially created on remote AFP share 42 | .AppleDB 43 | .AppleDesktop 44 | Network Trash Folder 45 | Temporary Items 46 | .apdisk 47 | 48 | ### Python ### 49 | # Byte-compiled / optimized / DLL files 50 | __pycache__/ 51 | *.py[cod] 52 | *$py.class 53 | 54 | # C extensions 55 | *.so 56 | 57 | # Distribution / packaging 58 | .Python 59 | build/ 60 | develop-eggs/ 61 | dist/ 62 | downloads/ 63 | eggs/ 64 | .eggs/ 65 | parts/ 66 | sdist/ 67 | var/ 68 | wheels/ 69 | pip-wheel-metadata/ 70 | share/python-wheels/ 71 | *.egg-info/ 72 | .installed.cfg 73 | *.egg 74 | MANIFEST 75 | 76 | # PyInstaller 77 | # Usually these files are written by a python script from a template 78 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 79 | *.manifest 80 | *.spec 81 | 82 | # Installer logs 83 | pip-log.txt 84 | pip-delete-this-directory.txt 85 | 86 | # Unit test / coverage reports 87 | htmlcov/ 88 | .tox/ 89 | .nox/ 90 | .coverage 91 | .coverage.* 92 | .cache 93 | nosetests.xml 94 | coverage.xml 95 | *.cover 96 | *.py,cover 97 | .hypothesis/ 98 | .pytest_cache/ 99 | pytestdebug.log 100 | 101 | # Translations 102 | *.mo 103 | *.pot 104 | 105 | # Django stuff: 106 | *.log 107 | local_settings.py 108 | db.sqlite3 109 | db.sqlite3-journal 110 | 111 | # Flask stuff: 112 | instance/ 113 | .webassets-cache 114 | 115 | # Scrapy stuff: 116 | .scrapy 117 | 118 | # Sphinx documentation 119 | docs/_build/ 120 | doc/_build/ 121 | 122 | # PyBuilder 123 | target/ 124 | 125 | # Jupyter Notebook 126 | .ipynb_checkpoints 127 | 128 | # IPython 129 | profile_default/ 130 | ipython_config.py 131 | 132 | # pyenv 133 | .python-version 134 | 135 | # pipenv 136 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 137 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 138 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 139 | # install all needed dependencies. 140 | #Pipfile.lock 141 | 142 | # poetry 143 | #poetry.lock 144 | 145 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 146 | __pypackages__/ 147 | 148 | # Celery stuff 149 | celerybeat-schedule 150 | celerybeat.pid 151 | 152 | # SageMath parsed files 153 | *.sage.py 154 | 155 | # Environments 156 | # .env 157 | .env/ 158 | .venv/ 159 | env/ 160 | venv/ 161 | ENV/ 162 | env.bak/ 163 | venv.bak/ 164 | pythonenv* 165 | 166 | # Spyder project settings 167 | .spyderproject 168 | .spyproject 169 | 170 | # Rope project settings 171 | .ropeproject 172 | 173 | # mkdocs documentation 174 | /site 175 | 176 | # mypy 177 | .mypy_cache/ 178 | .dmypy.json 179 | dmypy.json 180 | 181 | # Pyre type checker 182 | .pyre/ 183 | 184 | # pytype static type analyzer 185 | .pytype/ 186 | 187 | # operating system-related files 188 | # file properties cache/storage on macOS 189 | *.DS_Store 190 | # thumbnail cache on Windows 191 | Thumbs.db 192 | 193 | # profiling data 194 | .prof 195 | 196 | 197 | # End of https://www.toptal.com/developers/gitignore/api/python,macos,linux 198 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python: Current File", 9 | "type": "python", 10 | "request": "launch", 11 | "program": "${file}", 12 | "console": "integratedTerminal" 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.pytestArgs": [ 3 | "." 4 | ], 5 | "python.testing.unittestEnabled": false, 6 | "python.testing.pytestEnabled": true 7 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Sendhil Panchadsaram 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Yabai Stack Navigator 2 | 3 | A simple script to make navigating between stacks and windows in [Yabai](https://github.com/koekeishiya/yabai) easier. I wrote this as I wanted to be able to use the same keyboard shortcut to navigate between windows and stacks. I found some options listed in [this](https://github.com/koekeishiya/yabai/issues/203) issue but they didn't quite do everything I wanted (specifically rotate at the end of the stacks). 4 | 5 | # Installation 6 | 7 | ``` 8 | pip install yabai-stack-navigator 9 | ``` 10 | 11 | # Usage 12 | 13 | Call the script with `--next` or `--previous` and it'll navigate to the appropriate window/stack. I use [skhd](https://github.com/koekeishiya/skhd) and my setup looks like: 14 | 15 | ``` 16 | alt - h : yabai-stack-navigator --prev 17 | alt - l : yabai-stack-navigator --next 18 | ``` 19 | 20 | Here's a video of this in action (note, I use the [Stackline](https://github.com/AdamWagner/stackline) to help with visualizing stacks). 21 | 22 | ![yabai stack navigator](https://user-images.githubusercontent.com/437043/132923238-e103370c-3bd8-43ba-8f01-45f451ce4f40.gif) 23 | 24 | 25 | # Changes 26 | 27 | ## 1.0.8 28 | 29 | - Fixed ordering bug with more than one external monitor. 30 | 31 | ## 1.0.7 32 | 33 | - Added ability to navigate between displays (except when encountering a stack). 34 | 35 | ## 1.0.6 36 | 37 | - Being a relative Python newbie, I made a mistake in the way I setup the CLI and it resulted in the main.py being added to the root folder of `site-packages`. Upgrading to this version should fix this, but you can double check by seeing if `main.py` in the python `site-packages` folder(e.g. `/opt/homebrew/lib/python3.x/site-packages/main.py`) matches [this](https://github.com/sendhil/yabai-stack-navigator/blob/7986767f48e4e26afbdca627c58df11658637e32/main.py) code and if it does please remove it. Sorry for the inconvenience. 38 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | with open("README.md", "r", encoding="utf-8") as fh: 4 | long_description = fh.read() 5 | 6 | setup( 7 | name='yabai_stack_navigator', 8 | packages=find_packages(), 9 | version='1.0.8', 10 | description='Script to make navigating between stacks on Yabai easier.', 11 | author='Sendhil Panchadsaram', 12 | license='MIT', 13 | long_description= 14 | "A simple script to make navigating between stacks and windows in Yabai(https://github.com/koekeishiya/yabai) easier. Details at https://github.com/sendhil/yabai-stack-navigator.", 15 | setup_requires=['pytest-runner'], 16 | tests_require=['pytest'], 17 | test_suite='tests', 18 | py_modules=['yabai_stack_navigator'], 19 | project_urls={ 20 | 'GitHub': 'https://github.com/sendhil/yabai-stack-navigator', 21 | }, 22 | entry_points=''' 23 | [console_scripts] 24 | yabai-stack-navigator=yabai_stack_navigator.cli:main 25 | '''), 26 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendhil/yabai-stack-navigator/22c84c5f473985f1ef0aaa6a008a614533fa5448/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_yabai_layout_details.py: -------------------------------------------------------------------------------- 1 | from yabai_stack_navigator.yabai_layout_details import YabaiLayoutDetails 2 | from unittest.mock import Mock, MagicMock 3 | 4 | 5 | def test_get_space_info_uses_correct_parameters(): 6 | mock = Mock() 7 | layout_details = YabaiLayoutDetails(yabai_provider=mock) 8 | 9 | layout_details.get_space_info() 10 | 11 | mock.call_yabai.assert_called_once_with( 12 | ["-m", "query", "--spaces", "--space"]) 13 | 14 | 15 | def test_get_data_for_windows_in_space_passes_index(): 16 | mock = Mock() 17 | layout_details = YabaiLayoutDetails(yabai_provider=mock) 18 | test_index = "test_index" 19 | 20 | layout_details.get_data_for_windows_in_space(test_index) 21 | 22 | mock.call_yabai.assert_called_once_with( 23 | ["-m", "query", "--windows", "--space", test_index]) 24 | 25 | 26 | def test_is_layout_stacked(): 27 | data_with_stack = {"type": "stack"} 28 | data_without_stack = {"type": "window"} 29 | 30 | layout_details = YabaiLayoutDetails() 31 | 32 | layout_details.get_space_info = MagicMock(return_value=data_with_stack) 33 | assert (layout_details.is_layout_stacked()) 34 | 35 | layout_details.get_space_info = MagicMock(return_value=data_without_stack) 36 | assert (not layout_details.is_layout_stacked()) 37 | 38 | 39 | def test_get_layout_index(): 40 | data = {"index": 5} 41 | 42 | layout_details = YabaiLayoutDetails() 43 | 44 | assert (layout_details.get_layout_index(data) == data["index"]) 45 | 46 | 47 | def test_sort_stack_windows_sorts_by_stack_index(): 48 | test_data = [ 49 | { 50 | "id": 3, 51 | "stack-index": 3 52 | }, 53 | { 54 | "id": 2, 55 | "stack-index": 2 56 | }, 57 | { 58 | "id": 1, 59 | "stack-index": 1 60 | }, 61 | ] 62 | 63 | layout_details = YabaiLayoutDetails() 64 | results = layout_details.sort_stacked_windows(test_data) 65 | 66 | assert (len(results) == 3) 67 | assert (results[0]["id"] == 1) 68 | assert (results[1]["id"] == 2) 69 | assert (results[2]["id"] == 3) 70 | 71 | 72 | def test_filter_windows(): 73 | test_data = [ 74 | { 75 | "window_id": 1, 76 | "app": "VSCode" 77 | }, 78 | { 79 | "window_id": 2, 80 | "app": "Hammerspoon" 81 | }, 82 | { 83 | "window_id": 3, 84 | "app": "Hammerspoon" 85 | }, 86 | { 87 | "window_id": 4, 88 | "app": "iTerm2" 89 | }, 90 | ] 91 | 92 | layout_details = YabaiLayoutDetails() 93 | results = layout_details.filter_windows(test_data) 94 | 95 | assert (len(results) == 2) 96 | assert (results[0]["window_id"] == 1 and results[0]["app"] == "VSCode") 97 | assert (results[1]["window_id"] == 4 and results[1]["app"] == "iTerm2") 98 | -------------------------------------------------------------------------------- /tests/test_yabai_navigator.py: -------------------------------------------------------------------------------- 1 | from yabai_stack_navigator.yabai_navigator import YabaiNavigator 2 | from unittest.mock import Mock, patch 3 | 4 | 5 | def test_focus_on_stacked_window_passes_in_window_id(): 6 | mock = Mock() 7 | test_window_id = "test_window_id" 8 | 9 | navigator = YabaiNavigator(yabai_provider=mock) 10 | navigator.focus_on_stacked_window(test_window_id) 11 | 12 | mock.call_yabai.assert_called_with( 13 | ["-m", "window", "--focus", test_window_id]) 14 | 15 | 16 | @patch('os.system') 17 | def test_focus_on_window_uses_next(mock_os): 18 | navigator = YabaiNavigator() 19 | navigator.focus_on_window(next=True) 20 | mock_os.assert_called_once() 21 | assert ("next" in mock_os.call_args[0][0]) 22 | 23 | 24 | @patch('os.system') 25 | def test_focus_on_window_uses_previous(mock_os): 26 | navigator = YabaiNavigator() 27 | navigator.focus_on_window(next=False) 28 | 29 | mock_os.assert_called_once() 30 | assert ("prev" in mock_os.call_args[0][0]) 31 | -------------------------------------------------------------------------------- /tests/test_yabai_stacked_window_provider.py: -------------------------------------------------------------------------------- 1 | from yabai_stack_navigator.yabai_stacked_window_provider \ 2 | import YabaiStackedWindowProvider 3 | from unittest.mock import Mock 4 | 5 | 6 | def base_test_data(): 7 | return [ 8 | { 9 | "focused": 0, 10 | "id": 1 11 | }, 12 | { 13 | "focused": 0, 14 | "id": 2 15 | }, 16 | { 17 | "focused": 0, 18 | "id": 3 19 | }, 20 | ] 21 | 22 | 23 | def new_data_format_test_data(): 24 | data = base_test_data() 25 | for index in range(len(data)): 26 | del data[index]["focused"] 27 | data[index]["has-focus"] = 0 28 | 29 | return data 30 | 31 | 32 | # Check the scenario when we're navigating from the middle 33 | def test_get_previous_and_next_windows_middle_node(): 34 | mock = Mock() 35 | test_data = base_test_data() 36 | test_data[1]["focused"] = 1 37 | mock.sort_stacked_windows.return_value = test_data 38 | provider = YabaiStackedWindowProvider(layout_details=mock) 39 | 40 | results = provider.get_previous_and_next_windows() 41 | 42 | assert (results["previous_window"]['id'] == 1) 43 | assert (results["next_window"]['id'] == 3) 44 | 45 | 46 | # Tests for the new data format which uses 'has-focus' instead of 'focused' 47 | def test_get_previous_and_next_windows_middle_node_new_data_format(): 48 | mock = Mock() 49 | test_data = new_data_format_test_data() 50 | test_data[1]["has-focus"] = 1 51 | mock.sort_stacked_windows.return_value = test_data 52 | provider = YabaiStackedWindowProvider(layout_details=mock) 53 | 54 | results = provider.get_previous_and_next_windows() 55 | 56 | assert (results["previous_window"]['id'] == 1) 57 | assert (results["next_window"]['id'] == 3) 58 | 59 | 60 | # Check the scenario when we're navigating from the first window 61 | def test_get_previous_and_next_windows_head_node(): 62 | mock = Mock() 63 | test_data = base_test_data() 64 | test_data[0]["focused"] = 1 65 | mock.sort_stacked_windows.return_value = test_data 66 | provider = YabaiStackedWindowProvider(layout_details=mock) 67 | 68 | results = provider.get_previous_and_next_windows() 69 | 70 | assert (results["previous_window"]['id'] == 3) 71 | assert (results["next_window"]['id'] == 2) 72 | 73 | 74 | # Check the scenario when we're navigating from the last window 75 | def test_get_previous_and_next_windows_tail_node(): 76 | mock = Mock() 77 | test_data = base_test_data() 78 | test_data[2]["focused"] = 1 79 | mock.sort_stacked_windows.return_value = test_data 80 | provider = YabaiStackedWindowProvider(layout_details=mock) 81 | 82 | results = provider.get_previous_and_next_windows() 83 | 84 | assert (results["previous_window"]['id'] == 2) 85 | assert (results["next_window"]['id'] == 1) 86 | 87 | 88 | def test_get_previous_and_next_windows_throws_exception(): 89 | mock = Mock() 90 | test_data = base_test_data() 91 | mock.sort_stacked_windows.return_value = test_data 92 | provider = YabaiStackedWindowProvider(layout_details=mock) 93 | 94 | try: 95 | provider.get_previous_and_next_windows() 96 | except Exception as e: 97 | assert (e is not None) 98 | assert ("Shoudln't" in str(e)) 99 | -------------------------------------------------------------------------------- /yabai_stack_navigator/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendhil/yabai-stack-navigator/22c84c5f473985f1ef0aaa6a008a614533fa5448/yabai_stack_navigator/__init__.py -------------------------------------------------------------------------------- /yabai_stack_navigator/__main__.py: -------------------------------------------------------------------------------- 1 | from yabai_stack_navigator import cli 2 | 3 | if __name__ == "__main__": 4 | cli.main() 5 | -------------------------------------------------------------------------------- /yabai_stack_navigator/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | import argparse 5 | import logging 6 | from typing import Any, Dict 7 | from yabai_stack_navigator.yabai_layout_details import YabaiLayoutDetails 8 | from yabai_stack_navigator.yabai_navigator import YabaiNavigator 9 | from yabai_stack_navigator.yabai_stacked_window_provider \ 10 | import YabaiStackedWindowProvider 11 | 12 | 13 | # TODO: Rewrite using click.py 14 | def parse_arg_data() -> Dict[str, Any]: 15 | parser = argparse.ArgumentParser() 16 | group = parser.add_mutually_exclusive_group() 17 | group.add_argument("-n", "--next", action="store_true", help="next window") 18 | group.add_argument("-p", 19 | "--previous", 20 | action="store_true", 21 | help="previous window") 22 | parser.add_argument("-v", 23 | "--verbose", 24 | action="store_true", 25 | help="Verbose mode to aid in debugging") 26 | 27 | if len(sys.argv) == 1: 28 | parser.print_help(sys.stderr) 29 | sys.exit(1) 30 | 31 | args = vars(parser.parse_args()) 32 | 33 | if args["verbose"]: 34 | logging.basicConfig(level=logging.DEBUG) 35 | 36 | return args 37 | 38 | 39 | def main(): 40 | args = parse_arg_data() 41 | 42 | navigator = YabaiNavigator() 43 | 44 | if YabaiLayoutDetails().is_layout_stacked(): 45 | logging.debug("Stack layout detected") 46 | window_navigation_data = YabaiStackedWindowProvider( 47 | ).get_previous_and_next_windows() 48 | window_key = "next_window" if args['next'] else "previous_window" 49 | if not args['next'] and not args['previous']: 50 | raise Exception("Should not get here") 51 | navigator.focus_on_window( 52 | window_navigation_data[window_key]["id"]) 53 | else: 54 | logging.debug("Non-stacked layout detected") 55 | if args['next']: 56 | navigator.focus_on_next_window() 57 | elif args['previous']: 58 | navigator.focus_on_previous_window() 59 | else: 60 | raise Exception("Should not get here") 61 | 62 | 63 | if __name__ == "__main__": 64 | main() 65 | -------------------------------------------------------------------------------- /yabai_stack_navigator/yabai_layout_details.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from yabai_stack_navigator.yabai_provider import YabaiProvider 3 | 4 | 5 | class YabaiLayoutDetails: 6 | AppsToFilterOut = set(["Hammerspoon", "Kap"]) 7 | 8 | def __init__(self, yabai_provider=YabaiProvider()): 9 | self.yabai_provider = yabai_provider 10 | 11 | def get_space_info(self): 12 | logging.debug("Called get_space_info") 13 | return self.yabai_provider.call_yabai( 14 | ["-m", "query", "--spaces", "--space"]) 15 | 16 | def get_space_info_for_display(self, display_index): 17 | logging.debug("Called get_space_info_for_display") 18 | return self.yabai_provider.call_yabai( 19 | ["-m", "query", "--spaces", "--display", str(display_index)]) 20 | 21 | def get_display_info(self): 22 | logging.debug("Called get_display_info") 23 | return self.yabai_provider.call_yabai( 24 | ["-m", "query", "--displays"]) 25 | 26 | def get_current_display_info(self): 27 | logging.debug("Called get_current_display_info") 28 | return self.yabai_provider.call_yabai( 29 | ["-m", "query", "--displays", "--display"]) 30 | 31 | def get_data_for_windows_in_current_space(self): 32 | logging.debug("Called get_data_for_windows_in_current_space") 33 | return self.yabai_provider.call_yabai( 34 | ["-m", "query", "--windows", "--space"]) 35 | 36 | def get_windows_for_display(self, display_index): 37 | logging.debug("Called get_windows_for_display") 38 | return self.yabai_provider.call_yabai( 39 | ["-m", "query", "--windows", "--display", str(display_index)]) 40 | 41 | def get_focused_window_index(self, windows): 42 | for index, window in enumerate(windows): 43 | if window["has-focus"]: 44 | return index 45 | logging.error("Could not find current window") 46 | raise "Could not find current window" 47 | 48 | def get_data_for_windows_in_space(self, index): 49 | logging.debug("Called get_data_for_windows_in_space") 50 | return self.yabai_provider.call_yabai( 51 | ["-m", "query", "--windows", "--space", 52 | str(index)]) 53 | 54 | def is_layout_stacked(self): 55 | logging.debug("Called is_layout_stacked") 56 | return self.get_space_info()["type"] == "stack" 57 | 58 | def get_layout_index(self, layout_data): 59 | logging.debug("Called get_layout_index") 60 | return layout_data["index"] 61 | 62 | def sort_stacked_windows(self, window_data): 63 | logging.debug("Called sort_stacked_windows") 64 | logging.debug(f"Data Before Sort: {window_data}") 65 | sorted_window_data = sorted(window_data, 66 | key=lambda i: i['stack-index']) 67 | logging.debug(f"Data After Sort: {sorted_window_data}") 68 | return sorted_window_data 69 | 70 | def filter_windows(self, window_data): 71 | logging.debug("Called filter_windows") 72 | return [ 73 | window for window in window_data 74 | if window["app"] not in YabaiLayoutDetails.AppsToFilterOut 75 | ] 76 | -------------------------------------------------------------------------------- /yabai_stack_navigator/yabai_navigator.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | from yabai_stack_navigator.yabai_provider import YabaiProvider 4 | from yabai_stack_navigator.yabai_layout_details import YabaiLayoutDetails 5 | from enum import Enum 6 | 7 | class Direction(Enum): 8 | East = 1 9 | West = 2 10 | 11 | class YabaiNavigator: 12 | def __init__(self, yabai_provider=YabaiProvider(), yabai_layout_details=YabaiLayoutDetails()): 13 | self.yabai_provider = yabai_provider 14 | self.yabai_layout_details = yabai_layout_details 15 | 16 | def focus_on_window(self, window_id): 17 | logging.debug("Focusing on window") 18 | args = ["-m", "window", "--focus", str(window_id)] 19 | return self.yabai_provider.call_yabai(args) 20 | 21 | def focus_on_display(self, display_index): 22 | logging.debug(f"Focusing on display : {display_index}") 23 | args = ["-m", "display", "--focus", str(display_index)] 24 | return self.yabai_provider.call_yabai(args) 25 | 26 | def focus_window_direction(self, direction:Direction): 27 | logging.debug(f"Focusing on window direction : {direction}") 28 | args = ["-m", "window", "--focus", "next" if direction == Direction.East else "prev"] 29 | return self.yabai_provider.call_yabai(args) 30 | 31 | def focus_on_next_window(self): 32 | self._handle_focus_window_in_direction(Direction.East) 33 | 34 | def focus_on_previous_window(self): 35 | self._handle_focus_window_in_direction(Direction.West) 36 | 37 | def _handle_focus_window_in_direction(self, direction:Direction): 38 | # 1. Get windows on current space 39 | logging.debug("Trying to find windows for the current space") 40 | windows_on_current_space = self.yabai_layout_details.get_data_for_windows_in_current_space() 41 | 42 | if not windows_on_current_space: 43 | logging.debug("Could not find windows on current space, going to just focus on the next display") 44 | next_display = self._get_next_display(direction) 45 | self.focus_on_display(next_display["index"]) 46 | return 47 | 48 | # 2. Is there a previous/next window? 49 | current_window = windows_on_current_space[self.yabai_layout_details.get_focused_window_index(windows_on_current_space)] 50 | if self._is_there_next_window(current_window["id"], direction): 51 | logging.debug("Found a next window to focus on in the current space") 52 | self.focus_window_direction(direction) 53 | return 54 | 55 | logging.debug("Did not find a next window to focus on in the current space") 56 | 57 | # 3. Determine next display (i.e. east/west, or roll over) 58 | logging.debug("Determining next display") 59 | next_display = self._get_next_display(direction) 60 | 61 | # 4. Are there windows on next display? If so, focus on the first. 62 | logging.debug("Trying to find windows on next display") 63 | 64 | has_windows, first_window_id = self._first_window_for_display(next_display["index"]) 65 | if has_windows: 66 | logging.debug(f"Found windows on next display, focusing on window {first_window_id}") 67 | self.focus_on_window(first_window_id) 68 | return 69 | 70 | logging.debug("Did not find windows on next display") 71 | 72 | # 5. Focus on next display 73 | logging.debug("Focusing on next display") 74 | self.focus_on_display(next_display["index"]) 75 | 76 | return 77 | 78 | def _is_there_next_window(self, current_window_id, direction:Direction): 79 | space_info = self.yabai_layout_details.get_space_info() 80 | if len(space_info["windows"]) <= 1: 81 | logging.debug("_is_there_next_window: one or fewer windows, so there can't be a next window") 82 | return False 83 | 84 | if direction == Direction.East: 85 | return space_info["last-window"] != current_window_id 86 | elif direction == Direction.West: 87 | return space_info["first-window"] != current_window_id 88 | 89 | raise "Should not get here" 90 | 91 | def _get_next_display(self, direction:Direction) -> int: 92 | display_info = self.yabai_layout_details.get_display_info() 93 | sorted(display_info, key=lambda info: info["frame"]["x"]) 94 | 95 | current_display_info = self.yabai_layout_details.get_current_display_info() 96 | current_display_index:Optional[int] = None 97 | for index, value in enumerate(display_info): 98 | if current_display_info["index"] == value["index"]: 99 | current_display_index = index 100 | break 101 | 102 | if current_display_index == None: 103 | logging.error("Could not find current display index") 104 | raise "Could not find current display index" 105 | 106 | delta = 1 if direction == Direction.East else -1 107 | next_display = display_info[(current_display_index + delta) % len(display_info)] 108 | logging.debug(f"Next display : {next_display}") 109 | 110 | return next_display 111 | 112 | # Returns (has_windows, window_id) 113 | def _first_window_for_display(self, display_index) -> (bool, int): 114 | space_info = self.yabai_layout_details.get_space_info_for_display(display_index) 115 | for item in space_info: 116 | if item["is-visible"]: 117 | return (item["first-window"] != 0, item["first-window"]) 118 | 119 | logging.error("Could not find a visible space") 120 | raise "Should not get here" 121 | 122 | 123 | 124 | -------------------------------------------------------------------------------- /yabai_stack_navigator/yabai_provider.py: -------------------------------------------------------------------------------- 1 | import json 2 | import subprocess 3 | import logging 4 | from typing import Any 5 | 6 | 7 | class YabaiProvider: 8 | """Class to abstract out calling out to Yabai""" 9 | def call_yabai(self, args) -> Any: 10 | if args[0] != "yabai": 11 | args = ["yabai"] + args 12 | 13 | logging.debug(f"Calling Yabai With Args: {args}") 14 | command_output = subprocess.run( 15 | args, stdout=subprocess.PIPE).stdout.decode('utf-8') 16 | 17 | if len(command_output) > 0: 18 | logging.debug(f"Output from Yabai: {command_output}") 19 | return json.loads(command_output) 20 | else: 21 | logging.debug("No output from Yabai") 22 | return None 23 | -------------------------------------------------------------------------------- /yabai_stack_navigator/yabai_stacked_window_provider.py: -------------------------------------------------------------------------------- 1 | from yabai_stack_navigator.yabai_layout_details import YabaiLayoutDetails 2 | 3 | 4 | class YabaiStackedWindowProvider: 5 | def __init__(self, layout_details=YabaiLayoutDetails()): 6 | self.layout_details = layout_details 7 | 8 | def get_previous_and_next_windows(self): 9 | space_data = self.layout_details.get_space_info() 10 | index = self.layout_details.get_layout_index(space_data) 11 | all_window_data = self.layout_details.get_data_for_windows_in_space( 12 | index) 13 | # This is to primarily remove Hammerspoon which shows up as 14 | # a part of the layout details in Yabai 15 | filtered_window_data = self.layout_details.filter_windows( 16 | all_window_data) 17 | sorted_window_data = self.layout_details.sort_stacked_windows( 18 | filtered_window_data) 19 | 20 | number_of_windows = len(sorted_window_data) 21 | for index, window in enumerate(sorted_window_data): 22 | if self._is_window_focused(window): 23 | next_window_index = (index + 1) % number_of_windows 24 | previous_window_index = (index - 1) % number_of_windows 25 | return { 26 | "previous_window": 27 | sorted_window_data[previous_window_index], 28 | "next_window": sorted_window_data[next_window_index] 29 | } 30 | 31 | raise Exception("Shoudln't get here") 32 | 33 | def _is_window_focused(self, window): 34 | # Yabai added breaking changes to it's data format in version 4.0. 35 | # This method just works around those changes and maintains backwards compatability. 36 | if "focused" in window: 37 | return window["focused"] 38 | elif "has-focus" in window: 39 | return window["has-focus"] 40 | else: 41 | raise "Focus key not found" 42 | --------------------------------------------------------------------------------