mirror of
https://github.com/vincentbernat/i3wm-configuration.git
synced 2025-07-02 22:34:20 +02:00
285 lines
8.3 KiB
Python
Executable file
285 lines
8.3 KiB
Python
Executable file
#!/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"<tt>{k:10}</tt> {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)
|