├── .dir-locals.el ├── .flake8 ├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE ├── README.md ├── bin ├── i3-companion ├── i3-tabbed ├── input-event ├── picom-configure ├── polybar ├── rofi-mediaplayer ├── rofi-wifi ├── screenshot ├── ssh-add ├── toggle-mute ├── wallpaper ├── weather ├── xdg-app-chooser ├── xsecurelock-saver ├── xsettingsd-setup ├── xss-dimmer └── xss-lock ├── config ├── dotfiles ├── XCompose ├── Xresources ├── dconf.ini ├── dunstrc ├── firefox.css ├── firefox.js ├── fonts.conf ├── gtk3.css ├── gtk4.css ├── gtkrc-2.0 ├── picom.conf ├── polybar.conf ├── portals.conf ├── qt5ct.conf ├── rofi.conf ├── systemd │ ├── autorandr.service │ ├── dunst.service │ ├── i3-companion.service │ ├── i3-session.target │ ├── i3.service │ ├── inputplug.service │ ├── misc-x.service │ ├── onedrive.service.d │ │ └── override.conf │ ├── picom.service │ ├── pipewire-pulse.service │ ├── pipewire-pulse.socket │ ├── pipewire.service │ ├── pipewire.socket │ ├── policykit-agent.service │ ├── polybar.service │ ├── redshift.service │ ├── ssh-agent.service │ ├── thunar.service │ ├── tray.target │ ├── wallpaper.service │ ├── wallpaper.timer │ ├── weather.service │ ├── weather.timer │ ├── wireplumber.service │ ├── xiccd.service │ ├── xsession.target │ ├── xsettingsd.service │ ├── xss-dimmer@.service │ ├── xss-lock.service │ └── xssproxy.service ├── thunderbird.js ├── xkb │ ├── 75percent.xkb │ ├── default.xkb │ ├── symbols │ │ └── vbe │ └── x1gen2.xkb ├── xsession └── xsettingsd ├── wallpapers └── list └── ws-emacs.json /.dir-locals.el: -------------------------------------------------------------------------------- 1 | ((python-mode . ((mode . apheleia)))) 2 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | --- 2 | github: vincentbernat 3 | custom: https://www.buymeacoffee.com/vincentbernat 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | /wallpapers 3 | /screenshots 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The license below applies to most, but not all content in this project. 2 | Files with different licensing and authorship terms are marked as such. 3 | That information must be considered when ensuring licensing compliance. 4 | 5 | ISC License 6 | 7 | Copyright (c) 2021, Vincent Bernat 8 | 9 | Permission to use, copy, modify, and/or distribute this software for any 10 | purpose with or without fee is hereby granted, provided that the above 11 | copyright notice and this permission notice appear in all copies. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 14 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 15 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 16 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 17 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 18 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 19 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vincent Bernat's i3 configuration 2 | 3 | This is my [i3](https://i3wm) configuration. It does not exactly 4 | feature the same keybindings as the default configuration. I don't 5 | recommend using it as-is by you can pick anything you need in it. 6 | 7 | ![Screenshot](https://d1g3mdmxf8zbo9.cloudfront.net/images/i3/desktop@1x.jpg) 8 | 9 | More details in this [blog post](https://vincent.bernat.ch/en/blog/2021-i3-window-manager). 10 | 11 | Here some of the things you may be interested in: 12 | 13 | - I use a Python script `bin/wallpaper` to build the wallpaper 14 | to be displayed. There is a random selection and it works with 15 | multihead setup. It seems that classic tools are now able to change 16 | the wallpaper per screen and therefore, the script may seem a bit 17 | useless but I keep it. 18 | 19 | - I am using `xss-lock` with `xsecurelock` as a screensaver. It 20 | relies on standard X screensaver handling (and therefore is easy 21 | for application to disable) and also supports systemd inhibitors. 22 | Nothing fancy but I reuse the wallpaper built above for both the 23 | dimmer (`xss-dimmer`) and the screen saver (`xsecurelock-saver`). 24 | 25 | - There is an `i3-companion` (in `bin/`) which I use to implement 26 | whatever does not match what I want in i3. I prefer to not have 27 | many Python binaries running. 28 | 29 | - There is a Quake console included. 30 | 31 | - Many stuff is handled by systemd. The session is still expected to 32 | be handled by Xsession but we invoke a custom `xsession.target` 33 | which binds to `graphical-session.target`. i3 will then invoke 34 | `i3-session.target` for stuff needing i3 to run. 35 | 36 | Also, I am using my custom terminal (`vbeterm`). You can also find the 37 | sources on [GitHub](https://github.com/vincentbernat/vbeterm). 38 | 39 | ## Requirements 40 | 41 | Required Debian packages to make everything work can be found in my 42 | [Puppet configuration][]. Packages are basically pulled from Debian 43 | unstable but some of them are pulled from Nix. Check my [home-manager 44 | configuration][]. 45 | 46 | [Puppet configuration]: https://github.com/vincentbernat/puppet-workstation/blob/master/local-modules/desktop/manifests/i3.pp 47 | [home-manager configuration]: https://github.com/vincentbernat/homemanager-configuration 48 | 49 | However, I am recompiling some stuff to get more recent versions: 50 | 51 | - `polybar` (check `vbe/master` branch) 52 | - `xsecurelock` (check `vbe/master` branch) 53 | 54 | The binaries are put in `~/.local/bin`. 55 | 56 | ## About Wayland 57 | 58 | What's missing for me to migrate to Wayland: 59 | 60 | - Sway does not support 61 | [`append_layout`](https://github.com/swaywm/sway/issues/1005), but it should 62 | be possible to get something close (also, I use it mostly on start) 63 | - Something to replace `xsecurelock` (I want to use my own screen saver), maybe 64 | [swaylock-plugin](https://github.com/mstoeckl/swaylock-plugin)? 65 | - Something to replace `polybar`, maybe [Waybar](https://github.com/Alexays/Waybar)? 66 | - Ability to [mirror outputs](https://github.com/swaywm/sway/issues/1666) (and 67 | more complex layouts would be nice too). 68 | 69 | Everything else should be adaptable: 70 | 71 | - i3 can be replaced by Sway 72 | - Dunst [supports](https://github.com/dunst-project/dunst/issues/264) Wayland 73 | - There is a [fork](https://github.com/lbonn/rofi) of Rofi with Wayland support 74 | - Wallpaper building should be adaptable 75 | 76 | Also see [this post from Anarcat](https://anarc.at/software/desktop/wayland/). 77 | 78 | ## Interesting links 79 | 80 | - [/r/unixporn on reddit](https://www.reddit.com/r/unixporn/search?q=i3&restrict_sr=1) 81 | - [Hacking i3: Automatic Layout](https://aduros.com/blog/hacking-i3-automatic-layout/) 82 | - [__luccy desktop](https://www.reddit.com/r/unixporn/comments/odlf79/i3gaps_simple_minimal_round/) 83 | - [SI7Bot desktop](https://github.com/cosmicraccoon/thinky-nature-dots) 84 | - [Catppucin theme](https://github.com/catppuccin/catppuccin) 85 | -------------------------------------------------------------------------------- /bin/i3-companion: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | """Personal i3 companion.""" 4 | 5 | import argparse 6 | import asyncio 7 | import collections 8 | import contextlib 9 | import errno 10 | import functools 11 | import glob 12 | import html 13 | import logging 14 | import logging.handlers 15 | import os 16 | import shlex 17 | import socket 18 | import struct 19 | import subprocess 20 | import sys 21 | import types 22 | 23 | import dbussy 24 | import i3ipc 25 | import ravel 26 | import xcffib 27 | import xcffib.randr 28 | import xcffib.dpms 29 | import xcffib.xproto 30 | from i3ipc.aio import Connection 31 | from systemd import daemon, journal 32 | 33 | 34 | def icon(font_number, char): 35 | """Turn an icon into a string for Polybar.""" 36 | # Font number is from Polybar configuration. 37 | # 2: https://fontawesome.com/v6.0/icons?s=solid 38 | # 3: https://fontawesome.com/v6.0/icons?s=brands 39 | return "%%{T%d}%s%%{T-}" % (font_number, char) 40 | 41 | 42 | # Configuration 43 | 44 | application_icons = { 45 | "86box": icon(2, ""), 46 | "anydesk": icon(2, ""), 47 | "blender": icon(2, ""), 48 | "calibre": icon(2, "📚"), 49 | "cheese": icon(2, ""), 50 | "chromium": icon(3, ""), 51 | "ungoogled_chromium": icon(3, ""), 52 | "d-feet": icon(2, ""), 53 | "darktable": icon(2, ""), 54 | "discord": icon(3, ""), 55 | "draw.io": icon(2, ""), 56 | "easyeffects": icon(2, ""), 57 | "emacs": icon(2, ""), 58 | "file-roller": icon(2, ""), 59 | "fileroller": icon(2, ""), 60 | "firefox": icon(3, ""), 61 | "gaupol": icon(2, ""), 62 | "geeqie": icon(2, ""), 63 | "gimp": icon(2, ""), 64 | "gitg": icon(2, ""), 65 | "gnome-boxes": icon(2, ""), 66 | "google-chrome": icon(3, ""), 67 | "grisbi": icon(2, ""), 68 | "inkscape": icon(2, ""), 69 | "jitsi meet": icon(2, ""), 70 | "libreoffice": icon(2, "📄"), 71 | "soffice": icon(2, "📄"), 72 | "onlyoffice desktop editors": icon(2, "📄"), 73 | "maps": icon(2, ""), 74 | "mednafen": icon(2, ""), 75 | "mpv": icon(2, ""), 76 | "nestopia": icon(2, ""), 77 | "nsxiv": icon(2, ""), 78 | "pavucontrol": icon(2, ""), 79 | "peek": icon(2, ""), 80 | "pinentry": icon(2, ""), 81 | "qalculate": icon(2, ""), 82 | "qemu": icon(2, ""), 83 | "qgis3": icon(2, ""), 84 | "retroarch": icon(2, ""), 85 | "scummvm": icon(2, ""), 86 | "signal": icon(2, ""), 87 | "snes9x": icon(2, ""), 88 | "spot": icon(3, ""), 89 | "spotify": icon(3, ""), 90 | "steam": icon(3, ""), 91 | "sxiv": icon(2, ""), 92 | "system-config-printer.py": icon(2, "⎙"), 93 | "thunar": icon(2, ""), 94 | "thunderbird": icon(2, ""), 95 | "vbeterm": icon(2, ""), 96 | "virt-manager": icon(2, ""), 97 | "webex": icon(2, ""), 98 | "wireshark": icon(2, ""), 99 | "zathura": icon(2, ""), 100 | "zeal": icon(2, ""), 101 | "zoom": icon(2, ""), 102 | } 103 | icons = { 104 | "access-point": icon(2, ""), 105 | "battery-100": icon(2, ""), 106 | "battery-75": icon(2, ""), 107 | "battery-50": icon(2, ""), 108 | "battery-25": icon(2, ""), 109 | "battery-0": icon(2, ""), 110 | "bluetooth": icon(2, ""), 111 | "camera": icon(2, "⎙"), 112 | "gamepad": icon(2, "🎮"), 113 | "headphones": icon(2, "🎧"), 114 | "headset": icon(2, ""), 115 | "keyboard": icon(2, "⌨"), 116 | "laptop": icon(2, "💻"), 117 | "loudspeaker": icon(2, ""), 118 | "microphone": icon(2, ""), 119 | "mouse": icon(2, ""), 120 | "notifications-disabled": icon(2, "🔕"), 121 | "notifications-enabled": icon(2, ""), 122 | "nowifi": icon(2, ""), 123 | "phone": icon(2, "📞"), 124 | "printer": icon(2, "⎙"), 125 | "scanner": icon(2, ""), 126 | "unknown": icon(2, ""), 127 | "vpn": icon(2, ""), 128 | "webcam": icon(2, ""), 129 | "wifi-high": icon(2, ""), 130 | "wifi-low": icon(2, ""), 131 | "wifi-medium": icon(2, ""), 132 | "wired": icon(2, ""), 133 | } 134 | application_icons_nomatch = icon(2, "") 135 | application_icons_ignore = {"dimmer"} 136 | application_icons_alone = {application_icons[k] for k in {"vbeterm"}} 137 | exclusive_apps = { 138 | "emacs", 139 | "firefox", 140 | "chromium-browser", 141 | "google-chrome", 142 | "thunderbird", 143 | "thunderbird-beta", 144 | "thunderbird-esr", 145 | } 146 | intrusive_apps = {"vbeterm"} 147 | 148 | logger = logging.getLogger("i3-companion") 149 | 150 | # Events for @on decorator 151 | DBusSignal = collections.namedtuple( 152 | "DBusSignal", 153 | ["interface", "member", "signature", "system", "path", "onlyif"], 154 | defaults=(True, "/", None), 155 | ) 156 | StartEvent = object() 157 | I3Event = i3ipc.Event 158 | CommandEvent = collections.namedtuple("CommandEvent", ["name"]) 159 | 160 | NM_ACTIVE_CONNECTION_STATE_ACTIVATED = 2 161 | NM_DEVICE_TYPE_ETHERNET = 1 162 | NM_DEVICE_TYPE_WIFI = 2 163 | NM_DEVICE_TYPE_MODEM = 8 164 | NM_DEVICE_STATE_UNMANAGED = 10 165 | NM_DEVICE_STATE_ACTIVATED = 100 166 | 167 | 168 | # Event helpers 169 | 170 | 171 | def static(**kwargs): 172 | """Define static variables for the event handler.""" 173 | 174 | def decorator(fn): 175 | fn.__dict__.update(kwargs) 176 | return fn 177 | 178 | return decorator 179 | 180 | 181 | @static(functions={}) 182 | def on(*events): 183 | """Tag events that should be provided to the function.""" 184 | 185 | def decorator(fn): 186 | @functools.wraps(fn) 187 | def wrapper(*args, **kwargs): 188 | return fn(*args, **kwargs) 189 | 190 | on.functions[fn] = events 191 | return wrapper 192 | 193 | return decorator 194 | 195 | 196 | def retry(max_retries): 197 | """Retry an async function.""" 198 | 199 | def decorator(fn): 200 | @functools.wraps(fn) 201 | async def wrapper(*args, **kwargs): 202 | retries = max_retries 203 | while True: 204 | try: 205 | logger.debug("execute %s (remaining tries: %s)", fn, retries) 206 | return await fn(*args, **kwargs) 207 | except Exception as e: 208 | if retries > 0: 209 | retries -= 1 210 | logger.warning( 211 | f"while executing {fn} (remaining tries: %d): %s", 212 | retries, 213 | e, 214 | ) 215 | else: 216 | logger.exception(f"while executing {fn}: %s", e) 217 | return 218 | 219 | return wrapper 220 | 221 | return decorator 222 | 223 | 224 | def debounce(sleep, *, unless=None): 225 | """Debounce a function call (batch successive calls into only one). 226 | Optional immediate execution. Ensure only one instance is 227 | executed. It is assumed the arguments provided to the debounced 228 | function have no effect on its execution.""" 229 | 230 | def decorator(fn): 231 | async def worker(): 232 | while True: 233 | with contextlib.suppress(asyncio.TimeoutError): 234 | # Wait for an urgent work or until sleep is elapsed 235 | await asyncio.wait_for(workers[fn].urgent.wait(), timeout=sleep) 236 | logger.debug("urgent work received for %s", fn) 237 | args, kwargs = workers[fn].queue 238 | workers[fn].queue = None 239 | workers[fn].urgent.clear() 240 | 241 | # Execute the work 242 | logger.debug("execute work for %s", fn) 243 | try: 244 | await fn(*args, **kwargs) 245 | except Exception as e: 246 | logger.debug("while running %s, worker got %s", fn, e) 247 | workers[fn] = None 248 | raise 249 | 250 | # Do we still have something to do? 251 | if workers[fn].queue is None: 252 | break 253 | 254 | # No more work 255 | logger.debug("no more work for %s", fn) 256 | workers[fn] = None 257 | 258 | @functools.wraps(fn) 259 | async def wrapper(*args, **kwargs): 260 | if workers[fn] is None: 261 | logger.debug("create new worker for %s", fn) 262 | workers[fn] = types.SimpleNamespace() 263 | workers[fn].task = asyncio.create_task(worker()) 264 | workers[fn].urgent = asyncio.Event() 265 | workers[fn].queue = (args, kwargs) 266 | else: 267 | logger.debug("enqueue new work for %s", fn) 268 | if unless is not None and unless(*args, **kwargs): 269 | logger.debug("wake up now for %s", fn) 270 | workers[fn].urgent.set() 271 | return await workers[fn].task 272 | 273 | workers[fn] = None 274 | return wrapper 275 | 276 | workers = {} 277 | return decorator 278 | 279 | 280 | def polybar(module): 281 | """Use returned string to update polybar module""" 282 | 283 | def decorator(fn): 284 | @functools.wraps(fn) 285 | async def wrapper(*args, **kwargs): 286 | content = await fn(*args, **kwargs) 287 | if type(content) is not str or cache.get(module) == content: 288 | return content 289 | 290 | # Update cache file (for when polybar restarts) 291 | with open(f"{os.getenv('XDG_RUNTIME_DIR')}/i3/{module}.txt", "w") as out: 292 | out.write(content) 293 | 294 | # Send it to polybar 295 | cmd = bytes(f"#{module}.send.{content}", "utf-8") 296 | data = ( 297 | b"polyipc" # magic 298 | + struct.pack("=BIB", 0, len(cmd), 2) # header: version, length, type 299 | + cmd 300 | ) 301 | for name in glob.glob(f"{os.getenv('XDG_RUNTIME_DIR')}/polybar/*.sock"): 302 | try: 303 | reader, writer = await asyncio.open_unix_connection(name) 304 | except OSError as e: 305 | if e.errno not in (errno.ENXIO, errno.ECONNREFUSED): 306 | raise 307 | else: 308 | try: 309 | writer.write(data) 310 | await writer.drain() 311 | await reader.read() 312 | finally: 313 | writer.close() 314 | await writer.wait_closed() 315 | 316 | logger.info(f"polybar/{module}: content updated") 317 | cache[module] = content 318 | return content 319 | 320 | return wrapper 321 | 322 | cache = {} 323 | return decorator 324 | 325 | 326 | # Other helpers 327 | 328 | 329 | async def notify(i3, **kwargs): 330 | """Send a notification with notify-send.""" 331 | conn = i3.session_bus["org.freedesktop.Notifications"] 332 | obj = conn["/org/freedesktop/Notifications"] 333 | notifications = await obj.get_async_interface("org.freedesktop.Notifications") 334 | parameters = dict( 335 | app_name=logger.name, 336 | replaces_id=0, 337 | app_icon="dialog-information", 338 | summary="", 339 | actions=[], 340 | hints={}, 341 | expire_timeout=5000, 342 | ) 343 | parameters.update(kwargs) 344 | return await notifications.Notify(**parameters) 345 | 346 | 347 | async def create_new_workspace(i3): 348 | """Create a new workspace and returns its number.""" 349 | workspaces = await i3.get_workspaces() 350 | workspace_nums = {w.num for w in workspaces} 351 | max_num = max(workspace_nums) 352 | available = (set(range(1, max_num + 2)) - workspace_nums).pop() 353 | logger.info(f"create new workspace number {available}") 354 | await i3.command(f'workspace number "{available}"') 355 | return available 356 | 357 | 358 | # Event handlers 359 | 360 | 361 | @on(StartEvent, I3Event.WINDOW_MOVE, I3Event.WINDOW_NEW, I3Event.WINDOW_CLOSE) 362 | @debounce(0.2) 363 | async def workspace_rename(i3, event): 364 | """Rename workspaces using icons to match what's inside it.""" 365 | tree = await i3.get_tree() 366 | workspaces = tree.workspaces() 367 | commands = [] 368 | 369 | for workspace in workspaces: 370 | icons = set() 371 | for window in workspace.leaves(): 372 | if window.sticky and window.floating in {"auto_on", "user_on"}: 373 | continue 374 | cls = (window.window_class or "").lower() 375 | if cls in application_icons_ignore: 376 | continue 377 | icon = application_icons.get( 378 | cls, 379 | application_icons.get( 380 | cls.split("-")[0], 381 | application_icons.get( 382 | cls.split("_")[0], application_icons.get(cls.split(".")[-1]) 383 | ), 384 | ), 385 | ) 386 | icons.add(icon or application_icons_nomatch) 387 | if any([i not in application_icons_alone for i in icons]): 388 | icons -= application_icons_alone 389 | new_name = f"{workspace.num}:{'|'.join(sorted(list(icons)))}".rstrip(":") 390 | if workspace.name != new_name: 391 | logger.debug("rename workspace %s", workspace.num) 392 | command = f'rename workspace "{workspace.name}" to "{new_name}"' 393 | commands.append(command) 394 | await i3.command(";".join(commands)) 395 | 396 | 397 | @on(CommandEvent("previous-workspace"), I3Event.WORKSPACE_FOCUS) 398 | @static(history=collections.defaultdict(list)) 399 | async def previous_workspace(i3, event): 400 | """Go to previous workspace on the same output.""" 401 | history = previous_workspace.history 402 | if isinstance(event, i3ipc.WorkspaceEvent) and event.old: 403 | data = event.old.ipc_data 404 | output, num = data["output"], data["num"] 405 | if history[output][-1:] != [num]: 406 | history[output].append(num) 407 | history[output] = history[output][-5:] 408 | logger.debug("on %s, history is %s", output, history[output]) 409 | elif event == "previous-workspace": 410 | workspaces = await i3.get_workspaces() 411 | try: 412 | focused = [w for w in workspaces if w.focused][0] 413 | except IndexError: 414 | return 415 | output = focused.output 416 | while True: 417 | try: 418 | previous = history[output].pop() 419 | if [ 420 | w 421 | for w in workspaces 422 | if w.num != focused.num and w.num == previous and w.output == output 423 | ]: 424 | break 425 | except IndexError: 426 | logger.debug("no previous workspace on %s", output) 427 | return 428 | logger.debug("switching to workspace %d on %s", previous, output) 429 | await i3.command(f"workspace number {previous}") 430 | 431 | 432 | @on(CommandEvent("new-workspace"), CommandEvent("move-to-new-workspace")) 433 | async def new_workspace(i3, event): 434 | """Create a new workspace and optionally move a window to it.""" 435 | # Get the currently focused window 436 | if event == "move-to-new-workspace": 437 | tree = await i3.get_tree() 438 | current = tree.find_focused() 439 | if not current: 440 | return 441 | 442 | num = await create_new_workspace(i3) 443 | 444 | # Move the window to this workspace 445 | if event == "move-to-new-workspace": 446 | await current.command(f"move container to workspace " f'number "{num}"') 447 | 448 | 449 | @on(CommandEvent("move-all-workspaces-to-next-output")) 450 | async def move_all_workspaces_to_next_output(i3, event): 451 | """Move all workspaces from the current output to the next one.""" 452 | outputs = await i3.get_outputs() 453 | if len(outputs) < 2: 454 | return 455 | workspaces = await i3.get_workspaces() 456 | try: 457 | focused = [w for w in workspaces if w.focused][0] 458 | except IndexError: 459 | return 460 | output = focused.output 461 | for workspace in workspaces: 462 | if workspace.output == output: 463 | await i3.command( 464 | f"workspace number {workspace.num} ; move workspace to output next" 465 | ) 466 | 467 | 468 | @on(I3Event.WORKSPACE_INIT) 469 | @static(lock=asyncio.Lock()) 470 | async def workspace_rename_duplicate(i3, event): 471 | """Rename a workspace when initialized empty with a duplicate number.""" 472 | # This will not be needed once https://github.com/i3/i3/pull/4252 is released. 473 | async with workspace_rename_duplicate.lock: 474 | workspace = event.current 475 | workspaces = await i3.get_workspaces() 476 | workspace_nums = {w.num for w in workspaces if workspace.id != w.ipc_data["id"]} 477 | if workspace.num in workspace_nums: 478 | max_num = max(workspace_nums) 479 | available = (set(range(1, max_num + 2)) - workspace_nums).pop() 480 | await i3.command(f"rename workspace {workspace.num} to {available}") 481 | 482 | 483 | @on(I3Event.WINDOW_NEW, CommandEvent("inhibit-exclusive")) 484 | @static(inhibited_by=False) 485 | async def workspace_exclusive(i3, event): 486 | """Move new windows on a new workspace instead of sharing a workspace 487 | with an exclusive app.""" 488 | if event == "inhibit-exclusive": 489 | logger.debug("inhibit exclusive workspace") 490 | workspace_exclusive.inhibited_by = me = object() 491 | await asyncio.sleep(1) 492 | if workspace_exclusive.inhibited_by is me: 493 | logger.info("cancel exclusive workspace inhibition") 494 | workspace_exclusive.inhibited_by = None 495 | return 496 | if workspace_exclusive.inhibited_by: 497 | workspace_exclusive.inhibited_by = None 498 | return 499 | w = event.container 500 | 501 | # Can the current window intrude the workspace? 502 | if ( 503 | w.floating in {"auto_on", "user_on"} 504 | or (w.window_class or "").lower() in intrusive_apps 505 | ): 506 | logger.debug("window %s can intrude", w.window_class) 507 | return 508 | 509 | tree = await i3.get_tree() 510 | 511 | # Get the window workspace. From an event, w.workspace() is None, 512 | # so search it in the tree. 513 | current_window = tree.find_by_id(w.id) 514 | if current_window is None: 515 | logger.debug("cannot find new window in tree?") 516 | return 517 | current_workspace = current_window.workspace() 518 | if current_workspace is None: 519 | logger.debug("cannot find new window workspace?") 520 | return 521 | 522 | # Get the list of workspaces with an exclusive app, excluding the 523 | # current window and windows of the same class. 524 | exclusive_workspaces = { 525 | ow.workspace().num 526 | for ow in tree.leaves() 527 | if w.id != ow.id 528 | and (w.window_class or object()) != ow.window_class 529 | and (ow.window_class or "").lower() in exclusive_apps 530 | and not (ow.sticky and ow.floating in {"auto_on", "user_on"}) 531 | } 532 | 533 | # If current one is OK, don't move 534 | if current_workspace.num not in exclusive_workspaces: 535 | logger.debug("no exclusive app, %s can go there", w.window_class) 536 | return 537 | 538 | # Are there other workspaces with the same app but no exclusive apps? 539 | candidate_workspaces = { 540 | ow.workspace().num 541 | for ow in tree.leaves() 542 | if w.id != ow.id and (w.window_class or object()) == ow.window_class 543 | } 544 | candidate_workspaces -= exclusive_workspaces 545 | candidate_workspaces -= {-1} # scratchpad 546 | 547 | if candidate_workspaces: 548 | # Use one of the candidates 549 | num = next(iter(candidate_workspaces)) 550 | else: 551 | # Create a new workspace 552 | num = await create_new_workspace(i3) 553 | 554 | logger.info(f"move window {w.window_class} to workspace {num}") 555 | await w.command(f'move container to workspace number "{num}", focus') 556 | 557 | 558 | @on(CommandEvent("quake-console")) 559 | async def quake_console(i3, event): 560 | """Spawn a quake console or toggle an existing one.""" 561 | try: 562 | _, term_exec, term_name, height = event.split(":") 563 | height = float(height) 564 | except Exception as exc: 565 | logger.warn(f"unable to parse payload {event}: {exc}") 566 | return 567 | 568 | # Look for the terminal or spawn it 569 | tree = await i3.get_tree() 570 | try: 571 | term = tree.find_instanced(term_name)[0] 572 | except IndexError: 573 | quake_window = asyncio.get_event_loop().create_future() 574 | 575 | def wait_for_quake(i3, event): 576 | w = event.container 577 | if quake_window.done() or w.window_instance != term_name: 578 | return 579 | quake_window.set_result(w) 580 | 581 | i3.on(I3Event.WINDOW_NEW, wait_for_quake) 582 | try: 583 | await i3.command(f"exec exec {term_exec} --name {term_name}") 584 | done, pending = await asyncio.wait((quake_window,), timeout=1) 585 | finally: 586 | i3.off(wait_for_quake) 587 | if not done: 588 | raise RuntimeError("unable to spawn terminal") 589 | term = quake_window.result() 590 | await term.command("move window to scratchpad") 591 | 592 | workspaces = await i3.get_workspaces() 593 | workspace = [ws for ws in workspaces if ws.focused][0] 594 | ws_x, ws_y = workspace.rect.x, workspace.rect.y 595 | ws_width, ws_height = workspace.rect.width, workspace.rect.height 596 | height = int(ws_height * height) 597 | command = ( 598 | f"[instance={term_name}] " 599 | "border none," 600 | f"resize set {ws_width} px {height} px," 601 | "scratchpad show," 602 | f"move absolute position {ws_x}px {ws_y}px" 603 | ) 604 | logger.debug("QuakeConsole: %s", command) 605 | await i3.command(command) 606 | 607 | 608 | @on(CommandEvent("container-info")) 609 | @static(last_id=0) 610 | async def container_info(i3, event): 611 | """Show information about the focused container.""" 612 | tree = await i3.get_tree() 613 | window = tree.find_focused() 614 | if not window: 615 | return 616 | logger.info(f"window raw information: {window.ipc_data}") 617 | summary = "About focused container" 618 | r = window.rect 619 | w = window 620 | info = { 621 | "name": w.name, 622 | "title": w.window_title, 623 | "class": w.window_class, 624 | "instance": w.window_instance, 625 | "role": w.window_role, 626 | "type": w.ipc_data["window_type"], 627 | "sticky": w.sticky, 628 | "floating": w.floating, 629 | "geometry": f"{r.width}×{r.height}+{r.x}+{r.y}", 630 | "layout": w.layout, 631 | "parcent": w.percent, 632 | "marks": ", ".join(w.marks) or "(none)", 633 | } 634 | body = "\n".join( 635 | ( 636 | f"{k:10} {html.escape(str(v))}" 637 | for k, v in info.items() 638 | if v is not None 639 | ) 640 | ) 641 | result = await notify( 642 | i3, 643 | app_icon="info", 644 | expire_timeout=10000, 645 | summary=summary, 646 | body=body, 647 | replaces_id=container_info.last_id, 648 | ) 649 | container_info.last_id = result[0] 650 | 651 | 652 | @on(CommandEvent("workspace-info")) 653 | @static(last_id=0) 654 | async def workspace_info(i3, event): 655 | """Show information about the focused workspace.""" 656 | workspaces = await i3.get_workspaces() 657 | focused = [w for w in workspaces if w.focused] 658 | if not focused: 659 | return 660 | workspace = focused[0] 661 | summary = f"Workspace {workspace.num} on {workspace.output}" 662 | tree = await i3.get_tree() 663 | workspace = [w for w in tree.workspaces() if w.num == workspace.num] 664 | 665 | def format(container): 666 | if container.focused: 667 | style = 'foreground="#ffaf00"' 668 | elif not container.window: 669 | style = 'foreground="#6c98ee"' 670 | else: 671 | style = "" 672 | if container.window: 673 | content = ( 674 | f"{(container.window_class or '???').lower()}: " 675 | f"{(container.window_title or '???')}" 676 | ) 677 | elif container.type == "workspace" and not container.nodes: 678 | # Empty workspaces use workspace_layout, but when default, 679 | # this is layout... 680 | layout = container.ipc_data["workspace_layout"] 681 | if layout == "default": 682 | layout = container.layout 683 | content = f"({layout})" 684 | else: 685 | content = f"({container.layout})" 686 | root = f"{content.lower()}" 687 | children = [] 688 | for child in container.nodes: 689 | if child == container.nodes[-1]: 690 | first = "└─" 691 | others = " " 692 | else: 693 | first = "├─" 694 | others = "│ " 695 | content = format(child).replace("\n", f"\n{others}") 696 | children.append(f"{first}{content}") 697 | children.insert(0, root) 698 | return "\n".join(children) 699 | 700 | body = format(workspace[0]).lstrip("\n") 701 | result = await notify( 702 | i3, 703 | app_icon="system-search", 704 | expire_timeout=20000, 705 | summary=summary, 706 | body=body, 707 | replaces_id=workspace_info.last_id, 708 | ) 709 | workspace_info.last_id = result[0] 710 | 711 | 712 | @on(I3Event.OUTPUT, StartEvent) 713 | @static(last_setup=None) 714 | @debounce(2) 715 | async def output_update(i3, event): 716 | """React to a XRandR change.""" 717 | 718 | # Grab current setup. Synchronous, but it's short enough 719 | randr = i3.x11(xcffib.randr.key) 720 | screen = i3.x11.get_setup().roots[0] 721 | monitors = randr.GetMonitors(screen.root, 1).reply().monitors 722 | current_setup = { 723 | ( 724 | i3.x11.core.GetAtomName(m.name).reply().name.to_string(), 725 | m.width, 726 | m.height, 727 | m.x, 728 | m.y, 729 | ) 730 | for m in monitors 731 | } 732 | 733 | # Compare to current setup 734 | if current_setup == output_update.last_setup: 735 | logger.debug("current xrandr setup unchanged") 736 | return 737 | output_update.last_setup = current_setup 738 | logger.info("xrandr setup: %s", current_setup) 739 | if event is StartEvent: 740 | return 741 | 742 | # Trigger changes 743 | logger.info("xrandr change detected") 744 | cmds = ( 745 | "systemctl --user reload --no-block xsettingsd.service", 746 | "systemctl --user start --no-block wallpaper.service", 747 | ) 748 | for cmd in cmds: 749 | proc = subprocess.run(shlex.split(cmd)) 750 | if proc.returncode != 0: 751 | logger.warning(f"{cmd} exited with {proc.returncode}") 752 | 753 | 754 | @on( 755 | DBusSignal( 756 | path="/org/bluez", 757 | interface="org.freedesktop.DBus.Properties", 758 | member="PropertiesChanged", 759 | signature="sa{sv}as", 760 | onlyif=lambda args: args[0] == "org.bluez.Device1" and "Connected" in args[1], 761 | ) 762 | ) 763 | async def bluetooth_notifications(i3, event, path, interface, changed, invalid): 764 | """Display notifications related to Bluetooth state.""" 765 | obj = i3.system_bus["org.bluez"][path] 766 | obd = await obj.get_async_interface(interface) 767 | name = await obd.Name 768 | icon = await obd.Icon 769 | state = await obd.Connected 770 | state = "connected" if state else "disconnected" 771 | await notify( 772 | i3, 773 | app_icon=icon, 774 | summary=name, 775 | body=f"Bluetooth device {state}", 776 | ) 777 | 778 | 779 | @on( 780 | StartEvent, 781 | DBusSignal( 782 | path="/org/bluez", 783 | interface="org.freedesktop.DBus.Properties", 784 | member="PropertiesChanged", 785 | signature="sa{sv}as", 786 | onlyif=lambda args: ( 787 | args[0] == "org.bluez.Device1" 788 | and "Connected" in args[1] 789 | or args[0] == "org.bluez.Device1" 790 | and "ServicesResolved" in args[1] 791 | or args[0] == "org.bluez.Adapter1" 792 | and "Powered" in args[1] 793 | or args[0] == "org.bluez.Battery1" 794 | and "Percentage" in args[1] 795 | ), 796 | ), 797 | DBusSignal( 798 | path="/", 799 | interface="org.freedesktop.DBus.ObjectManager", 800 | member="InterfacesAdded", 801 | signature="oa{sa{sv}}", 802 | onlyif=lambda args: ("org.bluez.Battery1" in args[1]), 803 | ), 804 | ) 805 | @retry(2) 806 | @debounce(1) 807 | @polybar("bluetooth") 808 | async def bluetooth_status(i3, event, *args): 809 | """Update bluetooth status for Polybar.""" 810 | if event is StartEvent: 811 | # Do we have a bluetooth device? 812 | if not os.path.exists("/sys/class/bluetooth"): 813 | logger.info("no bluetooth detected") 814 | return "" 815 | 816 | # OK, get the info 817 | conn = i3.system_bus["org.bluez"] 818 | om = await conn["/"].get_async_interface("org.freedesktop.DBus.ObjectManager") 819 | objects = await om.GetManagedObjects() 820 | objects = objects[0] 821 | powered = False 822 | devices = [] 823 | for path, interfaces in objects.items(): 824 | if "org.bluez.Adapter1" in interfaces: 825 | # We get an adapter! 826 | adapter = interfaces["org.bluez.Adapter1"] 827 | if adapter["Powered"][1]: 828 | powered = True 829 | elif "org.bluez.Device1" in interfaces: 830 | # We have a device! 831 | device = types.SimpleNamespace(battery=None, icon=None) 832 | interface = interfaces["org.bluez.Device1"] 833 | if not interface["Connected"][1]: 834 | continue 835 | try: 836 | device.battery = interfaces["org.bluez.Battery1"]["Percentage"][1] 837 | except KeyError: 838 | pass 839 | try: 840 | device.icon = interface["Icon"][1] 841 | except KeyError: 842 | pass 843 | devices.append(device) 844 | 845 | if not powered: 846 | return "" 847 | output = ["bluetooth"] 848 | for device in devices: 849 | bicons = { 850 | "audio-card": "loudspeaker", 851 | "audio-headphones": "headphones", 852 | "audio-headset": "headset", 853 | "camera-photo": "camera", 854 | "camera-video": "webcam", 855 | "computer": "laptop", 856 | "input-gaming": "gamepad", 857 | "input-keyboard": "keyboard", 858 | "input-mouse": "mouse", 859 | "network-wireless": "access-point", 860 | "phone": "phone", 861 | "printer": "printer", 862 | "scanner": "scanner", 863 | } 864 | output.append(bicons.get(device.icon, "unknown")) 865 | if device.battery is not None: 866 | output[-1] = (output[-1], f"battery-{(device.battery+12)//25*25}") 867 | return "|".join( 868 | (" ".join(icons[oo] for oo in o) if type(o) is tuple else icons[o]) 869 | for o in output 870 | ) 871 | 872 | 873 | @on( 874 | DBusSignal( 875 | system=False, 876 | path="/org/freedesktop/Notifications", 877 | interface="org.freedesktop.DBus.Properties", 878 | member="PropertiesChanged", 879 | signature="sa{sv}as", 880 | onlyif=lambda args: args[0] == "org.dunstproject.cmd0" and "paused" in args[1], 881 | ) 882 | ) 883 | @polybar("dunst") 884 | async def dunst_status_update(i3, event, path, interface, changed, invalid): 885 | """Update notification status in Polybar.""" 886 | paused = changed["paused"][1] 887 | return icons[paused and "notifications-disabled" or "notifications-enabled"] 888 | 889 | 890 | @on(StartEvent) 891 | @polybar("dunst") 892 | async def dunst_status_check(i3, event): 893 | """Display notification status for Polybar.""" 894 | conn = i3.session_bus["org.freedesktop.Notifications"] 895 | obj = conn["/org/freedesktop/Notifications"] 896 | dunst = await obj.get_async_interface("org.dunstproject.cmd0") 897 | paused = await dunst.paused 898 | return icons[paused and "notifications-disabled" or "notifications-enabled"] 899 | 900 | 901 | @on( 902 | DBusSignal( 903 | interface="org.usbguard.Devices1", 904 | member="DevicePolicyApplied", 905 | signature="uusua{ss}", 906 | ) 907 | ) 908 | @static(history=collections.defaultdict(list)) 909 | async def usbguard_notifications( 910 | i3, event, path, id, target_new, device_rule, rule_id, attributes 911 | ): 912 | """Display notifications related when a USBGuard policy is applied.""" 913 | if attributes["name"]: 914 | human = f"\"{attributes['name']}\" ({attributes['id']})" 915 | else: 916 | human = f"{attributes['id']}" 917 | extra_args = {} 918 | if target_new == 0: 919 | action = "accepted" 920 | elif target_new == 1: 921 | action = "blocked" 922 | extra_args["expire_timeout"] = 0 923 | elif target_new == 2: 924 | action = "rejected" 925 | else: 926 | return 927 | nid = await notify( 928 | i3, 929 | app_icon="media-removable", 930 | summary=f"New {action} USB device", 931 | replaces_id=usbguard_notifications.history.get(id, 0), 932 | body="\n".join( 933 | [ 934 | f"USB device {human} {action}", 935 | f"ID: {id}, Port: {attributes['via-port']}", 936 | ] 937 | ), 938 | **extra_args, 939 | ) 940 | if nid: 941 | usbguard_notifications.history[id] = nid[0] 942 | 943 | 944 | @on( 945 | DBusSignal( 946 | interface="org.freedesktop.NetworkManager.Connection.Active", 947 | member="StateChanged", 948 | signature="uu", 949 | ) 950 | ) 951 | async def network_manager_notifications(i3, event, path, state, reason): 952 | """Display notifications related to Network Manager state.""" 953 | ofnm = "org.freedesktop.NetworkManager" 954 | logger.debug("from %s state: %s, reason: %s", path, state, reason) 955 | if state not in {NM_ACTIVE_CONNECTION_STATE_ACTIVATED}: 956 | # Deactivated state does not contain enough information, 957 | # unless we maintain state. 958 | return 959 | obj = i3.system_bus[ofnm][path] 960 | try: 961 | nmca = await obj.get_async_interface(f"{ofnm}.Connection.Active") 962 | except dbussy.DBusError: 963 | logger.info(f"interface {path} has vanished") 964 | return 965 | kind = await nmca.Type 966 | id = await nmca.Id 967 | if kind == "vpn" or kind == "wireguard": 968 | await notify(i3, app_icon="network-vpn", summary=f"{id}", body="VPN connected!") 969 | elif kind == "802-3-ethernet": 970 | await notify( 971 | i3, 972 | app_icon="network-wired", 973 | summary=f"{id}", 974 | body="Ethernet connection established!", 975 | ) 976 | elif kind == "802-11-wireless": 977 | await notify( 978 | i3, 979 | app_icon="network-wireless", 980 | summary=f"{id}", 981 | body="Wireless connection established!", 982 | ) 983 | 984 | 985 | @on( 986 | StartEvent, 987 | DBusSignal( 988 | interface="org.freedesktop.NetworkManager.Connection.Active", 989 | member="StateChanged", 990 | signature="uu", 991 | ), 992 | DBusSignal( 993 | path="/org/freedesktop/NetworkManager/AccessPoint", 994 | interface="org.freedesktop.DBus.Properties", 995 | member="PropertiesChanged", 996 | signature="sa{sv}as", 997 | onlyif=lambda args: (args[0] == "org.freedesktop.NetworkManager.AccessPoint"), 998 | ), 999 | ) 1000 | @retry(2) 1001 | @debounce( 1002 | 1, 1003 | unless=lambda i3, event, *args: ( 1004 | isinstance(event, DBusSignal) and event.interface.endswith(".Active") 1005 | ), 1006 | ) 1007 | @polybar("network") 1008 | async def network_manager_status(i3, event, *args): 1009 | """Compute network manager status.""" 1010 | ofnm = "org.freedesktop.NetworkManager" 1011 | status = [] 1012 | 1013 | # Build status from devices 1014 | conn = i3.system_bus[ofnm] 1015 | nm = await conn["/org/freedesktop/NetworkManager"].get_async_interface(ofnm) 1016 | devices = await nm.AllDevices 1017 | for device in devices: 1018 | nmd = await conn[device].get_async_interface(f"{ofnm}.Device") 1019 | kind = await nmd.DeviceType 1020 | state = await nmd.State 1021 | if state == NM_DEVICE_STATE_UNMANAGED: 1022 | continue 1023 | if kind == NM_DEVICE_TYPE_WIFI: 1024 | 1025 | def wrap(s): 1026 | rofi = os.path.expanduser("~/.config/i3/bin/rofi-wifi") 1027 | return "%%{A1:%s:}%s%%{A}" % (rofi, s) 1028 | 1029 | if state != NM_DEVICE_STATE_ACTIVATED: 1030 | status.append(wrap(icons["nowifi"])) 1031 | continue 1032 | nmw = await conn[device].get_async_interface(f"{ofnm}.Device.Wireless") 1033 | ap = await nmw.ActiveAccessPoint 1034 | if not ap: 1035 | status.append(wrap(icons["nowifi"])) 1036 | continue 1037 | network_manager_status.active_ap = ap 1038 | nmap = await conn[ap].get_async_interface(f"{ofnm}.AccessPoint") 1039 | name = await nmap.Ssid 1040 | strength = int(await nmap.Strength) 1041 | status.append( 1042 | wrap( 1043 | [ 1044 | icons["wifi-low"], 1045 | icons["wifi-medium"], 1046 | icons["wifi-high"], 1047 | ][strength // 34] 1048 | + " " 1049 | + bytes(name).decode("utf-8", errors="replace").replace("%", "%%") 1050 | ) 1051 | ) 1052 | elif kind == NM_DEVICE_TYPE_ETHERNET and state == NM_DEVICE_STATE_ACTIVATED: 1053 | status.append(icons["wired"]) 1054 | 1055 | # Build status for VPN connection 1056 | connections = await nm.ActiveConnections 1057 | for connection in connections: 1058 | nma = await conn[connection].get_async_interface(f"{ofnm}.Connection.Active") 1059 | vpn = await nma.Vpn 1060 | kind = await nma.Type 1061 | if vpn or kind == "wireguard": 1062 | state = await nma.State 1063 | if state == NM_ACTIVE_CONNECTION_STATE_ACTIVATED: 1064 | status.append(icons["vpn"]) 1065 | status.append((await nma.Id).replace("%", "%%")) 1066 | 1067 | # Final status line 1068 | return " ".join(status) 1069 | 1070 | 1071 | @on( 1072 | DBusSignal( 1073 | interface="org.freedesktop.login1.Manager", 1074 | member="PrepareForSleep", 1075 | signature="b", 1076 | ), 1077 | ) 1078 | async def on_resume(i3, event, path, sleeping): 1079 | """Switch monitor on when resuming from sleep.""" 1080 | if not sleeping: 1081 | logger.info("resume from sleep") 1082 | dpms = i3.x11(xcffib.dpms.key) 1083 | if dpms.Info().reply().power_level != 0: 1084 | dpms.ForceLevel(0) 1085 | i3.x11.flush() 1086 | 1087 | 1088 | # Main function 1089 | 1090 | 1091 | async def main(options): 1092 | i3 = await Connection(auto_reconnect=True).connect() 1093 | i3.session_bus = await ravel.session_bus_async() 1094 | i3.system_bus = await ravel.system_bus_async() 1095 | i3.x11 = xcffib.connect() 1096 | 1097 | # Regular events 1098 | for fn, events in on.functions.items(): 1099 | for event in events: 1100 | if isinstance(event, I3Event): 1101 | 1102 | def wrapping(fn, event): 1103 | async def wrapped(i3, event): 1104 | logger.debug("received i3 event %s for %s", event, fn) 1105 | return await fn(i3, event) 1106 | 1107 | return wrapped 1108 | 1109 | i3.on(event, wrapping(fn, event)) 1110 | 1111 | # React to some bindings 1112 | async def binding_event(i3, event): 1113 | """Process a binding event.""" 1114 | # We only processes it when it is a nop command and we use 1115 | # this mechanism as an IPC mechanism. The alternative would be 1116 | # to use ticks but we would need to spawn an i3-msg process 1117 | # for that. 1118 | for cmd in event.binding.command.split(";"): 1119 | cmd = cmd.strip() 1120 | if not cmd.startswith("nop "): 1121 | continue 1122 | cmd = cmd[4:].strip(" \"'") 1123 | if not cmd: 1124 | continue 1125 | kind = cmd.split(":")[0] 1126 | for fn, events in on.functions.items(): 1127 | for e in events: 1128 | if isinstance(e, CommandEvent) and e.name == kind: 1129 | logger.debug("received command event %s for %s", event, fn) 1130 | await fn(i3, cmd) 1131 | 1132 | i3.on(I3Event.BINDING, binding_event) 1133 | 1134 | # React to ticks 1135 | async def tick_event(i3, event): 1136 | """Process tick events.""" 1137 | kind = event.payload.split(":")[0] 1138 | for fn, events in on.functions.items(): 1139 | for e in events: 1140 | if isinstance(e, CommandEvent) and e.name == kind: 1141 | logger.debug("received command event %s for %s", event, fn) 1142 | await fn(i3, event.payload) 1143 | 1144 | i3.on(I3Event.TICK, tick_event) 1145 | 1146 | # Listen to DBus events 1147 | for fn, events in on.functions.items(): 1148 | for event in events: 1149 | if isinstance(event, DBusSignal): 1150 | bus = i3.system_bus if event.system else i3.session_bus 1151 | 1152 | def wrapping(fn, event): 1153 | @ravel.signal( 1154 | name=event.member, 1155 | in_signature=event.signature, 1156 | path_keyword="path", 1157 | args_keyword="args", 1158 | ) 1159 | async def wrapped(path, args): 1160 | if event.onlyif is not None and not event.onlyif(args): 1161 | logger.debug( 1162 | "received DBus event for %s, not interested", fn 1163 | ) 1164 | return 1165 | logger.debug("received DBus event %s for %s", event, fn) 1166 | return await fn(i3, event, path, *args) 1167 | 1168 | return wrapped 1169 | 1170 | bus.listen_signal( 1171 | path=event.path, 1172 | fallback=True, 1173 | interface=event.interface, 1174 | name=event.member, 1175 | func=wrapping(fn, event), 1176 | ) 1177 | 1178 | # Run events that should run on start 1179 | start_tasks = [] 1180 | for fn, events in on.functions.items(): 1181 | for event in events: 1182 | if event is StartEvent: 1183 | start_tasks.append(asyncio.create_task(fn(i3, event))) 1184 | 1185 | daemon.notify("READY=1") 1186 | await i3.main() 1187 | 1188 | 1189 | if __name__ == "__main__": 1190 | # Parse 1191 | description = sys.modules[__name__].__doc__ or "" 1192 | for fn, events in on.functions.items(): 1193 | description += f" {fn.__doc__}" 1194 | parser = argparse.ArgumentParser(description=description) 1195 | parser.add_argument( 1196 | "--debug", 1197 | "-d", 1198 | action="store_true", 1199 | default=False, 1200 | help="enable debugging", 1201 | ) 1202 | options = parser.parse_args() 1203 | 1204 | # Logging 1205 | root = logging.getLogger("") 1206 | root.setLevel(logging.WARNING) 1207 | logger.setLevel(options.debug and logging.DEBUG or logging.INFO) 1208 | if sys.stderr.isatty(): 1209 | ch = logging.StreamHandler() 1210 | ch.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) 1211 | root.addHandler(ch) 1212 | else: 1213 | root.addHandler(journal.JournalHandler(SYSLOG_IDENTIFIER=logger.name)) 1214 | 1215 | try: 1216 | asyncio.run(main(options)) 1217 | except Exception as e: 1218 | logger.exception("%s", e) 1219 | sys.exit(1) 1220 | sys.exit(0) 1221 | -------------------------------------------------------------------------------- /bin/i3-tabbed: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Splits the current terminal into a tab layout, runs a command, then 4 | # restores the original layout. Handy for opening images and videos 5 | # "inside" a terminal. 6 | # 7 | # This effect is similar to dwm's window swallowing patch: https://www.youtube.com/watch?v=92uo5OBOKfY 8 | # 9 | # Stolen from https://github.com/aduros/dotfiles/blob/master/home/bin/i3-tabbed 10 | 11 | from i3ipc import Connection 12 | import subprocess 13 | import sys 14 | 15 | if len(sys.argv) < 2: 16 | print("Usage: %s " % sys.argv[0]) 17 | sys.exit(1) 18 | 19 | i3 = Connection() 20 | orig = i3.get_tree().find_focused() 21 | 22 | # If the layout was already tabbed or stacked, don't do anything 23 | layout = orig.parent.layout 24 | if layout == "splith": 25 | orig.command("split v") 26 | orig.command("layout tabbed") 27 | elif layout == "splitv": 28 | orig.command("split h") 29 | orig.command("layout tabbed") 30 | 31 | # Ensure we don't collide with exclusive app handling 32 | i3.send_tick("inhibit-exclusive") 33 | 34 | try: 35 | # Run the given command 36 | code = subprocess.run(sys.argv[1:]).returncode 37 | 38 | finally: 39 | # Unsplit the container if it was previously split to restore the old layout 40 | if layout == "splith": 41 | orig.command("layout default") 42 | orig.command("move left") 43 | elif layout == "splitv": 44 | orig.command("layout default") 45 | orig.command("move up") 46 | 47 | # Pass along the command's return code 48 | sys.exit(code) 49 | -------------------------------------------------------------------------------- /bin/input-event: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Event handler for inputplug 4 | 5 | event="$1" 6 | device="$2" 7 | use="$3" 8 | name="$4" 9 | 10 | # Load the given keymap 11 | xkb() { 12 | xkbcomp -i ${device} -w 0 -I$HOME/.config/i3/dotfiles/xkb \ 13 | $HOME/.config/i3/dotfiles/xkb/$1.xkb ${DISPLAY} 14 | } 15 | 16 | case $event in 17 | XIDeviceDisabled) exit 0 ;; 18 | XISlaveRemoved) exit 0 ;; 19 | esac 20 | 21 | printf "input-event: $use: [%3d] $event $name\n" $device 22 | 23 | set +x 24 | case "$event,$use,$(uname -n),$name" in 25 | *,XISlaveKeyboard,chocobo,"AT Translated Set 2 keyboard") 26 | xkb x1gen2 27 | ;; 28 | *,XISlaveKeyboard,*,"Yubico YubiKey OTP+FIDO+CCID") 29 | #ssh-add -e /usr/lib/x86_64-linux-gnu/libykcs11.so 2> /dev/null 30 | #ssh-add -s /usr/lib/x86_64-linux-gnu/libykcs11.so 31 | ;; 32 | *,XISlaveKeyboard,*,"IQUNIX IQUNIX ZX75 Mechanical Keyboard") 33 | xkb 75percent 34 | ;; 35 | *,XISlaveKeyboard,*) 36 | # Todo: detect the fact it is a "classic" keyboard 37 | xkb default 38 | ;; 39 | *,XISlavePointer,*,"Logitech USB Receiver Mouse") 40 | xinput set-ptr-feedback $device 30 5 2 41 | ;; 42 | *,XISlavePointer,*,"TPPS/2 IBM TrackPoint") 43 | xinput set-prop $device "libinput Accel Speed" 1 44 | ;; 45 | *,XISlavePointer,*,"ELAN"*" Touchpad") 46 | xinput set-prop $device "libinput Tapping Enabled" 1 47 | ;; 48 | *,XISlavePointer,*,"ELAN Touchscreen") 49 | xinput disable $device 50 | ;; 51 | *,XISlavePointer,*,"Wacom HID "*" Finger") 52 | xinput disable $device 53 | ;; 54 | esac 55 | -------------------------------------------------------------------------------- /bin/picom-configure: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | dpi=$(xrdb -query | sed -nE 's/^Xft\.dpi:\s*//p') 4 | 5 | POLYBAR_HEIGHT=$((20 * dpi / 96)) 6 | SHADOW_RADIUS=$((12 * dpi / 96)) 7 | SHADOW_OFFSET=$((SHADOW_RADIUS*2/3)) 8 | 9 | # Configure picom 10 | mkdir -p $XDG_RUNTIME_DIR/i3 11 | cat ~/.config/i3/dotfiles/picom.conf \ 12 | | sed -e "s/@POLYBAR_HEIGHT@/$POLYBAR_HEIGHT/" \ 13 | | sed -e "s/@SHADOW_RADIUS@/$SHADOW_RADIUS/" \ 14 | | sed -e "s/@SHADOW_OFFSET@/$SHADOW_OFFSET/" \ 15 | > $XDG_RUNTIME_DIR/i3/picom.conf.new 16 | 17 | # Put new configuration file in place 18 | cmp $XDG_RUNTIME_DIR/i3/picom.conf.new $XDG_RUNTIME_DIR/i3/picom.conf 2> /dev/null || \ 19 | mv $XDG_RUNTIME_DIR/i3/picom.conf.new $XDG_RUNTIME_DIR/i3/picom.conf 20 | -------------------------------------------------------------------------------- /bin/polybar: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | export DPI=$(xrdb -query | sed -nE 's/^Xft\.dpi:\s*//p') 4 | export HEIGHT=$((20 * DPI / 96)) 5 | export BACKLIGHT=$(cd /sys/class/backlight ; ls | head -1) 6 | export I3SOCK=$(i3 --get-socketpath) 7 | 8 | polybar --version 9 | 10 | # Example of setup: 11 | # xrandr --setmonitor '*'DisplayPort-3-left 1920/444x1440/334+0+0 DisplayPort-3 12 | # xrandr --setmonitor DisplayPort-3-right 1520/352x1440/334+1920+0 none 13 | 14 | MONITORS=$(polybar --list-monitors | sed -nE 's/([^ ]+): .*/\1/p' | tr '\n' ' ') 15 | PRIMARY=$(polybar --list-monitors | grep -F '(primary)' | sed -nE 's/([^ ]+): .*/\1/p') 16 | NMONITORS=$(echo $MONITORS | wc -w) 17 | PRIMARY=${PRIMARY:-${MONITORS%% *}} 18 | 19 | awk 'BEGIN { i=0 } ($4 == "/" && $3 !~ /^0:/) {print "mount-"i" = "$5; i++}' /proc/self/mountinfo \ 20 | > $XDG_RUNTIME_DIR/i3/polybar-filesystems.conf 21 | 22 | case $NMONITORS in 23 | 1) 24 | MONITOR=$PRIMARY polybar --reload alone & 25 | systemd-notify --status="Single polybar instance running on $PRIMARY" 26 | ;; 27 | *) 28 | MONITOR=$PRIMARY polybar --reload primary & 29 | for MONITOR in ${MONITORS}; do 30 | [ $MONITOR != $PRIMARY ] || continue 31 | MONITOR=$MONITOR polybar --reload secondary & 32 | done 33 | systemd-notify --status="$NMONITORS polybar instances running" 34 | ;; 35 | esac 36 | 37 | systemd-notify --ready 38 | trap "systemd-notify WATCHDOG=trigger" CHLD 39 | wait 40 | -------------------------------------------------------------------------------- /bin/rofi-mediaplayer: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Media player menu with rofi 4 | 5 | if [ -z "${ROFI_OUTSIDE}" ]; then 6 | export mediaplayer=$1 7 | export Mediaplayer=$(echo $1 | sed -E 's/(.)/\U\1/') 8 | dpi=$(xrdb -query | sed -nE 's/^Xft\.dpi:\s*//p') 9 | exec rofi -dpi $dpi -no-lazy-grab -show-icons -no-custom -modi m:$0 -show m \ 10 | -kb-custom-1 Super+z \ 11 | -kb-custom-2 Super+x \ 12 | -kb-custom-3 Super+c \ 13 | -kb-custom-4 Super+v \ 14 | -kb-custom-5 Super+b \ 15 | -kb-custom-6 Super+n \ 16 | -kb-custom-7 Super+m \ 17 | -kb-custom-8 Super+s \ 18 | -kb-cancel Escape,Control+g,Super+Escape 19 | fi 20 | 21 | if [ $ROFI_RETV -ge 10 ] && [ $ROFI_RETV -le 28 ]; then 22 | ROFI_INFO=$((ROFI_RETV-9)) 23 | ROFI_RETV=1 24 | fi 25 | 26 | playerctl="playerctl -p $mediaplayer" 27 | 28 | case $ROFI_RETV in 29 | 0) 30 | # Prompt 31 | printf "\00prompt\037media player\n" 32 | printf "\00message\037$...\n" 33 | printf "\00use-hot-keys\037true\n" 34 | 35 | # Available actions 36 | i=0 37 | while read icon description 38 | do 39 | i=$((i+1)) 40 | printf "$description\00icon\037$icon\037info\037$i\n" 41 | done <&2 74 | ;; 75 | esac 76 | -------------------------------------------------------------------------------- /bin/rofi-wifi: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Wifi selection menu with rofi 4 | 5 | if [ -z "${ROFI_OUTSIDE}" ]; then 6 | yoffset=$(( $(xrdb -query | sed -n 's/^Xft.dpi:\t\([0-9]*\)$/\1/p')*20/96 )) 7 | dpi=$(xrdb -query | sed -nE 's/^Xft\.dpi:\s*//p') 8 | exec rofi -dpi $dpi -show-icons -no-custom -modi m:$0 -show m -location 3 -yoffset $yoffset 9 | fi 10 | 11 | case $ROFI_RETV in 12 | 0) 13 | # Prompt 14 | printf "\00prompt\037wifi\n" 15 | printf "\00markup-rows\037true\n" 16 | 17 | case $(nmcli radio wifi) in 18 | enabled) 19 | printf "Turn wifi off\00info\037off\n" 20 | printf "Scan wifi networks...\00info\037rescan\n" 21 | ;; 22 | disabled) 23 | printf "Turn wifi on\00info\037on\n" 24 | ;; 25 | esac 26 | 27 | nmcli -f IN-USE,SSID,BSSID,SECURITY,FREQ,SIGNAL -m multiline device wifi list --rescan no \ 28 | | sed -e 's/&/\&/g' -e 's/%s (%s, %s, %s%%)\n", 37 | p["SSID"], p["FREQ"], p["SECURITY"], p["SIGNAL"]); 38 | } else { 39 | printf("%s (%s, %s)", 40 | p["SSID"], p["FREQ"], p["SECURITY"]); 41 | signal=p["SIGNAL"] 42 | printf("\00info\x1f%s\x1ficon\x1fnm-signal-%s%s\n", 43 | p["BSSID"], 44 | (signal > 75)?"100":\ 45 | (signal > 50)?"75":\ 46 | (signal > 25)?"50":\ 47 | "00", 48 | (p["SECURITY"] == "--")?"":"-secure"); 49 | } 50 | }' 51 | 52 | ;; 53 | 1) 54 | case ${ROFI_INFO} in 55 | rescan) 56 | >/dev/null nmcli device wifi list --rescan yes 57 | ;; 58 | on) 59 | >&2 nmcli radio wifi on 60 | >/dev/null nmcli device wifi list --rescan yes 61 | ;; 62 | off) 63 | >&2 nmcli radio wifi off 64 | ;; 65 | *) 66 | >&2 nmcli -w 0 device wifi connect ${ROFI_INFO} 67 | exit 0 68 | ;; 69 | esac 70 | export ROFI_RETV=0 71 | exec $0 72 | ;; 73 | esac 74 | -------------------------------------------------------------------------------- /bin/screenshot: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euf -o pipefail 4 | 5 | case $1 in 6 | desktop) 7 | target=~/.config/i3/screenshots/$(date -Iseconds).jpg 8 | maim -u -m 9 $target 9 | notify-send -i camera-photo "Desktop screenshot" "Saved in ${target##*/}" 10 | echo -n $target | xclip -selection clipboard 11 | ;; 12 | window) 13 | maim -u -s -b 5 -l -c 0.3,0.4,0.6,0.4 -d 0.1 \ 14 | | xclip -selection clipboard -t image/png 15 | notify-send -i camera-photo "Screenshot" "Saved to clipboard" 16 | ;; 17 | ocr) 18 | maim -u -s -b 5 -l -c 0.3,0.4,0.6,0.4 -d 0.1 \ 19 | | tesseract --dpi 96 -l eng - - \ 20 | | xclip -selection clipboard -t text/plain 21 | notify-send -i ebook-reader "OCR" "Saved to clipboard" 22 | ;; 23 | esac 24 | -------------------------------------------------------------------------------- /bin/ssh-add: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | export SSH_ASKPASS=/usr/bin/ssh-askpass 4 | ssh-add -l > /dev/null || ssh-add $( 5 | sed -n 's/^ *IdentityFile ~\/\([^ ]*\).*/\1/p' ~/.ssh/config ~/.ssh/*/config \ 6 | | sort \ 7 | | uniq 8 | ) 9 | 10 | ssh-add -l 11 | -------------------------------------------------------------------------------- /bin/toggle-mute: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Toggle mute on all sinks or sources. 4 | 5 | case $1 in 6 | source|sink) 7 | pactl list ${1}s \ 8 | | awk '/^(Source|Sink) / { source=substr($2,2) } 9 | /^\tName: .*.monitor$/ { source=0 } 10 | /^\tDescription: / { sub("^[^:]*: ", ""); descriptions[source] = $0 } 11 | /^\tMute: / { if (source > 0) muted[source] = ($2 == "yes") } 12 | END { 13 | if (length(muted) == 0) exit; 14 | allmuted=1; 15 | for (source in muted) { 16 | if (!muted[source]) { 17 | allmuted=0; 18 | } 19 | } 20 | for (source in muted) { 21 | gsub(/['"'"'"\\\$\(\)`]/, "\\\\&", descriptions[source]) 22 | print "echo "(allmuted?"🔊":"🔇")" "descriptions[source] 23 | print "pactl set-'$1'-mute "source" "(!allmuted); 24 | } 25 | }' \ 26 | | sh 27 | ;; 28 | esac 29 | -------------------------------------------------------------------------------- /bin/wallpaper: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Build a multi screen wallpaper.""" 4 | 5 | # Alternative: 6 | # curl -s https://bzamayo.com/extras/apple-tv-screensavers.json \ 7 | # | jq -r '.data[].screensavers[].videoURL' \ 8 | # | shuf \ 9 | # | xargs nix run nixpkgs.xwinwrap -c \ 10 | # xwinwrap -b -s -fs -st -sp -nf -ov -- \ 11 | # mpv -wid WID -really-quiet -framedrop=vo --no-audio --panscan=1.0 \ 12 | # -loop-playlist=inf 13 | 14 | # Alternative: 15 | # https://moewalls.com/category/pixel-art/ 16 | # We could extract the first frame as a wallpaper, but use the whole video for 17 | # the lock screen (and pause when the screen is off) 18 | 19 | import os 20 | import sys 21 | import random 22 | import argparse 23 | import tempfile 24 | import itertools 25 | import functools 26 | import operator 27 | import logging 28 | import logging.handlers 29 | import inspect 30 | import xattr 31 | 32 | from Xlib import display 33 | from Xlib.ext import randr 34 | from systemd import journal 35 | import PIL.Image 36 | from PIL.Image import Image 37 | from typing import Optional, NamedTuple 38 | 39 | # We use typing, but it seems mostly broken with PIL. 40 | 41 | logger = logging.getLogger("wallpaper") 42 | 43 | 44 | class Rectangle(NamedTuple): 45 | x: int 46 | y: int 47 | width: int 48 | height: int 49 | 50 | 51 | class WallpaperPart(NamedTuple): 52 | rectangle: Rectangle 53 | image: Image 54 | 55 | 56 | def get_outputs() -> tuple[list[Rectangle], Image]: 57 | """Get physical outputs.""" 58 | # Get display size 59 | d = display.Display() 60 | screen = d.screen() 61 | window = screen.root.create_window(0, 0, 1, 1, 1, screen.root_depth) 62 | background = PIL.Image.new("RGB", (screen.width_in_pixels, screen.height_in_pixels)) 63 | 64 | # Query randr extension 65 | outputs = [] 66 | edids = [] 67 | screen_resources = randr.get_screen_resources_current(window) 68 | for output in screen_resources.outputs: 69 | # Extract dimension 70 | output_info = randr.get_output_info(window, output, screen_resources.timestamp) 71 | if output_info.crtc == 0: 72 | continue 73 | crtc_info = randr.get_crtc_info(window, output_info.crtc, output_info.timestamp) 74 | outputs.append( 75 | Rectangle(crtc_info.x, crtc_info.y, crtc_info.width, crtc_info.height) 76 | ) 77 | # Extract EDID 78 | output_properties = randr.list_output_properties(window, output) 79 | edid = [0] * 128 80 | for atom in output_properties._data["atoms"]: 81 | atom_name = d.get_atom_name(atom) 82 | if atom_name == "EDID": 83 | edid = randr.get_output_property( 84 | window, output, atom, 19, 0, 128 85 | )._data["value"] 86 | break 87 | edids.append(edid) 88 | 89 | # If for some outputs, EDID is the same, merge them. We assume only 90 | # horizontal. For some reason, for a Dell Ultrasharp, EDID version and model 91 | # number is not the same for HDMI and DP. Version is bytes 18-19, while 92 | # product code are bytes 10-11 93 | if len(edids) >= 2: 94 | edids = [edid[:10] + edid[12:18] for edid in edids] 95 | changed = True 96 | while changed: 97 | changed = False 98 | for i, j in itertools.combinations(range(len(edids)), 2): 99 | if ( 100 | edids[i] == edids[j] 101 | and outputs[i].y == outputs[j].y 102 | and outputs[i].height == outputs[j].height 103 | and ( 104 | outputs[i].x + outputs[i].width == outputs[j].x 105 | or outputs[j].x + outputs[j].width == outputs[i].x 106 | ) 107 | ): 108 | logger.debug("merge outputs %s + %s", outputs[i], outputs[j]) 109 | outputs[i] = Rectangle( 110 | min(outputs[i].x, outputs[j].x), 111 | outputs[i].y, 112 | outputs[i].width + outputs[j].width, 113 | outputs[i].height, 114 | ) 115 | del edids[j] 116 | del outputs[j] 117 | changed = True 118 | break 119 | for o in outputs: 120 | logger.debug("output: %s", o) 121 | return outputs, background 122 | 123 | 124 | def get_covering_rectangles(outputs: list[Rectangle]) -> set[tuple[Rectangle, ...]]: 125 | """Compute all possible groups of covering boxes for the provided 126 | outputs. For each group, an output is included in exactly one box. 127 | 128 | >>> gcr = get_covering_rectangles 129 | >>> gcr([Rectangle(0, 0, 100, 100)]) 130 | {(Rectangle(x=0, y=0, width=100, height=100),)} 131 | >>> gcr([Rectangle(0, 0, 100, 100), 132 | ... Rectangle(100, 0, 100, 100)]) # doctest: +NORMALIZE_WHITESPACE 133 | {(Rectangle(x=0, y=0, width=100, height=100), 134 | Rectangle(x=100, y=0, width=100, height=100)), 135 | (Rectangle(x=0, y=0, width=200, height=100),)} 136 | >>> gcr([Rectangle(0, 0, 100, 100), 137 | ... Rectangle(100, 0, 100, 100), 138 | ... Rectangle(0, 100, 100, 100)]) # doctest: +NORMALIZE_WHITESPACE 139 | {(Rectangle(x=100, y=0, width=100, height=100), 140 | Rectangle(x=0, y=100, width=100, height=100), 141 | Rectangle(x=0, y=0, width=100, height=100)), 142 | (Rectangle(x=100, y=0, width=100, height=100), 143 | Rectangle(x=0, y=0, width=100, height=200)), 144 | (Rectangle(x=0, y=0, width=200, height=100), 145 | Rectangle(x=0, y=100, width=100, height=100))} 146 | >>> gcr([Rectangle(0, 0, 2560, 1440), 147 | ... Rectangle(2560, 0, 1920, 1080)]) # doctest: +NORMALIZE_WHITESPACE 148 | {(Rectangle(x=2560, y=0, width=1920, height=1080), 149 | Rectangle(x=0, y=0, width=2560, height=1440))} 150 | """ 151 | 152 | candidates = set() 153 | for output in outputs: 154 | candidates.add(output) 155 | for ooutput in outputs: 156 | if ooutput == output: 157 | continue 158 | if output.x > ooutput.x or output.y > ooutput.y: 159 | continue 160 | candidates.add( 161 | Rectangle( 162 | output.x, 163 | output.y, 164 | ooutput.x - output.x + ooutput.width, 165 | ooutput.y - output.y + ooutput.height, 166 | ) 167 | ) 168 | 169 | # Get all rectangle combinations to cover outputs without overlapping 170 | groups = set() 171 | for r in range(len(candidates)): 172 | for candidate in itertools.combinations(candidates, r + 1): 173 | for output in outputs: 174 | nb = 0 175 | for c in candidate: 176 | if ( 177 | c.x <= output.x < c.x + c.width 178 | and c.y <= output.y < c.y + c.height 179 | and output.x + output.width <= c.x + c.width 180 | and output.y + output.height <= c.y + c.height 181 | ): 182 | nb += 1 183 | if nb != 1: # output not contained in a single rectangle 184 | break 185 | else: 186 | # Test for overlap 187 | overlap = False 188 | for c1 in candidate: 189 | for c2 in candidate: 190 | if c1 == c2: 191 | continue 192 | if not ( 193 | c1.x >= c2.x + c2.width 194 | or c1.x + c1.width <= c2.x 195 | or c1.y >= c2.y + c2.height 196 | or c1.y + c1.height <= c2.y 197 | ): 198 | overlap = True 199 | if not overlap: 200 | groups.add(candidate) 201 | 202 | for g in groups: 203 | logger.debug("group: %s", g) 204 | return groups 205 | 206 | 207 | def get_random_images(directory: str, number: int) -> list[Image]: 208 | """Get random images from a directory.""" 209 | image_files = [] 210 | weights = [] 211 | counts = [] 212 | for base, _, files in os.walk(os.path.join(directory)): 213 | for i in files: 214 | if os.path.splitext(i)[1].lower() in (".jpg", ".jpeg", ".png", ".webp"): 215 | filename = os.path.join(base, i) 216 | image_files.append(filename) 217 | if options.count_attribute: 218 | try: 219 | count = int( 220 | xattr.getxattr(filename, options.count_attribute).decode() 221 | ) 222 | except (OSError, ValueError): 223 | count = 0 224 | counts.append(count) 225 | weights = [100 / ((count - min(counts) + 1) ** 3) for count in counts] 226 | images = [ 227 | PIL.Image.open(image) 228 | for image in functools.reduce( 229 | operator.add, 230 | (random.choices(image_files, weights=weights) for k in range(number)), 231 | [], 232 | ) 233 | ] 234 | 235 | for image in images: 236 | directory_len = len(directory) + 1 237 | logger.debug("image: %s %s×%s", image.filename[directory_len:], *image.size) 238 | return images 239 | 240 | 241 | def get_best_parts( 242 | groups: set[tuple[Rectangle, ...]], 243 | images: list[Image], 244 | ratio_score: int = 100, 245 | scale_score: int = 20, 246 | multiple_wallpaper_score: int = -50, 247 | ) -> Optional[list[WallpaperPart]]: 248 | """Find optimal association for images for the groups of covering rectangles. 249 | 250 | >>> gbp = get_best_parts 251 | >>> gbp([[Rectangle(0, 0, 100, 100)]], 252 | ... [PIL.Image.new("RGB", (100, 100))]) # doctest: +NORMALIZE_WHITESPACE +ELLIPSIS 253 | [WallpaperPart(rectangle=Rectangle(x=0, y=0, width=100, height=100), 254 | image=)] 255 | >>> gbp([[Rectangle(0, 0, 100, 100)]], 256 | ... [PIL.Image.new("RGB", (100, 100)), 257 | ... PIL.Image.new("RGB", (100, 200))]) # doctest: +NORMALIZE_WHITESPACE +ELLIPSIS 258 | [WallpaperPart(rectangle=Rectangle(x=0, y=0, width=100, height=100), 259 | image=)] 260 | >>> gbp([[Rectangle(0, 0, 100, 100)]], 261 | ... [PIL.Image.new("RGB", (50, 50)), 262 | ... PIL.Image.new("RGB", (100, 200))]) # doctest: +NORMALIZE_WHITESPACE +ELLIPSIS 263 | [WallpaperPart(rectangle=Rectangle(x=0, y=0, width=100, height=100), 264 | image=)] 265 | >>> gbp([[Rectangle(0, 0, 100, 100)]], 266 | ... [PIL.Image.new("RGB", (10, 10)), 267 | ... PIL.Image.new("RGB", (100, 200))]) # doctest: +NORMALIZE_WHITESPACE +ELLIPSIS 268 | [WallpaperPart(rectangle=Rectangle(x=0, y=0, width=100, height=100), 269 | image=)] 270 | >>> gbp([[Rectangle(0, 0, 100, 100), Rectangle(0, 100, 100, 100)], 271 | ... [Rectangle(0, 0, 200, 100)]], 272 | ... [PIL.Image.new("RGB", (100, 100)), 273 | ... PIL.Image.new("RGB", (200, 100))]) # doctest: +NORMALIZE_WHITESPACE +ELLIPSIS 274 | [WallpaperPart(rectangle=Rectangle(x=0, y=0, width=200, height=100), 275 | image=)] 276 | >>> gbp([[Rectangle(0, 0, 100, 100), Rectangle(100, 0, 100, 100)], 277 | ... [Rectangle(0, 0, 200, 100)]], 278 | ... [PIL.Image.new("RGB", (100, 100)), 279 | ... PIL.Image.new("RGB", (100, 100))]) # doctest: +NORMALIZE_WHITESPACE +ELLIPSIS 280 | [WallpaperPart(rectangle=Rectangle(x=0, y=0, width=100, height=100), 281 | image=), 282 | WallpaperPart(rectangle=Rectangle(x=100, y=0, width=100, height=100), 283 | image=)] 284 | >>> gbp([[Rectangle(0, 0, 1920, 1080), Rectangle(1920, 0, 1920, 1080)], 285 | ... [Rectangle(0, 0, 3840, 1080)]], 286 | ... [PIL.Image.new("RGB", (2560, 1440)), 287 | ... PIL.Image.new("RGB", (3840, 1440))]) # doctest: +NORMALIZE_WHITESPACE +ELLIPSIS 288 | [WallpaperPart(rectangle=Rectangle(x=0, y=0, width=3840, height=1080), 289 | image=)] 290 | """ 291 | best_association = None 292 | best_score: float = 0 293 | for group in groups: 294 | associations = [tuple(zip(group, p)) for p in itertools.permutations(images)] 295 | seen = [] 296 | for association in associations: 297 | if association in seen: 298 | continue 299 | seen.append(association) 300 | score: float = 0 301 | association_ = [ 302 | WallpaperPart(rectangle=assoc[0], image=assoc[1]) 303 | for assoc in association 304 | ] 305 | for assoc in association_: 306 | # Similar ratio 307 | oratio = assoc.rectangle.width * 100 // assoc.rectangle.height 308 | iratio = assoc.image.width * 100 // assoc.image.height 309 | r = iratio / oratio 310 | if r > 1: 311 | r = 1 / r 312 | score += r * ratio_score 313 | # Similar scale (when cropped) 314 | opixels = assoc.rectangle.width * assoc.rectangle.height 315 | ipixels = assoc.image.width * assoc.image.height * r 316 | r = ipixels / opixels 317 | if r >= 1: 318 | r = 1 319 | score += r * scale_score 320 | score += (len(group) - 1) * multiple_wallpaper_score 321 | logger.debug("association: %s, score %.2f", association_, score) 322 | if score > best_score or best_association is None: 323 | best_association = association_ 324 | best_score = score 325 | 326 | return best_association 327 | 328 | 329 | def build(background: Image, wallpaper_parts: list[WallpaperPart]) -> None: 330 | """Stitch wallpaper into provided background.""" 331 | for part in wallpaper_parts: 332 | rectangle = part.rectangle 333 | image = part.image 334 | 335 | imx, imy = rectangle.width, image.height * rectangle.width // image.width 336 | if imy < rectangle.height: 337 | imx, imy = image.width * rectangle.height // image.height, rectangle.height 338 | if image.size != (imx, imy): 339 | image = image.resize((imx, imy), PIL.Image.Resampling.LANCZOS) 340 | image = image.crop( 341 | ( 342 | (imx - rectangle.width) // 2, 343 | (imy - rectangle.height) // 2, 344 | imx - (imx - rectangle.width) // 2, 345 | imy - (imy - rectangle.height) // 2, 346 | ) 347 | ) 348 | background.paste(image, (rectangle.x, rectangle.y)) 349 | 350 | 351 | def save(wallpaper: Image, target: str, compression: int) -> None: 352 | """Save wallpaper to target.""" 353 | with tempfile.NamedTemporaryFile( 354 | delete=False, dir=os.path.dirname(os.path.realpath(target)) 355 | ) as tmp: 356 | wallpaper.save(tmp, "png", compress_level=compression) 357 | os.rename(tmp.name, target) 358 | 359 | 360 | if __name__ == "__main__": 361 | # Parse 362 | description = sys.modules[__name__].__doc__ 363 | parser = argparse.ArgumentParser() 364 | parser.add_argument( 365 | "--debug", 366 | action="store_true", 367 | default=False, 368 | help="enable debugging", 369 | ) 370 | group = parser.add_argument_group("image selection") 371 | group.add_argument( 372 | "-d", 373 | "--directory", 374 | default=".", 375 | help="search for images in DIRECTORY", 376 | ) 377 | group.add_argument( 378 | "--extra-images", 379 | default=1, 380 | metavar="N", 381 | help="consider N additional images per output to choose the best combination", 382 | ) 383 | group.add_argument( 384 | "--count-attribute", 385 | default="user.count", 386 | metavar="ATTR", 387 | help="store number of times an image was used in the provided attribute", 388 | ) 389 | params = inspect.signature(get_best_parts).parameters 390 | group.add_argument( 391 | "--ratio-score", 392 | default=params["ratio_score"].default, 393 | help="multiplicative weight applied to ratio matching for score", 394 | ) 395 | group.add_argument( 396 | "--scale-score", 397 | default=params["scale_score"].default, 398 | help="multiplicative weight applied to pixel matching for score", 399 | ) 400 | group.add_argument( 401 | "--multiple-wallpaper-score", 402 | default=params["multiple_wallpaper_score"].default, 403 | help="additive weight for each additional wallpaper used", 404 | ) 405 | group = parser.add_argument_group("image output") 406 | group.add_argument( 407 | "-t", 408 | "--target", 409 | default="background.png", 410 | help="write background to FILE", 411 | metavar="FILE", 412 | ) 413 | group.add_argument( 414 | "--compression", default=0, type=int, help="compression level when saving" 415 | ) 416 | group.add_argument( 417 | "--outputs", 418 | default=None, 419 | help="write number of outputs to FILE", 420 | metavar="FILE", 421 | ) 422 | options = parser.parse_args() 423 | 424 | # Logging 425 | root = logging.getLogger("") 426 | root.setLevel(logging.WARNING) 427 | logger.setLevel(options.debug and logging.DEBUG or logging.INFO) 428 | if sys.stderr.isatty(): 429 | ch = logging.StreamHandler() 430 | ch.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) 431 | root.addHandler(ch) 432 | else: 433 | root.addHandler(journal.JournalHandler(SYSLOG_IDENTIFIER=logger.name)) 434 | 435 | try: 436 | outputs, background = get_outputs() 437 | candidates = get_covering_rectangles(outputs) 438 | images = get_random_images( 439 | options.directory, len(outputs) * (1 + options.extra_images) 440 | ) 441 | wallpaper_parts = get_best_parts( 442 | candidates, 443 | images, 444 | ratio_score=options.ratio_score, 445 | scale_score=options.scale_score, 446 | multiple_wallpaper_score=options.multiple_wallpaper_score, 447 | ) 448 | assert wallpaper_parts is not None 449 | for part in wallpaper_parts: 450 | logger.info( 451 | "wallpaper: {} ({}×{})".format( 452 | part.image.filename[(len(options.directory) + 1) :], 453 | *part.image.size 454 | ) 455 | ) 456 | if options.count_attribute: 457 | try: 458 | count = int( 459 | xattr.getxattr(part.image.filename, options.count_attribute) 460 | ) 461 | except (OSError, ValueError): 462 | count = 0 463 | xattr.setxattr( 464 | part.image.filename, 465 | options.count_attribute, 466 | bytes(str(count + 1), "ascii"), 467 | ) 468 | build(background, wallpaper_parts) 469 | save(background, options.target, options.compression) 470 | 471 | if options.outputs is not None: 472 | with open(options.outputs, "w") as f: 473 | f.write(str(len(outputs))) 474 | except Exception as e: 475 | logger.exception("%s", e) 476 | sys.exit(1) 477 | sys.exit(0) 478 | -------------------------------------------------------------------------------- /bin/weather: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Get current weather condition and forecast for Polybar.""" 4 | 5 | import requests 6 | import logging 7 | import argparse 8 | import sys 9 | import os 10 | import subprocess 11 | import time 12 | from systemd import journal 13 | 14 | logger = logging.getLogger("weather") 15 | 16 | 17 | def get_location(): 18 | """Return current location as latitude/longitude tuple.""" 19 | logger.debug("query MaxMind for location") 20 | r = requests.get( 21 | "https://geoip.maxmind.com/geoip/v2.1/city/me", 22 | headers={"referer": "https://www.maxmind.com/en/locate-my-ip-address"}, 23 | timeout=10, 24 | ) 25 | r.raise_for_status() 26 | data = r.json() 27 | logger.debug("current location data: %s", data) 28 | location = (data.get("city") or data["country"])["names"]["en"] 29 | logger.info(f"current location: {location}") 30 | return ((data["location"]["latitude"], data["location"]["longitude"]), location) 31 | 32 | 33 | def get_weather(latitude, longitude): 34 | """Return data from met.no.""" 35 | logger.debug("query met.no for %s, %s", latitude, longitude) 36 | r = requests.get( 37 | "https://api.met.no/weatherapi/locationforecast/2.0/complete.json", 38 | params={ 39 | "lat": f"{latitude:.4f}", 40 | "lon": f"{longitude:.4f}", 41 | }, 42 | headers={ 43 | "user-agent": "WeatherWidget https://github.com/vincentbernat/i3wm-configuration" 44 | }, 45 | timeout=10, 46 | ) 47 | r.raise_for_status() 48 | data = r.json() 49 | logger.debug("weather data: %s", data) 50 | return data 51 | 52 | 53 | def format_icon(symbol_code): 54 | """Translate met.no icon to Font Awesome.""" 55 | # See https://github.com/metno/weathericons/blob/main/weather/legend.csv 56 | symbol_code = symbol_code.removeprefix("light") 57 | symbol_code = symbol_code.removeprefix("heavy") 58 | if symbol_code.startswith("ss"): 59 | symbol_code = symbol_code[1:] 60 | icon = { 61 | "clearsky_day": "\uf00d", 62 | "clearsky_night": "\uf02e", 63 | "cloudy": "\uf013", 64 | "fair_day": "\uf002", 65 | "fair_night": "\uf086", 66 | "fog": "\uf014", 67 | "partlycloudy_day": "\uf002", 68 | "partlycloudy_night": "\uf086", 69 | "rain": "\uf019", 70 | "rainandthunder": "\uf01e", 71 | "rainshowers_day": "\uf009", 72 | "rainshowers_night": "\uf037", 73 | "rainshowersandthunder_day": "\uf010", 74 | "rainshowersandthunder_night": "\uf03b", 75 | "sleet": "\uf0b5", 76 | "sleetandthunder": "\uf01d", 77 | "sleetshowers_day": "\uf0b2", 78 | "sleetshowers_night": "\uf0b3", 79 | "sleetshowersandthunder_day": "\uf068", 80 | "sleetshowersandthunder_night": "\uf069", 81 | "snow": "\uf01b", 82 | "snowandthunder": "\uf06b", 83 | "snowshowers_day": "\uf009", 84 | "snowshowers_night": "\uf038", 85 | "snowshowersandthunder_day": "\uf06b", 86 | "snowshowersandthunder_night": "\uf06c", 87 | }.get(symbol_code, "?") 88 | logger.debug("symbol %s translated to %s (\\u%04x)", symbol_code, icon, ord(icon)) 89 | output = ["%{Tx}", icon, "%{T-}"] 90 | return "".join(output) 91 | 92 | 93 | def update_status(status, output): 94 | """Update current status.""" 95 | # Write it to file 96 | with open(output, "w") as f: 97 | f.write(status) 98 | 99 | # Send it to polybar 100 | subprocess.run(["polybar-msg", "action", f"#weather.send.{status}"]) 101 | 102 | 103 | if __name__ == "__main__": 104 | # Parse 105 | description = sys.modules[__name__].__doc__ 106 | parser = argparse.ArgumentParser(description=description) 107 | parser.add_argument( 108 | "--debug", 109 | "-d", 110 | action="store_true", 111 | default=False, 112 | help="enable debugging", 113 | ) 114 | parser.add_argument( 115 | "--font-index", default=4, type=int, help="Polybar font 1-index" 116 | ) 117 | parser.add_argument( 118 | "--output", 119 | default=f"{os.environ['XDG_RUNTIME_DIR']}/i3/weather.txt", 120 | help="Output destination", 121 | ) 122 | parser.add_argument( 123 | "--online-timeout", 124 | default=30, 125 | type=int, 126 | help="Wait up to TIMEOUT minutes to be online", 127 | ) 128 | options = parser.parse_args() 129 | 130 | # Logging 131 | root = logging.getLogger("") 132 | root.setLevel(logging.WARNING) 133 | logger.setLevel(options.debug and logging.DEBUG or logging.INFO) 134 | if sys.stderr.isatty(): 135 | ch = logging.StreamHandler() 136 | ch.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) 137 | root.addHandler(ch) 138 | else: 139 | root.addHandler(journal.JournalHandler(SYSLOG_IDENTIFIER=logger.name)) 140 | 141 | try: 142 | # Get location 143 | while True: 144 | try: 145 | location, city = get_location() 146 | break 147 | except (requests.exceptions.ConnectionError, requests.exceptions.Timeout): 148 | # Wait to be online 149 | logger.info("not online, waiting") 150 | update_status("", options.output) 151 | time.sleep(5) 152 | process = subprocess.run( 153 | ["nm-online", "-q", "-t", str(options.online_timeout * 60)] 154 | ) 155 | if process.returncode != 0: 156 | logger.warning("not online, exiting") 157 | sys.exit(1) 158 | 159 | # Grab current weather and daily forecast 160 | weather = get_weather(*location) 161 | weather = weather["properties"]["timeseries"] 162 | 163 | # Compute min/max temperatures for the forecast. We use the next 24 164 | # hours. So we use 18 entries. 165 | mintemp = min( 166 | d["data"]["next_6_hours"]["details"]["air_temperature_min"] 167 | for d in weather[:18] 168 | ) 169 | maxtemp = max( 170 | d["data"]["next_6_hours"]["details"]["air_temperature_max"] 171 | for d in weather[:18] 172 | ) 173 | 174 | # Format output 175 | conditions = [ 176 | # Current conditions: use the symbol for the next hour and the 177 | # instant temperature 178 | format_icon(weather[0]["data"]["next_1_hours"]["summary"]["symbol_code"]), 179 | "{}°C".format( 180 | round(weather[0]["data"]["instant"]["details"]["air_temperature"]) 181 | ), 182 | # Forecast: use the symbol for the next 6 hours and the period after 183 | format_icon(weather[0]["data"]["next_6_hours"]["summary"]["symbol_code"]) 184 | + format_icon(weather[6]["data"]["next_6_hours"]["summary"]["symbol_code"]), 185 | # And the temperature range computed for the next 24 hours 186 | "{}—{}°C".format(round(mintemp), round(maxtemp)), 187 | ] 188 | city = city.replace("%", "%%") 189 | conditions.insert(0, f"%{{F#bbb}}{city}%{{F-}}") 190 | output = " ".join(conditions).replace("%{Tx}", "%%{T%d}" % options.font_index) 191 | logger.debug("output: %s", output) 192 | 193 | update_status(output, options.output) 194 | 195 | except Exception as e: 196 | logger.exception("%s", e) 197 | sys.exit(1) 198 | sys.exit(0) 199 | -------------------------------------------------------------------------------- /bin/xdg-app-chooser: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | """Simple application selector relying on GTK app chooser dialog. 4 | """ 5 | 6 | # This also sets the default application for a MIME type (similar to "xdg-mime 7 | # default something.desktop mime/type"). Usually, it will update 8 | # ~/.config/mimeapps.list. 9 | 10 | import argparse 11 | import sys 12 | import gi 13 | 14 | gi.require_version("Gtk", "3.0") 15 | from gi.repository import Gtk, Gio 16 | 17 | parser = argparse.ArgumentParser(description=sys.modules[__name__].__doc__) 18 | parser.add_argument("file", metavar="FILE", help="file to open") 19 | options = parser.parse_args() 20 | 21 | # Query MIME TYPE 22 | f = Gio.File.new_for_path(options.file) 23 | file_info = f.query_info("standard::content-type", 0) 24 | content_type = file_info.get_content_type() 25 | 26 | # Display app chooser dialog box 27 | dialog = Gtk.AppChooserDialog.new_for_content_type( 28 | None, Gtk.DialogFlags.MODAL, content_type 29 | ) 30 | dialog.set_position(Gtk.WindowPosition.CENTER_ALWAYS) 31 | dialog.get_widget().set_show_default(True) 32 | dialog.get_widget().set_show_fallback(True) 33 | response = dialog.run() 34 | 35 | # Execute the selected program 36 | if response == Gtk.ResponseType.OK: 37 | app_info = dialog.get_app_info() 38 | dialog.destroy() 39 | # TODO: make it launch synchronously 40 | sys.exit(0 if app_info.launch([f]) else 1) 41 | sys.exit(1) 42 | -------------------------------------------------------------------------------- /bin/xsecurelock-saver: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | """Saver module for xsecurelock. 4 | 5 | It displays a background image, clock and weather. Configuration is 6 | done through environment variables: 7 | 8 | - XSECURELOCK_SAVER_IMAGE: path to the background image to use 9 | - XSECURELOCK_SAVER_WEATHER: path to weather text 10 | - XSECURELOCK_SAVER_FONT: font family to use to display clock and weather 11 | - XSECURELOCK_SAVER_CLOCK_FONT_SIZE: font size to use to display clock 12 | - XSECURELOCK_SAVER_WEATHER_FONT_SIZE: font size to use to display weather 13 | 14 | """ 15 | 16 | # In case I want to put a video instead of an image: 17 | # https://gist.github.com/NBonaparte/89fb1b645c99470bc0f6. Check 18 | # `bin/wallpaper' for some sources. 19 | 20 | import os 21 | import types 22 | import datetime 23 | import re 24 | import socket 25 | import time 26 | import cairo 27 | import gi 28 | 29 | gi.require_version("Gtk", "3.0") 30 | from gi.repository import Gtk, Gdk, GdkX11, GLib, GdkPixbuf, Gio 31 | 32 | 33 | def on_win_realize(widget, ctx): 34 | """On realization, embed into XSCREENSAVER_WINDOW and remember parent position.""" 35 | parent_wid = int(os.getenv("XSCREENSAVER_WINDOW", 0)) 36 | if not parent_wid: 37 | return 38 | parent = GdkX11.X11Window.foreign_new_for_display(widget.get_display(), parent_wid) 39 | x, y, w, h = parent.get_geometry() 40 | ctx.position = x, y 41 | window = widget.get_window() 42 | window.resize(w, h) 43 | window.reparent(parent, 0, 0) 44 | 45 | 46 | def on_win_draw(widget, cctx, ctx): 47 | """Draw background image.""" 48 | x, y = ctx.position 49 | wwidth, wheight = widget.get_size() 50 | scale = widget.get_scale_factor() 51 | bg = None 52 | 53 | if ctx.background: 54 | bg = ctx.background.new_subpixbuf( 55 | x * scale, y * scale, wwidth * scale, wheight * scale 56 | ) 57 | 58 | cctx.set_operator(cairo.OPERATOR_SOURCE) 59 | if not bg: 60 | cctx.set_source_rgba(0, 0, 0, 1) 61 | cctx.paint() 62 | return 63 | 64 | cctx.save() 65 | cctx.scale(1 / scale, 1 / scale) 66 | Gdk.cairo_set_source_pixbuf(cctx, bg, 0, 0) 67 | cctx.paint() 68 | cctx.restore() 69 | 70 | 71 | def on_overlay_draw(widget, cctx, ctx): 72 | """Draw overlay (clock and weather).""" 73 | if not ctx.leader: 74 | return 75 | wwidth, wheight = widget.get_parent().get_size() 76 | cctx.set_operator(cairo.OPERATOR_OVER) 77 | 78 | def draw(what): 79 | x, y = cctx.get_current_point() 80 | cctx.set_source_rgba(0, 0, 0, 0.3) 81 | cctx.move_to(x + 2, y + 2) 82 | cctx.show_text(what) 83 | cctx.set_source_rgb(1, 1, 1) 84 | cctx.move_to(x, y) 85 | cctx.show_text(what) 86 | 87 | # Clock 88 | if ctx.clock: 89 | time, date = ctx.clock 90 | 91 | # Time 92 | cctx.select_font_face( 93 | ctx.font_family, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD 94 | ) 95 | cctx.set_font_size(ctx.clock_font_size) 96 | _, _, twidth, theight, _, _ = cctx.text_extents(re.sub(r"\d", "8", time)) 97 | cctx.move_to(wwidth // 2 - twidth // 2, wheight // 3) 98 | draw(time) 99 | 100 | # Date 101 | cctx.select_font_face( 102 | ctx.font_family, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL 103 | ) 104 | cctx.set_font_size(ctx.clock_font_size // 3) 105 | _, _, twidth, theight, _, _ = cctx.text_extents(date) 106 | cctx.move_to(wwidth // 2 - twidth // 2, wheight // 3 + theight * 1.5) 107 | draw(date) 108 | 109 | # Weather 110 | # We can have polybar markups in it. We assume %{Tx} means to use 111 | # Weather Icons and we ignore font color change. The parsing is 112 | # quite basic. 113 | if ctx.weather: 114 | data = re.sub(r"%{F[#\da-f+-]+?}", "", ctx.weather) 115 | data = re.split(r"(%{T[1-9-]})", data) 116 | font = ctx.font_family 117 | cctx.move_to(20, wheight - 20) 118 | for chunk in data: 119 | if chunk == "%{T-}": 120 | font = ctx.font_family 121 | continue 122 | elif chunk.startswith("%{T"): 123 | font = ctx.weather_font_family 124 | continue 125 | elif not chunk: 126 | continue 127 | cctx.select_font_face( 128 | font, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL 129 | ) 130 | cctx.set_font_size(ctx.weather_font_size) 131 | draw(chunk) 132 | 133 | 134 | def on_background_change(monitor, f1, f2, event, ctx): 135 | """Update background when changed.""" 136 | if event not in ( 137 | Gio.FileMonitorEvent.CHANGES_DONE_HINT, 138 | Gio.FileMonitorEvent.RENAMED, 139 | ): 140 | return 141 | try: 142 | new_background = GdkPixbuf.Pixbuf.new_from_file(ctx.background_image) 143 | except Exception: 144 | return 145 | ctx.background = new_background 146 | ctx.window.queue_draw() 147 | 148 | 149 | def on_clock_change(ctx): 150 | """Clock may have changed. Update it. 151 | 152 | We are checking more often than once a minute because we want to 153 | update the clock swiftly after suspend. An alternative would be to 154 | listen to PrepareForSleep signal from org.freedesktop.login1, but 155 | this is more complex. 156 | 157 | """ 158 | now = datetime.datetime.now() 159 | new_clock = now.strftime("%H:%M") 160 | if new_clock != ctx.clock: 161 | ctx.clock = (new_clock, now.strftime("%A %-d %B")) 162 | ctx.overlay.queue_draw() 163 | if ctx.leader is None: 164 | # Do leader "election" 165 | if ctx.position != (0, 0): 166 | time.sleep(0.2) 167 | s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 168 | try: 169 | s.bind("\0xsecurelock-saver") 170 | except OSError: 171 | ctx.leader = False 172 | else: 173 | ctx.leader = s 174 | if ctx.leader: 175 | GLib.timeout_add(min(60 - now.second, 3) * 1000, on_clock_change, ctx) 176 | 177 | 178 | def on_weather_change(monitor, f1, f2, event, ctx): 179 | """Weather file has changed.""" 180 | if event not in ( 181 | Gio.FileMonitorEvent.CHANGES_DONE_HINT, 182 | Gio.FileMonitorEvent.RENAMED, 183 | ): 184 | return 185 | try: 186 | with open(ctx.weather_file) as wfile: 187 | ctx.weather = wfile.read() 188 | ctx.overlay.queue_draw() 189 | except Exception: 190 | pass 191 | 192 | 193 | if __name__ == "__main__": 194 | ctx = types.SimpleNamespace() 195 | ctx.background_image = os.getenv("XSECURELOCK_SAVER_IMAGE", None) 196 | ctx.clock_font_size = int(os.getenv("XSECURELOCK_SAVER_CLOCK_FONT_SIZE", 120)) 197 | ctx.weather_font_size = int(os.getenv("XSECURELOCK_SAVER_CLOCK_FONT_SIZE", 40)) 198 | ctx.weather_file = os.getenv("XSECURELOCK_SAVER_WEATHER", None) 199 | ctx.font_family = os.getenv("XSECURELOCK_SAVER_FONT", "Iosevka Aile") 200 | ctx.weather_font_family = os.getenv( 201 | "XSECURELOCK_SAVER_WEATHER_FONT_FAMILY", "Weather Icons" 202 | ) 203 | ctx.background = None 204 | ctx.weather = None 205 | ctx.clock = None 206 | ctx.position = (0, 0) 207 | ctx.leader = None 208 | 209 | ctx.window = Gtk.Window() 210 | ctx.window.set_app_paintable(True) 211 | ctx.window.set_visual(ctx.window.get_screen().get_rgba_visual()) 212 | ctx.window.connect("realize", on_win_realize, ctx) 213 | ctx.window.connect("draw", on_win_draw, ctx) 214 | ctx.window.connect("delete-event", Gtk.main_quit) 215 | 216 | ctx.overlay = Gtk.DrawingArea() 217 | ctx.overlay.connect("draw", on_overlay_draw, ctx) 218 | ctx.window.add(ctx.overlay) 219 | 220 | gio_event_args = (None, None, None, Gio.FileMonitorEvent.CHANGES_DONE_HINT) 221 | if ctx.background_image: 222 | gfile = Gio.File.new_for_path(ctx.background_image) 223 | monitor1 = gfile.monitor_file(Gio.FileMonitorFlags.WATCH_MOVES, None) 224 | monitor1.connect("changed", on_background_change, ctx) 225 | on_background_change(*gio_event_args, ctx) 226 | if ctx.weather_file: 227 | gfile = Gio.File.new_for_path(ctx.weather_file) 228 | monitor2 = gfile.monitor_file(Gio.FileMonitorFlags.WATCH_MOVES, None) 229 | monitor2.connect("changed", on_weather_change, ctx) 230 | GLib.timeout_add(1000, on_weather_change, *gio_event_args, ctx) 231 | GLib.timeout_add(1000, on_clock_change, ctx) 232 | 233 | ctx.window.show_all() 234 | 235 | # Main loop 236 | Gtk.main() 237 | -------------------------------------------------------------------------------- /bin/xsettingsd-setup: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | showdpi() { 4 | output=$1 5 | pixels=$2 6 | mm=$3 7 | 8 | # Compute DPI 9 | dpi=$((pixels * 254 / 10 / mm)) 10 | 11 | # For laptop screens, we need to apply a correction factor as the 12 | # screen is nearer 13 | case $output in 14 | eDP*) corrected=$((dpi * 96/144)) ;; 15 | *) corrected=$dpi ;; 16 | esac 17 | 18 | # Authorized factors: 1, 1.25, 1.5, 2, 3, 4, ... 19 | rounded=$(((corrected + 12) / 24 * 24)) 20 | [ $rounded -gt 168 ] && rounded=$(((corrected + 48) / 96 * 96)) 21 | [ $rounded -lt 96 ] && rounded=96 22 | 23 | echo "$output: ${dpi}dpi (corrected to ${corrected}dpi, rounded to ${rounded}dpi)" >&2 24 | echo "$rounded" 25 | } 26 | 27 | # Examples: 28 | # showdpi HDMI-A-0 3840 527 29 | # HDMI-A-0: 185dpi (corrected to 185dpi, rounded to 192dpi) 30 | # showdpi DisplayPort-3 3840 880 31 | # DisplayPort-3: 110dpi (corrected to 110dpi, rounded to 120dpi) 32 | # showdpi eDP-1 2560 310 33 | # eDP-1: 209dpi (corrected to 139dpi, rounded to 144dpi) 34 | 35 | # Compute DPI of each screens 36 | dpis=$(xrandr --current \ 37 | | sed -En 's/^([^ ]+)* connected.* ([0-9]+)x.* ([0-9]+)mm x .*/\1 \2 \3/p' \ 38 | | while read output pixels mm; do 39 | showdpi $output $pixels $mm 40 | done \ 41 | | tr '\n' ' ') 42 | 43 | # Use first screen DPI 44 | dpi=${dpis%% *} 45 | dpi=${dpi:-96} 46 | 47 | echo "using ${dpi}dpi" >&2 48 | 49 | # Xrdb update 50 | { 51 | echo Xft.dpi: $dpi 52 | echo Xft.rgba: $( [ $dpi -gt 144 ] && echo none || echo rgb ) 53 | } | xrdb -merge 54 | 55 | # X server update (+ trigger update) 56 | xrandr --dpi $((dpi-1)) 57 | xrandr --dpi $dpi 58 | 59 | # Build xsettingsd 60 | { 61 | cat ~/.config/i3/dotfiles/xsettingsd 62 | echo Xft/DPI $(( $dpi*1024 )) 63 | echo Xft/RGBA \"$( [ $dpi -gt 144 ] && echo none || echo rgb )\" 64 | echo Gdk/WindowScalingFactor $(( $dpi/96 )) 65 | echo Gdk/UnscaledDPI $(( $dpi*1024/($dpi/96) )) 66 | } > $XDG_RUNTIME_DIR/i3/xsettingsd.conf 67 | -------------------------------------------------------------------------------- /bin/xss-dimmer: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Simple dimmer for xss-lock. 4 | 5 | It dim the screen using a provided delay and display a countdown. It 6 | will stop itself when the locker window is mapped. 7 | """ 8 | 9 | # It assumes we are using a compositor. 10 | 11 | import gi 12 | 13 | gi.require_version("Gtk", "3.0") 14 | gi.require_version("Gst", "1.0") 15 | from gi.repository import Gtk, Gdk, GLib, GdkPixbuf, GObject, Gst 16 | import cairo 17 | import argparse 18 | import threading 19 | import time 20 | import math 21 | import os 22 | import warnings 23 | from Xlib import display, X 24 | from Xlib.error import BadWindow 25 | from Xlib.protocol.event import MapNotify 26 | 27 | 28 | def on_xevent(source, condition, xdisplay, locker): 29 | while xdisplay.pending_events(): 30 | event = xdisplay.next_event() 31 | if event.type != X.MapNotify: 32 | continue 33 | try: 34 | wmclass = event.window.get_wm_class() 35 | except BadWindow: 36 | continue 37 | if wmclass and wmclass[1] == locker: 38 | Gtk.main_quit() 39 | return False 40 | return True 41 | 42 | 43 | def on_realize(widget): 44 | window = widget.get_window() 45 | window.set_override_redirect(True) 46 | 47 | 48 | def on_draw(widget, event, options, background, start): 49 | x, y = widget.get_position() 50 | wwidth, wheight = widget.get_size() 51 | delta = options.end_opacity - options.start_opacity 52 | elapsed = time.monotonic() - start 53 | current = easing_functions[options.easing_function](elapsed / options.delay) 54 | opacity = delta * current + options.start_opacity 55 | cctx = event 56 | 57 | # Background 58 | scale = widget.get_scale_factor() 59 | bg = None 60 | if background: 61 | bg = background.new_subpixbuf( 62 | x * scale, y * scale, wwidth * scale, wheight * scale 63 | ) 64 | cctx.set_operator(cairo.OPERATOR_SOURCE) 65 | if not bg: 66 | cctx.set_source_rgba(0, 0, 0, opacity) 67 | cctx.paint() 68 | else: 69 | cctx.save() 70 | cctx.scale(1 / scale, 1 / scale) 71 | Gdk.cairo_set_source_pixbuf(cctx, bg, 0, 0) 72 | cctx.paint_with_alpha(opacity) 73 | cctx.restore() 74 | 75 | # Remaining time 76 | cctx.set_operator(cairo.OPERATOR_OVER) 77 | remaining = str(round(options.delay - elapsed)) 78 | cctx.select_font_face(options.font, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD) 79 | cctx.set_font_size(wheight // 4) 80 | _, _, twidth, theight, _, _ = cctx.text_extents("8" * len(remaining)) 81 | text_position = wwidth // 2 - twidth // 2, wheight // 2 + theight // 2 82 | cctx.move_to(*text_position) 83 | cctx.set_source_rgba(1, 1, 1, opacity) 84 | cctx.show_text(remaining) 85 | cctx.move_to(*text_position) 86 | cctx.set_source_rgba(0, 0, 0, opacity * 2) 87 | cctx.set_line_width(4) 88 | cctx.text_path(remaining) 89 | cctx.stroke() 90 | 91 | 92 | def on_refresh(window, options, start): 93 | window.queue_draw() 94 | elapsed = time.monotonic() - start 95 | if elapsed < options.delay: 96 | next_step = min(options.step, options.delay - elapsed) 97 | GLib.timeout_add(next_step * 1000, on_refresh, window, options, start) 98 | 99 | 100 | def on_sound_ticker(options, sound, start): 101 | elapsed = time.monotonic() - start 102 | if elapsed < options.delay: 103 | if sound is None: 104 | Gst.init([]) 105 | sound = Gst.ElementFactory.make("playbin", "xss-dimmer") 106 | sound.set_property("uri", f"file://{os.path.abspath(options.sound)}") 107 | sound.set_state(Gst.State.NULL) 108 | sound.seek_simple( 109 | Gst.Format.TIME, Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT, 0 110 | ) 111 | sound.set_state(Gst.State.PLAYING) 112 | GLib.timeout_add(1000, on_sound_ticker, options, sound, now) 113 | 114 | 115 | # See: https://easings.net/ 116 | easing_functions = { 117 | "none": lambda x: x, 118 | "out-circ": lambda x: math.sqrt(1 - pow(x - 1, 2)), 119 | "out-sine": lambda x: math.sin(x * math.pi / 2), 120 | "out-cubic": lambda x: 1 - pow(1 - x, 3), 121 | "out-quint": lambda x: 1 - pow(1 - x, 5), 122 | "out-expo": lambda x: 1 - pow(2, -10 * x), 123 | "out-quad": lambda x: 1 - (1 - x) * (1 - x), 124 | "out-bounce": ( 125 | lambda n1, d1: lambda x: n1 * x * x 126 | if x < 1 / d1 127 | else n1 * pow(x - 1.5 / d1, 2) + 0.75 128 | if x < 2 / d1 129 | else n1 * pow(x - 2.25 / d1, 2) + 0.9375 130 | if (x < 2.5 / d1) 131 | else n1 * pow(x - 2.625 / d1, 2) + 0.984375 132 | )(7.5625, 2.75), 133 | "out-elastic": ( 134 | lambda x: pow(2, -10 * x) * math.sin((x * 10 - 0.75) * (2 * math.pi) / 3) + 1 135 | ), 136 | "inout-quad": lambda x: 2 * x * x if x < 0.5 else 1 - pow(-2 * x + 2, 2) / 2, 137 | "inout-quart": ( 138 | lambda x: 8 * x * x * x * x if x < 0.5 else 1 - pow(-2 * x + 2, 4) / 2 139 | ), 140 | "inout-expo": ( 141 | lambda x: pow(2, 20 * x - 10) / 2 if x < 0.5 else (2 - pow(2, -20 * x + 10)) / 2 142 | ), 143 | "inout-bounce": ( 144 | lambda x: (1 - easing_functions["out-bounce"](1 - 2 * x)) / 2 145 | if x < 0.5 146 | else (1 + easing_functions["out-bounce"](2 * x - 1)) / 2 147 | ), 148 | } 149 | 150 | if __name__ == "__main__": 151 | now = time.monotonic() 152 | parser = argparse.ArgumentParser() 153 | add = parser.add_argument 154 | add("--start-opacity", type=float, default=0, help="initial opacity") 155 | add("--end-opacity", type=float, default=1, help="final opacity") 156 | add("--step", type=float, default=0.1, help="step for changing opacity") 157 | add("--delay", type=float, default=10, help="delay from start to end") 158 | add("--font", default="Iosevka Aile", help="font for countdown") 159 | add("--locker", default="xsecurelock", help="quit if window class detected") 160 | add("--background", help="use a background instead of black") 161 | add("--no-randr", help="disable RandR", action="store_true") 162 | add( 163 | "--easing-function", 164 | default="none", 165 | choices=easing_functions.keys(), 166 | help="easing function for opacity", 167 | ) 168 | add("--sound", help="play a sound for each second elapsed while dimmer running") 169 | options = parser.parse_args() 170 | 171 | # This is a hack! 172 | try: 173 | with open( 174 | os.path.join(os.environ["XDG_RUNTIME_DIR"], "i3", "outputs.txt") 175 | ) as f: 176 | if int(f.read()) == 1: 177 | options.no_randr = True 178 | except: 179 | pass 180 | 181 | background = None 182 | if options.background: 183 | try: 184 | background = GdkPixbuf.Pixbuf.new_from_file(options.background) 185 | except Exception: 186 | pass 187 | 188 | # Setup dimmer windows on each monitor 189 | gdisplay = Gdk.Display.get_default() 190 | geoms = [] 191 | if options.no_randr: 192 | with warnings.catch_warnings(): 193 | warnings.filterwarnings("ignore", category=DeprecationWarning) 194 | screen = gdisplay.get_screen(0) 195 | geoms.append((0, 0, screen.get_width(), screen.get_height())) 196 | else: 197 | for i in range(gdisplay.get_n_monitors()): 198 | geom = gdisplay.get_monitor(i).get_geometry() 199 | geoms.append((geom.x, geom.y, geom.width, geom.height)) 200 | 201 | for x, y, width, height in geoms: 202 | window = Gtk.Window() 203 | window.set_app_paintable(True) 204 | window.set_type_hint(Gdk.WindowTypeHint.SPLASHSCREEN) 205 | window.set_visual(window.get_screen().get_rgba_visual()) 206 | 207 | window.set_default_size(width, height) 208 | window.move(x, y) 209 | 210 | window.connect("draw", on_draw, options, background, now) 211 | window.connect("delete-event", Gtk.main_quit) 212 | window.connect("realize", on_realize) 213 | 214 | window.show_all() 215 | 216 | # Schedule refresh with window.queue_draw() 217 | on_refresh(window, options, now) 218 | 219 | # Watch for locker window 220 | xdisplay = display.Display() 221 | root = xdisplay.screen().root 222 | root.change_attributes(event_mask=X.SubstructureNotifyMask) 223 | channel = GLib.IOChannel.unix_new(xdisplay.fileno()) 224 | channel.set_encoding(None) 225 | channel.set_buffered(False) 226 | GLib.io_add_watch( 227 | channel, 228 | GLib.PRIORITY_DEFAULT, 229 | GLib.IOCondition.IN, 230 | on_xevent, 231 | xdisplay, 232 | options.locker, 233 | ) 234 | xdisplay.pending_events() # otherwise, socket is inactive 235 | 236 | if options.sound: 237 | GLib.timeout_add(options.delay * 1000 // 3, on_sound_ticker, options, None, now) 238 | 239 | # Main loop 240 | Gtk.main() 241 | -------------------------------------------------------------------------------- /bin/xss-lock: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | me="$(readlink -f "$0")" 4 | timeout=300 5 | notify=$((timeout/10)) 6 | 7 | configure() { 8 | xset s $((timeout - notify)) $notify 9 | xset dpms $((timeout * 3)) $((timeout * 32 / 10)) $((timeout * 34 / 10)) 10 | } 11 | dimmer() { 12 | systemctl --user --no-block $1 xss-dimmer@$notify.service 13 | } 14 | 15 | case "$1" in 16 | start) 17 | configure 18 | exec xss-lock --session=${XDG_SESSION_ID} --notifier="$me notify" --transfer-sleep-lock "$me" lock 19 | ;; 20 | dim|notify) 21 | echo "notify: start (idle: $(xprintidle))" 22 | trap 'echo notify: user activity; dimmer stop; kill %% 2> /dev/null; exit 0' HUP # user activity 23 | trap 'echo notify: locker started; kill %% 2> /dev/null; exit 0' TERM # locker started 24 | dimmer start 25 | sleep infinity & 26 | wait 27 | echo "notify: end" 28 | ;; 29 | lock) 30 | echo "lock: lock screen (idle: $(xprintidle))" 31 | # Something may have meddled with screensaver settings 32 | configure 33 | # Pause music player and notifications 34 | playerctl -a pause 35 | dunstctl_state=$(dunstctl is-paused) 36 | dunstctl set-paused true 37 | # Then, lock screen 38 | env XSECURELOCK_SAVER=$HOME/.config/i3/bin/xsecurelock-saver \ 39 | `# Disable RandR 1.5 support to make screensaver spans accross the whole monitor` \ 40 | XSECURELOCK_NO_XRANDR15=1 \ 41 | `# Disable RandR when we know we have a single monitor` \ 42 | XSECURELOCK_NO_XRANDR=$($(grep -qFwx 1 $XDG_RUNTIME_DIR/i3/outputs.txt 2> /dev/null) && echo 1 || echo 0) \ 43 | `# Delay mapping saver window by 500ms to give some time to saver to start` \ 44 | XSECURELOCK_SAVER_DELAY_MS=500 \ 45 | `# Do not kill screensaver when DPMS is enabled` \ 46 | XSECURELOCK_SAVER_STOP_ON_BLANK=0 \ 47 | `# Do not mess with DPMS settings` \ 48 | XSECURELOCK_BLANK_TIMEOUT=-1 \ 49 | `# Image and text for saver` \ 50 | XSECURELOCK_SAVER_IMAGE=$XDG_RUNTIME_DIR/i3/current-wallpaper.png \ 51 | XSECURELOCK_SAVER_WEATHER=$XDG_RUNTIME_DIR/i3/weather.txt \ 52 | `# Font for authentication window` \ 53 | XSECURELOCK_FONT="Iosevka Term SS18" \ 54 | `# Timeout for authentication window` \ 55 | XSECURELOCK_AUTH_TIMEOUT=10 \ 56 | xsecurelock 57 | echo "lock: unlock screen" 58 | # After unlocking screen, stop dimmer, restore notifications 59 | dimmer stop 60 | dunstctl set-paused ${dunstctl_state} 61 | # Hack around an issue with brightness 62 | [[ "$(uname -n)" != "wally" ]] || brightnessctl -q set $(brightnessctl get) 63 | ;; 64 | esac 65 | -------------------------------------------------------------------------------- /config: -------------------------------------------------------------------------------- 1 | # i3 config file (v4) 2 | 3 | set $mod Mod4 4 | set $up l 5 | set $down k 6 | set $left j 7 | set $right semicolon 8 | set $term vbeterm 9 | set $mediaplayer spotify 10 | set $borderpx 3 11 | 12 | font pango:Iosevka Aile 9 13 | 14 | # style and colors 15 | default_border pixel $borderpx 16 | default_floating_border pixel $borderpx 17 | # class border backgr. text indicator child_border 18 | client.focused #cc5c00 #cc5c00 #ffffff #ee9c31 #cc5c00 19 | client.focused_inactive #5f676a #5f676a #ffffff #484e50 #5f676a 20 | client.unfocused #222222 #222222 #888888 #292d2e #222222 21 | client.urgent #d00000 #d00000 #ffffff #d00000 #d00000 22 | client.placeholder #0c0c0c #0c0c0c #ffffff #000000 #0c0c0c 23 | client.background #ffffff 24 | 25 | # gaps 26 | gaps inner 10 27 | # smart_gaps on 28 | 29 | # audio 30 | bindsym XF86AudioRaiseVolume exec --no-startup-id exec pactl set-sink-volume @DEFAULT_SINK@ +5% 31 | bindsym $mod+Up exec --no-startup-id exec pactl set-sink-volume @DEFAULT_SINK@ +5% 32 | bindsym XF86AudioLowerVolume exec --no-startup-id exec pactl set-sink-volume @DEFAULT_SINK@ -5% 33 | bindsym $mod+Down exec --no-startup-id exec pactl set-sink-volume @DEFAULT_SINK@ -5% 34 | bindsym XF86AudioMute exec --no-startup-id exec ~/.config/i3/bin/toggle-mute sink 35 | bindsym XF86AudioMicMute exec --no-startup-id exec ~/.config/i3/bin/toggle-mute source 36 | bindsym XF86AudioPlay exec --no-startup-id exec playerctl -p $mediaplayer play-pause 37 | bindsym $mod+Left exec --no-startup-id exec playerctl -p $mediaplayer play-pause 38 | bindsym XF86AudioPause exec --no-startup-id exec playerctl -p $mediaplayer pause 39 | bindsym XF86AudioStop exec --no-startup-id exec playerctl -p $mediaplayer stop 40 | bindsym XF86AudioNext exec --no-startup-id exec playerctl -p $mediaplayer next 41 | bindsym $mod+Right exec --no-startup-id exec playerctl -p $mediaplayer next 42 | bindsym XF86AudioPrev exec --no-startup-id exec playerctl -p $mediaplayer previous 43 | bindsym $mod+s exec --no-startup-id exec ~/.config/i3/bin/rofi-mediaplayer $mediaplayer 44 | 45 | # brightness 46 | bindsym XF86MonBrightnessUp exec --no-startup-id exec brightnessctl -q set +5% 47 | bindsym XF86MonBrightnessDown exec --no-startup-id exec brightnessctl -q set 5%- 48 | 49 | # use Mouse+$mod to drag floating windows to their wanted position 50 | floating_modifier $mod 51 | floating_minimum_size 55 x 30 52 | 53 | # start a terminal 54 | bindsym $mod+Return exec exec $term 55 | 56 | # kill focused window 57 | bindsym $mod+x kill 58 | 59 | # execute command 60 | set $rofi exec rofi -modi drun -show drun -show-icons -drun-match-fields name 61 | bindsym $mod+r exec --no-startup-id $rofi 62 | bindsym XF86LaunchA exec --no-startup-id $rofi 63 | 64 | # change focus 65 | bindsym $mod+$left focus left 66 | bindsym $mod+$down focus down 67 | bindsym $mod+$up focus up 68 | bindsym $mod+$right focus right 69 | focus_follows_mouse no 70 | 71 | # move focused window 72 | bindsym $mod+Shift+$left move left 73 | bindsym $mod+Shift+$down move down 74 | bindsym $mod+Shift+$up move up 75 | bindsym $mod+Shift+$right move right 76 | bindsym $mod+o move container to output next ; focus output next 77 | 78 | # resize focused window 79 | bindsym $mod+Ctrl+$left resize shrink width 10 px or 4 ppt 80 | bindsym $mod+Ctrl+$down resize grow height 10 px or 4 ppt 81 | bindsym $mod+Ctrl+$up resize shrink height 10 px or 4 ppt 82 | bindsym $mod+Ctrl+$right resize grow width 10 px or 4 ppt 83 | 84 | # enter fullscreen mode for the focused container 85 | bindsym $mod+f fullscreen toggle 86 | bindsym $mod+Shift+f fullscreen toggle global 87 | 88 | # change container layout (tabbed, toggle split) 89 | bindsym $mod+w layout toggle splitv splith tabbed 90 | bindsym $mod+v split toggle 91 | 92 | # toggle tiling / floating 93 | bindsym $mod+Shift+space floating toggle 94 | 95 | # toggle sticky 96 | bindsym $mod+period sticky toggle 97 | 98 | # change focus between tiling / floating windows 99 | bindsym $mod+space focus mode_toggle 100 | 101 | # focus the parent container 102 | bindsym $mod+a focus parent 103 | 104 | # default workspace layout: tabbed 105 | workspace_layout tabbed 106 | 107 | # switch to workspace 108 | bindsym $mod+1 workspace number 1 109 | bindsym $mod+2 workspace number 2 110 | bindsym $mod+3 workspace number 3 111 | bindsym $mod+4 workspace number 4 112 | bindsym $mod+5 workspace number 5 113 | bindsym $mod+6 workspace number 6 114 | bindsym $mod+7 workspace number 7 115 | bindsym $mod+8 workspace number 8 116 | bindsym $mod+9 workspace number 9 117 | bindsym $mod+0 workspace number 10 118 | bindsym $mod+Tab nop "previous-workspace" 119 | bindsym $mod+Ctrl+o focus output next 120 | bindsym $mod+n nop "new-workspace" 121 | focus_wrapping no 122 | 123 | # move focused container to workspace 124 | bindsym $mod+Shift+1 move container to workspace number 1 125 | bindsym $mod+Shift+2 move container to workspace number 2 126 | bindsym $mod+Shift+3 move container to workspace number 3 127 | bindsym $mod+Shift+4 move container to workspace number 4 128 | bindsym $mod+Shift+5 move container to workspace number 5 129 | bindsym $mod+Shift+6 move container to workspace number 6 130 | bindsym $mod+Shift+7 move container to workspace number 7 131 | bindsym $mod+Shift+8 move container to workspace number 8 132 | bindsym $mod+Shift+9 move container to workspace number 9 133 | bindsym $mod+Shift+0 move container to workspace number 10 134 | bindsym $mod+Shift+o move workspace to output next 135 | bindsym $mod+Ctrl+Shift+o nop "move-all-workspaces-to-next-output" 136 | bindsym $mod+Shift+n nop "move-to-new-workspace" 137 | 138 | # lock screen 139 | bindsym XF86ScreenSaver exec --no-startup-id exec loginctl lock-session 140 | bindsym $mod+Delete exec --no-startup-id exec loginctl lock-session 141 | # print screen 142 | bindsym Print exec --no-startup-id exec ~/.config/i3/bin/screenshot window 143 | bindsym $mod+Print exec --no-startup-id exec ~/.config/i3/bin/screenshot desktop 144 | bindsym $mod+F12 exec --no-startup-id exec ~/.config/i3/bin/screenshot window 145 | 146 | # Quake window 147 | bindsym $mod+grave nop "quake-console:$term:QuakeConsole:0.3" 148 | 149 | # get info about container/workspace 150 | bindsym $mod+i nop "container-info" 151 | bindsym $mod+Shift+i nop "workspace-info" 152 | 153 | # random rules 154 | no_focus [window_type="splash"] 155 | for_window [tiling] border pixel $borderpx 156 | for_window [class="Nm-connection-editor"] floating enable 157 | for_window [class="Shadow"] fullscreen enable 158 | for_window [window_role="PictureInPicture"] floating enable, resize set 1280 720 159 | for_window [class="pavucontrol"] floating enable 160 | # Firefox sharing indicator 161 | for_window [floating title="Firefox — Sharing Indicator"] border none, sticky enable, move position 15 ppt -5 px 162 | no_focus [floating title="Firefox — Sharing Indicator"] 163 | # Chromium sharing indicator 164 | for_window [floating title=" is sharing your screen.$"] border none, sticky enable, move position 15 ppt -5 px 165 | no_focus [floating title=" is sharing your screen.$"] 166 | # Jitsi sharing indicator 167 | for_window [floating title="Screen Sharing Tracker" instance="jitsi meet"] border none, sticky enable, move position 15 ppt -5 px 168 | no_focus [floating title="Screen Sharing Tracker" instance="jitsi meet"] 169 | # Zoom (😱?) 170 | for_window [class="^zoom$" title="^Zoom($|\s)"] floating disable, border pixel $borderpx 171 | for_window [class="^zoom$" title="^zoom"] floating enable, border none 172 | no_focus [class="^zoom$" title="^zoom"] 173 | 174 | # start stuff 175 | exec_always --no-startup-id exec systemctl --user start --no-block i3-session.target 176 | exec --no-startup-id exec i3-msg "\ 177 | workspace number 1; append_layout ~/.config/i3/ws-emacs.json; exec exec emacs; exec exec $term; exec exec $term; \ 178 | workspace number 2; exec nm-online -q -t 5 && exec firefox; \ 179 | workspace number 3; exec test -d ~/.thunderbird && exec thunderbird; \ 180 | workspace number 1" 181 | -------------------------------------------------------------------------------- /dotfiles/XCompose: -------------------------------------------------------------------------------- 1 | # See /usr/share/X11/locale/en_US.UTF-8/Compose for default ones. 2 | include "%L" 3 | -------------------------------------------------------------------------------- /dotfiles/Xresources: -------------------------------------------------------------------------------- 1 | Emacs*toolBar: 0 2 | Emacs*menuBar: 0 3 | Emacs*verticalScrollBars: off 4 | Xcursor.theme: Adwaita 5 | -------------------------------------------------------------------------------- /dotfiles/dconf.ini: -------------------------------------------------------------------------------- 1 | [com/github/wwmm/easyeffects] 2 | process-all-outputs=true 3 | process-all-inputs=true 4 | run-in-background=true 5 | 6 | [org/gnome/desktop/interface] 7 | color-scheme="prefer-dark" 8 | 9 | [org/virt-manager/virt-manager] 10 | xmleditor-enabled=false 11 | 12 | [org/virt-manager/virt-manager/connections] 13 | autoconnect=['qemu:///session'] 14 | 15 | [org/virt-manager/virt-manager/console] 16 | resize-guest=1 17 | scaling=2 18 | 19 | [org/virt-manager/virt-manager/new-vm] 20 | cpu-default='host-passthrough' 21 | firmware='uefi' 22 | graphics-type='spice' 23 | storage-format='qcow2' 24 | 25 | [org/virt-manager/virt-manager/stats] 26 | enable-disk-poll=true 27 | enable-net-poll=true 28 | 29 | [org/virt-manager/virt-manager/vmlist-fields] 30 | disk-usage=true 31 | network-traffic=true 32 | -------------------------------------------------------------------------------- /dotfiles/dunstrc: -------------------------------------------------------------------------------- 1 | [global] 2 | # Display on first monitor 3 | monitor = 0 4 | follow = none 5 | 6 | # Appearance 7 | width = 400 8 | offset = (5, 17) 9 | origin = top-right 10 | indicate_hidden = yes 11 | shrink = no 12 | notification_limit = 6 13 | separator_height = 2 14 | separator_color = frame 15 | padding = 8 16 | horizontal_padding = 8 17 | text_icon_padding = 8 18 | frame_width = 2 19 | frame_color = "#4c7899" 20 | transparency = 5 21 | font = Cantarell 10 22 | line_height = 0 23 | corner_radius = 5 24 | icon_corner_radius = 5 25 | 26 | # Put urgent notifications on top 27 | sort = yes 28 | 29 | # Don't remove messages, if the user is idle (no mouse or keyboard input) 30 | idle_threshold = 60 31 | # Don't show age of old messages 32 | show_age_threshold = -1 33 | 34 | # The format of the message. Possible variables are: 35 | # %a appname 36 | # %s summary 37 | # %b body 38 | # %i iconname (including its path) 39 | # %I iconname (without its path) 40 | # %p progress value if set ([ 0%] to [100%]) or nothing 41 | # %n progress value if set without any extra characters 42 | # %% Literal % 43 | # Markup is allowed 44 | format = "%s\n%b" 45 | markup = full 46 | alignment = left 47 | vertical_alignment = top 48 | word_wrap = no 49 | ellipsize = end 50 | ignore_newline = no 51 | stack_duplicates = true 52 | hide_duplicate_count = true 53 | show_indicators = no 54 | 55 | # Progress bar 56 | progress_bar = true 57 | 58 | # Icons 59 | icon_position = left 60 | min_icon_size = 32 61 | max_icon_size = 32 62 | # echo /usr/share/icons/{Adwaita,gnome}/{512x512,256x256,48x48}/{devices,status}(N) | tr ' ' ':' 63 | icon_path = /usr/share/icons/Adwaita/512x512/devices:/usr/share/icons/Adwaita/512x512/status:/usr/share/icons/Adwaita/256x256/status:/usr/share/icons/Adwaita/48x48/devices:/usr/share/icons/Adwaita/48x48/status:/usr/share/icons/gnome/256x256/devices:/usr/share/icons/gnome/256x256/status:/usr/share/icons/gnome/48x48/devices:/usr/share/icons/gnome/48x48/status:/home/bernat/.nix-profile/share/icons/hicolor/64x64/apps 64 | 65 | # History 66 | sticky_history = yes 67 | history_length = 20 68 | 69 | # Misc 70 | dmenu = rofi -dmenu -p dunst 71 | browser = /usr/bin/xdg-open 72 | always_run_script = true 73 | title = Dunst 74 | class = Dunst 75 | ignore_dbusclose = false 76 | 77 | # Mouse 78 | mouse_left_click = do_action 79 | mouse_middle_click = close_current 80 | mouse_right_click = close_current 81 | 82 | [urgency_low] 83 | background = "#222222" 84 | foreground = "#888888" 85 | timeout = 10 86 | 87 | [urgency_normal] 88 | background = "#222222" 89 | foreground = "#ffffff" 90 | timeout = 10 91 | 92 | [urgency_critical] 93 | background = "#900000" 94 | foreground = "#ffffff" 95 | timeout = 0 96 | -------------------------------------------------------------------------------- /dotfiles/firefox.css: -------------------------------------------------------------------------------- 1 | /* To be symlinked in Firefox profile as chrome/userContent.css */ 2 | -------------------------------------------------------------------------------- /dotfiles/firefox.js: -------------------------------------------------------------------------------- 1 | // Preferences for Firefox. To be symlinked in the profile as user.js. 2 | // Settings synced through Firefox Accounts may not be present. 3 | 4 | // Theme 5 | user_pref("extensions.activeThemeID", "firefox-compact-dark@mozilla.org"); 6 | user_pref("layout.css.prefers-color-scheme.content-override", 1); // light 7 | 8 | // Fonts 9 | user_pref("font.name.monospace.x-western", "Source Code Pro"); 10 | user_pref("font.name.monospace.x-unicode", "Source Code Pro"); 11 | user_pref("font.name.sans-serif.x-western", "Source Sans Pro"); 12 | user_pref("font.name.sans-serif.x-unicode", "Source Sans Pro"); 13 | user_pref("font.name.serif.x-western", "Source Serif Pro"); 14 | user_pref("font.name.serif.x-unicode", "Source Serif Pro"); 15 | 16 | // Ensure context menus stay open after left-click (useful when scale 17 | // == 1.5) 18 | user_pref("ui.context_menus.after_mouseup", true); 19 | 20 | // Don't display menubar when pressing Alt 21 | user_pref("ui.key.menuAccessKeyFocuses", false); 22 | // Keep GTK keybindings 23 | user_pref("ui.key.use_select_all_in_single_line_editor", false); 24 | 25 | // Don't beep when using type ahead find 26 | user_pref("accessibility.typeaheadfind.enablesound", false); 27 | 28 | // Be more compact 29 | user_pref("browser.uidensity", 1); 30 | 31 | // No popup at all! 32 | user_pref("browser.link.open_newwindow.restriction", 0); 33 | 34 | // Search settings 35 | user_pref("browser.search.region", "FR"); 36 | user_pref("browser.search.suggest.enabled", false); 37 | 38 | // Homepage is newtab. On launch, restore session. 39 | user_pref("browser.startup.homepage", "about:newtab"); 40 | user_pref("browser.startup.page", 3); 41 | 42 | // Sort tabs by recently used 43 | user_pref("browser.ctrlTab.sortByRecentlyUsed", true); 44 | 45 | // Enable tab groups 46 | user_pref("browser.tabs.groups.enabled", true); 47 | 48 | // Languages 49 | user_pref("intl.accept_languages", "en"); 50 | 51 | // Disable pocket 52 | user_pref("browser.newtabpage.activity-stream.feeds.section.topstories", false); 53 | user_pref("extensions.pocket.enabled", false); 54 | 55 | // Don't recommend extensions 56 | user_pref("browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons", false); 57 | user_pref("browser.discovery.enabled", false); 58 | 59 | // Backspace is like back 60 | user_pref("browser.backspace_action", 0); 61 | 62 | // Don't allow detaching a tab by pulling it 63 | user_pref("browser.tabs.allowTabDetach", false); 64 | 65 | // Don't display a close button for tabs 66 | user_pref("browser.tabs.tabClipWidth", 1000); 67 | 68 | // Don't display fullscreen warning 69 | user_pref("full-screen-api.warning.timeout", 0); 70 | user_pref("full-screen-api.transition.timeout", 0); 71 | 72 | // Don't autoplay videos (except when no audio) 73 | user_pref("media.autoplay.default", 1); 74 | 75 | // And VAAPI decoding with ffmpeg 76 | user_pref("media.ffmpeg.vaapi.enabled", true); 77 | 78 | // Disable DoH for now 79 | user_pref("network.trr.mode", 5); 80 | 81 | // Disable auto-update 82 | user_pref("app.update.auto", false); 83 | user_pref("app.update.interval", 259200); 84 | 85 | // Disable autofill 86 | user_pref("extensions.formautofill.addresses.enabled", false); 87 | user_pref("extensions.formautofill.creditCards.enabled", false); 88 | 89 | // Disable annoying prompts 90 | user_pref("browser.aboutConfig.showWarning", false); 91 | user_pref("browser.disableResetPrompt", true); 92 | user_pref("browser.tabs.firefox-view", false); 93 | 94 | //Don't close on last tab 95 | user_pref("browser.tabs.closeWindowWithLastTab", false); 96 | 97 | // Don't allow sites to override shortcuts 98 | // Also check https://www.math.cmu.edu/~gautam/sj/blog/20220329-firefox-disable-ctrl-w.html 99 | // Also check commit 289649e15479 100 | // user_pref("permissions.default.shortcuts", 2); 101 | 102 | // Disable safebrowsing malware (sends hash of each file to Google) 103 | user_pref("browser.safebrowsing.malware.enabled", false); 104 | 105 | // Don't trim URLs 106 | user_pref("browser.urlbar.trimURLs", false); 107 | 108 | // Don't show recent searchs 109 | user_pref("browser.urlbar.suggest.recentsearches", false); 110 | 111 | // Don't offer translating 112 | user_pref("browser.translations.automaticallyPopup", false); 113 | user_pref("browser.translations.panelShown", true); 114 | 115 | // Enable userContent.css (disabled) 116 | user_pref("toolkit.legacyUserProfileCustomizations.stylesheets", false); 117 | 118 | // Disable some telemetry stuff releated to search engines 119 | user_pref("browser.search.serpEventTelemetry.enabled", false); 120 | user_pref("browser.search.serpEventTelemetryCategorization.enabled", false); 121 | 122 | // Disable anonymized tracker 123 | user_pref("dom.private-attribution.submission.enabled", false); 124 | -------------------------------------------------------------------------------- /dotfiles/fonts.conf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | system-ui 6 | 7 | Cantarell 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /dotfiles/gtk3.css: -------------------------------------------------------------------------------- 1 | /* Useless: we cannot override properly by unbinding some keys */ 2 | /* @import url("/usr/share/themes/Emacs/gtk-3.0/gtk-keys.css"); */ 3 | 4 | @binding-set custom-text-entry 5 | { 6 | bind "b" { "move-cursor" (words, -1, 0) }; 7 | bind "b" { "move-cursor" (words, -1, 1) }; 8 | bind "f" { "move-cursor" (words, 1, 0) }; 9 | bind "f" { "move-cursor" (words, 1, 1) }; 10 | 11 | bind "a" { "move-cursor" (paragraph-ends, -1, 0) }; 12 | bind "a" { "move-cursor" (paragraph-ends, -1, 1) }; 13 | bind "e" { "move-cursor" (paragraph-ends, 1, 0) }; 14 | bind "e" { "move-cursor" (paragraph-ends, 1, 1) }; 15 | 16 | bind "y" { "paste-clipboard" () }; 17 | 18 | bind "k" { "delete-from-cursor" (paragraph-ends, 1) }; 19 | bind "u" { "move-cursor" (paragraph-ends, -1, 0) 20 | "delete-from-cursor" (paragraph-ends, 1) }; 21 | bind "backslash" { "delete-from-cursor" (whitespace, 1) }; 22 | bind "BackSpace" { "delete-from-cursor" (word-ends, -1) }; 23 | } 24 | 25 | entry, textview 26 | { 27 | -gtk-key-bindings: custom-text-entry; 28 | } 29 | 30 | tooltip, menu, popover > contents { 31 | border-radius: 0; 32 | } 33 | -------------------------------------------------------------------------------- /dotfiles/gtk4.css: -------------------------------------------------------------------------------- 1 | tooltip, menu, window, frame, popover > contents { 2 | border-radius: 0; 3 | } 4 | -------------------------------------------------------------------------------- /dotfiles/gtkrc-2.0: -------------------------------------------------------------------------------- 1 | gtk-theme-name="Adwaita-dark" 2 | gtk-icon-theme-name="Adwaita" 3 | gtk-cursor-theme-name="Adwaita" 4 | gtk-cursor-theme-size=0 5 | gtk-font-name="Cantarell 10" 6 | gtk-button-images=1 7 | gtk-menu-images=1 8 | gtk-fallback-icon-theme="gnome" 9 | gtk-toolbar-style=GTK_TOOLBAR_BOTH 10 | gtk-toolbar-icon-size=GTK_ICON_SIZE_LARGE_TOOLBAR 11 | gtk-decoration-layout=":menu" 12 | gtk-xft-antialias=1 13 | gtk-xft-hinting=1 14 | gtk-xft-hintstyle="hintslight" 15 | gtk-xft-rgba="rgb" 16 | 17 | gtk-key-theme-name="Emacs" 18 | binding "vbe-text-entry-bindings" { 19 | unbind "b" 20 | unbind "b" 21 | unbind "f" 22 | unbind "f" 23 | unbind "w" 24 | bind "BackSpace" { "delete-from-cursor" (word-ends, -1) } 25 | } 26 | class "GtkEntry" binding "vbe-text-entry-bindings" 27 | class "GtkTextView" binding "vbe-text-entry-bindings" 28 | -------------------------------------------------------------------------------- /dotfiles/picom.conf: -------------------------------------------------------------------------------- 1 | backend = "egl"; 2 | vsync = true; 3 | no-frame-pacing = true; # see https://github.com/yshui/picom/issues/1345 4 | # These two are recommended if they work. 5 | glx-no-stencil = true; 6 | glx-no-rebind-pixmap = true; 7 | 8 | mark-ovredir-focused = true; 9 | use-ewmh-active-win = true; 10 | 11 | # Opacity rules (first rule match) 12 | # Cannot use override-redirect: 13 | # https://github.com/yshui/picom/issues/625 14 | opacity-rule = [ 15 | "0:_NET_WM_STATE@[*]:32a = '_NET_WM_STATE_HIDDEN'", 16 | # GTK frame extents are used for client-side shadows, we shouldn't have that as i3 does not support them. 17 | "100:_GTK_FRAME_EXTENTS@:c", 18 | "100:fullscreen", 19 | "100:name = 'Zoom Meeting'", 20 | "100:name = 'zoom_linux_float_video_window'", 21 | "100:class_i = 'jitsi meet'", 22 | "100:class_g = 'Rofi'", 23 | "100:class_g = 'mpv'", 24 | "100:class_g = 'Xss-dimmer'", 25 | "100:class_g = 'scummvm'", 26 | "100:window_type = 'dock'", 27 | "100:window_type = 'utility'", 28 | "95:window_type = 'combo'", 29 | "95:window_type *= 'menu'", 30 | "85:!focused" 31 | ]; 32 | 33 | # Shadow and fading 34 | fade-in-step = 0.08; 35 | fade-out-step = 0.08; 36 | shadow-opacity = 0.8; 37 | shadow-radius = @SHADOW_RADIUS@; 38 | shadow-offset-x = -@SHADOW_OFFSET@; 39 | shadow-offset-y = -@SHADOW_OFFSET@; 40 | wintypes: 41 | { 42 | normal = { shadow = true; }; 43 | dock = { shadow = true; clip-shadow-above = true; }; 44 | combo = { fade = true; }; 45 | dropdown_menu = { fade = true; }; 46 | menu = { fade = true; }; 47 | popup_menu = { fade = true; }; 48 | tooltip = { fade = true; }; 49 | notification = { fade = true; shadow = true; }; 50 | splash = { fade = true; shadow = true; }; 51 | dialog = { fade = true; shadow = true; }; 52 | }; 53 | shadow-exclude = [ 54 | "_NET_WM_STATE@[*]:32a = '_NET_WM_STATE_HIDDEN'", 55 | # GTK frame extents are used for client-side shadows, we shouldn't have that. 56 | "_GTK_FRAME_EXTENTS@:c", 57 | "class_i = 'xss-dimmer'", 58 | "class_i = 'i3-frame'", 59 | "class_g = 'Rofi'", 60 | # Zoom 61 | "name = 'cpt_frame_window'", 62 | "name = 'cpt_frame_xcb_window'", 63 | "name = 'as_toolbar'", 64 | # Other sharing indicators 65 | "name ~= 'is sharing your screen.$'", # Chrome 66 | "name = 'Firefox — Sharing Indicator'", # Firefox 67 | "name = 'Screen Sharing Tracker'" # Jitsi 68 | ]; 69 | # crop-shadow-to-monitor = true; 70 | fade-exclude = [ ]; 71 | -------------------------------------------------------------------------------- /dotfiles/polybar.conf: -------------------------------------------------------------------------------- 1 | [colors] 2 | # Format: [aa]rrggbb 3 | background = #a0000000 4 | foreground = #eaeaea 5 | highlight = #4c7899 6 | warning = #ff3121 7 | focused = #cc5c00 8 | transparent = #00000000 9 | 10 | [settings] 11 | screenchange-reload = false 12 | 13 | [bar/common] 14 | enable-ipc = true 15 | width = 100% 16 | height = ${env:HEIGHT:} 17 | monitor = ${env:MONITOR:} 18 | monitor-strict = true 19 | dpi = ${env:DPI:} 20 | border-size = 0 21 | tray-position = none 22 | fixed-center = true 23 | module-margin = 1 24 | padding-right = 1 25 | 26 | background = ${colors.background} 27 | foreground = ${colors.foreground} 28 | 29 | font-0 = Iosevka Term SS18:style=Regular:size=10;4 30 | font-1 = Font Awesome 6 Pro:style=Solid:size=10;4 31 | font-2 = Font Awesome 6 Brands:style=Regular:size=10;4 32 | font-3 = Weather Icons:style=Regular:size=10;4 33 | 34 | modules-left = i3 35 | modules-center = date weather 36 | 37 | [bar/alone] 38 | inherit = bar/common 39 | modules-right = battery cpu memory brightness bluetooth network disk dunst micro speaker 40 | 41 | [bar/primary] 42 | inherit = bar/common 43 | modules-right = battery cpu memory brightness bluetooth network disk dunst micro speaker 44 | 45 | [bar/secondary] 46 | inherit = bar/common 47 | modules-center = date 48 | modules-right = micro speaker 49 | 50 | [module/i3] 51 | type = internal/i3 52 | format = 53 | index-sort = true 54 | wrapping-scroll = false 55 | pin-workspaces = true 56 | 57 | label-mode-background = ${colors.highlight} 58 | label-mode-padding = 1 59 | label-focused = %name% 60 | label-focused-background = ${colors.focused} 61 | label-focused-padding = 1 62 | label-unfocused = %name% 63 | label-unfocused-padding = 1 64 | label-visible = %name% 65 | label-visible-background = ${colors.highlight} 66 | label-visible-padding = 1 67 | label-urgent = %name% 68 | label-urgent-background = #a00000 69 | label-urgent-padding = 1 70 | 71 | # This needs 3.6+ 72 | [module/disk] 73 | type = internal/fs 74 | warn-percentage = 94 75 | fixed-values = true 76 | interval = 10 77 | format-unmounted = 78 | format-mounted = 79 | format-warn = 80 | format-warn-foreground = ${colors.warning} 81 | label-warn = 🖴 %mountpoint%: %percentage_used%% 82 | include-file = $XDG_RUNTIME_DIR/i3/polybar-filesystems.conf 83 | 84 | [module/brightness] 85 | type = internal/backlight 86 | card = ${env:BACKLIGHT:intel_backlight} 87 | format =