├── README.md ├── getModifierCode.py ├── setup.py ├── xpytile-remote-control.py ├── xpytile.py └── xpytilerc /README.md: -------------------------------------------------------------------------------- 1 | # xpytile 2 | 3 | **Tiling** and **simultaneous resizing** of side-by-side windows _(theoretically not only)_ for Xfce. 4 | 5 | 6 | # Purpose 7 | A Python script to auto-tile and to simultaneously resize docked windows for Xfce. 8 | 9 | 10 | ## Features: 11 | Simultaneous resizing of adjacent windows 12 | 5 different tilers 13 | 14 | Hotkeys for: 15 | - Tiling and/or simultaneous resizing can be enabled/disabled 16 | - Tiling can be triggered manually on demand 17 | - Changing tiler 18 | - Storing and re-creating current windows layout 19 | - Cycling windows 20 | - Swap windows 21 | - Set/unset window decoration 22 | - and more, see below 23 | 24 | The tiler can be controlled remotely. 25 | All settings are workspace specific. 26 | So for each workspace you can choose independently, if tiling is enabled and which tiler should be used. 27 | No limit of supported workspaces 28 | Config-file 29 | Pure Python, easily hackable 30 | 31 | ### New features 32 | Configurable which (CSD) applications should not be (un)decorated. 33 | Hotkey to focus previously active window 34 | Can be controlled remotely. 35 | Dragging a window with the mouse slightly over the left or the top or bottom border of the workspace 36 | triggers a re-tiling. So the window positions can be changed / swapped by dragging a window. 37 | Option to set the mouse-cursor in the middle of the new active window, when changed by hotkey. 38 |     _This visual feedback is helpful, especially when the window decoration is turned off._ 39 | Focus can be set to next adjacent window, in the direction of the pressed arrow-key 40 | Max. number of tiled windows for the currently active tiler can be increased/decreased per hot-key 41 | 42 | 43 | # Hotkeys 44 | Hotkeys can be defined in the config-file. 45 | Most important hotkeys _(full set see config-file)_: 46 | ```Super_L - 1```         tiler - master and stack vertically 47 | ```Super_L - 2```         tiler - vertically 48 | ```Super_L - 3```         tiler - master and stack horizontally 49 | ```Super_L - 4```         tiler - horizontally 50 | ```Super_L - 0```         tiler - maximize 51 | ```Super_L - c```         cycle tiler 52 | ```Super_L - 5```         restore windows layout 53 | ```Super_L - 6```         store windows layout 54 | ```Super_L - ^```         cycle windows 55 | ```Super_L - ESC```     swap current window with top/left-most window 56 | ```Super_L - q```         toggle simultaneous resizing (on/off) 57 | ```Super_L - w```         toggle tiling (on/off) 58 | ```Super_L - y```         toggle window-decoration (on/off) of tiled windows (*) 59 | ```Super_L - a```         shrink width/height of master window 60 | ```Super_L - s```         enlarge width/height of master window 61 | ```Super_L - m```         increment number of max. tiled windows for active tiler 62 | ```Super_L - n```         decrement number of max. tiled windows for active tiler 63 | ```Super_L - arrow```  focus next adjacent window in the given direction 64 | ```Super_L - b```         focus previously active window 65 | ```Super_L - .```         log name & tile of active window in ```/tmp/xpytile_.log``` 66 | ```Super_L - -```         exit 67 | *) Hint: In XFCE one can resize windows with ```Alt - Right-Click``` and drag, 68 |          which is useful when windows-decorations are turned off 69 | # Configuration 70 | Well, edit the _hopefully_ self-explanatory config-file xpytilerc 71 | # Installation 72 | Place xpytilerc in XDG_CONFIG_HOME in ~/.config/ or in /etc/ respectively 73 | ArchLinux - users can install ```xpytile-git``` from the AUR 74 | # Start 75 | ```./xpytile.py``` 76 | or, to let run in background: ```nohup ./xpytile.py > /dev/null 2>&1 &``` 77 | You may want to assign a hotkey. 78 | In Xfce for example, add a shortcut to xpytile.py with: 79 | Xfce-Menu -> Settings -> Keyboard -> Application Shortcuts 80 | # Dependencies 81 | notify-send _(package: notifylib for ArchLinux, libnotify-bin for Debian/Ubuntu)_ 82 | python3, python-xlib 83 | # Bugs 84 | I'm currently not aware of a bug. 85 | However, when the program crashes it writes traceback info in ```/tmp/xpytile_.log``` 86 | # License 87 | This program is free software: you can redistribute it and/or modify 88 | it under the terms of the GNU General Public License as published by 89 | the Free Software Foundation, either version 3 of the License, or 90 | (at your option) any later version. 91 | This program is distributed in the hope that it will be useful, 92 | but WITHOUT ANY WARRANTY; without even the implied warranty of 93 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 94 | GNU General Public License for more details. 95 | You should have received a copy of the GNU General Public License 96 | along with this program. If not, see . 97 | # Questions 98 | *(Q)* Are gaps supported? 99 | *(A)* Nope, but you could use an Xfce theme with gaps. 100 | 101 | *(Q)* Does xpytile support multiple monitor setups? 102 | *(A)* On workspaces that span multiple monitors, simultaneous resizing works fine, tiling not really. 103 | 104 | *(Q)* How do I get the exact name and title of a window I want xpytile to ignore? 105 | *(A)* Run xpyile with -v _or -vv_ or use the hotkey to log name and title of the current window. 106 | 107 | *(Q)* What can I do, xptile isn't picking up my hotkeys? 108 | *(A)* Run ```./getModifierCode.py```, press ```Super_L - 1``` _(or the modifier you'd like to use)_ 109 |       and check/edit ```xpytilerc``` _(line: modifier = )_ 110 | 111 | *(Q)* I'm running out of hotkeys, what can I do? 112 | *(A)* xpytile can be controlled remotely, consider making a dmenu or rofi script. 113 | -------------------------------------------------------------------------------- /getModifierCode.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from Xlib.display import Display 4 | from Xlib import X 5 | 6 | 7 | keyCode = 10 # keycode of key '1' 8 | 9 | def main(): 10 | 11 | disp = Display() 12 | Xroot = disp.screen().root 13 | 14 | Xroot.change_attributes(event_mask = X.KeyReleaseMask) 15 | Xroot.grab_key(keyCode, X.AnyModifier, 1, X.GrabModeAsync, X.GrabModeAsync) 16 | 17 | print('Ctrl-C stops program') 18 | print('Press 1') 19 | 20 | while True: 21 | event = Xroot.display.next_event() 22 | # print(f'event: {event}') 23 | print(f'keycode: {event.detail}') 24 | print(f'modifier: {event.state}\n') 25 | 26 | if __name__ == '__main__': 27 | main() 28 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name="xpytile", 5 | version="1.0.0", 6 | url="https://github.com/jaywilkas/xpytile.git", 7 | author="jaywilkas", 8 | description="Tiling and simultaneous resizing of side-by-side windows (not only) for Xfce", 9 | packages=find_packages(), 10 | install_requires=["python-xlib"], 11 | entry_points={ 12 | "console_scripts": [ 13 | "xpytile=xpytile:main", 14 | "getModifierCode=getModifierCode:main", 15 | ], 16 | }, 17 | ) 18 | -------------------------------------------------------------------------------- /xpytile-remote-control.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Demo remote control script for xpytile 6 | 7 | Sends an event with a command number to xpytile. 8 | The list of command-numbers can also be found 9 | in xpytilerc in section hotkeys. 10 | 11 | 12 | 13 | Copyright (C) 2021 jaywilkas 14 | 15 | This program is free software: you can redistribute it and/or modify 16 | it under the terms of the GNU General Public License as published by 17 | the Free Software Foundation, either version 3 of the License, or 18 | (at your option) any later version. 19 | 20 | This program is distributed in the hope that it will be useful, 21 | but WITHOUT ANY WARRANTY; without even the implied warranty of 22 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 23 | GNU General Public License for more details. 24 | 25 | You should have received a copy of the GNU General Public License 26 | along with this program. If not, see . 27 | """ 28 | 29 | 30 | import sys 31 | import Xlib.display 32 | import Xlib.X 33 | import Xlib.protocol 34 | 35 | 36 | # ---------------------------------------------------------------------------------------------------------------------- 37 | # no. command 38 | # 0 toggleResize 39 | # 1 toggleTiling 40 | # 2 toggleResizeAndTiling 41 | # 3 toggleMaximizeWhenOneWindowLeft 42 | # 4 toggleDecoration 43 | # 5 cycleWindows 44 | # 6 cycleTiler 45 | # 7 swapWindows 46 | # 8 storeCurrentWindowsLayout 47 | # 9 recreateWindowsLayout 48 | # 10 tileMasterAndStackVertically 49 | # 11 tileVertically 50 | # 12 tileMasterAndStackHorizontally 51 | # 13 tileHorizontally 52 | # 14 tileMaximize 53 | # 15 increaseMaxnumWindows 54 | # 16 decreaseMaxnumWindows 55 | # 17 exit 56 | # 18 logactiveWindow 57 | # 19 shrinkMaster 58 | # 20 enlargeMaster 59 | # 21 focusLeft 60 | # 22 focusRight 61 | # 23 focusUp 62 | # 24 focusDown 63 | # 25 focusPrevious 64 | # ---------------------------------------------------------------------------------------------------------------------- 65 | 66 | 67 | if len(sys.argv) != 2: 68 | print('missing command number') 69 | sys.exit(1) 70 | 71 | cmd = sys.argv[1] 72 | try: 73 | cmdNum = int(cmd) 74 | except ValueError: 75 | print('invalid command number') 76 | sys.exit(1) 77 | 78 | 79 | disp = Xlib.display.Display() 80 | screen = disp.screen() 81 | Xroot = screen.root 82 | 83 | XPYTILE_REMOTE = disp.intern_atom("_XPYTILE_REMOTE") 84 | data = (32, [cmdNum, 0,0,0,0]) 85 | 86 | clientMessage = Xlib.protocol.event.ClientMessage(window=Xroot, client_type=XPYTILE_REMOTE, data=data) 87 | mask = mask=(Xlib.X.SubstructureRedirectMask | Xlib.X.SubstructureNotifyMask) 88 | Xroot.send_event(clientMessage, event_mask=mask) 89 | disp.sync() 90 | -------------------------------------------------------------------------------- /xpytile.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | X-tiling helper 6 | with simultaneous resizing of docked (side-by-side) windows 7 | 8 | 9 | Copyright (C) 2021 jaywilkas 10 | 11 | This program is free software: you can redistribute it and/or modify 12 | it under the terms of the GNU General Public License as published by 13 | the Free Software Foundation, either version 3 of the License, or 14 | (at your option) any later version. 15 | 16 | This program is distributed in the hope that it will be useful, 17 | but WITHOUT ANY WARRANTY; without even the implied warranty of 18 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 19 | GNU General Public License for more details. 20 | 21 | You should have received a copy of the GNU General Public License 22 | along with this program. If not, see . 23 | """ 24 | 25 | import argparse 26 | import configparser 27 | import datetime 28 | from functools import lru_cache 29 | import os 30 | import re 31 | import shutil 32 | import socket 33 | import subprocess 34 | import sys 35 | import time 36 | import Xlib.display, Xlib.XK, Xlib.error, Xlib.protocol 37 | 38 | 39 | # ---------------------------------------------------------------------------------------------------------------------- 40 | class _list(object): 41 | """ 42 | Very simple "auto-expanding list like" - class 43 | Purpose: Easily handle desktop-specific tilingInfo when there are more desktops 44 | than the number of configured desktop-specfic entries (for example 45 | when the number of desktops get increased at runtime). 46 | """ 47 | 48 | def __init__(self, args=[]): 49 | self._list = args 50 | self.default_value = None 51 | 52 | def expand(self, i): 53 | if self.default_value is None and len(self._list): 54 | self.default_value = self._list[0] 55 | for n in range(len(self._list), i + 1): 56 | self._list.append(self.default_value) 57 | 58 | def __getitem__(self, i): 59 | if i >= len(self._list): 60 | self.expand(i) 61 | return self._list[i] 62 | 63 | def __setitem__(self, i, val): 64 | if i >= len(self._list): 65 | self.expand(i - 1) 66 | self._list[i] = val 67 | 68 | def append(self, val): 69 | self._list.append(val) 70 | 71 | def set_default(self, val): 72 | self.default_value = val 73 | 74 | def __str__(self): 75 | return str(self._list) 76 | # ---------------------------------------------------------------------------------------------------------------------- 77 | 78 | # ---------------------------------------------------------------------------------------------------------------------- 79 | def change_num_max_windows_by(deltaNum): 80 | """ 81 | Change the max number of windows to tile (limited between minimal 2 and maximal 9 windows) 82 | :param deltaNum: increment number of max windows by this value 83 | :return: 84 | """ 85 | global Xroot, NET_CURRENT_DESKTOP, ANY_PROPERTYTYPE 86 | 87 | currentDesktop = Xroot.get_full_property(NET_CURRENT_DESKTOP, ANY_PROPERTYTYPE).value[0] 88 | tilerNumber = tilingInfo['tiler'][currentDesktop] 89 | tilerNames_dict = {1: 'masterAndStackVertic', 2: 'vertically', 3: 'masterAndStackHoriz', 4: 'horizontally'} 90 | try: 91 | tilerName = tilerNames_dict[tilerNumber] 92 | except KeyError: 93 | return 94 | 95 | tilingInfo[tilerName]['maxNumWindows'] = min(max(tilingInfo[tilerName]['maxNumWindows'] + deltaNum, 2), 9) 96 | notify(tilingInfo[tilerName]['maxNumWindows']) 97 | # ---------------------------------------------------------------------------------------------------------------------- 98 | 99 | # ---------------------------------------------------------------------------------------------------------------------- 100 | def cycle_windows(): 101 | """ 102 | Cycles all -not minimized- windows of the current desktop 103 | :return: 104 | """ 105 | global disp, Xroot, NET_CURRENT_DESKTOP, ANY_PROPERTYTYPE 106 | 107 | # get a list of all -not minimized and not ignored- windows of the current desktop 108 | currentDesktop = Xroot.get_full_property(NET_CURRENT_DESKTOP, ANY_PROPERTYTYPE).value[0] 109 | winIDs = get_windows_on_desktop(currentDesktop) 110 | 111 | if len(winIDs) < 2: 112 | return 113 | 114 | for i, winID in enumerate(winIDs): 115 | try: 116 | winID_next = winIDs[i + 1] 117 | except IndexError as e: 118 | winID_next = winIDs[0] 119 | set_window_position(winID, x=windowsInfo[winID_next]['x'], y=windowsInfo[winID_next]['y']) 120 | set_window_size(winID, width=windowsInfo[winID_next]['width'], height=windowsInfo[winID_next]['height']) 121 | 122 | disp.sync() 123 | update_windows_info() 124 | # ---------------------------------------------------------------------------------------------------------------------- 125 | 126 | # ---------------------------------------------------------------------------------------------------------------------- 127 | def get_moved_border(winID, window): 128 | """ 129 | Return which border(s) of the window have been moved 130 | 131 | :param winID: ID of the window 132 | :param window: window 133 | :return: a number that indicates which window edges got shifted 134 | """ 135 | global windowsInfo 136 | 137 | moved_border = 0 138 | try: 139 | winInfo = windowsInfo[winID] 140 | geometry = get_window_geometry(window) 141 | if geometry is None: # window vanished 142 | return moved_border 143 | except KeyError: 144 | return moved_border 145 | 146 | if winInfo['x'] != geometry.x: 147 | moved_border += 1 # left border 148 | if winInfo['y'] != geometry.y: 149 | moved_border += 2 # upper border 150 | if winInfo['x2'] != geometry.x + geometry.width - 1: 151 | moved_border += 4 # right border 152 | if winInfo['y2'] != geometry.y + geometry.height - 1: 153 | moved_border += 8 # lower border 154 | 155 | return moved_border 156 | # ---------------------------------------------------------------------------------------------------------------------- 157 | 158 | # ---------------------------------------------------------------------------------------------------------------------- 159 | def get_parent_window(window): 160 | """ 161 | Thanks to this post: stackoverflow.com/questions/60141048/ 162 | Because an X window is not necessarily just what one thinks of 163 | as a window (the window manager may add an invisible frame, and 164 | so on), we record not just the active window but its ancestors 165 | up to the root, and treat a ConfigureNotify on any of those 166 | ancestors as meaning that the active window has been moved or resized. 167 | 168 | :param window: window 169 | :return: parent window 170 | """ 171 | 172 | try: 173 | pointer = window 174 | while pointer.id != Xroot.id: 175 | parentWindow = pointer 176 | pointer = pointer.query_tree().parent 177 | 178 | return parentWindow 179 | except: 180 | return None # window vanished 181 | # ---------------------------------------------------------------------------------------------------------------------- 182 | 183 | # ---------------------------------------------------------------------------------------------------------------------- 184 | def get_window_geometry(win): 185 | """ 186 | Return the geometry of the top most parent window. 187 | See the comment in get_active_window_and_ancestors() 188 | 189 | :param win: window 190 | :return: geometry of the top most parent window 191 | """ 192 | 193 | try: 194 | return get_parent_window(win).get_geometry() 195 | except: 196 | return None # window vanished 197 | # ---------------------------------------------------------------------------------------------------------------------- 198 | 199 | # ---------------------------------------------------------------------------------------------------------------------- 200 | def get_windows_name(winID, window): 201 | """ 202 | Get the application name of the window. 203 | Tries at first to find the name in the windowsInfo structure. 204 | If the winID is not yet known, get_wm_class() gets called. 205 | 206 | :param winID: ID of the window 207 | :param window: window 208 | :return: name of the window / application 209 | """ 210 | global windowsInfo 211 | 212 | try: 213 | name = windowsInfo[winID]['name'] 214 | except KeyError: 215 | try: 216 | wmclass, name = window.get_wm_class() 217 | except (TypeError, KeyError, Xlib.error.BadWindow): 218 | name = "UNKNOWN" 219 | 220 | return name 221 | # ---------------------------------------------------------------------------------------------------------------------- 222 | 223 | # ---------------------------------------------------------------------------------------------------------------------- 224 | def get_windows_on_desktop(desktop): 225 | """ 226 | Return a list of window-IDs of all -not minimized and not sticky- 227 | windows from our list on the given desktop 228 | 229 | :param desktop: number of desktop 230 | :return: list of window-IDs 231 | """ 232 | global windowsInfo, NET_WM_STATE_HIDDEN, NET_WM_STATE_STICKY 233 | 234 | winIDs = list() 235 | for winID, winInfo in windowsInfo.items(): 236 | try: 237 | if winInfo['desktop'] == desktop: 238 | propertyList = windowsInfo[winID]['win'].get_full_property(NET_WM_STATE, 0).value.tolist() 239 | if NET_WM_STATE_STICKY not in propertyList and NET_WM_STATE_HIDDEN not in propertyList: 240 | winIDs.append(winID) 241 | except Xlib.error.BadWindow: 242 | pass # window vanished 243 | 244 | return winIDs 245 | # ---------------------------------------------------------------------------------------------------------------------- 246 | 247 | # ---------------------------------------------------------------------------------------------------------------------- 248 | @lru_cache 249 | def get_windows_title(window): 250 | """ 251 | Get the title of the window. 252 | 253 | :param window: window 254 | :return: title of the window / application 255 | """ 256 | global NET_WM_NAME 257 | 258 | try: 259 | title = window.get_full_property(NET_WM_NAME, 0).value 260 | if isinstance(title, bytes): 261 | title = title.decode('UTF8', 'replace') 262 | except: 263 | title = '' 264 | 265 | return title 266 | # ---------------------------------------------------------------------------------------------------------------------- 267 | 268 | # ---------------------------------------------------------------------------------------------------------------------- 269 | def handle_key_event(keyCode, windowID_active, window_active): 270 | """ 271 | Perform the action associated with the hotkey 272 | 273 | :param keyCode: The code of the pressed (to be precise: released) hotkey 274 | :param windowID_active: ID of active window 275 | :param window_active: active window 276 | :return: windowID_active, window_active 277 | """ 278 | global hotkeys, tilingInfo, windowsInfo, disp 279 | 280 | if keyCode == hotkeys['toggleresize']: 281 | toggle_resize() 282 | elif keyCode == hotkeys['toggletiling']: 283 | if toggle_tiling(): 284 | update_windows_info() 285 | tile_windows(window_active) 286 | elif keyCode == hotkeys['toggleresizeandtiling']: 287 | toggle_resize() 288 | if toggle_tiling(): 289 | update_windows_info() 290 | tile_windows(window_active) 291 | elif keyCode == hotkeys['toggledecoration']: 292 | toggle_window_decoration() 293 | elif keyCode == hotkeys['enlargemaster']: 294 | tile_windows(window_active, resizeMaster=tilingInfo['stepSize']) 295 | elif keyCode == hotkeys['shrinkmaster']: 296 | tile_windows(window_active, resizeMaster=-tilingInfo['stepSize']) 297 | elif keyCode == hotkeys['togglemaximizewhenonewindowleft']: 298 | toggle_maximize_when_one_window() 299 | elif keyCode == hotkeys['cyclewindows']: 300 | update_windows_info() 301 | cycle_windows() 302 | elif keyCode == hotkeys['cycletiler']: 303 | update_windows_info() 304 | tile_windows(window_active, manuallyTriggered=True, tilerNumber='next') 305 | elif keyCode == hotkeys['swapwindows']: 306 | update_windows_info() 307 | swap_windows(windowID_active) 308 | elif keyCode == hotkeys['tilemasterandstackvertically']: 309 | update_windows_info() 310 | tile_windows(window_active, manuallyTriggered=True, tilerNumber=1) 311 | elif keyCode == hotkeys['tilevertically']: 312 | update_windows_info() 313 | tile_windows(window_active, manuallyTriggered=True, tilerNumber=2) 314 | elif keyCode == hotkeys['tilemasterandstackhorizontally']: 315 | update_windows_info() 316 | tile_windows(window_active, manuallyTriggered=True, tilerNumber=3) 317 | elif keyCode == hotkeys['tilehorizontally']: 318 | update_windows_info() 319 | tile_windows(window_active, manuallyTriggered=True, tilerNumber=4) 320 | elif keyCode == hotkeys['tilemaximize']: 321 | update_windows_info() 322 | tile_windows(window_active, manuallyTriggered=True, tilerNumber=5) 323 | elif keyCode == hotkeys['increasemaxnumwindows']: 324 | change_num_max_windows_by(1) 325 | update_windows_info() 326 | tile_windows(window_active) 327 | elif keyCode == hotkeys['decreasemaxnumwindows']: 328 | change_num_max_windows_by(-1) 329 | update_windows_info() 330 | tile_windows(window_active) 331 | elif keyCode == hotkeys['recreatewindowslayout']: 332 | recreate_window_geometries() 333 | elif keyCode == hotkeys['storecurrentwindowslayout']: 334 | update_windows_info() 335 | store_window_geometries() 336 | elif keyCode == hotkeys['logactivewindow']: 337 | log_active_window(windowID_active, window_active) 338 | elif keyCode == hotkeys['focusup']: 339 | windowID_active, window_active = set_window_focus(windowID_active, window_active, 'up') 340 | elif keyCode == hotkeys['focusdown']: 341 | windowID_active, window_active = set_window_focus(windowID_active, window_active, 'down') 342 | elif keyCode == hotkeys['focusleft']: 343 | windowID_active, window_active = set_window_focus(windowID_active, window_active, 'left') 344 | elif keyCode == hotkeys['focusright']: 345 | windowID_active, window_active = set_window_focus(windowID_active, window_active, 'right') 346 | elif keyCode == hotkeys['focusprevious']: 347 | windowID_active, window_active = set_window_focus_to_previous(windowID_active, window_active) 348 | elif keyCode == hotkeys['togglegroup0']: # handling groups of windows is under development 349 | toggle_group(windowID_active, 0) 350 | elif keyCode == hotkeys['showgroup0']: 351 | windowID_active, window_active = show_group(0) 352 | elif keyCode == hotkeys['togglegroup1']: 353 | toggle_group(windowID_active, 1) 354 | elif keyCode == hotkeys['showgroup1']: 355 | windowID_active, window_active = show_group(1) 356 | elif keyCode == hotkeys['exit']: 357 | # On exit, make sure all windows are decorated 358 | update_windows_info() 359 | for winID in windowsInfo: 360 | set_window_decoration(winID, True) 361 | disp.sync() 362 | notify('exit') 363 | quit() 364 | 365 | return windowID_active, window_active 366 | # ---------------------------------------------------------------------------------------------------------------------- 367 | 368 | # ---------------------------------------------------------------------------------------------------------------------- 369 | def toggle_group(windowID_active, group): 370 | # handling groups of windows is under development 371 | global windowsInfo 372 | 373 | if group in windowsInfo[windowID_active]['groups']: 374 | windowsInfo[windowID_active]['groups'].remove(group) 375 | else: 376 | windowsInfo[windowID_active]['groups'].append(group) 377 | 378 | #windowID_active, window_active = show_group(group) 379 | 380 | print(windowsInfo[windowID_active]['name'], windowsInfo[windowID_active]['groups']) 381 | 382 | #return windowID_active, window_active 383 | # ---------------------------------------------------------------------------------------------------------------------- 384 | 385 | # ---------------------------------------------------------------------------------------------------------------------- 386 | def show_group(group): 387 | global windowsInfo, disp, Xroot, NET_CURRENT_DESKTOP, ANY_PROPERTYTYPE 388 | 389 | currentDesktop = Xroot.get_full_property(NET_CURRENT_DESKTOP, ANY_PROPERTYTYPE).value[0] 390 | for winID, winInfo in windowsInfo.items(): 391 | try: 392 | if winInfo['desktop'] == currentDesktop: 393 | propertyList = winInfo['win'].get_full_property(NET_WM_STATE, 0).value.tolist() 394 | if NET_WM_STATE_STICKY not in propertyList: 395 | if group in winInfo['groups']: 396 | set_window_minimized_state(winInfo['win'], 0) 397 | print(winInfo['name'], 0) 398 | else: 399 | set_window_minimized_state(winInfo['win'], 1) 400 | print(winInfo['name'], 1) 401 | except Xlib.error.BadWindow: 402 | pass # window vanished 403 | time.sleep(0.2) 404 | window_active = disp.get_input_focus().focus 405 | tile_windows(window_active, manuallyTriggered=True) 406 | windowID_active = Xroot.get_full_property(NET_ACTIVE_WINDOW, ANY_PROPERTYTYPE).value[0] 407 | 408 | return windowID_active, window_active 409 | # ---------------------------------------------------------------------------------------------------------------------- 410 | 411 | 412 | # ---------------------------------------------------------------------------------------------------------------------- 413 | def handle_remote_control_event(event, windowID_active, window_active): 414 | """ 415 | Perform the action associated with the command-number, given in the event details 416 | 417 | :param event: The remote control event 418 | :param windowID_active: ID of active window 419 | :param window_active: active window 420 | :return: windowID_active, window_active 421 | """ 422 | 423 | cmdNum = event._data['data'][1].tolist()[0] 424 | cmdList= ('toggleresize', 'toggletiling', # 0, 1 425 | 'toggleresizeandtiling', 'togglemaximizewhenonewindowleft', # 2, 3 426 | 'toggledecoration', 'cyclewindows', # 4, 5 427 | 'cycletiler', 'swapwindows', # 6, 7 428 | 'storecurrentwindowslayout', 'recreatewindowslayout', # 8, 9 429 | 'tilemasterandstackvertically', 'tilevertically', # 10, 11 430 | 'tilemasterandstackhorizontally', 'tilehorizontally', # 12, 13 431 | 'tilemaximize', 'increasemaxnumwindows', # 14, 15 432 | 'decreasemaxnumwindows', 'exit', # 16, 17 433 | 'logactivewindow', 'shrinkmaster', # 18, 19 434 | 'enlargemaster', 'focusleft', # 20, 21 435 | 'focusright', 'focusup', # 22, 23 436 | 'focusdown', 'focusprevious', # 24, 25 437 | 'togglegroup0', 'showgroup0', # 26, 27 438 | 'togglegroup1', 'showgroup1') # 28, 29 439 | 440 | cmd = cmdList[cmdNum] 441 | 442 | # simply re-use function handle_key_event() here 443 | try: 444 | keyCode = hotkeys[cmd] 445 | windowID_active, window_active = handle_key_event(keyCode, windowID_active, window_active) 446 | except IndexError: 447 | pass 448 | 449 | return windowID_active, window_active 450 | # ---------------------------------------------------------------------------------------------------------------------- 451 | 452 | # ---------------------------------------------------------------------------------------------------------------------- 453 | def hightlight_mouse_cursor(): 454 | """ 455 | Highlight mouse cursor, by hiding/showing several times 456 | :return: 457 | """ 458 | global disp, Xroot 459 | 460 | if not disp.has_extension('XFIXES') or disp.query_extension('XFIXES') is None: 461 | return 462 | 463 | disp.xfixes_query_version() 464 | for i in range(3): 465 | if i != 0: 466 | time.sleep(0.05) 467 | Xroot.xfixes_hide_cursor() 468 | disp.sync() 469 | time.sleep(0.05) 470 | Xroot.xfixes_show_cursor() 471 | disp.sync() 472 | # ---------------------------------------------------------------------------------------------------------------------- 473 | 474 | # ---------------------------------------------------------------------------------------------------------------------- 475 | def init(configFile='~/.config/xpytilerc'): 476 | """ 477 | Initialization 478 | configFile: file-path of the config-file 479 | :return: window_active, window_active_parent, windowID_active 480 | """ 481 | global disp, Xroot, screen 482 | global windowsInfo 483 | global NET_ACTIVE_WINDOW, NET_WM_DESKTOP, NET_CLIENT_LIST, NET_CURRENT_DESKTOP, NET_WM_STATE_MAXIMIZED_VERT 484 | global NET_WM_STATE_MAXIMIZED_HORZ, NET_WM_STATE, NET_WM_STATE_HIDDEN, NET_WORKAREA, NET_WM_NAME, NET_WM_STATE_MODAL 485 | global NET_WM_STATE_STICKY, MOTIF_WM_HINTS, ANY_PROPERTYTYPE, GTK_FRAME_EXTENTS 486 | global XPYTILE_REMOTE 487 | 488 | disp = Xlib.display.Display() 489 | screen = disp.screen() 490 | Xroot = screen.root 491 | 492 | NET_ACTIVE_WINDOW = disp.get_atom('_NET_ACTIVE_WINDOW') 493 | NET_WM_DESKTOP = disp.get_atom('_NET_WM_DESKTOP') 494 | NET_CLIENT_LIST = disp.get_atom('_NET_CLIENT_LIST') 495 | NET_CURRENT_DESKTOP = disp.get_atom('_NET_CURRENT_DESKTOP') 496 | NET_WM_STATE_MAXIMIZED_VERT = disp.get_atom('_NET_WM_STATE_MAXIMIZED_VERT') 497 | NET_WM_STATE_MAXIMIZED_HORZ = disp.get_atom('_NET_WM_STATE_MAXIMIZED_HORZ') 498 | NET_WM_STATE = disp.get_atom('_NET_WM_STATE') 499 | NET_WM_STATE_HIDDEN = disp.get_atom('_NET_WM_STATE_HIDDEN') 500 | NET_WM_NAME = disp.get_atom('_NET_WM_NAME') 501 | NET_WORKAREA = disp.get_atom('_NET_WORKAREA') 502 | NET_WM_STATE_MODAL = disp.get_atom('_NET_WM_STATE_MODAL') 503 | NET_WM_STATE_STICKY = disp.get_atom('_NET_WM_STATE_STICKY') 504 | MOTIF_WM_HINTS = disp.get_atom('_MOTIF_WM_HINTS') 505 | GTK_FRAME_EXTENTS = disp.get_atom('_GTK_FRAME_EXTENTS') 506 | ANY_PROPERTYTYPE = Xlib.X.AnyPropertyType 507 | XPYTILE_REMOTE = disp.get_atom('_XPYTILE_REMOTE') 508 | 509 | config = configparser.ConfigParser() 510 | config.read(os.path.expanduser(configFile)) 511 | init_tiling_info(config) 512 | init_hotkeys_info(config) 513 | init_notification_info(config) 514 | 515 | # determine active window and its parent 516 | window_active = disp.get_input_focus().focus 517 | windowID_active = Xroot.get_full_property(NET_ACTIVE_WINDOW, ANY_PROPERTYTYPE).value[0] 518 | window_active_parent = get_parent_window(window_active) 519 | 520 | # dictionary to keep track of the windows, their geometry and other information 521 | windowsInfo = dict() 522 | update_windows_info(windowID_active) 523 | 524 | # configure event-mask 525 | Xroot.change_attributes(event_mask=Xlib.X.PropertyChangeMask | Xlib.X.SubstructureNotifyMask | 526 | Xlib.X.KeyReleaseMask) 527 | notify('start') 528 | 529 | return window_active, window_active_parent, windowID_active 530 | # ---------------------------------------------------------------------------------------------------------------------- 531 | 532 | # ---------------------------------------------------------------------------------------------------------------------- 533 | def init_hotkeys_info(config): 534 | """ 535 | Read hotkey-config, fill hotkeys-dictionary and register key-combinations 536 | 537 | :param config: parsed config-file 538 | :return: 539 | """ 540 | global hotkeys, Xroot 541 | 542 | modifier = config['Hotkeys'].getint('modifier') 543 | if modifier == -1: 544 | modifier = Xlib.X.AnyModifier 545 | 546 | hotkeys = dict() 547 | for item in config.items('Hotkeys'): 548 | if item[0] != 'modifier': 549 | hotkeys[item[0]] = int(item[1]) 550 | Xroot.grab_key(int(item[1]), modifier, 1, Xlib.X.GrabModeAsync, Xlib.X.GrabModeAsync) 551 | # ---------------------------------------------------------------------------------------------------------------------- 552 | 553 | # ---------------------------------------------------------------------------------------------------------------------- 554 | def init_notification_info(config): 555 | """ 556 | Create a dict with notification configuration 557 | 558 | :param config: parsed config-file 559 | :return: 560 | """ 561 | global notificationInfo 562 | 563 | notificationInfo = dict() 564 | for item in config.items('Notification'): 565 | notificationInfo[item[0]] = item[1] 566 | notificationInfo['active'] = notificationInfo['active'] != 'False' 567 | # ---------------------------------------------------------------------------------------------------------------------- 568 | 569 | # ---------------------------------------------------------------------------------------------------------------------- 570 | def init_tiling_info(config): 571 | """ 572 | Initialize the tiling info data structure 573 | :param config: parsed config-file 574 | :return: 575 | """ 576 | global tilingInfo 577 | 578 | # ---------------------------------------------------------------------------- 579 | def getConfigValue(config, sectionName, entryName, fallBackValue, type='int'): 580 | try: 581 | if type == 'int': 582 | value = config[sectionName].getint(entryName, fallback=fallBackValue) 583 | elif type == 'float': 584 | value = config[sectionName].getfloat(entryName, fallback=fallBackValue) 585 | elif type == 'bool': 586 | value = config[sectionName].getboolean(entryName, fallback=fallBackValue) 587 | except ValueError: 588 | value = fallBackValue 589 | return value 590 | 591 | # ---------------------------------------------------------------------------- 592 | def parseConfigIgnoreWindowEntry(entry): 593 | 594 | retVal = {'name': None, 'title': None, '!title': None} 595 | strPos_name = strPos_title = None 596 | 597 | r = re.search('name: *".*"', entry) 598 | if r: strPos_name = r.span()[0] 599 | 600 | r = re.search('!{0,1}title: *".*"', entry) 601 | if r: strPos_title = r.span()[0] 602 | 603 | if strPos_name is None and strPos_title is None: 604 | return None 605 | 606 | if strPos_name is not None and (strPos_title is None or strPos_title > strPos_name): 607 | if strPos_title is not None: 608 | r = re.match(r'(name:\s*)(".*")(\s*)(!{0,1}title:\s*)(".*")', entry) 609 | else: 610 | r = re.match(r'(name:\s*)(".*")', entry) 611 | if r: 612 | retVal['name'] = re.compile(r.group(2)[1:-1]) 613 | if strPos_title is not None: 614 | retVal['title'] = re.compile(r.group(5)[1:-1]) 615 | retVal['!title'] = not r.group(4).startswith('!') 616 | 617 | return retVal 618 | 619 | # ---------------------------------------------------------------------------- 620 | 621 | tilingInfo = dict() 622 | 623 | # configured settings that define ... 624 | # ... what windows should be ignored depending on their name and title. 625 | tilingInfo['ignoreWindows'] = list() 626 | for line in config['General']['ignoreWindows'].split('\n'): 627 | entry = parseConfigIgnoreWindowEntry(line) 628 | if entry is not None: 629 | tilingInfo['ignoreWindows'].append(entry) 630 | 631 | # ... what windows should be ignored regarding (un)decoratating depending on their name and title. 632 | tilingInfo['ignoreWindowsForDecoration'] = list() 633 | for line in config['General']['ignoreWindowsForDecoration'].split('\n'): 634 | entry = parseConfigIgnoreWindowEntry(line) 635 | if entry is not None: 636 | tilingInfo['ignoreWindowsForDecoration'].append(entry) 637 | 638 | # ... which application should be tiled after some delay, depending on their name. 639 | tilingInfo['delayTilingWindowsWithNames'] = list() 640 | for entry in config['General']['delayTilingWindowsWithNames'].split('\n'): 641 | tilingInfo['delayTilingWindowsWithNames'].append(re.compile(entry[1:-1])) 642 | 643 | tilingInfo['delayTimeTiling'] = getConfigValue(config, 'General', 'delayTimeTiling', 0.5, 'float') 644 | 645 | # ... resize- , tiling- and window-decoration - status for each desktop. 646 | tilingInfo['resizeWindows'] = _list([]) 647 | tilingInfo['resizeWindows'].set_default(True) 648 | 649 | tilingInfo['tileWindows'] = _list([]) 650 | tilingInfo['tileWindows'].set_default(True) 651 | 652 | tilingInfo['windowDecoration'] = _list([]) 653 | tilingInfo['windowDecoration'].set_default(True) 654 | 655 | tilingInfo['tiler'] = _list([]) 656 | tilingInfo['tiler'].set_default(getConfigValue(config, 'General', 'defaultTiler', 1)) 657 | i = 1 658 | while True: 659 | _temp = getConfigValue(config, 'DefaultTilerPerDesktop', f'Desktop{i}', None) 660 | if _temp is None: 661 | break 662 | tilingInfo['tiler'].append(_temp) 663 | i += 1 664 | 665 | tilingInfo['maximizeWhenOneWindowLeft'] = _list([]) 666 | _temp = getConfigValue(config, 'General', 'defaultMaximizeWhenOneWindowLeft', True, 'bool') 667 | tilingInfo['maximizeWhenOneWindowLeft'].set_default(_temp) 668 | i = 1 669 | while True: 670 | _temp = getConfigValue(config, 'maximizeWhenOneWindowLeft', f'Desktop{i}', None, 'bool') 671 | if _temp is None: 672 | break 673 | tilingInfo['maximizeWhenOneWindowLeft'].append(_temp) 674 | i += 1 675 | 676 | # ... a margin, where edges with a distance smaller than that margin are considered docked. 677 | tilingInfo['margin'] = getConfigValue(config, 'General', 'margin', 100) 678 | 679 | # ... a minimal size, so not to shrink width or height of a window smaller than this. 680 | tilingInfo['minSize'] = getConfigValue(config, 'General', 'minSize', 350) 681 | 682 | # ... the increment when resizing the master window by hotkey. 683 | tilingInfo['stepSize'] = getConfigValue(config, 'General', 'stepSize', 50) 684 | 685 | # ... whether the mouse-cursor should follow a new active window (if selected by hotkey). 686 | tilingInfo['moveMouseIntoActiveWindow'] = \ 687 | getConfigValue(config, 'General', 'moveMouseIntoActiveWindow', True, 'bool') 688 | 689 | tilingInfo['masterAndStackVertic'] = dict() 690 | tilingInfo['masterAndStackVertic']['maxNumWindows'] = \ 691 | getConfigValue(config, 'masterAndStackVertic', 'maxNumWindows', 3) 692 | tilingInfo['masterAndStackVertic']['defaultWidthMaster'] = \ 693 | getConfigValue(config, 'masterAndStackVertic', 'defaultWidthMaster', 0.5, 'float') 694 | 695 | tilingInfo['horizontally'] = dict() 696 | tilingInfo['horizontally']['maxNumWindows'] = \ 697 | getConfigValue(config, 'horizontally', 'maxNumWindows', 3) 698 | 699 | tilingInfo['vertically'] = dict() 700 | tilingInfo['vertically']['maxNumWindows'] = \ 701 | getConfigValue(config, 'vertically', 'maxNumWindows', 3) 702 | 703 | tilingInfo['masterAndStackHoriz'] = dict() 704 | tilingInfo['masterAndStackHoriz']['maxNumWindows'] = \ 705 | getConfigValue(config, 'masterAndStackHoriz', 'maxNumWindows', 3) 706 | tilingInfo['masterAndStackHoriz']['defaultHeightMaster'] = \ 707 | getConfigValue(config, 'masterAndStackHoriz', 'defaultHeightMaster', 0.5, 'float') 708 | 709 | tilingInfo['userDefinedGeom'] = dict() 710 | # ---------------------------------------------------------------------------------------------------------------------- 711 | 712 | # ---------------------------------------------------------------------------------------------------------------------- 713 | def log_active_window(windowID_active, window_active): 714 | """ 715 | Prints the name and the title of the currently active window into a log-file. 716 | The purpose of this function is to easily get the name and title of windows/applications 717 | which should be ignored. 718 | 719 | :param windowID_active: ID of active window 720 | :param window_active: active window 721 | :return: 722 | """ 723 | 724 | fileName = os.path.join('/tmp', f'xpytile_{os.environ["USER"]}.log') 725 | with open(fileName, 'a') as f: 726 | dateStr = datetime.datetime.strftime(datetime.datetime.now(), '%x %X') 727 | f.write(f'[{dateStr}] name: {get_windows_name(windowID_active, window_active)},' 728 | f' title: {get_windows_title(window_active)}\n') 729 | # ---------------------------------------------------------------------------------------------------------------------- 730 | 731 | # ---------------------------------------------------------------------------------------------------------------------- 732 | def match(compRexExList, string): 733 | """ 734 | Check whether the string matches any of the regexes 735 | 736 | :param compRexExList: list of compiled regex-pattern 737 | :param string: string to test 738 | :return: 739 | """ 740 | 741 | for r in compRexExList: 742 | if r.match(string): 743 | return True 744 | return False 745 | # ---------------------------------------------------------------------------------------------------------------------- 746 | 747 | # ---------------------------------------------------------------------------------------------------------------------- 748 | def match_ignore(ignoreWindows, name, title): 749 | """ 750 | Checks whether to ignore the window, depending on its name and title 751 | 752 | :param ignoreWindows: list of dict, what combinations of name/title should be ignored 753 | :param name: name of the window/application 754 | :param title: title of the window 755 | :return: status whether to ignore the window [True | False] 756 | """ 757 | 758 | for e in ignoreWindows: 759 | if e['name'].match(name): 760 | if e['title'] is None: 761 | if verbosityLevel > 1: 762 | print('Ignoring window:\t' 763 | f'name "{name}" matches pattern "{e["name"].pattern}"\t' 764 | f'title is irrelevant') 765 | return True 766 | if bool(e['title'].match(title)) == e['!title']: 767 | if verbosityLevel > 1: 768 | print('Ignoring window:\t' 769 | f'name "{name}" matches pattern "{e["name"].pattern}"\t' 770 | f'{"!" * (not e["!title"])}title "{title}" {("does not match", "matches")[e["!title"]]}' 771 | f'pattern "{e["title"].pattern}"') 772 | return True 773 | 774 | return False 775 | # ---------------------------------------------------------------------------------------------------------------------- 776 | 777 | # ---------------------------------------------------------------------------------------------------------------------- 778 | def notify(case, status=None): 779 | """ 780 | Show a notification message (if active) 781 | 782 | :param case: The circumstance 783 | :param status: True / False (or None) 784 | :return: 785 | """ 786 | global notificationInfo 787 | 788 | if not notificationInfo['active']: 789 | return 790 | 791 | if isinstance(case, int): 792 | summary = 'Num win handled' 793 | message = str(case) 794 | iconFilePath = '' 795 | else: 796 | case = case.lower() 797 | if status is not None: 798 | message = [notificationInfo['off_message'], notificationInfo['on_message']][int(status)] 799 | status_str = ['off', 'on'][int(status)] 800 | else: 801 | message = notificationInfo[f'{case}_message'] 802 | status_str = '' 803 | 804 | iconFilePath = notificationInfo[f'{case}{status_str}_icon'] 805 | summary = notificationInfo[f'{case}_summary'] 806 | 807 | try: 808 | subprocess.Popen(['notify-send', '-t', notificationInfo['time'], 809 | f'--icon={iconFilePath}', summary, message]) 810 | except FileNotFoundError as e: 811 | pass 812 | # ---------------------------------------------------------------------------------------------------------------------- 813 | 814 | # ---------------------------------------------------------------------------------------------------------------------- 815 | def recreate_window_geometries(): 816 | """ 817 | Re-creates the geometry of all -not minimized- windows of the current desktop 818 | :return: 819 | """ 820 | global tilingInfo, disp, NET_CURRENT_DESKTOP, ANY_PROPERTYTYPE 821 | 822 | # get a list of all -not minimized and not ignored- windows on the given desktop 823 | currentDesktop = Xroot.get_full_property(NET_CURRENT_DESKTOP, ANY_PROPERTYTYPE).value[0] 824 | winIDs = get_windows_on_desktop(currentDesktop) 825 | 826 | for winID in winIDs: 827 | try: 828 | x = tilingInfo['userDefinedGeom'][currentDesktop][winID]['x'] 829 | y = tilingInfo['userDefinedGeom'][currentDesktop][winID]['y'] 830 | width = tilingInfo['userDefinedGeom'][currentDesktop][winID]['width'] 831 | height = tilingInfo['userDefinedGeom'][currentDesktop][winID]['height'] 832 | unmaximize_window(windowsInfo[winID]['win']) 833 | 834 | windowsInfo[winID]['win'].set_input_focus(Xlib.X.RevertToParent, Xlib.X.CurrentTime) 835 | windowsInfo[winID]['win'].configure(stack_mode=Xlib.X.Above) 836 | set_window_position(winID, x=x, y=y) 837 | set_window_size(winID, width=width, height=height) 838 | disp.sync() 839 | update_windows_info() 840 | except KeyError: 841 | pass # window is not present anymore (on this desktop) 842 | # ---------------------------------------------------------------------------------------------------------------------- 843 | 844 | # ---------------------------------------------------------------------------------------------------------------------- 845 | def resize_docked_windows(windowID_active, window_active, moved_border): 846 | """ 847 | Resize the side-by-side docked windwows. 848 | The function deliberately retrieves the current window geometry of the active window 849 | rather than using the already existing information of the event structure. 850 | This saves a good amount of redrawing. 851 | 852 | :param windowID_active: ID of the active window 853 | :param window_active: active window 854 | :param moved_border: points out which border of the active window got moved 855 | :return: 856 | """ 857 | global disp, tilingInfo, NET_WORKAREA 858 | global windowsInfo # dict with windows and their geometries (before last resize of the active window) 859 | 860 | tolerance = 3 861 | 862 | if moved_border not in [1, 2, 4, 8]: # 1: left, 2: upper, 4: right, 8: lower 863 | return None 864 | 865 | winInfo_active = windowsInfo[windowID_active] 866 | 867 | # check whether resizing is active for the desktop of the resized window 868 | desktop = winInfo_active['desktop'] 869 | if not tilingInfo['resizeWindows'][desktop]: 870 | return 871 | 872 | # geometry of work area (screen without taskbar) 873 | workAreaWidth, workAreaHeight = Xroot.get_full_property(NET_WORKAREA, 0).value.tolist()[2:4] 874 | 875 | for winID, winInfo in windowsInfo.items(): 876 | if winID == windowID_active or winInfo['desktop'] != desktop: 877 | continue 878 | 879 | if moved_border == 1: # left border 880 | # check, whether the windows were docked, 881 | # before the geometry of the active window changed 882 | if abs(winInfo['x2'] + 1 - winInfo_active['x']) <= tilingInfo['margin'] + tolerance and \ 883 | winInfo_active['y'] <= max(winInfo['y'], 0) + tolerance and \ 884 | winInfo_active['y2'] >= min(winInfo['y2'], workAreaHeight) - tolerance: 885 | geometry = get_window_geometry(window_active) 886 | if geometry is None: # window vanished 887 | return 888 | newWidth = geometry.x - winInfo['x'] 889 | if newWidth >= tilingInfo['minSize']: 890 | # resize, according to the new geometry of the active window 891 | set_window_size(winID, width=newWidth) 892 | disp.sync() 893 | # update_windows_info() 894 | 895 | elif moved_border == 2: # upper border 896 | # check, whether the windows were docked, 897 | # before the geometry of the active window got changed 898 | if abs(winInfo['y2'] + 1 - winInfo_active['y']) <= tilingInfo['margin'] + tolerance and \ 899 | winInfo_active['x'] <= max(winInfo['x'], 0) + tolerance and \ 900 | winInfo_active['x2'] >= min(winInfo['x2'], workAreaWidth) - tolerance: 901 | geometry = get_window_geometry(window_active) 902 | if geometry is None: # window vanished 903 | return 904 | newHeight = geometry.y - winInfo['y'] 905 | if newHeight >= tilingInfo['minSize']: 906 | # resize, according to the new geometry of the active window 907 | set_window_size(winID, height=newHeight) 908 | disp.sync() 909 | # update_windows_info() 910 | 911 | elif moved_border == 4: # right border 912 | if abs(winInfo_active['x2'] + 1 - winInfo['x']) <= tilingInfo['margin'] + tolerance and \ 913 | winInfo_active['y'] <= max(winInfo['y'], 0) + tolerance and \ 914 | winInfo_active['y2'] >= min(winInfo['y2'], workAreaHeight) - tolerance: 915 | winActiveGeom = get_window_geometry(window_active) 916 | if winActiveGeom is None: # window vanished 917 | return 918 | winActive_x2 = winActiveGeom.x + winActiveGeom.width - 1 919 | newWidth = winInfo['x2'] - winActive_x2 920 | if newWidth >= tilingInfo['minSize']: 921 | set_window_position(winID, x=winActive_x2 + 1) 922 | set_window_size(winID, width=newWidth) 923 | disp.sync() 924 | # update_windows_info() 925 | 926 | elif moved_border == 8: # lower border 927 | if abs(winInfo_active['y2'] + 1 - winInfo['y']) <= tilingInfo['margin'] + tolerance and \ 928 | winInfo_active['x'] <= max(winInfo['x'], 0) + tolerance and \ 929 | winInfo_active['x2'] >= min(winInfo['x2'], workAreaWidth) - tolerance: 930 | winActiveGeom = get_window_geometry(window_active) 931 | if winActiveGeom is None: # window vanished 932 | return 933 | winActive_y2 = winActiveGeom.y + winActiveGeom.height - 1 934 | newHeight = winInfo['y2'] - winActive_y2 935 | if newHeight >= tilingInfo['minSize']: 936 | set_window_position(winID, y=winActive_y2 + 1) 937 | set_window_size(winID, height=newHeight) 938 | disp.sync() 939 | # update_windows_info() 940 | # ---------------------------------------------------------------------------------------------------------------------- 941 | 942 | # ---------------------------------------------------------------------------------------------------------------------- 943 | def set_setxy_win(winID): 944 | """ 945 | For some applications/windows the positioning works fine when using their parent window, 946 | while for other applications this works when using their own window. 947 | This function determines which of these windows to take for this operation 948 | and stores this information in the windowsInfo - dictionary. 949 | As a test, the function tries to temporarily move the window down by one pixel, 950 | retrieves the actual position and then places it back. 951 | 952 | Unfortunately the awesome emwh (https://github.com/parkouss/pyewmh) doesn't seem to be 953 | a good option here, since it's use for resizing leads to a very annoying flickering 954 | of some applications (e.g. the Vivaldi-browser) while resizing them. 955 | TODO: Find a more elegant solution 956 | 957 | :param winID: windows-ID 958 | :return: 959 | """ 960 | global windowsInfo 961 | 962 | try: 963 | if windowsInfo[winID]['winSetXY'] is not None: 964 | return # already set 965 | except KeyError: 966 | return 967 | 968 | try: 969 | unmaximize_window(windowsInfo[winID]['win']) 970 | oldY = windowsInfo[winID]['y'] 971 | oldX = windowsInfo[winID]['x'] 972 | windowsInfo[winID]['winParent'].configure(y=oldY + 1) 973 | disp.sync() 974 | 975 | time.sleep(0.05) 976 | newGeom = get_window_geometry(windowsInfo[winID]['winParent']) 977 | if abs(oldY + 1 - newGeom.y) <= 1: 978 | windowsInfo[winID]['winSetXY'] = windowsInfo[winID]['winParent'] 979 | else: 980 | windowsInfo[winID]['winSetXY'] = windowsInfo[winID]['win'] 981 | 982 | # restore old position 983 | windowsInfo[winID]['winSetXY'].configure(x=oldX, y=oldY) 984 | disp.sync() 985 | except (Xlib.error.BadWindow, AttributeError, KeyError) as e: 986 | pass # window vanished 987 | # ---------------------------------------------------------------------------------------------------------------------- 988 | 989 | # ---------------------------------------------------------------------------------------------------------------------- 990 | def set_window_decoration(winID, status): 991 | """ 992 | Undecorate / decorate the given window (title-bar and border) 993 | 994 | :param winID: ID of the window 995 | :param status: controls whether to show the decoration (True | False) 996 | :return: 997 | """ 998 | global windowsInfo, tilingInfo, MOTIF_WM_HINTS, ANY_PROPERTYTYPE, GTK_FRAME_EXTENTS 999 | 1000 | try: 1001 | window = windowsInfo[winID]['win'] 1002 | 1003 | # Don't change the decoration, if this window has CSD (client-side-decoration) 1004 | if window.get_property(GTK_FRAME_EXTENTS, ANY_PROPERTYTYPE, 0, 32) is not None: 1005 | return 1006 | if match_ignore(tilingInfo['ignoreWindowsForDecoration'], 1007 | window.get_wm_class()[1], get_windows_title(window)): 1008 | return 1009 | 1010 | if (result := window.get_property(MOTIF_WM_HINTS, ANY_PROPERTYTYPE, 0, 32)): 1011 | hints = result.value 1012 | if hints[2] == int(status): 1013 | return 1014 | hints[2] = int(status) 1015 | else: 1016 | hints = (2, 0, int(status), 0, 0) 1017 | window.change_property(MOTIF_WM_HINTS, MOTIF_WM_HINTS, 32, hints) 1018 | set_window_position(winID, x=windowsInfo[winID]['x'], y=windowsInfo[winID]['y']) 1019 | set_window_size(winID, width=windowsInfo[winID]['width'], height=windowsInfo[winID]['height']) 1020 | except: 1021 | pass 1022 | # ---------------------------------------------------------------------------------------------------------------------- 1023 | 1024 | # ---------------------------------------------------------------------------------------------------------------------- 1025 | def set_window_focus(windowID_active, window_active, direction='left'): 1026 | """ 1027 | Make another window the active one. 1028 | Move the focus from the currently active window to the next adjacent one in the given direction. 1029 | Metric: Distance in the given direction plus 1030 | half of the distance in the orthogonal direction 1031 | Place the mouse-cursor in the middle of the new window, if the active window gets changed and the respective 1032 | option is set. 1033 | 1034 | :param windowID_active: ID of active window 1035 | :param window_active: active window 1036 | :param direction: 'left', 'right', 'up' or 'down' 1037 | :return: windowID_active, window_active 1038 | """ 1039 | global windowsInfo, tilingInfo, disp, Xroot 1040 | 1041 | # get a list of all -not minimized and not ignored- windows of the current desktop 1042 | desktop = windowsInfo[windowID_active]['desktop'] 1043 | winIDs = get_windows_on_desktop(desktop) 1044 | 1045 | if len(winIDs) < 2: 1046 | return windowID_active, window_active 1047 | 1048 | winID_next = None 1049 | bestDistance = 1E99 1050 | 1051 | x_active = windowsInfo[windowID_active]['x'] 1052 | y_active = windowsInfo[windowID_active]['y'] 1053 | 1054 | # iterate over all windows on the current desktop 1055 | # and find the next adjacent in the given direction 1056 | for winID in winIDs: 1057 | if winID == windowID_active: 1058 | continue 1059 | 1060 | x = windowsInfo[winID]['x'] 1061 | y = windowsInfo[winID]['y'] 1062 | distance = 1E99 1063 | 1064 | if direction == 'up': 1065 | if y_active <= y: 1066 | continue 1067 | else: 1068 | distance = y_active - y + abs(x - x_active)/2 1069 | elif direction == 'down': 1070 | if y_active >= y: 1071 | continue 1072 | else: 1073 | distance = y - y_active + abs(x - x_active)/2 1074 | elif direction == 'right': 1075 | if x_active >= x: 1076 | continue 1077 | else: 1078 | distance = x - x_active + abs(y - y_active)/2 1079 | elif direction == 'left': 1080 | if x_active <= x: 1081 | continue 1082 | else: 1083 | distance = x_active - x + abs(y - y_active)/2 1084 | 1085 | if distance < bestDistance: 1086 | bestDistance = distance 1087 | winID_next = winID 1088 | 1089 | if winID_next: 1090 | # set focus and make sure the window is in foreground 1091 | windowsInfo[winID_next]["win"].set_input_focus(Xlib.X.RevertToParent, 0) 1092 | windowsInfo[winID_next]["win"].configure(stack_mode=Xlib.X.Above) 1093 | # place mouse-cursor in the middle of the new active window (if this option is activated) 1094 | if tilingInfo['moveMouseIntoActiveWindow']: 1095 | x = int((windowsInfo[winID_next]['x'] + windowsInfo[winID_next]['x2']) / 2) 1096 | y = int((windowsInfo[winID_next]['y'] + windowsInfo[winID_next]['y2']) / 2) 1097 | Xlib.ext.xtest.fake_input(disp, Xlib.X.MotionNotify, x=x, y=y) 1098 | hightlight_mouse_cursor() 1099 | # update windowID_active and window_active, to inform function run() 1100 | windowID_active = winID_next 1101 | window_active = disp.create_resource_object('window', windowID_active) 1102 | # time when currently active window got focussed 1103 | windowsInfo[windowID_active]['time'] = datetime.datetime.now() 1104 | 1105 | return windowID_active, window_active 1106 | # ---------------------------------------------------------------------------------------------------------------------- 1107 | 1108 | # ---------------------------------------------------------------------------------------------------------------------- 1109 | def set_window_focus_to_previous(windowID_active, window_active): 1110 | """ 1111 | Make another window the active one. 1112 | Move the focus from the currently active window to the previously focussed one. 1113 | Place the mouse-cursor in the middle of the new window, if the active window gets changed and the respective 1114 | option is set. 1115 | 1116 | :param windowID_active: ID of active window 1117 | :param window_active: active window 1118 | :return: windowID_active, window_active 1119 | """ 1120 | global windowsInfo, tilingInfo, disp, Xroot 1121 | 1122 | # get a list of all -not minimized and not ignored- windows of the current desktop 1123 | desktop = windowsInfo[windowID_active]['desktop'] 1124 | winIDs = get_windows_on_desktop(desktop) 1125 | 1126 | if len(winIDs) < 2: 1127 | return windowID_active, window_active 1128 | 1129 | winID_next = None 1130 | t_best = 0 1131 | 1132 | # iterate over all windows on the current desktop 1133 | # and find the previously focussed one 1134 | for winID in winIDs: 1135 | if winID == windowID_active: 1136 | continue 1137 | try: 1138 | if windowsInfo[winID]['time'] > t_best: 1139 | t_best = windowsInfo[winID]['time'] 1140 | winID_next = winID 1141 | except (KeyError, TypeError): 1142 | pass 1143 | 1144 | if winID_next: 1145 | # set focus and make sure the window is in foreground 1146 | windowsInfo[winID_next]["win"].set_input_focus(Xlib.X.RevertToParent, 0) 1147 | windowsInfo[winID_next]["win"].configure(stack_mode=Xlib.X.Above) 1148 | # place mouse-cursor in the middle of the new active window (if this option is activated) 1149 | if tilingInfo['moveMouseIntoActiveWindow']: 1150 | x = int((windowsInfo[winID_next]['x'] + windowsInfo[winID_next]['x2']) / 2) 1151 | y = int((windowsInfo[winID_next]['y'] + windowsInfo[winID_next]['y2']) / 2) 1152 | Xlib.ext.xtest.fake_input(disp, Xlib.X.MotionNotify, x=x, y=y) 1153 | hightlight_mouse_cursor() 1154 | # update windowID_active and window_active, to inform function run() 1155 | windowID_active = winID_next 1156 | window_active = disp.create_resource_object('window', windowID_active) 1157 | # time when currently active window got focussed 1158 | windowsInfo[windowID_active]['time'] = datetime.datetime.now() 1159 | 1160 | return windowID_active, window_active 1161 | # ---------------------------------------------------------------------------------------------------------------------- 1162 | 1163 | # ---------------------------------------------------------------------------------------------------------------------- 1164 | def set_window_minimized_state(window, state): 1165 | """ 1166 | Minimize or un-minimize the given window 1167 | 1168 | :param window: the window 1169 | :param state: 1: minimize, 0: un-minimize 1170 | :return: 1171 | """ 1172 | global Xroot, disp, NET_WM_STATE_HIDDEN, NET_WM_STATE 1173 | 1174 | mask = (Xlib.X.SubstructureRedirectMask | Xlib.X.SubstructureNotifyMask) 1175 | event = Xlib.protocol.event.ClientMessage(window=window, client_type=NET_WM_STATE, 1176 | data=(32, [state, NET_WM_STATE_HIDDEN, 0, 1, 0])) 1177 | Xroot.send_event(event, event_mask=mask) 1178 | disp.flush() 1179 | # ---------------------------------------------------------------------------------------------------------------------- 1180 | 1181 | # ---------------------------------------------------------------------------------------------------------------------- 1182 | def set_window_position(winID, **kwargs): 1183 | """ 1184 | Sets the position of the window. 1185 | 1186 | :param winID: ID of the window 1187 | :param kwargs: x- and/or y-position 1188 | :return: 1189 | """ 1190 | global windowsInfo, MOTIF_WM_HINTS, ANY_PROPERTYTYPE 1191 | 1192 | # Check whether the window is undecorated, and if so, always use the window itself -i.e. not the parent window- 1193 | # to configure the window position 1194 | try: 1195 | window = windowsInfo[winID]['win'] 1196 | if window.get_property(MOTIF_WM_HINTS, ANY_PROPERTYTYPE, 0, 32).value[2] == 0: 1197 | windowsInfo[winID]['win'].configure(**kwargs) 1198 | return 1199 | except (AttributeError, KeyError) as e: 1200 | return # window vanished 1201 | 1202 | # Window is decorated, for some windows the parent window needs to be used - see function set_setxy_win() 1203 | try: 1204 | windowsInfo[winID]['winSetXY'].configure(**kwargs) 1205 | except (AttributeError, KeyError) as e: 1206 | set_setxy_win(winID) 1207 | try: 1208 | windowsInfo[winID]['winSetXY'].configure(**kwargs) 1209 | except (AttributeError, KeyError) as e: 1210 | pass # window vanished 1211 | # ---------------------------------------------------------------------------------------------------------------------- 1212 | 1213 | # ---------------------------------------------------------------------------------------------------------------------- 1214 | def set_window_size(winID, **kwargs): 1215 | """ 1216 | Sets the size of the window. 1217 | 1218 | :param winID: ID of the window 1219 | :param kwargs: width and/or height 1220 | :return: 1221 | """ 1222 | global windowsInfo 1223 | 1224 | try: 1225 | windowsInfo[winID]['winParent'].configure(**kwargs) 1226 | except KeyError as e: 1227 | pass 1228 | # ---------------------------------------------------------------------------------------------------------------------- 1229 | 1230 | # ---------------------------------------------------------------------------------------------------------------------- 1231 | def store_window_geometries(): 1232 | """ 1233 | Saves the geometry of all -not minimized- windows of the current desktop 1234 | :return: 1235 | """ 1236 | global tilingInfo, NET_CURRENT_DESKTOP, ANY_PROPERTYTYPE 1237 | 1238 | # get a list of all -not minimized and not ignored- windows of the current desktop 1239 | currentDesktop = Xroot.get_full_property(NET_CURRENT_DESKTOP, ANY_PROPERTYTYPE).value[0] 1240 | winIDs = get_windows_on_desktop(currentDesktop) 1241 | 1242 | tilingInfo['userDefinedGeom'][currentDesktop] = dict() 1243 | for winID in winIDs: 1244 | tilingInfo['userDefinedGeom'][currentDesktop][winID] = dict() 1245 | tilingInfo['userDefinedGeom'][currentDesktop][winID]['x'] = windowsInfo[winID]['x'] 1246 | tilingInfo['userDefinedGeom'][currentDesktop][winID]['y'] = windowsInfo[winID]['y'] 1247 | tilingInfo['userDefinedGeom'][currentDesktop][winID]['width'] = windowsInfo[winID]['width'] 1248 | tilingInfo['userDefinedGeom'][currentDesktop][winID]['height'] = windowsInfo[winID]['height'] 1249 | 1250 | notify('storeCurrentWindowsLayout') 1251 | # ---------------------------------------------------------------------------------------------------------------------- 1252 | 1253 | # ---------------------------------------------------------------------------------------------------------------------- 1254 | def swap_windows(winID): 1255 | """ 1256 | Swap the position of window winID with the upper- / left-most window 1257 | :param winID: ID of the window which should be moved 1258 | :return: 1259 | """ 1260 | global disp, Xroot, NET_CURRENT_DESKTOP, ANY_PROPERTYTYPE 1261 | 1262 | # get a list of all -not minimized and not ignored- windows of the current desktop 1263 | currentDesktop = Xroot.get_full_property(NET_CURRENT_DESKTOP, ANY_PROPERTYTYPE).value[0] 1264 | winIDs = get_windows_on_desktop(currentDesktop) 1265 | 1266 | if len(winIDs) < 2: 1267 | return 1268 | 1269 | # sort winIDs: first by y- then by x-position 1270 | winIDs = sorted(winIDs, key=lambda winID: (windowsInfo[winID]['y'], windowsInfo[winID]['x'])) 1271 | 1272 | if winID == winIDs[0]: 1273 | return # selected window is the top- / left- most one 1274 | 1275 | try: 1276 | set_window_position(winID, x=windowsInfo[winIDs[0]]['x'], y=windowsInfo[winIDs[0]]['y']) 1277 | set_window_size(winID, width=windowsInfo[winIDs[0]]['width'], height=windowsInfo[winIDs[0]]['height']) 1278 | set_window_position(winIDs[0], x=windowsInfo[winID]['x'], y=windowsInfo[winID]['y']) 1279 | set_window_size(winIDs[0], width=windowsInfo[winID]['width'], height=windowsInfo[winID]['height']) 1280 | 1281 | disp.sync() 1282 | update_windows_info() 1283 | except: 1284 | pass # window vanished 1285 | # ---------------------------------------------------------------------------------------------------------------------- 1286 | 1287 | # ---------------------------------------------------------------------------------------------------------------------- 1288 | def tile_windows(window_active, manuallyTriggered=False, tilerNumber=None, desktopList=None, resizeMaster=0): 1289 | """ 1290 | Calls the current or manually selected tiler 1291 | for the current desktop, or -if given- for the desktops in desktopList 1292 | 1293 | :param window_active: active window 1294 | :param manuallyTriggered: status, whether called automatically or manually 1295 | :param tilerNumber: number which tiler to set and use, 1296 | or None, to take the currently selected tiler, 1297 | or 'next' to cycle to next tiler 1298 | :param desktopList: list of desktops where tiling needs to be done 1299 | :param resizeMaster: number of pixels the master should be resized 1300 | :return: 1301 | """ 1302 | global Xroot, NET_CURRENT_DESKTOP, ANY_PROPERTYTYPE 1303 | 1304 | if desktopList is None: 1305 | currentDesktop = Xroot.get_full_property(NET_CURRENT_DESKTOP, ANY_PROPERTYTYPE).value[0] 1306 | desktopList = [currentDesktop] 1307 | 1308 | for desktop in desktopList: 1309 | if not manuallyTriggered and not tilingInfo['tileWindows'][desktop]: 1310 | continue 1311 | 1312 | if manuallyTriggered: 1313 | if tilerNumber == 'next': 1314 | tilingInfo['tiler'][desktop] = [2,3,4,5,1][tilingInfo['tiler'][desktop]-1] 1315 | elif tilerNumber is not None: 1316 | tilingInfo['tiler'][desktop] = tilerNumber 1317 | 1318 | if resizeMaster != 0 and tilingInfo['tiler'][desktop] not in [1, 3]: 1319 | continue # no tiler with a master window 1320 | 1321 | if tilingInfo['tiler'][desktop] == 1: 1322 | tile_windows_master_and_stack_vertically(desktop, resizeMaster) 1323 | elif tilingInfo['tiler'][desktop] == 2: 1324 | tile_windows_vertically(desktop) 1325 | elif tilingInfo['tiler'][desktop] == 3: 1326 | tile_windows_master_and_stack_horizontally(desktop, resizeMaster) 1327 | elif tilingInfo['tiler'][desktop] == 4: 1328 | tile_windows_horizontally(desktop) 1329 | elif tilingInfo['tiler'][desktop] == 5: 1330 | tile_windows_maximize(desktop, window_active) 1331 | # ---------------------------------------------------------------------------------------------------------------------- 1332 | 1333 | # ---------------------------------------------------------------------------------------------------------------------- 1334 | def tile_windows_horizontally(desktop): 1335 | """ 1336 | Stacks the -not minimized- windows of the given desktop horizontally, from left to right 1337 | 1338 | :param desktop: desktop 1339 | :return: 1340 | """ 1341 | global tilingInfo, disp, Xroot, NET_WORKAREA 1342 | 1343 | # get a list of all -not minimized and not ignored- windows of the current desktop 1344 | winIDs = get_windows_on_desktop(desktop) 1345 | 1346 | if len(winIDs) == 0: 1347 | return 1348 | 1349 | # geometry of work area (screen without taskbar) 1350 | workAreaX0, workAreaY0, workAreaWidth, workAreaHeight = Xroot.get_full_property(NET_WORKAREA, 0).value.tolist()[:4] 1351 | 1352 | if len(winIDs) == 1: 1353 | set_window_decoration(winIDs[0], tilingInfo['windowDecoration'][desktop]) 1354 | if tilingInfo['maximizeWhenOneWindowLeft'][desktop]: 1355 | set_window_position(winIDs[0], x=workAreaX0, y=workAreaY0) 1356 | set_window_size(winIDs[0], width=workAreaWidth, height=workAreaHeight) 1357 | disp.sync() 1358 | update_windows_info() 1359 | return 1360 | 1361 | # sort the winIDs by x-position 1362 | winIDs = sorted(winIDs, key=lambda winID: windowsInfo[winID]['x']) 1363 | N = min(tilingInfo['horizontally']['maxNumWindows'], len(winIDs)) 1364 | 1365 | set_window_decoration(winIDs[0], tilingInfo['windowDecoration'][desktop]) 1366 | # check whether this window can stay as it is 1367 | if windowsInfo[winIDs[0]]['x'] == workAreaX0 and windowsInfo[winIDs[0]]['y'] == workAreaY0 and \ 1368 | windowsInfo[winIDs[0]]['y2'] - workAreaY0 == workAreaHeight - 1 and \ 1369 | tilingInfo['minSize'] < windowsInfo[winIDs[0]]['x2'] - workAreaX0 < \ 1370 | workAreaWidth - (N - 1) * tilingInfo['minSize']: 1371 | set_window_position(winIDs[0], x=workAreaX0, y=workAreaY0) 1372 | I = 1 1373 | x = windowsInfo[winIDs[0]]['x2'] + 1 1374 | width = int((workAreaWidth - x) / (N - 1)) 1375 | else: 1376 | I = 0 1377 | x = workAreaX0 1378 | width = int(workAreaWidth / N) 1379 | 1380 | # Place (all or remaining) windows from left to right (max. maxNumWindows) 1381 | y = workAreaY0 1382 | for i, winID in enumerate(winIDs[I:N]): 1383 | if i == N - 1: 1384 | width = workAreaWidth + workAreaX0 - x + 1 1385 | unmaximize_window(windowsInfo[winID]["win"]) 1386 | set_window_decoration(winID, tilingInfo['windowDecoration'][desktop]) 1387 | set_window_position(winID, x=x, y=workAreaY0) 1388 | set_window_size(winID, width=width, height=workAreaHeight + 1) 1389 | x += width 1390 | 1391 | # make sure that all remaining (ignored) windows are decorated 1392 | for winID in winIDs[N:]: 1393 | set_window_decoration(winID, True) 1394 | 1395 | disp.sync() 1396 | update_windows_info() 1397 | # ---------------------------------------------------------------------------------------------------------------------- 1398 | 1399 | # ---------------------------------------------------------------------------------------------------------------------- 1400 | def tile_windows_master_and_stack_horizontally(desktop, resizeMaster=0): 1401 | """ 1402 | Tiles the -not minimized- windows of the given desktop, 1403 | one master on the upper and a stack of windows (from left to right) on the lower part of the screen 1404 | 1405 | :param desktop: desktop 1406 | :param resizeMaster number of pixels the master window should be resized 1407 | :return: 1408 | """ 1409 | global tilingInfo, disp, Xroot, NET_WORKAREA 1410 | 1411 | # get a list of all -not minimized and not ignored- windows of the given desktop 1412 | winIDs = get_windows_on_desktop(desktop) 1413 | 1414 | if len(winIDs) == 0: 1415 | return 1416 | 1417 | # geometry of work area (screen without taskbar) 1418 | workAreaX0, workAreaY0, workAreaWidth, workAreaHeight = Xroot.get_full_property(NET_WORKAREA, 0).value.tolist()[:4] 1419 | 1420 | if len(winIDs) == 1: 1421 | set_window_decoration(winIDs[0], tilingInfo['windowDecoration'][desktop]) 1422 | if tilingInfo['maximizeWhenOneWindowLeft'][desktop]: 1423 | set_window_position(winIDs[0], x=workAreaX0, y=workAreaY0) 1424 | set_window_size(winIDs[0], width=workAreaWidth, height=workAreaHeight) 1425 | disp.sync() 1426 | update_windows_info() 1427 | return 1428 | 1429 | # sort winIDs: first by y- then by x-position 1430 | winIDs = sorted(winIDs, key=lambda winID: (windowsInfo[winID]['y'], windowsInfo[winID]['x'])) 1431 | 1432 | set_window_decoration(winIDs[0], tilingInfo['windowDecoration'][desktop]) 1433 | # Place first window as master on the upper part of the screen 1434 | if windowsInfo[winIDs[0]]['x'] == workAreaX0 and windowsInfo[winIDs[0]]['y'] == workAreaY0 and \ 1435 | windowsInfo[winIDs[0]]['x2'] - workAreaX0 == workAreaWidth - 1 and \ 1436 | tilingInfo['minSize'] < windowsInfo[winIDs[0]]['y2'] - workAreaY0 < workAreaHeight - tilingInfo['minSize']: 1437 | # window can stay as it is 1438 | set_window_position(winIDs[0], x=workAreaX0, y=workAreaY0) 1439 | height = windowsInfo[winIDs[0]]['height'] 1440 | if resizeMaster != 0: 1441 | height += resizeMaster 1442 | height = max(height, tilingInfo['minSize'] + 2) 1443 | height = min(height, workAreaHeight - tilingInfo['minSize']) 1444 | set_window_size(winIDs[0], width=workAreaWidth, height=height) 1445 | resize_docked_windows(winIDs[0], windowsInfo[winIDs[0]]['win'], 8) 1446 | disp.sync() 1447 | update_windows_info() 1448 | return 1449 | else: 1450 | # the window needs to be repositioned 1451 | unmaximize_window(windowsInfo[winIDs[0]]["win"]) 1452 | height = int(workAreaHeight * tilingInfo['masterAndStackHoriz']['defaultHeightMaster']) 1453 | set_window_position(winIDs[0], x=workAreaX0, y=workAreaY0) 1454 | set_window_size(winIDs[0], width=workAreaWidth, height=height) 1455 | 1456 | # Stack the remaining windows (max. maxNumWindows - 1) on the lower part of the screen 1457 | N = min(tilingInfo['masterAndStackHoriz']['maxNumWindows'] - 1, len(winIDs) - 1) 1458 | x = workAreaX0 1459 | y = height + workAreaY0 1460 | height = workAreaHeight - height 1461 | width = int(workAreaWidth / N) 1462 | 1463 | for i, winID in enumerate(winIDs[1:N + 1]): 1464 | if i == N - 1: 1465 | width = workAreaWidth + workAreaX0 - x + 1 1466 | unmaximize_window(windowsInfo[winID]["win"]) 1467 | set_window_decoration(winID, tilingInfo['windowDecoration'][desktop]) 1468 | set_window_position(winID, x=x, y=y) 1469 | set_window_size(winID, width=width, height=height) 1470 | x += width 1471 | 1472 | # make sure that all remaining (ignored) windows are decorated 1473 | for winID in winIDs[N + 1:]: 1474 | set_window_decoration(winID, True) 1475 | 1476 | disp.sync() 1477 | update_windows_info() 1478 | # ---------------------------------------------------------------------------------------------------------------------- 1479 | 1480 | # ---------------------------------------------------------------------------------------------------------------------- 1481 | def tile_windows_master_and_stack_vertically(desktop, resizeMaster=0): 1482 | """ 1483 | Tiles the -not minimized- windows of the given desktop, 1484 | one master on the left and a stack of windows on the right (from top to bottom) 1485 | 1486 | :param desktop: desktop 1487 | :param resizeMaster: number of pixels the master window should be resized 1488 | :return: 1489 | """ 1490 | global tilingInfo, disp, Xroot, NET_WORKAREA 1491 | 1492 | # get a list of all -not minimized and not ignored- windows of the current desktop 1493 | winIDs = get_windows_on_desktop(desktop) 1494 | 1495 | if len(winIDs) == 0: 1496 | return 1497 | 1498 | # geometry of work area (screen without taskbar) 1499 | workAreaX0, workAreaY0, workAreaWidth, workAreaHeight = Xroot.get_full_property(NET_WORKAREA, 0).value.tolist()[:4] 1500 | 1501 | if len(winIDs) == 1: 1502 | set_window_decoration(winIDs[0], tilingInfo['windowDecoration'][desktop]) 1503 | if tilingInfo['maximizeWhenOneWindowLeft'][desktop]: 1504 | set_window_position(winIDs[0], x=workAreaX0, y=workAreaY0) 1505 | set_window_size(winIDs[0], width=workAreaWidth, height=workAreaHeight) 1506 | disp.sync() 1507 | update_windows_info() 1508 | return 1509 | 1510 | # sort winIDs: first by x- then by y-position 1511 | winIDs = sorted(winIDs, key=lambda winID: (windowsInfo[winID]['x'], windowsInfo[winID]['y'])) 1512 | 1513 | set_window_decoration(winIDs[0], tilingInfo['windowDecoration'][desktop]) 1514 | # Place first window as master on the left side of the screen 1515 | if windowsInfo[winIDs[0]]['x'] == workAreaX0 and windowsInfo[winIDs[0]]['y'] == workAreaY0 and \ 1516 | windowsInfo[winIDs[0]]['y2'] - workAreaY0 == workAreaHeight - 1 and \ 1517 | tilingInfo['minSize'] < windowsInfo[winIDs[0]]['x2'] - workAreaX0 < workAreaWidth - tilingInfo['minSize']: 1518 | # the window can stay there 1519 | set_window_position(winIDs[0], x=workAreaX0, y=workAreaY0) 1520 | width = windowsInfo[winIDs[0]]['width'] 1521 | if resizeMaster != 0: 1522 | width += resizeMaster 1523 | width = max(width, tilingInfo['minSize'] + 2) 1524 | width = min(width, workAreaWidth - tilingInfo['minSize']) 1525 | set_window_size(winIDs[0], width=width, height=workAreaHeight) 1526 | resize_docked_windows(winIDs[0], windowsInfo[winIDs[0]]['win'], 4) 1527 | disp.sync() 1528 | update_windows_info() 1529 | return 1530 | else: 1531 | # the window needs to be repositioned 1532 | unmaximize_window(windowsInfo[winIDs[0]]['win']) 1533 | width = int(workAreaWidth * tilingInfo['masterAndStackVertic']['defaultWidthMaster']) + resizeMaster 1534 | set_window_position(winIDs[0], x=workAreaX0, y=workAreaY0) 1535 | set_window_size(winIDs[0], width=width, height=workAreaHeight) 1536 | 1537 | # Stack the remaining windows (max. maxNumWindows - 1) on the right part of the screen 1538 | N = min(tilingInfo['masterAndStackVertic']['maxNumWindows'] - 1, len(winIDs) - 1) 1539 | x = width + workAreaX0 1540 | y = workAreaY0 1541 | width = workAreaWidth - width 1542 | height = int(workAreaHeight / N) 1543 | 1544 | for i, winID in enumerate(winIDs[1:N + 1]): 1545 | if i == N - 1: 1546 | height = workAreaHeight + workAreaY0 - y + 1 1547 | unmaximize_window(windowsInfo[winID]["win"]) 1548 | set_window_decoration(winID, tilingInfo['windowDecoration'][desktop]) 1549 | set_window_position(winID, x=x, y=y) 1550 | set_window_size(winID, width=width, height=height) 1551 | y += height 1552 | 1553 | # make sure that all remaining (ignored) windows are decorated 1554 | for winID in winIDs[N + 1:]: 1555 | set_window_decoration(winID, True) 1556 | 1557 | disp.sync() 1558 | update_windows_info() 1559 | # ---------------------------------------------------------------------------------------------------------------------- 1560 | 1561 | # ---------------------------------------------------------------------------------------------------------------------- 1562 | def tile_windows_maximize(desktop, window_active, winID=None): 1563 | """ 1564 | This 'tiler' just maximizes the active window 1565 | 1566 | :param desktop: desktop (given for possible future use) 1567 | :param window_active: active window 1568 | :param winID: ID of the window, if None: retrieve ID of active window 1569 | :return: 1570 | """ 1571 | global Xroot, disp, NET_WM_STATE_MAXIMIZED_VERT, NET_WM_STATE_MAXIMIZED_HORZ, NET_WM_STATE, ANY_PROPERTYTYPE 1572 | 1573 | # geometry of work area (screen without taskbar) 1574 | workAreaX0, workAreaY0, workAreaWidth, workAreaHeight = Xroot.get_full_property(NET_WORKAREA, 0).value.tolist()[:4] 1575 | 1576 | if winID is None: 1577 | winID = Xroot.get_full_property(NET_ACTIVE_WINDOW, ANY_PROPERTYTYPE).value[0] 1578 | set_window_decoration(winID, tilingInfo['windowDecoration'][desktop]) 1579 | set_window_position(winID, x=workAreaX0, y=workAreaY0) 1580 | set_window_size(winID, width=workAreaWidth, height=workAreaHeight) 1581 | 1582 | mask = (Xlib.X.SubstructureRedirectMask | Xlib.X.SubstructureNotifyMask) 1583 | event = Xlib.protocol.event.ClientMessage(window=window_active, client_type=NET_WM_STATE, 1584 | data=(32, [1, NET_WM_STATE_MAXIMIZED_VERT, 0, 1, 0])) 1585 | Xroot.send_event(event, event_mask=mask) 1586 | event = Xlib.protocol.event.ClientMessage(window=window_active, client_type=NET_WM_STATE, 1587 | data=(32, [1, NET_WM_STATE_MAXIMIZED_HORZ, 0, 1, 0])) 1588 | Xroot.send_event(event, event_mask=mask) 1589 | disp.flush() 1590 | # ---------------------------------------------------------------------------------------------------------------------- 1591 | 1592 | # ---------------------------------------------------------------------------------------------------------------------- 1593 | def tile_windows_vertically(desktop): 1594 | """ 1595 | Stacks the -not minimized- windows of the given desktop vertically, from top to bottom 1596 | 1597 | :param desktop: desktop 1598 | :return: 1599 | """ 1600 | global tilingInfo, disp, Xroot, NET_WORKAREA 1601 | 1602 | # get a list of all -not minimized and not ignored- windows of the current desktop 1603 | winIDs = get_windows_on_desktop(desktop) 1604 | 1605 | if len(winIDs) == 0: 1606 | return 1607 | 1608 | # geometry of work area (screen without taskbar) 1609 | workAreaX0, workAreaY0, workAreaWidth, workAreaHeight = Xroot.get_full_property(NET_WORKAREA, 0).value.tolist()[:4] 1610 | 1611 | if len(winIDs) == 1: 1612 | set_window_decoration(winIDs[0], tilingInfo['windowDecoration'][desktop]) 1613 | if tilingInfo['maximizeWhenOneWindowLeft'][desktop]: 1614 | set_window_position(winIDs[0], x=workAreaX0, y=workAreaY0) 1615 | set_window_size(winIDs[0], width=workAreaWidth, height=workAreaHeight) 1616 | disp.sync() 1617 | update_windows_info() 1618 | return 1619 | 1620 | # sort the winIDs by y-position 1621 | winIDs = sorted(winIDs, key=lambda winID: windowsInfo[winID]['y']) 1622 | 1623 | # Stack windows (max. maxNumWindows) 1624 | N = min(tilingInfo['vertically']['maxNumWindows'], len(winIDs)) 1625 | y = workAreaY0 1626 | height = int(workAreaHeight / N) 1627 | for i, winID in enumerate(winIDs[:N]): 1628 | if i == N - 1: 1629 | height = workAreaHeight + workAreaY0 - y + 1 1630 | unmaximize_window(windowsInfo[winID]['win']) 1631 | set_window_decoration(winID, tilingInfo['windowDecoration'][desktop]) 1632 | set_window_position(winID, x=workAreaX0, y=y) 1633 | set_window_size(winID, width=workAreaWidth, height=height) 1634 | y += height 1635 | 1636 | # make sure that all remaining (ignored) windows are decorated 1637 | for winID in winIDs[N:]: 1638 | set_window_decoration(winID, True) 1639 | 1640 | disp.sync() 1641 | update_windows_info() 1642 | # ---------------------------------------------------------------------------------------------------------------------- 1643 | 1644 | # ---------------------------------------------------------------------------------------------------------------------- 1645 | def toggle_maximize_when_one_window(): 1646 | """ 1647 | Toggles whether a tiling should maximize a window when it's the only one on the current desktop 1648 | :return: 1649 | """ 1650 | global tilingInfo, NET_CURRENT_DESKTOP, ANY_PROPERTYTYPE 1651 | 1652 | currentDesktop = Xroot.get_full_property(NET_CURRENT_DESKTOP, ANY_PROPERTYTYPE).value[0] 1653 | tilingInfo['maximizeWhenOneWindowLeft'][currentDesktop] = \ 1654 | not tilingInfo['maximizeWhenOneWindowLeft'][currentDesktop] 1655 | 1656 | notify('maximizeWhenOneWindowLeft', tilingInfo["maximizeWhenOneWindowLeft"][currentDesktop]) 1657 | # ---------------------------------------------------------------------------------------------------------------------- 1658 | 1659 | # ---------------------------------------------------------------------------------------------------------------------- 1660 | def toggle_resize(): 1661 | """ 1662 | Toggles whether resizing of docked windows is active for the current desktop 1663 | :return: 1664 | """ 1665 | global tilingInfo, NET_CURRENT_DESKTOP, ANY_PROPERTYTYPE 1666 | 1667 | currentDesktop = Xroot.get_full_property(NET_CURRENT_DESKTOP, ANY_PROPERTYTYPE).value[0] 1668 | tilingInfo['resizeWindows'][currentDesktop] = not tilingInfo['resizeWindows'][currentDesktop] 1669 | 1670 | notify('resizing', tilingInfo["resizeWindows"][currentDesktop]) 1671 | # ---------------------------------------------------------------------------------------------------------------------- 1672 | 1673 | # ---------------------------------------------------------------------------------------------------------------------- 1674 | def toggle_tiling(): 1675 | """ 1676 | Toggles whether tiling is active for the current desktop 1677 | 1678 | :return: new tiling state of the current desktop [True | False] 1679 | """ 1680 | global tilingInfo, NET_CURRENT_DESKTOP, ANY_PROPERTYTYPE 1681 | 1682 | currentDesktop = Xroot.get_full_property(NET_CURRENT_DESKTOP, ANY_PROPERTYTYPE).value[0] 1683 | tilingInfo['tileWindows'][currentDesktop] = not tilingInfo['tileWindows'][currentDesktop] 1684 | 1685 | notify('tiling', tilingInfo["tileWindows"][currentDesktop]) 1686 | return tilingInfo['tileWindows'][currentDesktop] 1687 | # ---------------------------------------------------------------------------------------------------------------------- 1688 | 1689 | # ---------------------------------------------------------------------------------------------------------------------- 1690 | def toggle_window_decoration(): 1691 | """ 1692 | Toggles whether tiled windows on the current desktop should be decorated. 1693 | The remaining, ignored windows are always (re-)decorated. 1694 | :return: 1695 | """ 1696 | global tilingInfo, NET_CURRENT_DESKTOP, ANY_PROPERTYTYPE 1697 | 1698 | currentDesktop = Xroot.get_full_property(NET_CURRENT_DESKTOP, ANY_PROPERTYTYPE).value[0] 1699 | status = tilingInfo['windowDecoration'][currentDesktop] = not tilingInfo['windowDecoration'][currentDesktop] 1700 | update_windows_info() 1701 | for winID in windowsInfo: 1702 | if windowsInfo[winID]['desktop'] == currentDesktop: 1703 | set_window_decoration(winID, status) 1704 | disp.sync() 1705 | # ---------------------------------------------------------------------------------------------------------------------- 1706 | 1707 | # ---------------------------------------------------------------------------------------------------------------------- 1708 | def unmaximize_window(window): 1709 | """ 1710 | Un-maximize the given window 1711 | 1712 | :param window: the window 1713 | :return: 1714 | """ 1715 | global Xroot, disp, NET_WM_STATE_MAXIMIZED_VERT, NET_WM_STATE_MAXIMIZED_HORZ, NET_WM_STATE 1716 | 1717 | mask = (Xlib.X.SubstructureRedirectMask | Xlib.X.SubstructureNotifyMask) 1718 | event = Xlib.protocol.event.ClientMessage(window=window, client_type=NET_WM_STATE, 1719 | data=(32, [0, NET_WM_STATE_MAXIMIZED_VERT, 0, 1, 0])) 1720 | Xroot.send_event(event, event_mask=mask) 1721 | event = Xlib.protocol.event.ClientMessage(window=window, client_type=NET_WM_STATE, 1722 | data=(32, [0, NET_WM_STATE_MAXIMIZED_HORZ, 0, 1, 0])) 1723 | Xroot.send_event(event, event_mask=mask) 1724 | 1725 | disp.flush() 1726 | # ---------------------------------------------------------------------------------------------------------------------- 1727 | 1728 | # ---------------------------------------------------------------------------------------------------------------------- 1729 | def update_windows_info(windowID_active=None): 1730 | """ 1731 | Update the dictionary containing all windows, parent-windows, names, desktop-number and geometry. 1732 | Windows with names / titles that match the ignore-list and modal and sticky windows are not taken into account. 1733 | 1734 | :param window_active: active windo 1735 | :return: status whether the number of windows has changed, and 1736 | desktopList list of desktops, when a window got moved from one desktop to another 1737 | """ 1738 | global NET_CLIENT_LIST, NET_WM_DESKTOP, NET_WM_STATE_MODAL, NET_WM_STATE_STICKY, ANY_PROPERTYTYPE, Xroot, disp 1739 | global tilingInfo, windowsInfo, verbosityLevel 1740 | 1741 | windowIDs = Xroot.get_full_property(NET_CLIENT_LIST, ANY_PROPERTYTYPE).value 1742 | numWindowsChanged = False 1743 | doDelay = False 1744 | desktopList = list() 1745 | 1746 | # delete closed windows from the windowsInfo structure 1747 | for winID in list(windowsInfo.keys()): 1748 | if winID not in windowIDs: 1749 | del windowsInfo[winID] 1750 | numWindowsChanged = True 1751 | 1752 | # update the geometry of existing windows and add new windows 1753 | for winID in windowIDs: 1754 | try: 1755 | try: 1756 | win = windowsInfo[winID]['win'] 1757 | except KeyError: 1758 | win = disp.create_resource_object('window', winID) 1759 | 1760 | if NET_WM_STATE_MODAL in win.get_full_property(NET_WM_STATE, 0).value.tolist(): 1761 | if verbosityLevel > 1: 1762 | title = get_windows_title(win) 1763 | winClass, name = win.get_wm_class() 1764 | print('Ignoring modal window:\t' 1765 | f'name: "{name}"\ttitle: "{title}"') 1766 | continue # ignore modal window (dialog box) 1767 | 1768 | if NET_WM_STATE_STICKY in win.get_full_property(NET_WM_STATE, 0).value.tolist(): 1769 | if verbosityLevel > 1: 1770 | title = get_windows_title(win) 1771 | winClass, name = win.get_wm_class() 1772 | print('Ignoring sticky window:\t' 1773 | f'name: "{name}"\ttitle: "{title}"') 1774 | continue # ignore sticky window (visible on all workspaces) 1775 | 1776 | if winID in windowsInfo or not match_ignore(tilingInfo['ignoreWindows'], 1777 | (name := win.get_wm_class()[1]), 1778 | get_windows_title(win)): 1779 | desktop = win.get_full_property(NET_WM_DESKTOP, ANY_PROPERTYTYPE).value[0] 1780 | geometry = get_window_geometry(win) 1781 | if geometry is None: # window vanished 1782 | continue 1783 | if winID not in windowsInfo: 1784 | windowsInfo[winID] = dict() 1785 | windowsInfo[winID]['name'] = name 1786 | windowsInfo[winID]['win'] = win 1787 | windowsInfo[winID]['winParent'] = get_parent_window(win) 1788 | windowsInfo[winID]['winSetXY'] = None 1789 | windowsInfo[winID]['groups'] = [0] 1790 | numWindowsChanged = True 1791 | if match(tilingInfo['delayTilingWindowsWithNames'], name): 1792 | doDelay = True # An app, that needs some delay, got launched 1793 | try: 1794 | if windowsInfo[winID]['desktop'] != desktop: 1795 | numWindowsChanged = True # Window was moved to another desktop 1796 | desktopList.append(desktop) # Tiling (if activated) needs to be done 1797 | desktopList.append(windowsInfo[winID]['desktop']) # on both desktops 1798 | except KeyError: 1799 | pass 1800 | windowsInfo[winID]['desktop'] = desktop 1801 | windowsInfo[winID]['x'] = geometry.x 1802 | windowsInfo[winID]['y'] = geometry.y 1803 | windowsInfo[winID]['height'] = geometry.height 1804 | windowsInfo[winID]['width'] = geometry.width 1805 | windowsInfo[winID]['x2'] = geometry.x + geometry.width - 1 1806 | windowsInfo[winID]['y2'] = geometry.y + geometry.height - 1 1807 | except: 1808 | pass # window has vanished 1809 | 1810 | # time when currently active window got focussed 1811 | if windowID_active is not None: 1812 | try: 1813 | windowsInfo[windowID_active]['time'] = time.time() 1814 | except KeyError: 1815 | pass 1816 | 1817 | if doDelay: 1818 | time.sleep(tilingInfo['delayTimeTiling']) 1819 | 1820 | return numWindowsChanged, set(desktopList) 1821 | # ---------------------------------------------------------------------------------------------------------------------- 1822 | 1823 | # ---------------------------------------------------------------------------------------------------------------------- 1824 | def write_crashlog(): 1825 | """ 1826 | Writes, respectively appends trace-back information into /tmp/xpytile_.log 1827 | :return: 1828 | """ 1829 | 1830 | import traceback 1831 | exc_type, exc_value, exc_traceback = sys.exc_info() 1832 | exception_message = traceback.format_exception(exc_type, exc_value, exc_traceback) 1833 | fileName = os.path.join('/tmp', f'xpytile_crash_{os.environ["USER"]}.log') 1834 | with open(fileName, 'a') as f: 1835 | dateStr = datetime.datetime.strftime(datetime.datetime.now(), '%x %X') 1836 | f.write(f'[{dateStr}] {exception_message}\n') 1837 | # ---------------------------------------------------------------------------------------------------------------------- 1838 | 1839 | # ---------------------------------------------------------------------------------------------------------------------- 1840 | def run(window_active, window_active_parent, windowID_active): 1841 | """ 1842 | Waits for events (change of active window, window-resizing, hotkeys, remote-control-event) 1843 | and resizes docked windows and does a little bit of tiling 1844 | 1845 | :param window_active: active window 1846 | :param window_active_parent: parent window of the active window 1847 | :param windowID_active: ID of the active window 1848 | :return: 1849 | """ 1850 | global disp, Xroot, NET_ACTIVE_WINDOW, NET_WM_DESKTOP, windowsInfo, tilingInfo, verbosityLevel 1851 | global ANY_PROPERTYTYPE, NET_CURRENT_DESKTOP, XPYTILE_REMOTE 1852 | 1853 | PROPERTY_NOTIFY = Xlib.X.PropertyNotify 1854 | CONFIGURE_NOTIFY = Xlib.X.ConfigureNotify 1855 | KEY_RELEASE = Xlib.X.KeyRelease 1856 | 1857 | tile_windows(window_active) 1858 | while True: 1859 | event = disp.next_event() # sleep until an event occurs 1860 | 1861 | if event.type == Xlib.X.ClientMessage and event._data['client_type'] == XPYTILE_REMOTE: 1862 | windowID_active, window_active = handle_remote_control_event(event, windowID_active, window_active) 1863 | 1864 | elif event.type == PROPERTY_NOTIFY and event.atom in [NET_ACTIVE_WINDOW, NET_CURRENT_DESKTOP]: 1865 | # the active window or the desktop has changed 1866 | windowID_active = Xroot.get_full_property(NET_ACTIVE_WINDOW, ANY_PROPERTYTYPE).value[0] 1867 | window_active = disp.create_resource_object('window', windowID_active) 1868 | window_active_parent = get_parent_window(window_active) 1869 | numWindowsChanged, desktopList = update_windows_info(windowID_active) 1870 | 1871 | if verbosityLevel > 0: 1872 | if event.atom == NET_ACTIVE_WINDOW: 1873 | print('Active window has changed:\t' 1874 | f'name: "{get_windows_name(windowID_active, window_active)}"\t' 1875 | f'title: "{get_windows_title(window_active)}"' 1876 | f'{["", ", num. windows changed"][numWindowsChanged]}') 1877 | else: 1878 | print(f'Desktop changed' 1879 | f'{["", ", num. windows changed"][numWindowsChanged]}') 1880 | 1881 | if desktopList: 1882 | tile_windows(window_active, False, None, desktopList) 1883 | elif numWindowsChanged: 1884 | tile_windows(window_active) 1885 | else: 1886 | # The number of windows has not changed, neither the desktop, 1887 | # but another window is active. So if the maximize-'tiler' 1888 | # is in action, the active window must be maximized. 1889 | try: 1890 | currentDesktop = Xroot.get_full_property(NET_CURRENT_DESKTOP, ANY_PROPERTYTYPE).value[0] 1891 | if tilingInfo['tiler'][currentDesktop] == 5: 1892 | tile_windows(window_active, False, 0) # maximize active window 1893 | except: 1894 | pass 1895 | 1896 | elif event.type == PROPERTY_NOTIFY: 1897 | # The number of windows has changed but not the active window (?) 1898 | # This happens when XFCE is configured to not automatically focus new windows. 1899 | numWindowsChanged, _ = update_windows_info(windowID_active) 1900 | if numWindowsChanged: 1901 | if verbosityLevel > 0: 1902 | print('num. windows changed') 1903 | tile_windows(window_active) 1904 | 1905 | elif event.type == CONFIGURE_NOTIFY and event.window == window_active_parent: 1906 | moved_border = get_moved_border(windowID_active, window_active) 1907 | if moved_border: 1908 | resize_docked_windows(windowID_active, window_active, moved_border) 1909 | else: 1910 | # A window was moved. Check whether its new position should trigger re-tiling. 1911 | geometry = get_window_geometry(window_active) 1912 | workAreaWidth, workAreaHeight = Xroot.get_full_property(NET_WORKAREA, 0).value.tolist()[2:4] 1913 | if geometry.x <= -20 or geometry.y <= -20 \ 1914 | or geometry.x + geometry.width > workAreaWidth + 20 \ 1915 | or geometry.y + geometry.height > workAreaHeight + 20: 1916 | tile_windows(window_active) 1917 | time.sleep(0.1) 1918 | update_windows_info() 1919 | 1920 | elif event.type == KEY_RELEASE: 1921 | windowID_active, window_active = handle_key_event(event.detail, windowID_active, window_active) 1922 | # ---------------------------------------------------------------------------------------------------------------------- 1923 | 1924 | # ---------------------------------------------------------------------------------------------------------------------- 1925 | def main(): 1926 | # --- Config-file --- 1927 | configFile = 'xpytilerc' 1928 | configPath = os.getenv('XDG_CONFIG_HOME') 1929 | if configPath: 1930 | configFilePath = os.path.join(configPath, configFile) 1931 | else: 1932 | configFilePath = os.path.join('~/.config/', configFile) 1933 | 1934 | # If there is no user-specific config-file, try to copy it from /etc 1935 | if not os.path.exists(os.path.expanduser(configFilePath)): 1936 | try: 1937 | shutil.copyfile(os.path.join('/etc', configFile), os.path.expanduser(configFilePath)) 1938 | except: 1939 | write_crashlog() 1940 | raise SystemExit('No config-file found') 1941 | 1942 | 1943 | # --- Command line arguments --- 1944 | global verbosityLevel 1945 | parser = argparse.ArgumentParser(prog='xpytile.py') 1946 | parser.add_argument('-v', '--verbose', action="store_true", help='Print name and title of new windows') 1947 | parser.add_argument('-vv', '--verbose2', action="store_true", 1948 | help='also print details about checking name and title whether to ignore the new window') 1949 | args = parser.parse_args() 1950 | if args.verbose2: 1951 | verbosityLevel = 2 1952 | elif args.verbose: 1953 | verbosityLevel = 1 1954 | else: 1955 | verbosityLevel = 0 1956 | 1957 | 1958 | # --- Create singleton using abstract socket (prefix with \0) --- 1959 | try: 1960 | sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 1961 | sock.bind('\0xpytile_lock') 1962 | except socket.error: 1963 | config = configparser.ConfigParser() 1964 | config.read(os.path.expanduser(configFilePath)) 1965 | init_notification_info(config) 1966 | notify('alreadyRunning') 1967 | raise SystemExit('xpytile already running, exiting.') 1968 | 1969 | 1970 | # --- Do the actual work --- 1971 | try: 1972 | # Initialize 1973 | window_active, window_active_parent, windowID_active = init(configFilePath) 1974 | # Run: wait for events and handle them 1975 | run(window_active, window_active_parent, windowID_active) 1976 | except KeyboardInterrupt: 1977 | raise SystemExit(' terminated by ctrl-c') 1978 | except SystemExit: 1979 | pass 1980 | except: 1981 | # Something went wrong, write traceback info in /tmp 1982 | write_crashlog() 1983 | # ---------------------------------------------------------------------------------------------------------------------- 1984 | 1985 | if __name__ == '__main__': 1986 | main() 1987 | -------------------------------------------------------------------------------- /xpytilerc: -------------------------------------------------------------------------------- 1 | # Config-file for tiling helper "xpytile" 2 | # 3 | # File: xpytilerc 4 | # place this file in XDG_CONFIG_HOME or if that's not set in ~/.config 5 | # 6 | 7 | 8 | # ----------------------------------------------------------------------------------------------------- 9 | [General] 10 | # ----------------------------------------------------------------------------------------------------- 11 | # Ignore windows when their name AND -if configured- title matches one of these regular expressions. 12 | # !title means: Ignore window when the name matches AND title does NOT match the regex. 13 | # Modal windows are ignored by default 14 | # 15 | # (The first 8 entries are specific for Xfce4) 16 | # 17 | ignoreWindows = name: "Wrapper-2.0" 18 | name: "Xfdesktop" 19 | name: "Xfwm4" 20 | name: "Xfce4-(?!terminal).*" 21 | name: "Exo-desktop-item-edit" 22 | name: "Nm-connection-editor" 23 | name: "Polkit-gnome-authentication-agent-1" 24 | name: "Globaltime" 25 | name: "Gimp" 26 | name: "krusader" !title: "^Krusader$" 27 | name: "Thunderbird" !title: ".*Mozilla Thunderbird.*" 28 | name: "Doublecmd" !title: "^Double Commander " 29 | name: "jetbrains-pycharm" title: "(Confirm|Tip(p){0,1}|Settings)" 30 | name: "Soffice" title: "Tipp des Tages.*" 31 | name: "Ulauncher" 32 | name: "rofi" 33 | 34 | 35 | # Don't (un)decorate windows when their name AND -if configured- title matches one of these regular expressions. 36 | # !title means: Ignore window when the name matches AND title does NOT match the regex. 37 | # The decoration of windows with client-side decoration (CSD) should not be changed by xpytile. 38 | # Windows (like Firefox, when CSD is enabled) with the property _GTK_FRAME_EXTENTS are decorated client-side 39 | # and ignored by default. 40 | # But there are CSD applications (e.g. Qt apps) that lack the _GTK_FRAME_EXTENTS property and therefore 41 | # CSD mode can't be detected (at least I don't know how). 42 | # (Demo entry for: "qml webbrowser.qml" https://github.com/johanhelsing/qt-csd-demo) 43 | ignoreWindowsForDecoration = name: "Qml Runtime" title: "Stack" 44 | 45 | 46 | # Delay the auto-tiling when a new application was lauched and its name 47 | # matches one of these regexes. 48 | # For example LibreOffice needs some delay time. 49 | delayTilingWindowsWithNames = "Soffice" 50 | 51 | 52 | # Delay time [sec] for the auto-tiling, in case an application with one of 53 | # the above names was lauched. 54 | delayTimeTiling = 0.75 55 | 56 | 57 | # Use this tiler as default, when there are more desktops than configured 58 | # in [DefaultTilerPerDesktop] 59 | # Available Tilers: 60 | # 1 masterAndStackVertic one window on the left, stack on the right side 61 | # 2 masterAndStackHoriz one window on the upper, stack on the lower part 62 | # 3 horizontally horizontal stack of windows, left to right, full height 63 | # 4 vertically vertical stack of windows, from top to bottom, full width 64 | # 5 maximize always maximize active window 65 | defaultTiler = 1 66 | 67 | # Use this value as default, when there are more desktops than configured 68 | # in [maximizeWhenOneWindowLeft] 69 | defaultMaximizeWhenOneWindowLeft = True 70 | 71 | # Edges with a distance smaller than margin are considered docked. 72 | margin = 100 73 | 74 | # Don't shrink width or height of a window smaller than this. 75 | minSize = 350 76 | 77 | # Step size when enlaging/shrinking master window by hotkey 78 | stepSize = 50 79 | 80 | # Move the mouse-cursor to the middle of the new active window, when the focus got changed 81 | # by pressing the focusUp-, focusDown-, focusLeft- or focusRight- hotkey 82 | # This visual feedback is helpful especially when the window decoration is turned off. 83 | moveMouseIntoActiveWindow = True 84 | 85 | 86 | # ----------------------------------------------------------------------------------------------------- 87 | # Define the default/initial tiler for each desktop/workspace. 88 | # (Number of configured workspaces is not limited.) 89 | [DefaultTilerPerDesktop] 90 | # ----------------------------------------------------------------------------------------------------- 91 | Desktop1 = 1 92 | Desktop2 = 1 93 | Desktop3 = 1 94 | Desktop4 = 1 95 | 96 | 97 | 98 | # ----------------------------------------------------------------------------------------------------- 99 | # Define the default/initial behaviour when there is one window left 100 | # (Number of configured workspaces is not limited.) 101 | [maximizeWhenOneWindowLeft] 102 | # ----------------------------------------------------------------------------------------------------- 103 | Desktop1 = True 104 | Desktop2 = True 105 | Desktop3 = True 106 | Desktop4 = True 107 | 108 | 109 | 110 | # ----------------------------------------------------------------------------------------------------- 111 | # Tiler - master and stack vertically 112 | [masterAndStackVertic] 113 | # ----------------------------------------------------------------------------------------------------- 114 | # Number of windows to tile, remaining windows will be ignored 115 | maxNumWindows = 3 116 | # Default-width of master (rel. part of available screen width) 117 | defaultWidthMaster = 0.5 118 | # Maximize the last remaining window, when the 2nd last window got closed 119 | maximizeWhenOneWindowLeft = True 120 | 121 | 122 | 123 | # ----------------------------------------------------------------------------------------------------- 124 | # Tiler - stack from top to bottom, full width 125 | [vertically] 126 | # ----------------------------------------------------------------------------------------------------- 127 | # Number of windows to tile, ignore remaining windows 128 | maxNumWindows = 3 129 | # Maximize the last remaining window, when the 2nd last window got closed 130 | maximizeWhenOneWindowLeft = True 131 | 132 | 133 | 134 | # ----------------------------------------------------------------------------------------------------- 135 | # Tiler - master and stack horizontally 136 | [masterAndStackHoriz] 137 | # ----------------------------------------------------------------------------------------------------- 138 | # Number of windows to tile, ignore all remaining windows 139 | maxNumWindows = 3 140 | # Default-height of the master (rel. part of avail. screen height) 141 | defaultHeightMaster = 0.5 142 | # Maximize the last remaining window, when the 2nd last window got closed 143 | maximizeWhenOneWindowLeft = True 144 | 145 | 146 | 147 | # ----------------------------------------------------------------------------------------------------- 148 | # Tiler - stack from left to right, full height 149 | [horizontally] 150 | # ----------------------------------------------------------------------------------------------------- 151 | # Number of windows to tile, ignore remaining windows 152 | maxNumWindows = 3 153 | # Maximize the last remaining window, when the 2nd last window got closed 154 | maximizeWhenOneWindowLeft = True 155 | 156 | 157 | 158 | # ----------------------------------------------------------------------------------------------------- 159 | # Keycodes of the hotkeys 160 | # (The programm xev helps to figure them out. 161 | # testModifier.py can be used to get the modifier-code.) 162 | # 163 | # 164 | # These actions can also be triggered by sending an X-message with xpytile-remote-client.py 165 | # I.e. ./xpytile-remote-client.py 4 166 | # sends command-number 4 to xpytile - this number is associated with toggleDecoration 167 | # The associated command-numbers are hard-coded and can NOT be configured here. 168 | # 169 | [Hotkeys] 170 | # ----------------------------------------------------------------------------------------------------- 171 | # Hotkey modifier 172 | # 64: "Super_L" 173 | # -1: any modifier 174 | modifier = 64 175 | 176 | 177 | # toggle the status of the current desktop 178 | # whether to simultaneously resize docked windows 179 | # 24: "q" 180 | toggleResize = 24 181 | # command-number 0 182 | 183 | # toggle the status of the current desktop 184 | # whether tiling is active 185 | # 25: "w" 186 | toggleTiling = 25 187 | # command-number 1 188 | 189 | # toggle the status of the current desktop 190 | # whether to simultaneously resize docked windows 191 | # and whether tiling is active 192 | # 26: "e" 193 | toggleResizeAndTiling = 26 194 | # command-number 2 195 | 196 | # toggle the status of the current desktop 197 | # whether to maximize the last window 198 | # when the 2nd last window got closed 199 | # 27: "r" 200 | toggleMaximizeWhenOneWindowLeft = 27 201 | # command-number 3 202 | 203 | # toggle the status of the current desktop 204 | # whether to decorate the tiled windows 205 | # 52: "y" 206 | toggleDecoration = 52 207 | # command-number 4 208 | 209 | # cycle all -not minimized- windows on the current desktop 210 | # 49: ^ 211 | cycleWindows = 49 212 | # command-number 5 213 | 214 | # cycle tiler 215 | # 54: 'c' 216 | cycleTiler = 54 217 | # command-number 6 218 | 219 | # swap active window with the top most- / left- one 220 | # 9: ESC 221 | swapWindows = 9 222 | # command-number 7 223 | 224 | # store the layout of the windows on the current desktop 225 | # 15: "6" 226 | storeCurrentWindowsLayout = 15 227 | # command-number 8 228 | 229 | # restore the layout of the windows geometry on the current desktop 230 | # 14: "5" 231 | recreateWindowsLayout = 14 232 | # command-number 9 233 | 234 | # switch to tiler "masterAndStackVertic" and do the tiling 235 | # 10: "1" 236 | tileMasterAndStackVertically = 10 237 | # command-number 10 238 | 239 | # switch to tiler "vertically" and do the tiling 240 | # 11: "2" 241 | tileVertically = 11 242 | # command-number 11 243 | 244 | # switch to tiler "masterAndStackHoriz" and do the tiling 245 | # 12: "3" 246 | tileMasterAndStackHorizontally = 12 247 | # command-number 12 248 | 249 | # switch to tiler "horizontally" and do the tiling 250 | # 13: "4" 251 | tileHorizontally = 13 252 | # command-number 13 253 | 254 | # switch to tiler "maximize" and maximize the window 255 | # 19: "0" 256 | tileMaximize = 19 257 | # command-number 14 258 | 259 | # increase max number of windows to tile 260 | # 58 "m" 261 | increaseMaxNumWindows = 58 262 | # command-number 15 263 | 264 | # decrease max number of windows to tile 265 | # 57 "n" 266 | decreaseMaxNumWindows = 57 267 | # command-number 16 268 | 269 | # exit the tiling helper 270 | # 61: "-" 271 | exit = 61 272 | # command-number 17 273 | 274 | # log name and tile of currently active window 275 | # in /tmp/xpytile_.log 276 | # 60: '.' 277 | logActiveWindow = 60 278 | # command-number 18 279 | 280 | # shrink width/height of master window and (re-)tile 281 | # 38: "a" 282 | shrinkMaster = 38 283 | # command-number 19 284 | 285 | # enlarge width/height of master window and (re-)tile 286 | # 39: "s" 287 | enlargeMaster = 39 288 | # command-number 20 289 | 290 | # make left window the active one 291 | # 113 "arrow-left" 292 | focusLeft = 113 293 | # command-number 21 294 | 295 | # make right window the active one 296 | # 114 "arrow-right" 297 | focusRight = 114 298 | # command-number 22 299 | 300 | # make upper window the active one 301 | # 111 "arrow-up" 302 | focusUp = 111 303 | # command-number 23 304 | 305 | # make lower window the active one 306 | # 116 "arrow-down" 307 | focusDown = 116 308 | # command-number 24 309 | 310 | # make the previously focussed one the active one 311 | # 56 "b" 312 | focusPrevious = 56 313 | # command-number 25 314 | 315 | # ----------------------------------------------------------------------------------------------------- 316 | # Notifications are send (if turned on) on start-up 317 | # on exit, and when certain hotkeys are pressed. 318 | [Notification] 319 | # ----------------------------------------------------------------------------------------------------- 320 | # Turn on / off notifications [True | False] 321 | active = True 322 | 323 | # Time [ms] the notification will be shown 324 | time = 2500 325 | 326 | # Notification summary, message and icons 327 | on_Message = +++ ON +++ 328 | off_Message = \-\- off \-\- 329 | 330 | infoTilingMustBeOn_Message = Tiling must be on 331 | infoTilingMustBeOn_Icon = /usr/share/icons/gnome/32x32/emblem/emblem-important.png 332 | infoTilingMustBeOn_Summary = Info 333 | 334 | tilingOn_Icon = /usr/share/icons/gnome/32x32/emblems/emblem-default.png 335 | tilingOff_Icon = /usr/share/icons/gnome/32x32/actions/list-remove.png 336 | tiling_Summary = Tiling 337 | 338 | storeCurrentWindowsLayout_Icon = /usr/share/icons/gnome/32x32/devices/video-display.png 339 | storeCurrentWindowsLayout_Message = Layout stored 340 | storeCurrentWindowsLayout_Summary = Windows 341 | 342 | resizingOn_Icon = /usr/share/icons/gnome/32x32/emblems/emblem-default.png 343 | resizingOff_Icon = /usr/share/icons/gnome/32x32/actions/list-remove.png 344 | resizing_Summary = Resizing 345 | 346 | maximizeWhenOneWindowLeftOn_Icon = /usr/share/icons/gnome/32x32/emblems/emblem-default.png 347 | maximizeWhenOneWindowLeftOff_Icon = /usr/share/icons/gnome/32x32/actions/list-remove.png 348 | maximizeWhenOneWindowLeft_Summary = max. when one window 349 | 350 | start_Icon = /usr/share/icons/gnome/32x32/devices/video-display.png 351 | start_Message = +++ ON +++ 352 | start_Summary = Tiling 353 | 354 | alreadyRunning_Icon = /usr/share/icons/gnome/32x32/devices/video-display.png 355 | alreadyRunning_Message = already on 356 | alreadyRunning_Summary = Tiling 357 | 358 | exit_Icon = /usr/share/icons/gnome/32x32/devices/video-display.png 359 | exit_Message = \-\- Exit \-\- 360 | exit_Summary = Tiling 361 | --------------------------------------------------------------------------------