├── pwm ├── __init__.py ├── utils.py └── window_manager.py ├── xinitrc ├── run_pwm ├── README.md ├── .gitignore ├── config.yaml ├── preview.sh ├── setup.py └── LICENSE /pwm/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /xinitrc: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | exec ./run_pwm 4 | -------------------------------------------------------------------------------- /run_pwm: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from pwm.window_manager import WindowManager 4 | 5 | if __name__ == '__main__': 6 | WindowManager().run() 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-window-manager 2 | 3 | An example window manager with comments explaining what is going on. 4 | 5 | [Here is the guide I wrote for this.](https://monroeclinton.com/build-your-own-window-manager/) 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Optimized files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # Environments 7 | .env 8 | .venv 9 | env/ 10 | venv/ 11 | ENV/ 12 | 13 | # Build 14 | .Python 15 | build/ 16 | .eggs/ 17 | *.egg-info/ -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | # Modifier is added to actions, so press Mod1 + c to open xclock 2 | # Run xmodmap to see mappings 3 | modifier: "Mod1" 4 | 5 | actions: 6 | # Programs to launch 7 | - command: "xclock" 8 | key: "c" 9 | - command: "st" 10 | key: "t" 11 | # How to cycle through windows 12 | - action: "NEXT_WINDOW" 13 | key: "Right" 14 | - action: "PREVIOUS_WINDOW" 15 | key: "Left" 16 | -------------------------------------------------------------------------------- /preview.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Stop running if we hit an error 4 | set -e 5 | 6 | # The xinit command takes a first option of a xinitrc script to run 7 | # and a second option of the command to start an X server. 8 | 9 | # Run xinit then specify Xephyr command to start a nested X server 10 | # with a display of 1 to use. In X11 this basically means that 11 | # an X server runs on localhost:1 If display 1 is in use, change it 12 | # to another number like 10. 13 | xinit ./xinitrc -- $(command -v Xephyr) :1 -screen 1024x768 14 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name='python-window-manager', 5 | version='0.1', 6 | description='Example window manager.', 7 | license='MIT', 8 | url='http://github.com/monroeclinton/python-window-manager', 9 | author='Monroe Clinton', 10 | scripts=['run_pwm'], 11 | install_requires=[ 12 | 'pyyaml', 13 | 'xcffib==0.11.1', 14 | 'xpybutil==0.0.6', 15 | ], 16 | classifiers=[ 17 | 'Programming Language :: Python', 18 | 'License :: OSI Approved :: MIT License', 19 | 'Operating System :: POSIX :: Linux', 20 | ], 21 | python_requires='>=3.6', 22 | ) 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Monroe Clinton 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /pwm/utils.py: -------------------------------------------------------------------------------- 1 | import xpybutil 2 | import xpybutil.keybind 3 | 4 | 5 | class KeyUtil: 6 | def __init__(self, conn): 7 | self.conn = conn 8 | 9 | # The the min and max of keycodes associated with your keyboard. A keycode will never 10 | # be less than eight because I believe the 0-7 keycodes are reserved. The keycode zero 11 | # symbolizes AnyKey and I can't find references to the other seven. The max keycode is 255. 12 | # Further readings: 13 | # https://bugs.freedesktop.org/show_bug.cgi?id=11227 14 | # https://who-t.blogspot.com/2014/12/why-255-keycode-limit-in-x-is-real.html 15 | self.min_keycode = self.conn.get_setup().min_keycode 16 | self.max_keycode = self.conn.get_setup().max_keycode 17 | 18 | self.keyboard_mapping = self.conn.core.GetKeyboardMapping( 19 | # The array of keysyms returned by this function will start at min_keycode so that 20 | # the modifiers are not included. 21 | self.min_keycode, 22 | # Total number of keycodes 23 | self.max_keycode - self.min_keycode + 1 24 | ).reply() 25 | 26 | def string_to_keysym(string): 27 | return xpybutil.keysymdef.keysyms[string] 28 | 29 | def get_keysym(self, keycode, keysym_offset): 30 | """ 31 | Get a keysym from a keycode and state/modifier. 32 | 33 | Only a partial implementation. For more details look at Keyboards section in X Protocol: 34 | https://www.x.org/docs/XProtocol/proto.pdf 35 | 36 | :param keycode: Keycode of keysym 37 | :param keysym_offset: The modifier/state/offset we are accessing 38 | :returns: Keysym 39 | """ 40 | 41 | keysyms_per_keycode = self.keyboard_mapping.keysyms_per_keycode 42 | 43 | # The keyboard_mapping keysyms. This is a 2d array of keycodes x keysyms mapped to a 1d 44 | # array. Each keycode row has a certain number of keysym columns. Imagine we had the 45 | # keycode for 't'. In the 1d array we first jump to the 't' row with 46 | # keycode * keysyms_per_keycode. Now the next keysyms_per_keycode number 47 | # of items in the array are columns for the keycode row of 't'. To access a specific 48 | # column we just add the keysym position to the keycode * keysyms_per_keycode position. 49 | return self.keyboard_mapping.keysyms[ 50 | # The keysyms array does not include modifiers, so subtract min_keycode from keycode. 51 | (keycode - self.min_keycode) * self.keyboard_mapping.keysyms_per_keycode + keysym_offset 52 | ] 53 | 54 | def get_keycode(self, keysym): 55 | """ 56 | Get a keycode from a keysym 57 | 58 | :param keysym: keysym you wish to convert to keycode 59 | :returns: Keycode if found, else None 60 | """ 61 | 62 | # X must map the keys on your keyboard to something it can understand. To do this it has 63 | # the concept of keysyms and keycodes. A keycode is a number 8-255 that maps to a physical 64 | # key on your keyboard. X then generates an array that maps keycodes to keysyms. 65 | # Keysyms differ from keycodes in that they take into account modifiers. With keycodes 66 | # 't' and 'T' are the same, but they have different keysyms. You can think of 'T' 67 | # as 't + CapsLock' or 't + Shift'. 68 | 69 | keysyms_per_keycode = self.keyboard_mapping.keysyms_per_keycode 70 | 71 | # Loop through each keycode. Think of this as a row in a 2d array. 72 | # Row: loop from the min_keycode through the max_keycode 73 | for keycode in range(self.min_keycode, self.max_keycode + 1): 74 | # Col: loop from 0 to keysyms_per_keycode. Think of this as a column in a 2d array. 75 | for keysym_offset in range(0, keysyms_per_keycode): 76 | if self.get_keysym(keycode, keysym_offset) == keysym: 77 | return keycode 78 | 79 | return None 80 | -------------------------------------------------------------------------------- /pwm/window_manager.py: -------------------------------------------------------------------------------- 1 | from pwm.utils import KeyUtil 2 | import logging 3 | import subprocess 4 | import xcffib 5 | import xcffib.xproto 6 | import yaml 7 | 8 | 9 | # Constants to use in window manager 10 | NEXT_WINDOW = 'NEXT_WINDOW' 11 | PREVIOUS_WINDOW = 'PREVIOUS_WINDOW' 12 | 13 | 14 | class WindowManager: 15 | def __init__(self): 16 | # Load config file. This should be moved into ~/.config/ and be read from there. 17 | with open('config.yaml') as f: 18 | self.config = yaml.safe_load(f) 19 | 20 | # Connect to X server. Since we don't specify params it will connect to display 1 21 | # that was set in preview.sh. 22 | self.conn = xcffib.connect() 23 | 24 | # Class that contains utils for switching between keycodes/keysyms 25 | self.key_util = KeyUtil(self.conn) 26 | 27 | # Get first available screen 28 | self.screen = self.conn.get_setup().roots[0] 29 | 30 | # All windows have a parent, the root window is the final parent of the tree of windows. 31 | # It is created when a screen is added to the X server. It is used for background images 32 | # and colors. It also can receive events that happen to its children such as mouse in/out, 33 | # window creation/deletion, etc. 34 | self.root_window = self.screen.root 35 | 36 | # An array of windows 37 | self.windows = [] 38 | self.current_window = 0 39 | 40 | def run(self): 41 | """ 42 | Setup and run window manager. This includes checking if another window manager is 43 | running, listening for certain key presses, and handling events. 44 | """ 45 | 46 | # Tell X server which events we wish to receive for the root window. 47 | cookie = self.conn.core.ChangeWindowAttributesChecked( 48 | self.root_window, 49 | xcffib.xproto.CW.EventMask, # Window attribute to set which events we want 50 | [ 51 | # We want to receive any substructure changes. This includes window 52 | # creation/deletion, resizes, etc. 53 | xcffib.xproto.EventMask.SubstructureNotify | 54 | # We want X server to redirect children substructure notifications to the 55 | # root window. Our window manager then processes these notifications. 56 | # Only a single X client can use SubstructureRedirect at a time. 57 | # This means if the request to changes attributes fails, another window manager 58 | # is probably running. 59 | xcffib.xproto.EventMask.SubstructureRedirect, 60 | ] 61 | ) 62 | 63 | # Check if request was valid 64 | try: 65 | cookie.check() 66 | except: 67 | logging.error(logging.traceback.format_exc()) 68 | print('Is another window manager running?') 69 | exit() 70 | 71 | # Loop through actions listed in config and grab keys. This means we 72 | # will get a KeyPressEvent if the key combination is pressed. 73 | for action in self.config['actions']: 74 | # Get keycode from string 75 | keycode = self.key_util.get_keycode( 76 | KeyUtil.string_to_keysym(action['key']) 77 | ) 78 | 79 | # Get modifier from string 80 | modifier = getattr(xcffib.xproto.KeyButMask, self.config['modifier'], 0) 81 | 82 | self.conn.core.GrabKeyChecked( 83 | # We want owner_events to be false so all key events are sent to root window 84 | False, 85 | self.root_window, 86 | modifier, 87 | keycode, 88 | xcffib.xproto.GrabMode.Async, 89 | xcffib.xproto.GrabMode.Async 90 | ).check() 91 | 92 | while True: 93 | event = self.conn.wait_for_event() 94 | 95 | if isinstance(event, xcffib.xproto.KeyPressEvent): 96 | self._handle_key_press_event(event) 97 | if isinstance(event, xcffib.xproto.MapRequestEvent): 98 | self._handle_map_request_event(event) 99 | if isinstance(event, xcffib.xproto.ConfigureRequestEvent): 100 | self._handle_configure_request_event(event) 101 | 102 | # Flush requests to send to X server 103 | self.conn.flush() 104 | 105 | def _handle_action(self, action): 106 | """ 107 | Handle actions defined in config.yaml 108 | 109 | :param action: Window manager action to handle 110 | """ 111 | 112 | # Cycle to the next window 113 | if action == NEXT_WINDOW: 114 | if len(self.windows) == 0: return 115 | 116 | self.conn.core.UnmapWindow(self.windows[self.current_window]) 117 | 118 | # Get the next window 119 | self.current_window += 1 120 | # If overflowed go to the first window 121 | if self.current_window >= len(self.windows): 122 | self.current_window = 0 123 | 124 | self.conn.core.MapWindow(self.windows[self.current_window]) 125 | 126 | # Cycle to the previous window 127 | if action == PREVIOUS_WINDOW: 128 | if len(self.windows) == 0: return 129 | 130 | self.conn.core.UnmapWindow(self.windows[self.current_window]) 131 | 132 | # Get the previous window 133 | self.current_window -= 1 134 | # If underflowed go to last window 135 | if self.current_window < 0: 136 | self.current_window = len(self.windows) - 1 137 | 138 | self.conn.core.MapWindow(self.windows[self.current_window]) 139 | 140 | def _handle_key_press_event(self, event): 141 | """ 142 | We receive key press events on windows below the root_window that match keysyms we are 143 | listening on from the GrabKey method above. 144 | 145 | :param event: KeyPressEvent to handle 146 | """ 147 | 148 | for action in self.config['actions']: 149 | keycode = self.key_util.get_keycode( 150 | KeyUtil.string_to_keysym(action['key']) 151 | ) 152 | 153 | modifier = getattr(xcffib.xproto.KeyButMask, self.config['modifier'], 0) 154 | 155 | # If the keycode and modifier of the action match the event's keycode/modifier then 156 | # run the command. 157 | if keycode == event.detail and modifier == event.state: 158 | if 'command' in action: 159 | subprocess.Popen(action['command']) 160 | if 'action' in action: 161 | self._handle_action(action['action']) 162 | 163 | def _handle_map_request_event(self, event): 164 | """ 165 | When a window wants to map, meaning make itself visible, it send a MapRequestEvent that 166 | gets send to the window manager. Here we add it to our client list and finish by sending 167 | a MapWindow request to the server. This request tells the X server to make the window 168 | visible. 169 | 170 | :param event: MapRequestEvent to handle 171 | """ 172 | 173 | # Get attributes associated with the window 174 | attributes = self.conn.core.GetWindowAttributes( 175 | event.window 176 | ).reply() 177 | 178 | # If the window has the override_redirect attribute set as true then the window manager 179 | # should not manage the window. 180 | if attributes.override_redirect: 181 | return 182 | 183 | # Send map window request to server, telling the server to make this window visible 184 | self.conn.core.MapWindow(event.window) 185 | 186 | # Resize the window to take up whole screen 187 | self.conn.core.ConfigureWindow( 188 | event.window, 189 | xcffib.xproto.ConfigWindow.X | 190 | xcffib.xproto.ConfigWindow.Y | 191 | xcffib.xproto.ConfigWindow.Width | 192 | xcffib.xproto.ConfigWindow.Height, 193 | [ 194 | 0, 195 | 0, 196 | self.screen.width_in_pixels, 197 | self.screen.height_in_pixels, 198 | ] 199 | ) 200 | 201 | # Add event window to window list 202 | if event.window not in self.windows: 203 | self.windows.insert(0, event.window) 204 | self.current_window = 0 205 | 206 | def _handle_configure_request_event(self, event): 207 | """ 208 | A configure request is a request that is asking to change a certain thing about a window. 209 | This can include width/height, x/y, border width, border color, etc. 210 | 211 | :param event: ConfigureRequestEvent to handle 212 | """ 213 | 214 | # Pass on configure request to X server 215 | self.conn.core.ConfigureWindow( 216 | event.window, 217 | xcffib.xproto.ConfigWindow.X | 218 | xcffib.xproto.ConfigWindow.Y | 219 | xcffib.xproto.ConfigWindow.Width | 220 | xcffib.xproto.ConfigWindow.Height | 221 | xcffib.xproto.ConfigWindow.BorderWidth | 222 | xcffib.xproto.ConfigWindow.Sibling | 223 | xcffib.xproto.ConfigWindow.StackMode, 224 | [ 225 | event.x, 226 | event.y, 227 | event.width, 228 | event.height, 229 | event.border_width, 230 | # Siblings are windows that share the same parent. When configuring a window 231 | # you can specify a sibling window and a stack mode. For example if you 232 | # specify a sibling window and Above as the stack mode, the window 233 | # will appear above the sibling window specified. 234 | event.sibling, 235 | # Stacking order is where the window should appear. 236 | # For example above/below the sibling window above. 237 | event.stack_mode 238 | ] 239 | ) 240 | --------------------------------------------------------------------------------