├── swayrst ├── __init__.py └── swayrst.py ├── requirements.txt ├── .github ├── pull_request_template.md └── workflows │ └── build.yml ├── CONTRIBUTING.md ├── setup.py ├── LICENSE └── README.md /swayrst/__init__.py: -------------------------------------------------------------------------------- 1 | from .swayrst import * 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | sh 2 | git+https://github.com/altdesktop/i3ipc-python.git 3 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | Please read our [contributing guidelines](/CONTRIBUTING.md) before submitting a pull request. 2 | Make sure to test your code and the existing code! -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Please follow these things, so your code can be merged seamlessly. 2 | 3 | 1. Use 4 spaces for indentation 4 | 2. Use ' instead of " for strings 5 | 3. Use [cherry-picking](https://docs.github.com/en/desktop/contributing-and-collaborating-using-github-desktop/managing-commits/cherry-picking-a-commit) to avoid unnecessary commits in the pull request 6 | 4. Maybe squash your commits before pull requesting 7 | 5. Revert accidents instead of changing them back with a new commit 8 | 6. Test your and rest of the code 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name='swayrst', 5 | version='1.3', 6 | packages=['swayrst'], 7 | scripts=['swayrst/swayrst.py'], 8 | url='https://github.com/Nama/swayrst', 9 | license='MIT', 10 | author='Yama', 11 | author_email='', 12 | description='Restore workspaces in sway to displays and move applications to saved workspaces.', 13 | entry_points={ 14 | 'console_scripts': [ 15 | 'swayrst = swayrst:main', 16 | ] 17 | } 18 | ) 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Murat 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 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build Executable 2 | 3 | on: 4 | push: 5 | branches: [ 'main' ] 6 | pull_request: 7 | branches: [ 'main' ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Set up Python 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: '3.x' 23 | cache: 'pip' 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install flake8 pyinstaller 28 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 29 | - name: Lint with flake8 30 | run: | 31 | # stop the build if there are Python syntax errors or undefined names 32 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 33 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 34 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 35 | - name: Build package 36 | run: python -m PyInstaller --onefile swayrst/swayrst.py 37 | - name: Add artifact 38 | uses: actions/upload-artifact@v4 39 | with: 40 | name: swayrst 41 | path: dist/swayrst 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # swayrst 2 | Called *sway-workspaces* before 3 | Restore workspaces in sway to displays and move already open windows to their workspaces. 4 | 5 | ## Setup 6 | 1. Download 7 | * Either from [AUR](https://aur.archlinux.org/packages/swayrst-git) with `yay -S swayrst-git` 8 | * Or download from [Actions](https://github.com/Nama/sway-workspaces/actions) or [Releases](https://github.com/Nama/sway-workspaces/releases) 9 | * `unzip` and `chmod +x` the file 10 | 1. Setup displays, e.g. with `swaymsg output ...` or a [GUI tool](https://github.com/swaywm/sway/wiki/Useful-add-ons-for-sway#displayoutputs) 11 | 1. Move the windows to your desired workspaces 12 | 1. Run `swayrst save ` 13 | 1. Repeat with another `profilename` for every display setup (desk, mobile, work) 14 | 1. Run `swayrst load ` to restore 15 | 1. Make [kanshi](https://sr.ht/~emersion/kanshi/) or [shikane](https://gitlab.com/w0lff/shikane) run this command for more automation 16 | 17 | 18 | ## Development 19 | ### Used objects in tree 20 | * `output` in `workspace` is replaced with the display name 21 | * The `nodes` list is used. All workspaces are in outputs (displays) 22 | * Windows are in a list in workspaces 23 | * Windows can be nested indefinitely, so `node_getter()` is used 24 | * This can happen if the user changes the layout of single windows, hence creates a new container 25 | * Workspace to output mapping are saved separately from the sway tree json 26 | * We get the information about which window is in which workspace from the tree: `i3.get_tree()`, `swaymsg -t get_tree` 27 | * There is no known way (to me) to identify the exact same windows after a reboot (to move multiple windows of the same tool) 28 | * `window` is only set for Xwayland windows - and is reset after a reboot 29 | * node IDs reset after a reboot 30 | -------------------------------------------------------------------------------- /swayrst/swayrst.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import os 4 | import sys 5 | import json 6 | import i3ipc 7 | import pickle 8 | import argparse 9 | from time import sleep 10 | from difflib import SequenceMatcher 11 | 12 | 13 | parser = argparse.ArgumentParser(description='Restore workspaces in sway to displays and move applications to saved workspaces ') 14 | parser.add_argument('command', choices=['save', 'load']) 15 | parser.add_argument('profile') 16 | parser.add_argument('-v', action='store_true') 17 | 18 | try: 19 | import sh 20 | except ModuleNotFoundError: 21 | sh = None 22 | if sh: 23 | try: 24 | from sh import dunstify 25 | except ImportError: 26 | dunstify = None 27 | notifysend = sh.Command('notify-send') 28 | else: 29 | dunstify = None 30 | notifysend = None 31 | 32 | PATH = None 33 | home_folder = os.path.expanduser('~') + '/' 34 | try: 35 | config_folder = os.environ['XDG_CONFIG_HOME'] + '/' 36 | except KeyError: 37 | config_folder = home_folder + '.config/' 38 | paths = [ 39 | home_folder + '.sway/', 40 | config_folder + 'sway/', 41 | home_folder + '.i3/', 42 | config_folder + 'i3/' 43 | ] 44 | for path in paths: 45 | if os.path.isdir(path): 46 | PATH = path + 'workspace_' 47 | break 48 | 49 | appname = sys.argv[0] 50 | workspace_mapping = None 51 | debug = parser.parse_args().v 52 | command = parser.parse_args().command 53 | profile = parser.parse_args().profile 54 | defaulted = [] 55 | couldnt_find = [] 56 | touched = [] 57 | 58 | sleep(2) 59 | 60 | 61 | def notify(headline, text): 62 | if dunstify: 63 | dunstify(f'--appname={appname}', headline, text) 64 | elif notifysend: 65 | notifysend(headline, text) 66 | 67 | 68 | def node_getter(nodes): 69 | apps = [] 70 | for node in nodes['nodes']: 71 | if 'app_id' in node.keys(): 72 | apps.append(node) 73 | else: 74 | gets = node_getter(node) 75 | for app in gets: 76 | apps.append(app) 77 | return apps 78 | 79 | 80 | def similar(a, b): 81 | return SequenceMatcher(None, a, b).ratio() 82 | 83 | 84 | def have_touched(tree_app): 85 | return tree_app in touched 86 | 87 | 88 | def touch_app(tree_app): 89 | if not have_touched(tree_app): 90 | touched.append(tree_app) 91 | 92 | 93 | def get_app(tree, app): 94 | if app.get('app_id'): 95 | # wayland 96 | app_class = app['app_id'] 97 | elif app.get('window'): 98 | # xwayland 99 | app_class = app.get('window_properties').get('class') 100 | else: 101 | return None 102 | found_apps = tree.find_classed(app_class) 103 | found_apps = [tree_app for tree_app in found_apps if not have_touched(tree_app)] 104 | if len(found_apps) == 0: 105 | return None 106 | elif len(found_apps) == 1: 107 | return found_apps[0] 108 | name = app.get('name') 109 | for tree_app in found_apps: 110 | if tree_app.name == name: 111 | touch_app(tree_app) 112 | if debug: 113 | print(f'Exact title match: {name}') 114 | return tree_app 115 | found_apps.sort(key=lambda tree_app: similar(tree_app.name, name)) 116 | tree_app = found_apps[-1] 117 | defaulted.append(tree_app) 118 | if debug: 119 | print(f'Similar title: {name}') 120 | return tree_app 121 | 122 | 123 | def main(): 124 | if not PATH: 125 | notify('Sway config not found!', 'Make sure to use a default config path (man sway)') 126 | print(f'Sway config not found! Make sure to use a default config path (man sway)') 127 | sys.exit(1) 128 | 129 | i3 = i3ipc.Connection() 130 | tree = i3.get_tree() 131 | outputs = i3.get_outputs() 132 | if command == 'save': 133 | # save current tree 134 | tree_file = open(f'{PATH}{profile}_tree.json', 'w') 135 | json.dump(tree.ipc_data, tree_file, indent=4) 136 | # save workspaces 137 | workspaces = i3.get_workspaces() 138 | for i, ws in enumerate(workspaces): 139 | for output in outputs: 140 | if output.name == ws.output: 141 | make = output.make 142 | model = output.model 143 | serial = output.serial 144 | output_identifier = f'{make} {model} {serial}' 145 | workspaces[i].output = output_identifier 146 | break 147 | pickle.dump(workspaces, open(PATH + profile, 'wb')) 148 | notify('Saved Workspace Setup', profile) 149 | elif command == 'load': 150 | try: 151 | workspace_mapping = pickle.load(open(PATH + profile, 'rb')) 152 | except FileNotFoundError: 153 | workspace_mapping = None 154 | if not workspace_mapping: 155 | notify('Can\'t find this mapping', profile) 156 | sys.exit(1) 157 | 158 | # Save focused workspaces to focus them again once we are done 159 | # Might be annoying for the first run after boot, we'll see 160 | active_workspaces = [] 161 | for output in outputs: 162 | if output.current_workspace: 163 | active_workspaces.append(output.current_workspace) 164 | # Move applications to the workspaces 165 | tree_file = open(f'{PATH}{profile}_tree.json') 166 | tree_loaded = json.load(tree_file) 167 | for output in tree_loaded['nodes']: 168 | if output['name'] == '__i3_scratch': 169 | continue 170 | for ws in output['nodes']: 171 | ws_name = ws['name'] 172 | ws_orientation = ws['orientation'] 173 | if len(ws['nodes']) == 0: # empty workspace 174 | continue 175 | apps = node_getter(ws) # in case of nested workspace, can happen indefinitely 176 | for app in apps: 177 | tree_app = get_app(tree, app) 178 | if not tree_app: 179 | couldnt_find.append(app) 180 | if debug: 181 | print(f'Couldn\'t find: {app.get("name")}') 182 | continue 183 | elif tree_app.workspace().name == ws_name: 184 | if debug: 185 | print(f'Window {app.get("name")} already in correct workspace {ws_name}') 186 | continue 187 | if ws_orientation == 'horizontal': 188 | o = 'h' 189 | else: 190 | o = 'v' 191 | tree_app.command(f'split {o}') 192 | tree_app.command(f'move --no-auto-back-and-forth container to workspace {ws_name}') 193 | if debug: 194 | print(f'Moved {tree_app.name} to {ws_name}') 195 | 196 | # Move workspaces to outputs 197 | for workspace in workspace_mapping: 198 | i3.command(f'workspace {workspace.name}') 199 | i3.command(f'move workspace to output "{workspace.output}"') 200 | for workspace in filter(lambda w: w.visible, workspace_mapping): 201 | i3.command(f'workspace {workspace.name}') 202 | for workspace in active_workspaces: 203 | if debug: 204 | print(f'active workspace: {workspace}') 205 | i3.command(f'workspace {workspace}') 206 | notify('Loaded Workspace Setup', profile) 207 | 208 | if not debug: 209 | sys.exit(0) 210 | if len(defaulted) != 0: 211 | print(f'Chose {len(defaulted)} heuristically:') 212 | for window in defaulted: 213 | print(window.app_id, window.name) 214 | total = len(tree.leaves()) 215 | num_touched = len(touched) 216 | if num_touched != total: 217 | print(f'Total windows: {total}') 218 | print(f'Left {total - num_touched} untouched') 219 | print(f'Touched: {num_touched}:') 220 | for window in touched: 221 | print(window.app_id, window.name) 222 | if len(couldnt_find) != 0: 223 | print(f'Couldn\'t find {len(couldnt_find)}:') 224 | for nodes in couldnt_find: 225 | for node in nodes['nodes']: 226 | print(node['name']) 227 | 228 | 229 | if __name__ == '__main__': 230 | main() 231 | --------------------------------------------------------------------------------