Skip to content
ahsand97Public

About

Scripting extension for NETWM compliant window managers

Topics

Resources

Stars

14 stars

Watchers

1 watching

Forks

Latest commit

Β 

History

83 Commits

Folders and files

Repository files navigation

πŸͺŸ orcsome3

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.

πŸ—οΈ Architecture

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 what config.py should import.
  • Only orcsome3.libs.xlib and orcsome3.libs.ev import orcsome3_backend. Do not import the .so from config.py.
  • The backend is typed via a generated orcsome3_backend.pyi (make stubs). The package ships py.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.

✨ What you can do

  • 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_icon takes 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)

πŸ“¦ Install

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.

X11 libraries

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 libxtst

Python deps (dbus-next, typing_extensions) come with the package.

From PyPI

python3 -m pip install orcsome3

Linux 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.

Build from source

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 rust

Then:

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)

πŸš€ Quick start

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.

Decorators

  • @wm.on_init / @wm.on_deinit β€” no parentheses, no arguments. on_init runs once at startup after root events are selected, before existing clients are scanned. on_deinit runs in stop() after key/button grabs and timers are torn down.
  • @wm.on_key(...) β€” XGrabKey on KeyPress. Default is a global hotkey (grab on the root). The callback's window is that grab window; use wm.current_window for the focused client. Pass window_matcher= to grab on matching clients instead. orcsome3 consumes the key unless propagate_event=True. CapsLock/NumLock variants are grabbed for you. "Control + b" or a KeyDefinition; modifiers Control/Ctrl, Alt/Meta, Shift, Win/Super, AnyModifier; keys are X keysym names (XK_ stripped).
  • @wm.on_key_release(...) β€” same grab as on_key, but KeyRelease. Register both for the same combo; they share one XGrabKey.
  • @wm.on_button(...) β€” XGrabButton on ButtonPress (BUTTONS.Button1…Button5 or AnyButton). Same root-vs-window_matcher and propagate_event rules as on_key.
  • @wm.on_create(...) β€” CreateNotify, including windows already mapped when orcsome3 starts. Optional matcher=WindowMatchers(...). event is None for that startup sweep (no real CreateNotify exists for windows that already existed) and a real XCreateWindowEvent otherwise.
  • @wm.on_manage(...) β€” same as on_create, but skips the startup sweep, so event is always a real XCreateWindowEvent, never None. Nest per-window @wm.on_destroy(window=...) (and similar) here so you do not attach once per existing client.
  • @wm.on_destroy(...) β€” DestroyNotify. window is 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 / NotifyWhileGrabbed only; 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 is event.width / event.height (not a later get_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. Return True to stop; None/falsy keeps it. Also .start() / .stop() / .again(). Needs the process event loop (normal orcsome3 has 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 orcsome3

Useful 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.

πŸ–±οΈ Tray icon

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 zenity or kdialog is installed on your system; opens a file picker and switches to the selected config live (no restart).
  • Quit β€” sends SIGTERM.

πŸ› οΈ Develop

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.

πŸ“„ License

MIT. See LICENSE.

About

Scripting extension for NETWM compliant window managers

Topics

Resources

Stars

14 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages