#!/usr/bin/python3 """Personal i3 companion This script will listen to i3 events and react accordingly. """ import argparse import logging import logging.handlers import os import sys import re import asyncio import shlex import subprocess import html from i3ipc.aio import Connection from i3ipc import Event logger = logging.getLogger(os.path.splitext(os.path.basename(sys.argv[0]))[0]) class CustomFormatter(argparse.RawDescriptionHelpFormatter, argparse.ArgumentDefaultsHelpFormatter): pass def parse_args(args=sys.argv[1:]): """Parse arguments.""" parser = argparse.ArgumentParser( description=sys.modules[__name__].__doc__, formatter_class=CustomFormatter) g = parser.add_mutually_exclusive_group() g.add_argument("--debug", "-d", action="store_true", default=False, help="enable debugging") g.add_argument("--silent", "-s", action="store_true", default=False, help="don't log") return parser.parse_args(args) def setup_logging(options): """Configure logging.""" root = logging.getLogger("") root.setLevel(logging.WARNING) logger.setLevel(options.debug and logging.DEBUG or logging.INFO) if not options.silent: ch = logging.StreamHandler() ch.setFormatter(logging.Formatter( "%(levelname)s[%(name)s] %(message)s")) root.addHandler(ch) # See https://fontawesome.com/v5.15/icons application_icons = { "chromium": "", "discord": "", "emacs": "", "firefox": "", "gimp": "", "google-chrome": "", "inkscape": "", "signal": "", "snes9x-gtk": "", "spotify": "", "steam": "", "vbeterm": "", "zathura": "", "zoom": "", "NOMATCH": "" } application_icons_alone = { application_icons[k] for k in { "vbeterm" } } def application_icon(window): """Get application icon for a window.""" for attr in ('name', 'window_instance', 'window_class'): name = getattr(window, attr, None) if name is None: continue for k, v in application_icons.items(): if re.match(k, name, re.IGNORECASE): logger.debug(f"in {attr}, found '{name}', matching {k}") return v return application_icons["NOMATCH"] async def workspace_rename(i3, event): """Rename workspaces using icons to match what's inside it.""" tree = await i3.get_tree() workspaces = tree.workspaces() commands = [] for workspace in workspaces: icons = set() for window in workspace.leaves(): icon = application_icon(window) if icon is not None: icons.add(icon) if any([i not in application_icons_alone for i in icons]): icons -= application_icons_alone new_name = f"{workspace.num}:{'|'.join(icons)}".rstrip(":") if workspace.name != new_name: logger.debug(f"rename workspace {workspace.num}") command = f'rename workspace "{workspace.name}" to "{new_name}"' commands.append(command) await i3.command(';'.join(commands)) async def new_workspace(i3, event): """Create a new workspace and optionally move a window to it.""" if event.payload not in {"new-workspace", "move-to-new-workspace"}: return # Get the currently focused window if event.payload == "move-to-new-workspace": tree = await i3.get_tree() current = tree.find_focused() if not current: return # Create a new workspace workspaces = await i3.get_workspaces() workspace_nums = {w.num for w in workspaces} max_num = max(workspace_nums) available = (set(range(1, max_num + 2)) - workspace_nums).pop() logger.info(f'create new workspace number {available}') await i3.command(f'workspace number "{available}"') # Move the window to this workspace if event.payload == "move-to-new-workspace": await current.command(f'move container to workspace number "{available}"') async def quake_console(i3, event): """Spawn a quake console or toggle an existing one.""" if type(event.payload) is not str or \ not event.payload.startswith("quake-console:"): return try: _, term_exec, term_name, height = event.payload.split(":") height = float(height) except Exception as exc: logger.warn(f"unable to parse payload {event.payload}: {exc}") return tree = await i3.get_tree() term = tree.find_instanced(term_name) if not term: await i3.command(f'exec {term_exec} --name {term_name}') tries = 5 while not term and tries: tree = await i3.get_tree() term = tree.find_instanced(term_name) await asyncio.sleep(0.2) tries -= 1 if not term: raise RuntimeError("unable to spawn terminal") term = term[0] workspaces = await i3.get_workspaces() workspace = [ws for ws in workspaces if ws.focused][0] ws_x = workspace.rect.x ws_y = workspace.rect.y ws_width = workspace.rect.width ws_height = workspace.rect.height width = ws_width height = int(ws_height * height) posx = ws_x posy = ws_y command = (f'[instance={term_name}] ' 'border none,' f'resize set {width} px {height} px,' 'scratchpad show,' f'move absolute position {posx}px {posy}px') logger.debug(f"QuakeConsole: {command}") await i3.command(command) async def window_info(i3, event): """Show information about the focused window.""" if event.payload != "info": return tree = await i3.get_tree() window = tree.find_focused() if not window: return logger.info(f"window raw information: {window.ipc_data}") summary = f"Information about window {window.window}" r = window.rect w = window info = { "name": w.name, "title": w.window_title, "class": w.window_class, "instance": w.window_instance, "role": w.window_role, "sticky": w.sticky, "floating": w.floating, "geometry": f"{r.width}×{r.height}+{r.x}+{r.y}", "layout": w.layout, "marks": ", ".join(w.marks) or "(none)" } body = "\n".join((f"{k:10} {html.escape(str(v))}" for k, v in info.items() if v is not None)) proc = await asyncio.create_subprocess_exec( "notify-send", "-i", "system-search", summary, body) await proc.communicate() output_update_running = None async def output_update(i3, event): """React to a XRandR change.""" global output_update_running if output_update_running is not None: output_update_running.cancel() logger.debug("schedule XRandR change") output_update_running = asyncio.get_event_loop().call_later( 1, output_update_now) def output_update_now(): """Execute actions to react to XRandR change.""" global output_update_running output_update_running = None logger.info("XRandR change detected") cmds = ( "systemctl --user reload --no-block xsettingsd.service", "systemctl --user start --no-block wallpaper.service", ) for cmd in cmds: proc = subprocess.run(shlex.split(cmd)) if proc.returncode != 0: logger.warning(f"{cmd} exited with {proc.returncode}") async def main(options): i3 = await Connection().connect() # Rename workspace depending on what is inside for event in {Event.WINDOW_MOVE, Event.WINDOW_NEW, Event.WINDOW_CLOSE}: i3.on(event, workspace_rename) # Create a new workspace or move to a new workspace i3.on(Event.TICK, new_workspace) # Create/display a quake console i3.on(Event.TICK, quake_console) # Get information about focused window i3.on(Event.TICK, window_info) # React to XRandR changes i3.on(Event.OUTPUT, output_update) await i3.main() if __name__ == "__main__": options = parse_args() setup_logging(options) try: asyncio.get_event_loop().run_until_complete(main(options)) except Exception as e: logger.exception("%s", e) sys.exit(1) sys.exit(0)