Metadata-Version: 2.1
Name: orcsome3
Version: 1.0.0
Summary: Scripting extension for NETWM compliant window managers
Author-email: Ahsan Pérez <ahsand.perez@gmail.com>
License: The MIT License
        
        Copyright (c) 2022 Ahsan Pérez
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in
        all copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
        THE SOFTWARE.
        
Project-URL: repository, https://github.com/ahsand97/orcsome3
Classifier: Programming Language :: Python :: 3
Classifier: License :: OSI Approved :: MIT License
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: X11 Applications
Classifier: Topic :: Desktop Environment :: Window Managers
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: End Users/Desktop
Classifier: Natural Language :: English
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: dbus-next
Requires-Dist: typing-extensions
Provides-Extra: dev
Requires-Dist: mypy; extra == "dev"
Requires-Dist: basedpyright; extra == "dev"
Requires-Dist: ruff; extra == "dev"
Requires-Dist: types-setuptools; extra == "dev"

# 🪟 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](https://github.com/baverman/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

```text
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:

```bash
# 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

```bash
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:

```bash
# 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:

```bash
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:

```bash
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`:

```python
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:

```bash
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](LICENSE).
