Python 3 scripting for a NETWM-compliant X11 window manager you already run (Openbox, etc.). It is not a window manager.
Based in orcsome. Linux / X11 only. Requires Python 3.8 or newer (3.8, 3.9, 3.10, β¦ current).
You write ~/.config/orcsome3/config.py. orcsome3 grabs keys, watches window create/destroy/property/focus, can change EWMH state and icons, and can post desktop notifications over D-Bus.
config.py (your script)
ββ orcsome3 WindowManager, Window, Notification, get_wm, keys
ββ orcsome3.libs.xlib / ev typed Python wrappers
ββ orcsome3_backend.so Cython (Xlib + statically linked ev / cairo / Magick / gd / resvg)
ββ libX11, libXext, libXss, libXtst (system, dynamic)
- Public API lives in
orcsome3/(get_wm,window_manager,notify,keys). That is whatconfig.pyshould import. - Only
orcsome3.libs.xlibandorcsome3.libs.evimportorcsome3_backend. Do not import the.sofromconfig.py. - The backend is typed via a generated
orcsome3_backend.pyi(make stubs). The package shipspy.typed. - libev, Cairo, ImageMagick 7, gd, resvg (and their image-format deps) are downloaded and statically linked into the
.so. You do not install ImageMagick from source yourself. X11 stays a normal system library.
- Global and per-window hotkeys (
XGrabKey), including CapsLock/NumLock variants - React to window create / manage / destroy / property / focus
- NETWM/EWMH: desktops, maximize, fullscreen, decorations, close, move/resize, icons β
set_icontakes SVG (via resvg) or PNG/JPEG/GIF/BMP/ICO/WebP/TIFF/β¦ (via ImageMagick 7), not just one format - Freedesktop notifications (
dbus-nextβorg.freedesktop.Notifications) - Typed
config.py(mypy / basedpyright)
libev, Cairo, ImageMagick 7, gd, and resvg are all statically linked into orcsome3_backend.so. Whether you install the wheel from PyPI or build from source, you never install any of them yourself β the only external runtime dependency is X11.
orcsome3 needs the usual X11 client libraries (libX11, libXext, libXss, libXtst). A Linux desktop almost always has them already. Install them only if the module fails to load:
# Debian / Ubuntu
sudo apt install libx11-6 libxss1 libxext6 libxtst6
# Arch
sudo pacman -S libx11 libxss libxext libxtstPython deps (dbus-next, typing_extensions) come with the package.
python3 -m pip install orcsome3Linux manylinux wheels cover CPython 3.8 through current (3.15 as of cibuildwheel 4.2). No musllinux, free-threaded (3.14t, β¦), Windows, or macOS. A GitHub Release (or Upload Python Package from Actions) publishes the sdist and wheels. Bump the pypa/cibuildwheel pins in .github/workflows/python-publish.yml when a new CPython ships.
First-time native build fetches git/tarball sources and compiles static libs. You need a C/C++ toolchain, the X11 headers, and:
# Debian / Ubuntu
sudo apt install git cmake autoconf automake libtool pkg-config meson ninja-build \
libx11-dev libxss-dev libxext-dev libxtst-dev curl
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y # resvg
# Arch
sudo pacman -S git cmake autoconf automake libtool pkgconf meson ninja \
libx11 libxss libxext libxtst rustThen:
git clone https://github.com/ahsand97/orcsome3.git
cd orcsome3
make native # static libs + orcsome3_backend.so (slow once)
python3 -m pip install .pip install . reuses orcsome3_built_libraries/; it does not download those libs again. Run make native first on a clean clone.
To hack without installing the package:
make dev # venv + deps + native
make run # python -m orcsome3 (needs ~/.config/orcsome3/config.py)Create ~/.config/orcsome3/config.py (or pass -c). If the file is missing, orcsome3 logs an error and exits.
Every hook except on_init/on_deinit/on_timer calls back as (window, event) β on_key/on_button/on_create and friends all follow this shape, so there's one pattern to learn. Most take window=None (every window) or window= a specific id (typically the window parameter from on_manage, nested). wm.event_window still works as a side-channel (e.g. from code that isn't itself inside a callback), but the parameter is what you'll normally use. Decorated functions get .remove() to unregister.
@wm.on_init/@wm.on_deinitβ no parentheses, no arguments.on_initruns once at startup after root events are selected, before existing clients are scanned.on_deinitruns instop()after key/button grabs and timers are torn down.@wm.on_key(...)βXGrabKeyon KeyPress. Default is a global hotkey (grab on the root). The callback'swindowis that grab window; usewm.current_windowfor the focused client. Passwindow_matcher=to grab on matching clients instead. orcsome3 consumes the key unlesspropagate_event=True. CapsLock/NumLock variants are grabbed for you."Control + b"or aKeyDefinition; modifiersControl/Ctrl,Alt/Meta,Shift,Win/Super,AnyModifier; keys are X keysym names (XK_stripped).@wm.on_key_release(...)β same grab ason_key, but KeyRelease. Register both for the same combo; they share oneXGrabKey.@wm.on_button(...)βXGrabButtonon ButtonPress (BUTTONS.Button1β¦Button5orAnyButton). Same root-vs-window_matcherandpropagate_eventrules ason_key.@wm.on_create(...)β CreateNotify, including windows already mapped when orcsome3 starts. Optionalmatcher=WindowMatchers(...).eventisNonefor that startup sweep (no real CreateNotify exists for windows that already existed) and a realXCreateWindowEventotherwise.@wm.on_manage(...)β same ason_create, but skips the startup sweep, soeventis always a realXCreateWindowEvent, neverNone. Nest per-window@wm.on_destroy(window=...)(and similar) here so you do not attach once per existing client.@wm.on_destroy(...)β DestroyNotify.windowis only that id; reading properties on it will fail (it is already gone).@wm.on_property_change(property="...")β PropertyNotify when an atom (_NET_WM_STATE, β¦) gets a new value.@wm.on_focus()/@wm.on_unfocus()β FocusIn / FocusOut (NotifyNormal/NotifyWhileGrabbedonly; pointer-detail and grab-notify are ignored).@wm.on_map()/@wm.on_unmap()/@wm.on_configure()β MapNotify / UnmapNotify / ConfigureNotify. Configure fires often during resize; size isevent.width/event.height(not a laterget_geometry()).@wm.on_client_message(message_type="...")β ClientMessage filtered by atom name (_NET_ACTIVE_WINDOW, β¦).@wm.on_timer(timeout=...)β repeating libev timer (seconds), callback takes no arguments. ReturnTrueto stop;None/falsy keeps it. Also.start()/.stop()/.again(). Needs the process event loop (normalorcsome3has one).
Example config.py:
from pathlib import Path
from typing import Optional
from orcsome3 import get_wm
from orcsome3.keys import KeyboardModifiers, KeyDefinition, WindowMatchers
from orcsome3.libs.xlib import (
BUTTONS,
XButtonEvent,
XClientMessageEvent,
XConfigureEvent,
XCreateWindowEvent,
XDestroyWindowEvent,
XFocusChangeEvent,
XKeyEvent,
XMapEvent,
XPropertyEvent,
XUnmapEvent,
)
from orcsome3.notify import Notification
from orcsome3.window_manager import Window, WindowManager
wm: WindowManager = get_wm()
# Hide the title bar while a window is maximized, restore it otherwise.
@wm.on_property_change(property="_NET_WM_STATE")
def hide_title_bar_when_maximized(window: Window, event: XPropertyEvent) -> None:
if window.maximized_horz and window.maximized_vert:
if window.decorated:
window.set_state(decorate=False)
elif not window.decorated:
window.set_state(decorate=True)
@wm.on_init
def on_start() -> None:
print("orcsome3 started")
@wm.on_deinit
def on_stop() -> None:
print("orcsome3 stopping")
# Global hotkey. `window` is root; focused client is `wm.current_window`.
@wm.on_key(key_definition="Control + b")
def on_control_b(window: Window, event: XKeyEvent) -> None:
print("Control + b")
# Per-client grab (every URxvt, including ones already mapped after init).
@wm.on_key(
key_definition=KeyDefinition(modifiers=KeyboardModifiers.Control, key=KeyDefinition.Key(name="d")),
window_matcher=WindowMatchers(class_="URxvt"),
)
def close_urxvt(window: Window, event: XKeyEvent) -> None:
window.close()
@wm.on_key_release(key_definition="Control + b")
def on_control_b_up(window: Window, event: XKeyEvent) -> None:
print("Control + b released")
@wm.on_button(button=BUTTONS.Button1, modifiers=KeyboardModifiers.Control)
def on_ctrl_click(window: Window, event: XButtonEvent) -> None:
print(event.x, event.y)
# `event` is None during the startup sweep (no real CreateNotify for windows that already existed).
@wm.on_create()
def on_any_create(window: Window, event: Optional[XCreateWindowEvent]) -> None:
print(window.get_name_and_class())
@wm.on_manage(matcher=WindowMatchers(name="easyeffects", class_="easyeffects"))
def on_easyeffects(window: Window, event: XCreateWindowEvent) -> None:
# .svg goes through resvg; any other extension (.png, .jpg, .ico, β¦) goes through ImageMagick
window.set_icon(icon=Path("/path/to/icon.svg"))
@wm.on_destroy(window=window)
def on_easyeffects_gone(window: Window, event: XDestroyWindowEvent) -> None:
print("easyeffects closed")
@wm.on_destroy()
def on_any_destroy(window: Window, event: XDestroyWindowEvent) -> None:
print(f"destroyed {window}")
@wm.on_focus()
def on_focus(window: Window, event: XFocusChangeEvent) -> None:
print(f"focus {window}")
@wm.on_unfocus()
def on_unfocus(window: Window, event: XFocusChangeEvent) -> None:
print(f"unfocus {window}")
@wm.on_map()
def on_map(window: Window, event: XMapEvent) -> None:
print(f"mapped {window}")
@wm.on_unmap()
def on_unmap(window: Window, event: XUnmapEvent) -> None:
print(f"unmapped {window}")
@wm.on_configure()
def on_configure(window: Window, event: XConfigureEvent) -> None:
print(window, event.width, event.height)
@wm.on_client_message(message_type="_NET_ACTIVE_WINDOW")
def on_active(window: Window, event: XClientMessageEvent) -> None:
print(window, event.message_type)
@wm.on_timer(timeout=60.0)
def every_minute() -> Optional[bool]:
print("tick")
return None # return True to stop
@wm.on_manage(matcher=WindowMatchers(name="Navigator", class_="firefox", window_type=["_NET_WM_WINDOW_TYPE_NORMAL"]))
def firefox_opened(window: Window, event: XCreateWindowEvent) -> None:
n: Notification = Notification(
app_name="Firefox",
summary="Firefox is now open!",
body="<b>Notification body</b>",
actions=[Notification.Action(visible_name="Action #1", callback=lambda: print("Action #1"))],
on_close=lambda: print("notification closed"),
hints=Notification.Hints(urgency=Notification.Hints.Urgency.NORMAL),
)
n.show()Then:
orcsome3
# or: python3 -m orcsome3Useful flags: --config PATH, --log-file PATH, --log-level DEBUG, --version.
One process per $DISPLAY (lock under $XDG_RUNTIME_DIR/orcsome3/). Extra monitors on the same X server share that instance. A second X server (Chrome Remote Desktop, VNC, :1) is a separate process and does not steal grabs from :0. Saving the loaded config reloads it automatically (or send SIGUSR1). SIGINT / SIGTERM quit.
If the config file fails to load (syntax error, exception at import time), orcsome3 does not crash β it logs the exception and keeps running with no handlers installed until the next successful reload. This applies to the very first load too. A missing --config path is different and still exits at startup; a broken file at a real path does not. See the tray icon below for how that error is surfaced.
A tray icon appears when your panel has a StatusNotifier (SNI) host β most do (KDE, LXQt, many tint2/Waybar/Polybar setups, etc.). If no host is on the session bus, there is no icon; signals still work. The icon is rendered straight from the packaged SVG into the D-Bus pixmap (via resvg, already a build dependency) β nothing is ever copied into ~/.local/share/icons or left behind on uninstall.
Right-click for the menu:
- Config:
<path>β the config file currently loaded (informational). - Error:
...β only shown while the config has a load error; same text as the icon's tooltip. The icon itself also switches to a distinct "error" variant (red dot) while this is active, and a desktop notification fires too if a notification daemon is running. Clears on the next successful reload. - Change config file... β only shown if
zenityorkdialogis installed on your system; opens a file picker and switches to the selected config live (no restart). - Quit β sends
SIGTERM.
| Command | What it does |
|---|---|
make native |
Download/build static libs and compile the Cython backend |
make native-fast |
Re-cythonize/link only (needs a prior native) |
make native-rebuild |
Wipe the lib cache and rebuild |
make stubs |
Regenerate orcsome3_backend.pyi from the .pyx (needs the .so) |
make format / make lint |
ruff, mypy, basedpyright, extra checks, stub --check |
make test |
unittest in tests/ (X tests skip if the display cannot be opened; grab delivery prefers Xephyr/Xvfb) |
make clean |
Build artifacts, orcsome3_built_libraries/, .so |
Do not hand-edit generated .c files or orcsome3_built_libraries/.
python -m orcsome3.libs.build --build-dir . is the same as make native. --dynamic links system libev/gd/Magick instead of the static copies.
MIT. See LICENSE.