2021-07-04 19:08:48 +02:00
|
|
|
|
#!/usr/bin/python3
|
|
|
|
|
|
2021-07-07 14:14:35 +02:00
|
|
|
|
"""Personal i3 companion."""
|
2021-07-04 19:08:48 +02:00
|
|
|
|
|
|
|
|
|
import argparse
|
2021-07-12 07:32:39 +02:00
|
|
|
|
import asyncio
|
|
|
|
|
import collections
|
|
|
|
|
import errno
|
|
|
|
|
import functools
|
|
|
|
|
import glob
|
|
|
|
|
import html
|
2021-07-04 19:08:48 +02:00
|
|
|
|
import logging
|
|
|
|
|
import logging.handlers
|
2021-07-12 07:32:39 +02:00
|
|
|
|
import os
|
2021-07-04 19:08:48 +02:00
|
|
|
|
import re
|
2021-07-07 06:34:19 +02:00
|
|
|
|
import shlex
|
|
|
|
|
import subprocess
|
2021-07-12 07:32:39 +02:00
|
|
|
|
import sys
|
2021-07-04 19:08:48 +02:00
|
|
|
|
|
2021-07-12 09:05:17 +02:00
|
|
|
|
import i3ipc
|
2021-07-07 00:24:23 +02:00
|
|
|
|
from i3ipc.aio import Connection
|
2021-07-10 13:17:07 +02:00
|
|
|
|
from systemd import journal
|
2021-07-11 12:18:19 +02:00
|
|
|
|
import ravel
|
2021-07-11 22:05:36 +02:00
|
|
|
|
import dbussy
|
2021-07-11 12:18:19 +02:00
|
|
|
|
|
2021-07-12 12:02:02 +02:00
|
|
|
|
|
|
|
|
|
class Icon(str):
|
|
|
|
|
def __new__(cls, font, char):
|
|
|
|
|
return str.__new__(cls, "%%{T%s}%s%%{T-}" % (font, char))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# See https://fontawesome.com/v6.0/icons, number is the font number in
|
|
|
|
|
# polybar configuration.
|
2021-07-12 10:10:38 +02:00
|
|
|
|
application_icons = {
|
2021-07-12 12:02:02 +02:00
|
|
|
|
"chromium": Icon(2, ""),
|
|
|
|
|
"discord": Icon(2, ""),
|
|
|
|
|
"emacs": Icon(1, ""),
|
|
|
|
|
"firefox": Icon(2, ""),
|
|
|
|
|
"gimp": Icon(1, ""),
|
|
|
|
|
"gitg": Icon(1, ""),
|
|
|
|
|
"google-chrome": Icon(2, ""),
|
|
|
|
|
"inkscape": Icon(1, ""),
|
|
|
|
|
"libreoffice": Icon(1, ""),
|
|
|
|
|
"mpv": Icon(1, ""),
|
|
|
|
|
"pavucontrol": Icon(1, ""),
|
|
|
|
|
"signal": Icon(1, ""),
|
|
|
|
|
"snes9x-gtk": Icon(1, ""),
|
|
|
|
|
"spotify": Icon(2, ""),
|
|
|
|
|
"steam": Icon(2, ""),
|
|
|
|
|
"vbeterm": Icon(1, ""),
|
|
|
|
|
"zathura": Icon(1, ""),
|
|
|
|
|
"zoom": Icon(1, ""),
|
2021-07-12 10:10:38 +02:00
|
|
|
|
}
|
2021-07-12 12:02:02 +02:00
|
|
|
|
icons = {
|
|
|
|
|
"nowifi": Icon(1, ""),
|
|
|
|
|
"vpn": Icon(1, ""),
|
|
|
|
|
"wifi-low": Icon(1, ""),
|
|
|
|
|
"wifi-medium": Icon(1, ""),
|
|
|
|
|
"wifi-high": Icon(1, ""),
|
|
|
|
|
"wired": Icon(1, ""),
|
|
|
|
|
}
|
|
|
|
|
application_icons_nomatch = Icon(1, "")
|
2021-07-12 10:10:38 +02:00
|
|
|
|
application_icons_alone = {application_icons[k] for k in {"vbeterm"}}
|
|
|
|
|
exclusive_apps = {"emacs", "firefox"}
|
|
|
|
|
intrusive_apps = {"vbeterm"}
|
2021-07-04 23:50:48 +02:00
|
|
|
|
|
2021-07-10 13:17:07 +02:00
|
|
|
|
logger = logging.getLogger("i3-companion")
|
2021-07-12 09:05:17 +02:00
|
|
|
|
|
|
|
|
|
# Events for @on decorator
|
2021-07-11 22:05:36 +02:00
|
|
|
|
DBusSignal = collections.namedtuple(
|
|
|
|
|
"DBusSignal", ["path", "interface", "member", "signature"]
|
|
|
|
|
)
|
2021-07-12 07:32:39 +02:00
|
|
|
|
StartEvent = object()
|
2021-07-12 09:05:17 +02:00
|
|
|
|
I3Event = i3ipc.Event
|
2021-07-12 09:10:27 +02:00
|
|
|
|
CommandEvent = collections.namedtuple("CommandEvent", ["name"])
|
2021-07-12 07:32:39 +02:00
|
|
|
|
|
|
|
|
|
NM_ACTIVE_CONNECTION_STATE_ACTIVATED = 2
|
|
|
|
|
NM_DEVICE_TYPE_ETHERNET = 1
|
|
|
|
|
NM_DEVICE_TYPE_WIFI = 2
|
|
|
|
|
NM_DEVICE_TYPE_MODEM = 8
|
|
|
|
|
NM_DEVICE_STATE_UNMANAGED = 10
|
|
|
|
|
NM_DEVICE_STATE_ACTIVATED = 100
|
2021-07-04 19:08:48 +02:00
|
|
|
|
|
|
|
|
|
|
2021-07-07 13:44:55 +02:00
|
|
|
|
def on(*events):
|
|
|
|
|
"""Tag events that should be provided to the function."""
|
2021-07-11 12:23:00 +02:00
|
|
|
|
|
2021-07-07 13:44:55 +02:00
|
|
|
|
def decorator(fn):
|
|
|
|
|
@functools.wraps(fn)
|
|
|
|
|
def wrapper(*args, **kwargs):
|
|
|
|
|
return fn(*args, **kwargs)
|
2021-07-11 12:23:00 +02:00
|
|
|
|
|
2021-07-07 13:44:55 +02:00
|
|
|
|
on.functions = getattr(on, "functions", {})
|
|
|
|
|
on.functions[fn] = events
|
|
|
|
|
return wrapper
|
2021-07-11 12:23:00 +02:00
|
|
|
|
|
2021-07-07 13:44:55 +02:00
|
|
|
|
return decorator
|
|
|
|
|
|
|
|
|
|
|
2021-07-11 12:20:42 +02:00
|
|
|
|
async def notify(i3, **kwargs):
|
|
|
|
|
"""Send a notification with notify-send."""
|
|
|
|
|
peer = i3.session_bus["org.freedesktop.Notifications"]
|
|
|
|
|
peer = peer["/org/freedesktop/Notifications"]
|
2021-07-12 07:32:39 +02:00
|
|
|
|
notifications = await peer.get_async_interface(
|
|
|
|
|
"org.freedesktop.Notifications"
|
|
|
|
|
)
|
2021-07-11 12:20:42 +02:00
|
|
|
|
parameters = dict(
|
|
|
|
|
app_name=logger.name,
|
|
|
|
|
replaces_id=0,
|
|
|
|
|
app_icon="dialog-information",
|
|
|
|
|
summary="",
|
|
|
|
|
actions=[],
|
|
|
|
|
hints={},
|
2021-07-11 12:23:00 +02:00
|
|
|
|
expire_timeout=5000,
|
|
|
|
|
)
|
2021-07-11 12:20:42 +02:00
|
|
|
|
parameters.update(kwargs)
|
2021-07-12 07:32:39 +02:00
|
|
|
|
return await notifications.Notify(**parameters)
|
2021-07-11 12:20:42 +02:00
|
|
|
|
|
|
|
|
|
|
2021-07-12 12:02:02 +02:00
|
|
|
|
@on(StartEvent, I3Event.WINDOW_MOVE, I3Event.WINDOW_NEW, I3Event.WINDOW_CLOSE)
|
2021-07-07 00:24:23 +02:00
|
|
|
|
async def workspace_rename(i3, event):
|
2021-07-04 19:08:48 +02:00
|
|
|
|
"""Rename workspaces using icons to match what's inside it."""
|
2021-07-07 00:24:23 +02:00
|
|
|
|
tree = await i3.get_tree()
|
|
|
|
|
workspaces = tree.workspaces()
|
2021-07-04 19:08:48 +02:00
|
|
|
|
commands = []
|
2021-07-07 14:14:12 +02:00
|
|
|
|
|
|
|
|
|
def application_icon(window):
|
|
|
|
|
"""Get application icon for a window."""
|
2021-07-11 12:23:00 +02:00
|
|
|
|
for attr in ("window_instance", "window_class"):
|
2021-07-07 14:14:12 +02:00
|
|
|
|
name = getattr(window, attr, None)
|
|
|
|
|
if name is None:
|
|
|
|
|
continue
|
|
|
|
|
for k, v in application_icons.items():
|
2021-07-08 14:25:14 +02:00
|
|
|
|
if re.match(rf"^{k}\b", name, re.IGNORECASE):
|
2021-07-07 14:14:12 +02:00
|
|
|
|
logger.debug(f"in {attr}, found '{name}', matching {k}")
|
|
|
|
|
return v
|
2021-07-08 14:25:14 +02:00
|
|
|
|
return application_icons_nomatch
|
2021-07-07 14:14:12 +02:00
|
|
|
|
|
2021-07-04 19:08:48 +02:00
|
|
|
|
for workspace in workspaces:
|
|
|
|
|
icons = set()
|
|
|
|
|
for window in workspace.leaves():
|
|
|
|
|
icon = application_icon(window)
|
|
|
|
|
if icon is not None:
|
|
|
|
|
icons.add(icon)
|
2021-07-04 23:50:48 +02:00
|
|
|
|
if any([i not in application_icons_alone for i in icons]):
|
|
|
|
|
icons -= application_icons_alone
|
2021-07-06 08:25:14 +02:00
|
|
|
|
new_name = f"{workspace.num}:{'|'.join(icons)}".rstrip(":")
|
2021-07-04 19:08:48 +02:00
|
|
|
|
if workspace.name != new_name:
|
|
|
|
|
logger.debug(f"rename workspace {workspace.num}")
|
|
|
|
|
command = f'rename workspace "{workspace.name}" to "{new_name}"'
|
|
|
|
|
commands.append(command)
|
2021-07-11 12:23:00 +02:00
|
|
|
|
await i3.command(";".join(commands))
|
2021-07-04 19:08:48 +02:00
|
|
|
|
|
|
|
|
|
|
2021-07-07 17:34:58 +02:00
|
|
|
|
async def _new_workspace(i3):
|
|
|
|
|
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()
|
2021-07-11 12:23:00 +02:00
|
|
|
|
logger.info(f"create new workspace number {available}")
|
2021-07-07 17:34:58 +02:00
|
|
|
|
await i3.command(f'workspace number "{available}"')
|
|
|
|
|
return available
|
|
|
|
|
|
|
|
|
|
|
2021-07-12 09:10:27 +02:00
|
|
|
|
@on(CommandEvent("new-workspace"), CommandEvent("move-to-new-workspace"))
|
2021-07-07 00:24:23 +02:00
|
|
|
|
async def new_workspace(i3, event):
|
2021-07-05 19:54:21 +02:00
|
|
|
|
"""Create a new workspace and optionally move a window to it."""
|
|
|
|
|
# Get the currently focused window
|
2021-07-08 11:58:29 +02:00
|
|
|
|
if event == "move-to-new-workspace":
|
2021-07-07 00:24:23 +02:00
|
|
|
|
tree = await i3.get_tree()
|
|
|
|
|
current = tree.find_focused()
|
2021-07-05 19:54:21 +02:00
|
|
|
|
if not current:
|
|
|
|
|
return
|
2021-07-05 19:52:25 +02:00
|
|
|
|
|
2021-07-07 17:34:58 +02:00
|
|
|
|
num = await _new_workspace(i3)
|
2021-07-05 19:07:41 +02:00
|
|
|
|
|
2021-07-05 19:54:21 +02:00
|
|
|
|
# Move the window to this workspace
|
2021-07-08 11:58:29 +02:00
|
|
|
|
if event == "move-to-new-workspace":
|
2021-07-11 20:47:22 +02:00
|
|
|
|
await current.command(
|
|
|
|
|
f"move container to workspace " f'number "{num}"'
|
|
|
|
|
)
|
2021-07-07 17:34:58 +02:00
|
|
|
|
|
|
|
|
|
|
2021-07-12 09:05:17 +02:00
|
|
|
|
@on(I3Event.WINDOW_NEW)
|
2021-07-07 17:34:58 +02:00
|
|
|
|
async def worksplace_exclusive(i3, event):
|
|
|
|
|
"""Move new windows on a new workspace instead of sharing a workspace
|
|
|
|
|
with an exclusive app."""
|
|
|
|
|
w = event.container
|
|
|
|
|
|
|
|
|
|
def can_intrude(w):
|
|
|
|
|
"""Can this new window intrude any workspace?"""
|
|
|
|
|
if w.floating in {"auto_on", "user_on"}:
|
|
|
|
|
return True
|
2021-07-11 12:23:00 +02:00
|
|
|
|
if w.ipc_data["window_type"] not in {"normal", "splash", "unknown"}:
|
2021-07-07 17:34:58 +02:00
|
|
|
|
return True
|
|
|
|
|
if w.sticky:
|
|
|
|
|
return True
|
2021-07-11 12:23:00 +02:00
|
|
|
|
ids = {
|
|
|
|
|
s is not None and s.lower() or None
|
|
|
|
|
for s in {w.name, w.window_class, w.window_instance}
|
|
|
|
|
}
|
2021-07-07 17:34:58 +02:00
|
|
|
|
if ids.intersection(intrusive_apps):
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Can the new window just intrude?
|
|
|
|
|
if can_intrude(w):
|
|
|
|
|
logger.debug("window {w.name} can intrude")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Does the current workspace contains an exclusive app?
|
|
|
|
|
tree = await i3.get_tree()
|
|
|
|
|
workspace = tree.find_focused().workspace()
|
|
|
|
|
if not workspace:
|
|
|
|
|
return
|
2021-07-11 12:23:00 +02:00
|
|
|
|
ids = {
|
|
|
|
|
s is not None and s.lower() or None
|
|
|
|
|
for ow in workspace.leaves()
|
|
|
|
|
for s in {ow.name, ow.window_class, ow.window_instance}
|
|
|
|
|
if w.id != ow.id
|
|
|
|
|
}
|
2021-07-07 17:34:58 +02:00
|
|
|
|
exclusives = ids.intersection(exclusive_apps)
|
|
|
|
|
if not exclusives:
|
|
|
|
|
logger.debug("no exclusive app, {w.name} can go there")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Create a new workspace and move the window here
|
|
|
|
|
num = await _new_workspace(i3)
|
2021-07-11 12:23:00 +02:00
|
|
|
|
logger.info(f"move window {w.name} to workspace {num}")
|
2021-07-07 17:34:58 +02:00
|
|
|
|
await w.command(f'move container to workspace number "{num}"')
|
2021-07-05 19:52:25 +02:00
|
|
|
|
|
2021-07-05 19:07:41 +02:00
|
|
|
|
|
2021-07-12 09:10:27 +02:00
|
|
|
|
@on(CommandEvent("quake-console"))
|
2021-07-07 00:24:23 +02:00
|
|
|
|
async def quake_console(i3, event):
|
2021-07-06 10:37:01 +02:00
|
|
|
|
"""Spawn a quake console or toggle an existing one."""
|
|
|
|
|
try:
|
2021-07-08 11:58:29 +02:00
|
|
|
|
_, term_exec, term_name, height = event.split(":")
|
2021-07-06 10:37:01 +02:00
|
|
|
|
height = float(height)
|
|
|
|
|
except Exception as exc:
|
2021-07-08 11:58:29 +02:00
|
|
|
|
logger.warn(f"unable to parse payload {event}: {exc}")
|
2021-07-06 10:37:01 +02:00
|
|
|
|
return
|
|
|
|
|
|
2021-07-07 00:24:23 +02:00
|
|
|
|
tree = await i3.get_tree()
|
|
|
|
|
term = tree.find_instanced(term_name)
|
2021-07-06 10:37:01 +02:00
|
|
|
|
if not term:
|
2021-07-11 12:23:00 +02:00
|
|
|
|
await i3.command(f"exec exec {term_exec} --name {term_name}")
|
2021-07-06 10:37:01 +02:00
|
|
|
|
tries = 5
|
|
|
|
|
while not term and tries:
|
2021-07-07 00:24:23 +02:00
|
|
|
|
tree = await i3.get_tree()
|
|
|
|
|
term = tree.find_instanced(term_name)
|
|
|
|
|
await asyncio.sleep(0.2)
|
2021-07-06 10:37:01 +02:00
|
|
|
|
tries -= 1
|
|
|
|
|
if not term:
|
|
|
|
|
raise RuntimeError("unable to spawn terminal")
|
|
|
|
|
term = term[0]
|
2021-07-07 00:24:23 +02:00
|
|
|
|
workspaces = await i3.get_workspaces()
|
|
|
|
|
workspace = [ws for ws in workspaces if ws.focused][0]
|
2021-07-06 10:37:01 +02:00
|
|
|
|
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
|
2021-07-11 12:23:00 +02:00
|
|
|
|
command = (
|
|
|
|
|
f"[instance={term_name}] "
|
|
|
|
|
"border none,"
|
|
|
|
|
f"resize set {width} px {height} px,"
|
|
|
|
|
"scratchpad show,"
|
|
|
|
|
f"move absolute position {posx}px {posy}px"
|
|
|
|
|
)
|
2021-07-06 10:37:01 +02:00
|
|
|
|
logger.debug(f"QuakeConsole: {command}")
|
2021-07-07 00:24:23 +02:00
|
|
|
|
await i3.command(command)
|
2021-07-06 10:37:01 +02:00
|
|
|
|
|
|
|
|
|
|
2021-07-12 09:10:27 +02:00
|
|
|
|
@on(CommandEvent("container-info"))
|
2021-07-09 16:12:00 +02:00
|
|
|
|
async def container_info(i3, event):
|
|
|
|
|
"""Show information about the focused container."""
|
2021-07-07 12:30:14 +02:00
|
|
|
|
tree = await i3.get_tree()
|
|
|
|
|
window = tree.find_focused()
|
|
|
|
|
if not window:
|
|
|
|
|
return
|
|
|
|
|
logger.info(f"window raw information: {window.ipc_data}")
|
2021-07-07 22:03:39 +02:00
|
|
|
|
summary = "About focused container"
|
2021-07-07 12:30:14 +02:00
|
|
|
|
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,
|
2021-07-11 12:23:00 +02:00
|
|
|
|
"type": w.ipc_data["window_type"],
|
2021-07-07 12:30:14 +02:00
|
|
|
|
"sticky": w.sticky,
|
|
|
|
|
"floating": w.floating,
|
|
|
|
|
"geometry": f"{r.width}×{r.height}+{r.x}+{r.y}",
|
|
|
|
|
"layout": w.layout,
|
2021-07-07 22:04:38 +02:00
|
|
|
|
"parcent": w.percent,
|
2021-07-11 12:23:00 +02:00
|
|
|
|
"marks": ", ".join(w.marks) or "(none)",
|
2021-07-07 12:30:14 +02:00
|
|
|
|
}
|
2021-07-11 12:23:00 +02:00
|
|
|
|
body = "\n".join(
|
|
|
|
|
(
|
|
|
|
|
f"<tt>{k:10}</tt> {html.escape(str(v))}"
|
|
|
|
|
for k, v in info.items()
|
|
|
|
|
if v is not None
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
result = await notify(
|
|
|
|
|
i3,
|
|
|
|
|
app_icon="system-search",
|
|
|
|
|
expire_timeout=10000,
|
|
|
|
|
summary=summary,
|
|
|
|
|
body=body,
|
|
|
|
|
replaces_id=getattr(container_info, "last_id", 0),
|
|
|
|
|
)
|
2021-07-11 12:18:19 +02:00
|
|
|
|
container_info.last_id = result[0]
|
2021-07-09 16:12:00 +02:00
|
|
|
|
|
|
|
|
|
|
2021-07-12 09:10:27 +02:00
|
|
|
|
@on(CommandEvent("workspace-info"))
|
2021-07-09 16:12:00 +02:00
|
|
|
|
async def workspace_info(i3, event):
|
|
|
|
|
"""Show information about the focused workspace."""
|
|
|
|
|
workspaces = await i3.get_workspaces()
|
|
|
|
|
focused = [w for w in workspaces if w.focused]
|
|
|
|
|
if not focused:
|
|
|
|
|
return
|
|
|
|
|
workspace = focused[0]
|
2021-07-09 20:11:56 +02:00
|
|
|
|
summary = f"Workspace {workspace.num} on {workspace.output}"
|
2021-07-09 16:12:00 +02:00
|
|
|
|
tree = await i3.get_tree()
|
2021-07-11 12:23:00 +02:00
|
|
|
|
workspace = [w for w in tree.workspaces() if w.num == workspace.num]
|
2021-07-09 16:12:00 +02:00
|
|
|
|
|
|
|
|
|
def format(container):
|
2021-07-09 20:35:17 +02:00
|
|
|
|
if container.focused:
|
|
|
|
|
style = 'foreground="#ffaf00"'
|
|
|
|
|
elif not container.window:
|
|
|
|
|
style = 'foreground="#6c98ee"'
|
2021-07-09 16:12:00 +02:00
|
|
|
|
else:
|
2021-07-11 12:23:00 +02:00
|
|
|
|
style = ""
|
2021-07-09 20:35:17 +02:00
|
|
|
|
if container.window:
|
2021-07-11 12:23:00 +02:00
|
|
|
|
content = (
|
|
|
|
|
f"{(container.window_class or '???').lower()}: "
|
|
|
|
|
f"{(container.window_title or '???')}"
|
|
|
|
|
)
|
|
|
|
|
elif container.type == "workspace" and not container.nodes:
|
2021-07-09 20:35:17 +02:00
|
|
|
|
# Empty workspaces use workspace_layout, but when default,
|
|
|
|
|
# this is layout...
|
2021-07-11 12:23:00 +02:00
|
|
|
|
layout = container.ipc_data["workspace_layout"]
|
2021-07-09 20:35:17 +02:00
|
|
|
|
if layout == "default":
|
|
|
|
|
layout = container.layout
|
|
|
|
|
content = f"({layout})"
|
|
|
|
|
else:
|
|
|
|
|
content = f"({container.layout})"
|
2021-07-11 12:23:00 +02:00
|
|
|
|
root = f"<span {style}>{content.lower()}</span>"
|
2021-07-09 16:12:00 +02:00
|
|
|
|
children = []
|
|
|
|
|
for child in container.nodes:
|
|
|
|
|
if child == container.nodes[-1]:
|
2021-07-11 12:20:42 +02:00
|
|
|
|
first = "└─"
|
2021-07-09 16:12:00 +02:00
|
|
|
|
others = " "
|
|
|
|
|
else:
|
2021-07-11 12:20:42 +02:00
|
|
|
|
first = "├─"
|
2021-07-09 16:12:00 +02:00
|
|
|
|
others = "│ "
|
|
|
|
|
content = format(child).replace("\n", f"\n{others}")
|
|
|
|
|
children.append(f"<tt>{first}</tt>{content}")
|
|
|
|
|
children.insert(0, root)
|
|
|
|
|
return "\n".join(children)
|
|
|
|
|
|
2021-07-11 12:18:19 +02:00
|
|
|
|
body = format(workspace[0]).lstrip("\n")
|
2021-07-11 12:23:00 +02:00
|
|
|
|
result = await notify(
|
|
|
|
|
i3,
|
|
|
|
|
app_icon="system-search",
|
|
|
|
|
expire_timeout=20000,
|
|
|
|
|
summary=summary,
|
|
|
|
|
body=body,
|
|
|
|
|
replaces_id=getattr(workspace_info, "last_id", 0),
|
|
|
|
|
)
|
2021-07-11 12:18:19 +02:00
|
|
|
|
workspace_info.last_id = result[0]
|
2021-07-07 12:30:14 +02:00
|
|
|
|
|
|
|
|
|
|
2021-07-12 09:05:17 +02:00
|
|
|
|
@on(I3Event.OUTPUT)
|
2021-07-07 06:34:19 +02:00
|
|
|
|
async def output_update(i3, event):
|
|
|
|
|
"""React to a XRandR change."""
|
2021-07-11 11:40:27 +02:00
|
|
|
|
running = getattr(output_update, "running", None)
|
|
|
|
|
if running is not None:
|
|
|
|
|
running.cancel()
|
|
|
|
|
output_update.running = None
|
2021-07-07 06:34:19 +02:00
|
|
|
|
|
2021-07-12 07:32:39 +02:00
|
|
|
|
async def output_update_now():
|
|
|
|
|
try:
|
|
|
|
|
await asyncio.sleep(2)
|
|
|
|
|
except asyncio.CancelledError:
|
|
|
|
|
return
|
2021-07-11 11:40:27 +02:00
|
|
|
|
output_update.running = None
|
2021-07-07 14:14:12 +02:00
|
|
|
|
|
|
|
|
|
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}")
|
|
|
|
|
|
2021-07-07 06:34:19 +02:00
|
|
|
|
logger.debug("schedule XRandR change")
|
2021-07-12 07:32:39 +02:00
|
|
|
|
output_update.running = asyncio.create_task(output_update_now())
|
2021-07-07 06:34:19 +02:00
|
|
|
|
|
|
|
|
|
|
2021-07-12 13:18:34 +02:00
|
|
|
|
@on(
|
|
|
|
|
DBusSignal(
|
|
|
|
|
path="/org/bluez",
|
|
|
|
|
interface="org.freedesktop.DBus.Properties",
|
|
|
|
|
member="PropertiesChanged",
|
|
|
|
|
signature="sa{sv}as",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
async def bluetooth_notifications(
|
|
|
|
|
i3, event, path, interface, changed, invalid
|
|
|
|
|
):
|
|
|
|
|
"""Display notifications related to Bluetooth state."""
|
|
|
|
|
if interface != "org.bluez.Device1":
|
|
|
|
|
return
|
|
|
|
|
if not "Connected" in changed:
|
|
|
|
|
return
|
|
|
|
|
logger.info(changed["Connected"])
|
|
|
|
|
peer = i3.system_bus["org.bluez"][path]
|
|
|
|
|
obd = await peer.get_async_interface(interface)
|
|
|
|
|
name = await obd.Name
|
|
|
|
|
icon = await obd.Icon
|
|
|
|
|
state = await obd.Connected
|
|
|
|
|
state = "connected" if state else "disconnected"
|
|
|
|
|
await notify(
|
|
|
|
|
i3,
|
|
|
|
|
app_icon=icon,
|
|
|
|
|
summary=name,
|
|
|
|
|
body=f"Bluetooth device {state}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2021-07-11 22:05:36 +02:00
|
|
|
|
@on(
|
|
|
|
|
DBusSignal(
|
|
|
|
|
path="/",
|
|
|
|
|
interface="org.freedesktop.NetworkManager.Connection.Active",
|
|
|
|
|
member="StateChanged",
|
|
|
|
|
signature="uu",
|
|
|
|
|
)
|
|
|
|
|
)
|
2021-07-11 22:51:30 +02:00
|
|
|
|
async def network_manager_notifications(i3, event, path, state, reason):
|
2021-07-12 07:32:39 +02:00
|
|
|
|
"""Display notifications related to Network Manager state."""
|
2021-07-12 07:57:50 +02:00
|
|
|
|
ofnm = "org.freedesktop.NetworkManager"
|
2021-07-12 07:32:39 +02:00
|
|
|
|
logger.debug(f"from {path} state: {state}, reason: {reason}")
|
2021-07-11 22:05:36 +02:00
|
|
|
|
if state not in {NM_ACTIVE_CONNECTION_STATE_ACTIVATED}:
|
2021-07-11 22:51:30 +02:00
|
|
|
|
# We cannot get proper state unless the connection is
|
|
|
|
|
# activated, unless we maintain state.
|
2021-07-11 22:05:36 +02:00
|
|
|
|
return
|
2021-07-12 07:57:50 +02:00
|
|
|
|
peer = i3.system_bus[ofnm][path]
|
2021-07-11 22:05:36 +02:00
|
|
|
|
try:
|
2021-07-12 07:57:50 +02:00
|
|
|
|
nmca = await peer.get_async_interface(f"{ofnm}.Connection.Active")
|
2021-07-11 22:05:36 +02:00
|
|
|
|
except dbussy.DBusError:
|
2021-07-12 07:32:39 +02:00
|
|
|
|
logger.info(f"interface {path} has vanished")
|
2021-07-11 22:05:36 +02:00
|
|
|
|
return
|
2021-07-12 07:32:39 +02:00
|
|
|
|
kind = await nmca.Type
|
|
|
|
|
id = await nmca.Id
|
2021-07-11 22:05:36 +02:00
|
|
|
|
if kind == "vpn":
|
|
|
|
|
await notify(
|
|
|
|
|
i3, app_icon="network-vpn", summary=f"{id}", body="VPN connected!"
|
|
|
|
|
)
|
|
|
|
|
elif kind == "802-3-ethernet":
|
|
|
|
|
await notify(
|
|
|
|
|
i3,
|
|
|
|
|
app_icon="network-wired",
|
|
|
|
|
summary=f"{id}",
|
|
|
|
|
body="Ethernet connection established!",
|
|
|
|
|
)
|
|
|
|
|
elif kind == "802-11-wireless":
|
|
|
|
|
await notify(
|
|
|
|
|
i3,
|
|
|
|
|
app_icon="network-wireless",
|
|
|
|
|
summary=f"{id}",
|
|
|
|
|
body="Wireless connection established!",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2021-07-12 07:32:39 +02:00
|
|
|
|
@on(
|
|
|
|
|
StartEvent,
|
|
|
|
|
DBusSignal(
|
|
|
|
|
path="/",
|
|
|
|
|
interface="org.freedesktop.NetworkManager.Connection.Active",
|
|
|
|
|
member="StateChanged",
|
|
|
|
|
signature="uu",
|
|
|
|
|
),
|
|
|
|
|
DBusSignal(
|
|
|
|
|
path="/",
|
|
|
|
|
interface="org.freedesktop.NetworkManager.AccessPoint",
|
|
|
|
|
member="PropertiesChanged",
|
|
|
|
|
signature="a{sv}",
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
async def network_manager_status(i3, event, *args):
|
|
|
|
|
"""Compute network manager status."""
|
2021-07-12 07:57:50 +02:00
|
|
|
|
ofnm = "org.freedesktop.NetworkManager"
|
2021-07-12 07:32:39 +02:00
|
|
|
|
|
2021-07-12 07:57:50 +02:00
|
|
|
|
# Dampen updates
|
2021-07-12 07:32:39 +02:00
|
|
|
|
running = getattr(network_manager_status, "running", None)
|
|
|
|
|
if running is not None:
|
|
|
|
|
running.cancel()
|
|
|
|
|
network_manager_status.running = None
|
|
|
|
|
|
|
|
|
|
async def network_manager_status_now(sleep=1):
|
|
|
|
|
try:
|
|
|
|
|
await asyncio.sleep(sleep)
|
|
|
|
|
except asyncio.CancelledError:
|
|
|
|
|
return
|
|
|
|
|
network_manager_status.running = None
|
|
|
|
|
try:
|
2021-07-12 08:45:09 +02:00
|
|
|
|
await _network_manager_status_now()
|
2021-07-12 07:32:39 +02:00
|
|
|
|
except Exception as e:
|
2021-07-12 08:13:25 +02:00
|
|
|
|
logger.warning("while updating network status: %s", str(e))
|
2021-07-12 07:32:39 +02:00
|
|
|
|
if network_manager_status.running is None:
|
|
|
|
|
network_manager_status.running = asyncio.create_task(
|
|
|
|
|
network_manager_status_now(5)
|
|
|
|
|
)
|
|
|
|
|
|
2021-07-12 08:45:09 +02:00
|
|
|
|
async def _network_manager_status_now():
|
2021-07-12 07:32:39 +02:00
|
|
|
|
status = []
|
|
|
|
|
|
|
|
|
|
# Build status from devices
|
2021-07-12 07:57:50 +02:00
|
|
|
|
bus = i3.system_bus[ofnm]
|
2021-07-12 07:32:39 +02:00
|
|
|
|
nm = await bus["/org/freedesktop/NetworkManager"].get_async_interface(
|
2021-07-12 07:57:50 +02:00
|
|
|
|
ofnm
|
2021-07-12 07:32:39 +02:00
|
|
|
|
)
|
|
|
|
|
devices = await nm.AllDevices
|
|
|
|
|
for device in devices:
|
2021-07-12 07:57:50 +02:00
|
|
|
|
nmd = await bus[device].get_async_interface(f"{ofnm}.Device")
|
2021-07-12 07:32:39 +02:00
|
|
|
|
kind = await nmd.DeviceType
|
|
|
|
|
state = await nmd.State
|
|
|
|
|
if state == NM_DEVICE_STATE_UNMANAGED:
|
|
|
|
|
continue
|
|
|
|
|
if kind == NM_DEVICE_TYPE_WIFI:
|
|
|
|
|
if state != NM_DEVICE_STATE_ACTIVATED:
|
2021-07-12 12:02:02 +02:00
|
|
|
|
status.append(icons["nowifi"])
|
2021-07-12 07:32:39 +02:00
|
|
|
|
continue
|
|
|
|
|
nmw = await bus[device].get_async_interface(
|
2021-07-12 07:57:50 +02:00
|
|
|
|
f"{ofnm}.Device.Wireless"
|
2021-07-12 07:32:39 +02:00
|
|
|
|
)
|
|
|
|
|
ap = await nmw.ActiveAccessPoint
|
|
|
|
|
if not ap:
|
2021-07-12 12:02:02 +02:00
|
|
|
|
status.append(icons["nowifi"])
|
2021-07-12 07:32:39 +02:00
|
|
|
|
continue
|
|
|
|
|
network_manager_status.active_ap = ap
|
2021-07-12 07:57:50 +02:00
|
|
|
|
nmap = await bus[ap].get_async_interface(f"{ofnm}.AccessPoint")
|
2021-07-12 07:32:39 +02:00
|
|
|
|
name = await nmap.Ssid
|
|
|
|
|
strength = int(await nmap.Strength)
|
2021-07-12 12:02:02 +02:00
|
|
|
|
status.append(
|
|
|
|
|
[
|
|
|
|
|
icons["wifi-low"],
|
|
|
|
|
icons["wifi-medium"],
|
|
|
|
|
icons["wifi-high"],
|
|
|
|
|
][strength // 34]
|
|
|
|
|
)
|
|
|
|
|
status.append(
|
|
|
|
|
bytes(name)
|
|
|
|
|
.decode("utf-8", errors="replace")
|
|
|
|
|
.replace("%", "%%")
|
|
|
|
|
)
|
2021-07-12 07:32:39 +02:00
|
|
|
|
elif (
|
|
|
|
|
kind == NM_DEVICE_TYPE_ETHERNET
|
|
|
|
|
and state == NM_DEVICE_STATE_ACTIVATED
|
|
|
|
|
):
|
2021-07-12 12:02:02 +02:00
|
|
|
|
status.append(icons["wired"])
|
2021-07-12 07:32:39 +02:00
|
|
|
|
|
|
|
|
|
# Build status for VPN connection
|
|
|
|
|
connections = await nm.ActiveConnections
|
|
|
|
|
for conn in connections:
|
|
|
|
|
nma = await bus[conn].get_async_interface(
|
2021-07-12 07:57:50 +02:00
|
|
|
|
f"{ofnm}.Connection.Active"
|
2021-07-12 07:32:39 +02:00
|
|
|
|
)
|
|
|
|
|
vpn = await nma.Vpn
|
|
|
|
|
if vpn:
|
|
|
|
|
state = await nma.State
|
|
|
|
|
if state == NM_ACTIVE_CONNECTION_STATE_ACTIVATED:
|
2021-07-12 13:18:34 +02:00
|
|
|
|
status.append(icons["vpn"])
|
2021-07-12 07:32:39 +02:00
|
|
|
|
status.append(await nma.Id)
|
|
|
|
|
|
|
|
|
|
# Final status line
|
2021-07-12 12:02:02 +02:00
|
|
|
|
status = " ".join(status)
|
2021-07-12 07:32:39 +02:00
|
|
|
|
last = getattr(network_manager_status, "last", None)
|
|
|
|
|
|
|
|
|
|
if status != last:
|
|
|
|
|
logger.info(f"network status: {status}")
|
|
|
|
|
|
|
|
|
|
# Update cache file (for when polybar restarts)
|
2021-07-12 07:57:50 +02:00
|
|
|
|
with open(
|
|
|
|
|
f"{os.getenv('XDG_RUNTIME_DIR')}/i3/network.txt", "w"
|
|
|
|
|
) as out:
|
2021-07-12 07:32:39 +02:00
|
|
|
|
out.write(status)
|
|
|
|
|
|
2021-07-12 07:57:50 +02:00
|
|
|
|
# Send it to polybar's module/network
|
2021-07-12 07:32:39 +02:00
|
|
|
|
for name in glob.glob("/tmp/polybar_mqueue.*"):
|
|
|
|
|
try:
|
2021-07-12 09:05:17 +02:00
|
|
|
|
with open(
|
|
|
|
|
os.open(name, os.O_WRONLY | os.O_NONBLOCK), "w"
|
|
|
|
|
) as out:
|
2021-07-12 07:32:39 +02:00
|
|
|
|
cmd = f"action:#network.send.{status}"
|
|
|
|
|
out.write(cmd)
|
|
|
|
|
except OSError as e:
|
2021-07-12 08:45:09 +02:00
|
|
|
|
if e.errno != errno.ENXIO:
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
network_manager_status.last = status
|
2021-07-12 07:32:39 +02:00
|
|
|
|
|
2021-07-12 07:57:50 +02:00
|
|
|
|
if (
|
|
|
|
|
isinstance(event, DBusSignal)
|
|
|
|
|
and event.interface == f"{ofnm}.Connection.Active"
|
|
|
|
|
):
|
|
|
|
|
await network_manager_status_now(0)
|
|
|
|
|
else:
|
|
|
|
|
network_manager_status.running = asyncio.create_task(
|
|
|
|
|
network_manager_status_now()
|
|
|
|
|
)
|
2021-07-12 07:32:39 +02:00
|
|
|
|
|
|
|
|
|
|
2021-07-07 13:44:55 +02:00
|
|
|
|
async def main(options):
|
|
|
|
|
i3 = await Connection().connect()
|
2021-07-11 12:18:19 +02:00
|
|
|
|
i3.session_bus = await ravel.session_bus_async()
|
2021-07-11 22:05:36 +02:00
|
|
|
|
i3.system_bus = await ravel.system_bus_async()
|
2021-07-07 12:30:14 +02:00
|
|
|
|
|
2021-07-07 14:14:12 +02:00
|
|
|
|
# Regular events
|
2021-07-07 13:44:55 +02:00
|
|
|
|
for fn, events in on.functions.items():
|
|
|
|
|
for event in events:
|
2021-07-12 09:05:17 +02:00
|
|
|
|
if isinstance(event, I3Event):
|
2021-07-07 13:44:55 +02:00
|
|
|
|
i3.on(event, fn)
|
2021-07-07 14:14:12 +02:00
|
|
|
|
|
2021-07-08 11:58:29 +02:00
|
|
|
|
# React to some bindings
|
|
|
|
|
async def binding_event(i3, event):
|
|
|
|
|
"""Process a binding event."""
|
|
|
|
|
# We only processes it when it is a nop command and we use
|
|
|
|
|
# this mechanism as an IPC mechanism. The alternative would be
|
|
|
|
|
# to use ticks but we would need to spawn an i3-msg process
|
|
|
|
|
# for that.
|
|
|
|
|
cmd = event.binding.command
|
|
|
|
|
if not cmd.startswith("nop "):
|
2021-07-07 14:14:12 +02:00
|
|
|
|
return
|
2021-07-11 12:23:00 +02:00
|
|
|
|
cmd = cmd[4:].strip("\"'")
|
2021-07-08 11:58:29 +02:00
|
|
|
|
if not cmd:
|
|
|
|
|
return
|
|
|
|
|
kind = cmd.split(":")[0]
|
2021-07-07 14:14:12 +02:00
|
|
|
|
for fn, events in on.functions.items():
|
|
|
|
|
for e in events:
|
2021-07-12 09:10:27 +02:00
|
|
|
|
if isinstance(e, CommandEvent) and e.name == kind:
|
2021-07-08 11:58:29 +02:00
|
|
|
|
await fn(i3, cmd)
|
2021-07-11 12:23:00 +02:00
|
|
|
|
|
2021-07-12 09:05:17 +02:00
|
|
|
|
i3.on(I3Event.BINDING, binding_event)
|
2021-07-05 19:07:41 +02:00
|
|
|
|
|
2021-07-11 22:05:36 +02:00
|
|
|
|
# Listen to DBus events
|
|
|
|
|
for fn, events in on.functions.items():
|
|
|
|
|
for event in events:
|
|
|
|
|
if isinstance(event, DBusSignal):
|
|
|
|
|
for bus in {i3.session_bus, i3.system_bus}:
|
|
|
|
|
|
2021-07-11 22:46:53 +02:00
|
|
|
|
def wrapping(fn, event):
|
|
|
|
|
@ravel.signal(
|
|
|
|
|
name=event.member,
|
|
|
|
|
in_signature=event.signature,
|
|
|
|
|
path_keyword="path",
|
|
|
|
|
args_keyword="args",
|
|
|
|
|
)
|
|
|
|
|
async def wrapped(path, args):
|
|
|
|
|
return await fn(i3, event, path, *args)
|
2021-07-11 22:51:30 +02:00
|
|
|
|
|
2021-07-11 22:46:53 +02:00
|
|
|
|
return wrapped
|
2021-07-11 22:05:36 +02:00
|
|
|
|
|
|
|
|
|
bus.listen_signal(
|
|
|
|
|
path=event.path,
|
|
|
|
|
fallback=True,
|
|
|
|
|
interface=event.interface,
|
|
|
|
|
name=event.member,
|
2021-07-11 22:46:53 +02:00
|
|
|
|
func=wrapping(fn, event),
|
2021-07-11 22:05:36 +02:00
|
|
|
|
)
|
|
|
|
|
|
2021-07-12 07:32:39 +02:00
|
|
|
|
# Run events that should run on start
|
|
|
|
|
for fn, events in on.functions.items():
|
|
|
|
|
for event in events:
|
|
|
|
|
if event is StartEvent:
|
|
|
|
|
asyncio.create_task(fn(i3, event))
|
|
|
|
|
|
2021-07-07 00:24:23 +02:00
|
|
|
|
await i3.main()
|
2021-07-06 10:37:01 +02:00
|
|
|
|
|
2021-07-07 13:44:55 +02:00
|
|
|
|
|
2021-07-07 00:24:23 +02:00
|
|
|
|
if __name__ == "__main__":
|
2021-07-07 14:14:12 +02:00
|
|
|
|
# Parse
|
2021-07-07 14:14:35 +02:00
|
|
|
|
description = sys.modules[__name__].__doc__
|
|
|
|
|
for fn, events in on.functions.items():
|
|
|
|
|
description += f" {fn.__doc__}"
|
|
|
|
|
parser = argparse.ArgumentParser(description=description)
|
2021-07-11 12:23:00 +02:00
|
|
|
|
parser.add_argument(
|
2021-07-11 20:47:22 +02:00
|
|
|
|
"--debug",
|
|
|
|
|
"-d",
|
|
|
|
|
action="store_true",
|
|
|
|
|
default=False,
|
|
|
|
|
help="enable debugging",
|
2021-07-11 12:23:00 +02:00
|
|
|
|
)
|
2021-07-07 14:14:12 +02:00
|
|
|
|
options = parser.parse_args()
|
|
|
|
|
|
|
|
|
|
# Logging
|
|
|
|
|
root = logging.getLogger("")
|
|
|
|
|
root.setLevel(logging.WARNING)
|
|
|
|
|
logger.setLevel(options.debug and logging.DEBUG or logging.INFO)
|
2021-07-10 13:17:07 +02:00
|
|
|
|
if sys.stdin.isatty():
|
|
|
|
|
ch = logging.StreamHandler()
|
|
|
|
|
ch.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
|
|
|
|
|
else:
|
|
|
|
|
root.addHandler(journal.JournalHandler(SYSLOG_IDENTIFIER=logger.name))
|
2021-07-07 00:24:23 +02:00
|
|
|
|
|
|
|
|
|
try:
|
2021-07-12 07:32:39 +02:00
|
|
|
|
asyncio.run(main(options))
|
2021-07-04 19:08:48 +02:00
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.exception("%s", e)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
sys.exit(0)
|