├── .gitignore ├── LICENSE ├── README.md ├── example_config.json ├── requirements.txt ├── version.txt └── xborders /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .update_ignore.txt 3 | .idea 4 | venv -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # xborders 2 | Active window border replacement for window managers. 3 | 4 | ## Usage 5 | ```sh 6 | git clone https://github.com/deter0/xborder 7 | cd xborder 8 | chmod +x xborders 9 | pip install -r requirements.txt 10 | ./xborders --help 11 | ``` 12 | ### Dependencies 13 | Make sure to install dependencies first! 14 | `pip install -r requirements.txt` 15 | * pycairo (Tested with version 1.21.0) 16 | * requests (Tested with version 2.28.1) 17 | * libwnck (Tested with version 40.1-1, Arch: `sudo pacman -S libwnck3` Debian: `sudo apt install libwnck-3-0` NOTE: may need 'libwnck-3-0-dev' for Debian) 18 | * gtk 19 | * a compositor ([picom](https://github.com/yshui/picom) is what I am using or you can use another compositor) although even compton with work, just something that supports transparent windows. 20 | 21 | ### Recommended Dependencies 22 | * libnotify (Debian: `sudo apt install libnotify-bin` Arch: `sudo pacman -S libnotify`) 23 | 24 | ### Note for compositor 25 | If you don't want your entire screen blurred please add `role = 'xborder'` to your blur-exclude! 26 | ``` 27 | blur-background-exclude = [ 28 | # prevents picom from blurring the background 29 | "role = 'xborder'", 30 | ... 31 | ]; 32 | ``` 33 | 34 | ## xborders ontop of i3: 35 | ![image](https://user-images.githubusercontent.com/82973108/160370439-8b7a5feb-c186-4954-a029-b718b59fd957.png) 36 | ## i3 default: 37 | ![image](https://user-images.githubusercontent.com/82973108/160370578-3ea7e3e9-723a-4054-b7b0-2b0110d809c0.png) 38 | 39 | ### Config 40 | Configuration options can be found by passing in the argument `--help` on the command line, or by specifying a config file with the argument `-c`. The config file is just a simple json file with the keys being the same as the command-line arguments (except without the "--" at the beginning). 41 | 42 | # Updating 43 | cd into the xborders directory and run `git pull origin main` 44 | -------------------------------------------------------------------------------- /example_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "border-rgba": "0xFFFFFFFF", 3 | "border-radius": 14, 4 | "border-width": 4, 5 | "border-mode": "outside", 6 | "disable-version-warning": false, 7 | 8 | "positive-x-offset": 0, 9 | "positive-y-offset": 0, 10 | "negative-x-offset": 0, 11 | "negative-y-offset": 0 12 | } 13 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pycairo==1.21.0 2 | requests==2.28.1 3 | 4 | -------------------------------------------------------------------------------- /version.txt: -------------------------------------------------------------------------------- 1 | 3.4 2 | -------------------------------------------------------------------------------- /xborders: -------------------------------------------------------------------------------- 1 | #!/bin/python3 2 | import argparse 3 | import json 4 | import os 5 | import subprocess 6 | import sys 7 | import threading 8 | import webbrowser 9 | 10 | import cairo 11 | import gi 12 | import requests 13 | 14 | gi.require_version("Gtk", "3.0") 15 | gi.require_version("Gdk", "3.0") 16 | gi.require_version("Wnck", "3.0") 17 | gi.require_version("GObject", "2.0") 18 | from gi.repository import Gtk, Gdk, Wnck, GObject 19 | 20 | VERSION = 3.4 21 | 22 | INSIDE = 'inside' 23 | OUTSIDE = 'outside' 24 | CENTER = 'center' 25 | BORDER_MODES = [INSIDE, OUTSIDE, CENTER] 26 | BORDER_MODE = INSIDE 27 | BORDER_RADIUS = 14 28 | BORDER_WIDTH = 4 29 | BORDER_R = 123 30 | BORDER_G = 88 31 | BORDER_B = 220 32 | BORDER_A = 1 33 | SMART_HIDE_BORDER = False 34 | NO_VERSION_NOTIFY = False 35 | OFFSETS = [0, 0, 0, 0] 36 | 37 | 38 | def set_border_rgba(args): 39 | try: 40 | literal_value = int(args.border_rgba.replace("#", "0x"), 16) 41 | except: 42 | raise ValueError(f"`{args.border_rgba}` is an invalid hexadecimal number!") 43 | 44 | if len(args.border_rgba) == 4*2+1: 45 | args.border_red = literal_value >> (3 * 8) & 0xFF 46 | args.border_green = literal_value >> (2 * 8) & 0xFF 47 | args.border_blue = literal_value >> (1 * 8) & 0xFF 48 | args.border_alpha = (literal_value >> (0 * 8) & 0xFF) / 255 # map from 0 to 1 49 | elif len(args.border_rgba) == 3*2+1: 50 | args.border_red = literal_value >> (2 * 8) & 0xFF 51 | args.border_green = literal_value >> (1 * 8) & 0xFF 52 | args.border_blue = literal_value >> (0 * 8) & 0xFF 53 | args.border_alpha = 1.0 54 | else: 55 | raise ValueError(f"`{args.border_rgba}` is an invalid hexadecimal color string.") 56 | 57 | 58 | def get_args(): 59 | parser = argparse.ArgumentParser() 60 | parser.add_argument( 61 | "--config", "-c", 62 | type=str, 63 | help="The path to the config file" 64 | ) 65 | parser.add_argument( 66 | "--border-radius", 67 | type=int, 68 | default=14, 69 | help="The border radius, in pixels" 70 | ) 71 | parser.add_argument( 72 | "--border-width", 73 | type=int, 74 | default=4, 75 | help="The border width in pixels" 76 | ) 77 | parser.add_argument( 78 | "--border-red", 79 | type=int, 80 | default=123, 81 | help="The border's red value, between 0 and 255", 82 | ) 83 | parser.add_argument( 84 | "--border-green", 85 | type=int, 86 | default=88, 87 | help="The border's green value, between 0 and 255", 88 | ) 89 | parser.add_argument( 90 | "--border-blue", 91 | type=int, 92 | default=220, 93 | help="The border's blue value, between 0 and 255", 94 | ) 95 | parser.add_argument( 96 | "--border-alpha", 97 | type=float, 98 | default=1, 99 | help="The border's alpha value, between zero and 1", 100 | ) 101 | parser.add_argument( 102 | "--border-rgba", 103 | default=None, 104 | help="The colours of the border in hex format, example: #FF0000FF", 105 | ) 106 | parser.add_argument( 107 | "--border-mode", 108 | type=str, 109 | default="outside", 110 | help="Whether to place the border on the outside, inside or in the center of windows. Values are `outside`, `inside`, `center`" 111 | ) 112 | parser.add_argument( 113 | "--smart-hide-border", 114 | action='store_true', 115 | help="Don't display a border if the window is alone in the workspace." 116 | ) 117 | parser.add_argument( 118 | "--disable-version-warning", 119 | action='store_true', 120 | help="Send a notification if xborders is out of date." 121 | ) 122 | parser.add_argument( 123 | "--positive-x-offset", 124 | default=0, 125 | type=int, 126 | help="How much to increase the windows size to the right." 127 | ) 128 | parser.add_argument( 129 | "--negative-x-offset", 130 | default=0, 131 | type=int, 132 | help="How much to increase the windows size to the left." 133 | ) 134 | parser.add_argument( 135 | "--positive-y-offset", 136 | default=0, 137 | type=int, 138 | help="How much to increase the windows size upwards." 139 | ) 140 | parser.add_argument( 141 | "--negative-y-offset", 142 | default=0, 143 | type=int, 144 | help="How much to increase the windows size downwards." 145 | ) 146 | parser.add_argument( 147 | "--version", 148 | action="store_true", 149 | help="Print the version of xborders and exit." 150 | ) 151 | args = parser.parse_args() 152 | if args.version is True: 153 | print(f"xborders v{VERSION}") 154 | exit(0) 155 | if args.border_rgba is not None: 156 | set_border_rgba(args) 157 | 158 | # Extract the literal values 159 | if args.config is not None: 160 | with open(args.config, "r") as f: 161 | raw = f.read().replace("-", "_") 162 | dat = json.loads(raw) 163 | for ident in dat: 164 | if ident == "border_rgba": 165 | args.border_rgba = dat[ident] 166 | set_border_rgba(args) 167 | else: 168 | args.__dict__[ident] = dat[ 169 | ident 170 | ] # Idea gotten from here: https://stackoverflow.com/a/1325798 171 | 172 | global BORDER_RADIUS 173 | global BORDER_WIDTH 174 | global BORDER_MODE 175 | global BORDER_R 176 | global BORDER_G 177 | global BORDER_B 178 | global BORDER_A 179 | global SMART_HIDE_BORDER 180 | global NO_VERSION_NOTIFY 181 | global OFFSETS 182 | 183 | BORDER_RADIUS = args.border_radius 184 | BORDER_WIDTH = args.border_width 185 | BORDER_R = args.border_red 186 | BORDER_G = args.border_green 187 | BORDER_B = args.border_blue 188 | BORDER_A = args.border_alpha 189 | NO_VERSION_NOTIFY = args.disable_version_warning 190 | SMART_HIDE_BORDER = args.smart_hide_border 191 | OFFSETS = [ 192 | args.positive_x_offset or 0, 193 | args.positive_y_offset or 0, 194 | args.negative_x_offset or 0, 195 | args.negative_y_offset or 0 196 | ] 197 | 198 | if args.border_mode in BORDER_MODES: 199 | BORDER_MODE = args.border_mode 200 | else: 201 | raise ValueError( 202 | f"Invalid border_mode: '{args.border_mode}'. Valid border_modes are: inside, outside and center.") 203 | 204 | return 205 | 206 | 207 | def get_screen_size(display): # TODO: Multiple monitor size support 208 | mon_geoms = [display.get_monitor(i).get_geometry() for i in range(display.get_n_monitors())] 209 | 210 | x0 = min(r.x for r in mon_geoms) 211 | y0 = min(r.y for r in mon_geoms) 212 | x1 = max(r.x + r.width for r in mon_geoms) 213 | y1 = max(r.y + r.height for r in mon_geoms) 214 | 215 | return x1 - x0, y1 - y0 216 | 217 | 218 | def notify_about_version(latest_version: float): 219 | notification_string = f"xborders has an update! [{VERSION} 🡢 {latest_version}]" 220 | completed_process = subprocess.run( 221 | ["notify-send", "--app-name=xborder", "--expire-time=5000", notification_string, "--action=How to Update?", 222 | "--action=Ignore Update"], 223 | capture_output=True 224 | ) 225 | if completed_process.returncode == 0: 226 | result_string = completed_process.stdout.decode("utf-8") 227 | if result_string == '': 228 | return 229 | result = int(result_string) 230 | if result == 1: 231 | our_location = os.path.dirname(os.path.abspath(__file__)) 232 | file = open(our_location + "/.update_ignore.txt", "w") 233 | file.write(str(latest_version)) 234 | file.close() 235 | elif result == 0: 236 | webbrowser.open_new_tab("https://github.com/deter0/xborder#updating") 237 | else: 238 | print("something went wrong in notify-send.") 239 | 240 | 241 | def notify_version(): 242 | if NO_VERSION_NOTIFY: 243 | return 244 | try: 245 | our_location = os.path.dirname(os.path.abspath(__file__)) 246 | 247 | url = "https://raw.githubusercontent.com/deter0/xborder/main/version.txt" # Maybe hardcoding it is a bad idea 248 | request = requests.get(url, allow_redirects=True) 249 | latest_version_string = request.content.decode("utf-8") 250 | 251 | latest_version = float(latest_version_string) 252 | 253 | if os.path.isfile(our_location + "/.update_ignore.txt"): 254 | ignore_version_file = open(our_location + "/.update_ignore.txt", "r") 255 | ignored_version_string = ignore_version_file.read() 256 | ignored_version = float(ignored_version_string) 257 | if ignored_version == latest_version: 258 | return 259 | 260 | if VERSION < latest_version: 261 | threading._start_new_thread(notify_about_version, (latest_version)) 262 | except: 263 | subprocess.Popen(["notify-send", "--app-name=xborders", "ERROR: xborders couldn't get latest version!"]) 264 | 265 | 266 | class Highlight(Gtk.Window): 267 | def __init__(self, screen_width, screen_height): 268 | super().__init__(type=Gtk.WindowType.POPUP) 269 | notify_version() 270 | 271 | self.wnck_screen = Wnck.Screen.get_default() 272 | 273 | self.set_app_paintable(True) 274 | self.screen = self.get_screen() 275 | self.set_visual(self.screen.get_rgba_visual()) 276 | 277 | # As described here: https://docs.gtk.org/gtk3/method.Window.set_wmclass.html 278 | # Picom blur exclusion would be: 279 | # "role = 'xborder'", 280 | self.set_role("xborder") 281 | 282 | # We can still set this for old configurations, we don't want to update and have the user confused as to what 283 | # has happened 284 | self.set_wmclass("xborders", "xborder") 285 | 286 | self.resize(screen_width, screen_height) 287 | self.move(0, 0) 288 | 289 | self.fullscreen() 290 | self.set_decorated(False) 291 | self.set_skip_taskbar_hint(True) 292 | self.set_skip_pager_hint(True) 293 | self.set_keep_above(True) 294 | self.set_type_hint(Gdk.WindowTypeHint.NOTIFICATION) 295 | 296 | self.set_accept_focus(False) 297 | self.set_focus_on_map(False) 298 | 299 | self.drawingarea = Gtk.DrawingArea() 300 | self.drawingarea.set_events(Gdk.EventMask.EXPOSURE_MASK) 301 | self.add(self.drawingarea) 302 | self.input_shape_combine_region(cairo.Region()) 303 | 304 | self.set_keep_above(True) 305 | self.set_title("xborders") 306 | self.show_all() 307 | self.border_path = [0, 0, 0, 0] 308 | 309 | # Event connection: 310 | self.connect("draw", self._draw) 311 | self.connect("destroy", Gtk.main_quit) 312 | self.connect('composited-changed', self._composited_changed_event) 313 | self.wnck_screen.connect("active-window-changed", self._active_window_changed_event) 314 | 315 | # Call initial events 316 | self._composited_changed_event(None) 317 | self._active_window_changed_event(None, None) 318 | self._geometry_changed_event(None) 319 | 320 | # This triggers every time the window composited state changes. 321 | # https://docs.gtk.org/gtk3/signal.Widget.composited-changed.html 322 | def _composited_changed_event(self, _arg): 323 | if self.screen.is_composited(): 324 | self.move(0, 0) 325 | else: 326 | self.move(1e6, 1e6) 327 | subprocess.Popen(["notify-send", "--app-name=xborder", 328 | "xborders requires a compositor. Resuming once a compositor is running."]) 329 | 330 | # Avoid memory leaks 331 | old_window = None 332 | old_signals_to_disconnect = [None, None] 333 | 334 | def is_alone_in_workspace(self): 335 | workspace = Wnck.Screen.get_active_workspace(self.wnck_screen) 336 | windows = Wnck.Screen.get_windows(self.wnck_screen) 337 | windows_on_workspace = list(filter(lambda w: w.is_visible_on_workspace(workspace), windows)) 338 | return len(windows_on_workspace) == 1 339 | 340 | # This event will trigger every active window change, it will queue a border to be drawn and then do nothing. 341 | # See: Signals available for the Wnck.Screen class: 342 | # https://lazka.github.io/pgi-docs/Wnck-3.0/classes/Screen.html#signals Signals available for the Wnck.Window 343 | # class: https://lazka.github.io/pgi-docs/Wnck-3.0/classes/Window.html#signals 344 | def _active_window_changed_event(self, _screen, _previous_active_window): 345 | if self.old_window and len(self.old_signals_to_disconnect) > 0: 346 | for sig_id in self.old_signals_to_disconnect: 347 | GObject.signal_handler_disconnect(self.old_window, sig_id) 348 | 349 | self.old_signals_to_disconnect = [] 350 | self.old_window = None 351 | 352 | active_window = self.wnck_screen.get_active_window() 353 | 354 | self.border_path = [0, 0, 0, 0] 355 | if active_window is not None and not (SMART_HIDE_BORDER and self.is_alone_in_workspace()): 356 | # Find if the window has a 'geometry-changed' event connected. 357 | 358 | geom_signal_id = GObject.signal_lookup('geometry-changed', active_window) 359 | state_signal_id = GObject.signal_lookup('state-changed', active_window) 360 | geom_has_event_connected = GObject.signal_has_handler_pending(active_window, geom_signal_id, 0, False) 361 | state_has_event_connected = GObject.signal_has_handler_pending(active_window, state_signal_id, 0, False) 362 | 363 | # if it doesn't have one. 364 | if not geom_has_event_connected: 365 | # Connect it. 366 | # Has to be done this way in order to not connect an event 367 | # every time the active window changes, thus, drawing unnecesary frames. 368 | sig_id = active_window.connect('geometry-changed', self._geometry_changed_event) 369 | self.old_signals_to_disconnect.append(sig_id) 370 | 371 | if not state_has_event_connected: 372 | sig_id = active_window.connect('state-changed', self._state_changed_event) 373 | self.old_signals_to_disconnect.append(sig_id) 374 | 375 | self.old_window = active_window 376 | 377 | self._calc_border_geometry(active_window) 378 | self.queue_draw() 379 | 380 | def _state_changed_event(self, active_window, _changed_mask, new_state): 381 | if new_state & Wnck.WindowState.FULLSCREEN != 0: 382 | self._calc_border_geometry(active_window) 383 | self.queue_draw() 384 | 385 | # This is weird, "_window_changed" is not necessarily the active window, 386 | # it is the window which receives the signal of resizing and is not necessarily 387 | # the active window, this means the border will get drawn on other windows. 388 | def _geometry_changed_event(self, _window_changed): 389 | active_window = self.wnck_screen.get_active_window() 390 | if active_window is None or (active_window.get_state() & Wnck.WindowState.FULLSCREEN != 0): 391 | self.border_path = [0, 0, 0, 0] 392 | else: 393 | self._calc_border_geometry(active_window) 394 | self.queue_draw() 395 | 396 | def _calc_border_geometry(self, window): 397 | if (window.get_state() & Wnck.WindowState.FULLSCREEN != 0): 398 | self.border_path = [0, 0, 0, 0] 399 | return 400 | # TODO(kay:) Find out why `get_geometry` works better than `get_client_window_geometry` on Gnome but for some windows it doesnt 401 | x, y, w, h = window.get_client_window_geometry() 402 | 403 | # Inside 404 | if BORDER_MODE == INSIDE: 405 | x += BORDER_WIDTH / 2 406 | y += BORDER_WIDTH / 2 407 | w -= BORDER_WIDTH 408 | h -= BORDER_WIDTH 409 | 410 | # Outside 411 | elif BORDER_MODE == OUTSIDE: 412 | x -= BORDER_WIDTH / 2 413 | y -= BORDER_WIDTH / 2 414 | w += BORDER_WIDTH 415 | h += BORDER_WIDTH 416 | 417 | # Offsets 418 | 419 | w += OFFSETS[0] or 0 420 | h += OFFSETS[1] or 0 421 | 422 | x -= OFFSETS[2] or 0 423 | w += OFFSETS[2] or 0 424 | 425 | y -= OFFSETS[3] or 0 426 | h += OFFSETS[3] or 0 427 | 428 | # Center 429 | self.border_path = [x, y, w, h] 430 | 431 | def _draw(self, _wid, ctx): 432 | ctx.save() 433 | if self.border_path != [0, 0, 0, 0]: 434 | x, y, w, h = self.border_path 435 | if BORDER_WIDTH != 0: 436 | if BORDER_RADIUS > 0: 437 | degrees = 0.017453292519943295 # pi/180 438 | ctx.arc(x + w - BORDER_RADIUS, y + BORDER_RADIUS, BORDER_RADIUS, -90 * degrees, 0 * degrees) 439 | ctx.arc(x + w - BORDER_RADIUS, y + h - BORDER_RADIUS, BORDER_RADIUS, 0 * degrees, 90 * degrees) 440 | ctx.arc(x + BORDER_RADIUS, y + h - BORDER_RADIUS, BORDER_RADIUS, 90 * degrees, 180 * degrees) 441 | ctx.arc(x + BORDER_RADIUS, y + BORDER_RADIUS, BORDER_RADIUS, 180 * degrees, 270 * degrees) 442 | ctx.close_path() 443 | else: 444 | ctx.rectangle(x, y, w, h) 445 | 446 | ctx.set_source_rgba(BORDER_R / 255, BORDER_G / 255, BORDER_B / 255, BORDER_A) 447 | ctx.set_line_width(BORDER_WIDTH) 448 | ctx.stroke() 449 | ctx.restore() 450 | 451 | 452 | def main(): 453 | get_args() 454 | root = Gdk.get_default_root_window() 455 | root.get_screen() 456 | screen_width, screen_height = get_screen_size(Gdk.Display.get_default()) 457 | Highlight(screen_width, screen_height) 458 | Gtk.main() 459 | 460 | 461 | if __name__ == "__main__": 462 | try: 463 | main() 464 | except KeyboardInterrupt: 465 | exit(0) 466 | else: 467 | print( 468 | "xborders: This program is not meant to be imported to other Python modules. Please run xborders as a " 469 | "standalone script!") 470 | --------------------------------------------------------------------------------